diff --git a/colibris/authentication/model.py b/colibris/authentication/model.py index 24e92ef..8fec46f 100644 --- a/colibris/authentication/model.py +++ b/colibris/authentication/model.py @@ -6,7 +6,10 @@ class ModelBackend(AuthenticationBackend): def __init__(self, model, active_field=None, inactive_field=None, **kwargs): - self.model = utils.import_member(model) + self.model = model + if isinstance(model, str): + self.model = utils.import_member(self.model) + self.active_field = active_field self.inactive_field = inactive_field diff --git a/colibris/authorization/base.py b/colibris/authorization/base.py index 572b861..3a48504 100644 --- a/colibris/authorization/base.py +++ b/colibris/authorization/base.py @@ -7,5 +7,4 @@ def get_actual_permissions(self, account, method, path): raise NotImplementedError def authorize(self, account, method, path, handler, required_permissions): - actual_permissions = self.get_actual_permissions(account, method, path) - required_permissions.verify(actual_permissions) + required_permissions.verify(self.get_actual_permissions(account, method, path)) diff --git a/colibris/authorization/model.py b/colibris/authorization/model.py index 992cee7..b00d4ba 100644 --- a/colibris/authorization/model.py +++ b/colibris/authorization/model.py @@ -6,7 +6,10 @@ class ModelBackend(AuthorizationBackend): def __init__(self, model, account_field, **kwargs): - self.model = utils.import_member(model) + self.model = model + if isinstance(model, str): + self.model = utils.import_member(self.model) + self.account_field = account_field super().__init__(**kwargs) diff --git a/colibris/authorization/null.py b/colibris/authorization/null.py index 9ed66c2..3cc005f 100644 --- a/colibris/authorization/null.py +++ b/colibris/authorization/null.py @@ -12,4 +12,4 @@ def authorize(self, account, method, path, handler, required_permissions): return True def get_actual_permissions(self, account, method, path): - return () + return set() diff --git a/colibris/authorization/permissions.py b/colibris/authorization/permissions.py index 9d347a1..5a22549 100644 --- a/colibris/authorization/permissions.py +++ b/colibris/authorization/permissions.py @@ -11,8 +11,6 @@ def combine(self, permissions): return Permissions(self.and_set | permissions.and_set, self.or_set | permissions.or_set) def verify(self, actual_permissions): - actual_permissions = set(actual_permissions) - # Verify permissions in and set for p in self.and_set: if p not in actual_permissions: @@ -24,7 +22,7 @@ def verify(self, actual_permissions): permissions = self.or_set & actual_permissions if len(permissions) == 0: - raise PermissionNotMet(permissions.pop()) + raise PermissionNotMet(list(self.or_set)[0]) def __str__(self): s = '' diff --git a/colibris/authorization/rights.py b/colibris/authorization/rights.py index a3b71ca..f185a3c 100644 --- a/colibris/authorization/rights.py +++ b/colibris/authorization/rights.py @@ -27,4 +27,4 @@ def get_actual_permissions(self, account, method, path): # Gather all rights from all entries for the given account rights = self.model.select().where(self.get_account_field() == account) - return ['{}:{}'.format(self.get_resource(r), self.get_operation(r)) for r in rights] + return {'{}:{}'.format(self.get_resource(r), self.get_operation(r)) for r in rights} diff --git a/colibris/authorization/role.py b/colibris/authorization/role.py index 2708db2..0e8dfcc 100644 --- a/colibris/authorization/role.py +++ b/colibris/authorization/role.py @@ -19,7 +19,7 @@ def get_role(self, account): def get_actual_permissions(self, account, method, path): role = self.get_role(account) - actual_permissions = [role] + actual_permissions = {role} try: index = self.order.index(role) @@ -27,6 +27,6 @@ def get_actual_permissions(self, account, method, path): except ValueError: index = 0 - actual_permissions += self.order[:index] + actual_permissions.update(self.order[:index]) return actual_permissions diff --git a/colibris/conf/backends.py b/colibris/conf/backends.py index 494ca16..1fb2c93 100644 --- a/colibris/conf/backends.py +++ b/colibris/conf/backends.py @@ -20,13 +20,18 @@ def configure(cls, settings): cls._instance = None try: - backend_path = settings.pop('backend') + backend = settings.pop('backend') except KeyError: return # Backend class not specified - cls._class = utils.import_member(backend_path) - logger.debug('%s: using class %s', cls.__name__, backend_path) + if isinstance(backend, str): + cls._class = utils.import_member(backend) + logger.debug('%s: using class %s', cls.__name__, backend) + + else: + cls._class = backend + logger.debug('%s: using class %s', cls.__name__, backend.__name__) @classmethod def get_instance(cls): diff --git a/tests/conftest.py b/tests/conftest.py index 938d0e9..8805ce1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,2 +1,3 @@ from .functional.fixtures import * +from .unit.fixtures import * diff --git a/tests/unit/authorization/__init__.py b/tests/unit/authorization/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/authorization/fixtures.py b/tests/unit/authorization/fixtures.py new file mode 100644 index 0000000..62ff025 --- /dev/null +++ b/tests/unit/authorization/fixtures.py @@ -0,0 +1,10 @@ + +import types + + +DUMMY_PERMISSION = 'dummy_permission' +ANOTHER_PERMISSION = 'another_permission' +YET_ANOTHER_PERMISSION = 'yet_another_permission' + +DUMMY_ACCOUNT = types.SimpleNamespace(role=DUMMY_PERMISSION) +ANOTHER_ACCOUNT = types.SimpleNamespace(role=ANOTHER_PERMISSION) diff --git a/tests/unit/authorization/test_permissions_class.py b/tests/unit/authorization/test_permissions_class.py new file mode 100644 index 0000000..65faddd --- /dev/null +++ b/tests/unit/authorization/test_permissions_class.py @@ -0,0 +1,67 @@ + +import pytest + +from colibris.authorization import permissions + +from .fixtures import DUMMY_PERMISSION, ANOTHER_PERMISSION, YET_ANOTHER_PERMISSION + + +def test_conjunction(): + p = permissions.Permissions(and_set={DUMMY_PERMISSION, ANOTHER_PERMISSION}) + + with pytest.raises(permissions.PermissionNotMet): + p.verify(set()) + + with pytest.raises(permissions.PermissionNotMet): + p.verify({DUMMY_PERMISSION}) + + with pytest.raises(permissions.PermissionNotMet): + p.verify({ANOTHER_PERMISSION}) + + p.verify({DUMMY_PERMISSION, ANOTHER_PERMISSION}) + + +def test_disjunction(): + p = permissions.Permissions(or_set={DUMMY_PERMISSION, ANOTHER_PERMISSION}) + + with pytest.raises(permissions.PermissionNotMet): + p.verify(set()) + + p.verify({DUMMY_PERMISSION}) + p.verify({ANOTHER_PERMISSION}) + p.verify({DUMMY_PERMISSION, ANOTHER_PERMISSION}) + + +def test_conjunction_disjunction(): + p = permissions.Permissions(and_set={DUMMY_PERMISSION, ANOTHER_PERMISSION}, + or_set={DUMMY_PERMISSION, YET_ANOTHER_PERMISSION}) + + with pytest.raises(permissions.PermissionNotMet): + p.verify(set()) + + with pytest.raises(permissions.PermissionNotMet): + p.verify({DUMMY_PERMISSION}) + + with pytest.raises(permissions.PermissionNotMet): + p.verify({ANOTHER_PERMISSION}) + + with pytest.raises(permissions.PermissionNotMet): + p.verify({YET_ANOTHER_PERMISSION}) + + with pytest.raises(permissions.PermissionNotMet): + p.verify({DUMMY_PERMISSION, YET_ANOTHER_PERMISSION}) + + with pytest.raises(permissions.PermissionNotMet): + p.verify({ANOTHER_PERMISSION, YET_ANOTHER_PERMISSION}) + + p.verify({DUMMY_PERMISSION, ANOTHER_PERMISSION}) + p.verify({DUMMY_PERMISSION, ANOTHER_PERMISSION, YET_ANOTHER_PERMISSION}) + + +def test_combine(): + p1 = permissions.Permissions(and_set={DUMMY_PERMISSION}, or_set={ANOTHER_PERMISSION}) + p2 = permissions.Permissions(and_set={ANOTHER_PERMISSION}, or_set={YET_ANOTHER_PERMISSION}) + + c = p1.combine(p2) + assert c.and_set == {DUMMY_PERMISSION, ANOTHER_PERMISSION} + assert c.or_set == {ANOTHER_PERMISSION, YET_ANOTHER_PERMISSION} diff --git a/tests/unit/authorization/test_rights_backend.py b/tests/unit/authorization/test_rights_backend.py new file mode 100644 index 0000000..39c149b --- /dev/null +++ b/tests/unit/authorization/test_rights_backend.py @@ -0,0 +1,57 @@ + +import pytest + +from colibris.authorization.rights import RightsBackend +from colibris import persist + + +USERNAME1 = 'username1' +USERNAME2 = 'username2' + +RESOURCE = 'resource' +OPERATION = 'operation' +PERMISSION = '{}:{}'.format(RESOURCE, OPERATION) + + +class User(persist.Model): + username = persist.CharField() + + +class Right(persist.Model): + user = persist.ForeignKeyField(User) + resource = persist.CharField() + operation = persist.CharField() + + +@pytest.fixture +def database(database_maker): + return database_maker(models=[User, Right]) + + +@pytest.fixture +def user1(database): + return User.create(username=USERNAME1) + + +@pytest.fixture +def user2(database): + return User.create(username=USERNAME2) + + +@pytest.fixture +def right(user1): + return Right.create(user=user1, resource=RESOURCE, operation=OPERATION) + + +@pytest.fixture +def backend(): + return RightsBackend(model=Right, account_field='user', + resource_field='resource', operation_field='operation') + + +def test_allowed(backend, user1, right): + assert backend.get_actual_permissions(account=user1, method='GET', path='/') == {PERMISSION} + + +def test_forbidden(backend, user2, right): + assert backend.get_actual_permissions(account=user2, method='GET', path='/') == set() diff --git a/tests/unit/authorization/test_role_backend.py b/tests/unit/authorization/test_role_backend.py new file mode 100644 index 0000000..6639f06 --- /dev/null +++ b/tests/unit/authorization/test_role_backend.py @@ -0,0 +1,20 @@ + +from colibris.authorization.role import RoleBackend + +from .fixtures import DUMMY_PERMISSION, ANOTHER_PERMISSION, YET_ANOTHER_PERMISSION, DUMMY_ACCOUNT, ANOTHER_ACCOUNT + + +def test_extract_role(): + backend = RoleBackend(role_field='role') + assert backend.get_role(account=DUMMY_ACCOUNT) == DUMMY_PERMISSION + + +def test_actual_permissions_simple(): + backend = RoleBackend(role_field='role') + assert backend.get_actual_permissions(account=DUMMY_ACCOUNT, method='GET', path='/') == {DUMMY_PERMISSION} + + +def test_actual_permissions_order(): + backend = RoleBackend(role_field='role', order=[DUMMY_PERMISSION, ANOTHER_PERMISSION, YET_ANOTHER_PERMISSION]) + permissions = backend.get_actual_permissions(account=ANOTHER_ACCOUNT, method='GET', path='/') + assert permissions == {DUMMY_PERMISSION, ANOTHER_PERMISSION} diff --git a/tests/unit/authorization/test_view_permissions.py b/tests/unit/authorization/test_view_permissions.py new file mode 100644 index 0000000..433ba51 --- /dev/null +++ b/tests/unit/authorization/test_view_permissions.py @@ -0,0 +1,92 @@ + +from aiohttp import web + +from colibris import authorization +from colibris import views + +from .fixtures import DUMMY_PERMISSION, ANOTHER_PERMISSION + + +class DummyView(views.View): + async def get(self): + return web.json_response({'message': 'dummy'}) + + +class DummyViewWithClassPermission(DummyView): + permissions = {DUMMY_PERMISSION} + + +class DummyViewWithMethodPermission(DummyView): + @authorization.require_permission(DUMMY_PERMISSION) + async def get(self): + return await super().get() + + +class PermissionAuthorizationBackend(authorization.AuthorizationBackend): + def get_actual_permissions(self, account, method, path): + return {DUMMY_PERMISSION} + + +class NoPermissionAuthorizationBackend(authorization.AuthorizationBackend): + def get_actual_permissions(self, account, method, path): + return set() + + +class WrongPermissionAuthorizationBackend(authorization.AuthorizationBackend): + def get_actual_permissions(self, account, method, path): + return {ANOTHER_PERMISSION} + + +async def test_default_view_permissions(http_client_maker): + client = await http_client_maker(routes=[('/dummy', DummyView)]) + response = await client.get('/dummy') + + assert response.status == 200 + + +async def test_class_required_permissions_fulfilled(http_client_maker): + client = await http_client_maker(routes=[('/dummy', DummyViewWithClassPermission)], + authorization_backend=PermissionAuthorizationBackend) + response = await client.get('/dummy') + + assert response.status == 200 + + +async def test_class_required_permissions_no_permissions(http_client_maker): + client = await http_client_maker(routes=[('/dummy', DummyViewWithClassPermission)], + authorization_backend=NoPermissionAuthorizationBackend) + response = await client.get('/dummy') + + assert response.status == 403 + + +async def test_class_required_permissions_wrong_permissions(http_client_maker): + client = await http_client_maker(routes=[('/dummy', DummyViewWithClassPermission)], + authorization_backend=WrongPermissionAuthorizationBackend) + response = await client.get('/dummy') + + assert response.status == 403 + + +async def test_method_required_permissions_fulfilled(http_client_maker): + client = await http_client_maker(routes=[('/dummy', DummyViewWithMethodPermission)], + authorization_backend=PermissionAuthorizationBackend) + response = await client.get('/dummy') + + assert response.status == 200 + + +async def test_method_required_permissions_no_permissions(http_client_maker): + client = await http_client_maker(routes=[('/dummy', DummyViewWithMethodPermission)], + authorization_backend=NoPermissionAuthorizationBackend) + response = await client.get('/dummy') + + assert response.status == 403 + + +async def test_method_required_permissions_wrong_permissions(http_client_maker): + client = await http_client_maker(routes=[('/dummy', DummyViewWithMethodPermission)], + authorization_backend=WrongPermissionAuthorizationBackend) + response = await client.get('/dummy') + + assert response.status == 403 diff --git a/tests/unit/fixtures.py b/tests/unit/fixtures.py new file mode 100644 index 0000000..108459d --- /dev/null +++ b/tests/unit/fixtures.py @@ -0,0 +1,70 @@ + +import pytest + +from aiohttp import web + +from colibris import authentication +from colibris import authorization +from colibris import persist +from colibris.middleware.errors import handle_errors_json +from colibris.middleware.auth import handle_auth + +from .authorization.fixtures import * + + +@pytest.fixture +def http_client_maker(aiohttp_client): + + def client(authentication_backend=None, authorization_backend=None, + routes=None, middlewares=None): + + if middlewares is None: + middlewares = [ + handle_errors_json, + handle_auth + ] + + app = web.Application(middlewares=middlewares) + routes = routes or [] + + for path, view in routes: + app.router.add_route('*', path, view) + + if not authentication_backend: + authentication_backend = 'colibris.authentication.null.NullBackend' + + if not authorization_backend: + authorization_backend = 'colibris.authorization.null.NullBackend' + + authentication.AuthenticationBackend.configure({ + 'backend': authentication_backend + }) + + if authorization_backend: + authorization.AuthorizationBackend.configure({ + 'backend': authorization_backend + }) + + return aiohttp_client(app) + + return client + + +@pytest.fixture +def database_maker(): + + def database(models, **settings): + # Use in-memory SQLite db by default + settings.setdefault('backend', 'colibris.persist.backends.SQLiteBackend') + if settings['backend'] == 'colibris.persist.backends.SQLiteBackend': + settings.setdefault('database', ':memory:') + + persist.DatabaseBackend.configure(settings) + db = persist.get_database() + db.connect() + persist.models.set_database(db) + db.create_tables(models) + + return db + + return database diff --git a/tests/unit/views/test_api_view.py b/tests/unit/views/test_api_view.py index 91af7a8..cc549fc 100644 --- a/tests/unit/views/test_api_view.py +++ b/tests/unit/views/test_api_view.py @@ -1,10 +1,10 @@ import pytest -from aiohttp import web, hdrs +from aiohttp import web from marshmallow import Schema, fields -from colibris.middleware.errors import handle_errors_json from colibris.views import APIView +from colibris.middleware.errors import handle_errors_json class ItemSchema(Schema): @@ -34,15 +34,9 @@ async def post(self): @pytest.fixture -def http_client(loop, aiohttp_client): - middlewares = [ - handle_errors_json - ] - - app = web.Application(middlewares=middlewares) - app.router.add_route('*', '/items', ItemsView) - - return loop.run_until_complete(aiohttp_client(app)) +async def http_client(http_client_maker): + return await http_client_maker(middlewares=[handle_errors_json], + routes=[('/items', ItemsView)]) async def test_get(http_client): @@ -53,7 +47,7 @@ async def test_get(http_client): 'count': 222 } - response = await http_client.request(hdrs.METH_GET, '/items', params=sent_args) + response = await http_client.get('/items', params=sent_args) assert response.status == 200 @@ -75,7 +69,7 @@ async def test_post_body(http_client): 'count': 22, } - response = await http_client.request(hdrs.METH_POST, '/items', json=sent_data) + response = await http_client.post('/items', json=sent_data) assert response.status == 200 @@ -93,7 +87,7 @@ async def test_post_query(http_client): 'count': 22, } - response = await http_client.request(hdrs.METH_POST, '/items', params=sent_data) + response = await http_client.post('/items', params=sent_data) assert response.status == 200 diff --git a/tests/unit/views/test_model_view.py b/tests/unit/views/test_model_view.py index f83af9c..67b6668 100644 --- a/tests/unit/views/test_model_view.py +++ b/tests/unit/views/test_model_view.py @@ -1,6 +1,4 @@ import pytest -from aiohttp import web, hdrs -from peewee import SqliteDatabase from colibris.middleware.errors import handle_errors_json from colibris.pagination import PageNumberPagination @@ -41,41 +39,33 @@ class ItemView(RetrieveUpdateDestroyModelView): @pytest.fixture -def database(): - db = SqliteDatabase(':memory:') - db.bind(MODELS) - db.connect() - db.create_tables(MODELS) +def database(database_maker): + return database_maker(models=MODELS) @pytest.fixture -def http(loop, aiohttp_client): - middlewares = [ - handle_errors_json - ] - - app = web.Application(middlewares=middlewares) - app.router.add_route('*', '/paginated-items', ItemsPaginatedView) - app.router.add_route('*', '/items', ItemsView) - app.router.add_route('*', '/items/{id}', ItemView) - return loop.run_until_complete(aiohttp_client(app)) +async def http_client(http_client_maker): + return await http_client_maker(middlewares=[handle_errors_json], + routes=[('/paginated-items', ItemsPaginatedView), + ('/items', ItemsView), + ('/items/{id}', ItemView)]) class TestList: - async def test_get_items(self, database, http): - response = await http.request(hdrs.METH_GET, '/items') + async def test_get_items(self, database, http_client): + response = await http_client.get('/items') assert response.status == 200 data = await response.json() assert isinstance(data, list) - async def test_get_pagination_default_page_and_size(self, database, http): + async def test_get_pagination_default_page_and_size(self, database, http_client): item__name = 'Ligula Egestas Fermentum' item_info = 'Ridiculus Fermentum Quam Porta' Item.create(name=item__name, info=item_info) - response = await http.request(hdrs.METH_GET, '/paginated-items') + response = await http_client.get('/paginated-items') assert response.status == 200 data = await response.json() @@ -86,7 +76,7 @@ async def test_get_pagination_default_page_and_size(self, database, http): assert 'page' in data assert 'page_size' in data - async def test_get_pagination_different_page(self, database, http): + async def test_get_pagination_different_page(self, database, http_client): item_info = 'Ridiculus Fermentum Quam Porta' first_item_name = 'Ligula Egestas Fermentum' second_item_name = 'Euismod Ipsum Vulputate' @@ -96,7 +86,7 @@ async def test_get_pagination_different_page(self, database, http): Item.create(name=second_item_name, info=item_info) Item.create(name=third_item_name, info=item_info) - response = await http.request(hdrs.METH_GET, '/paginated-items', params={'page_size': 1, 'page': 1}) + response = await http_client.get('/paginated-items', params={'page_size': 1, 'page': 1}) assert response.status == 200 data = await response.json() @@ -106,28 +96,28 @@ async def test_get_pagination_different_page(self, database, http): assert data['page_size'] == 1 assert data['results'][0]['name'] == third_item_name - response = await http.request(hdrs.METH_GET, '/paginated-items', params={'page_size': 1, 'page': 2}) + response = await http_client.get('/paginated-items', params={'page_size': 1, 'page': 2}) assert response.status == 200 data = await response.json() assert data['results'][0]['name'] == second_item_name - response = await http.request(hdrs.METH_GET, '/paginated-items', params={'page_size': 1, 'page': 3}) + response = await http_client.get('/paginated-items', params={'page_size': 1, 'page': 3}) assert response.status == 200 data = await response.json() assert data['results'][0]['name'] == first_item_name - response = await http.request(hdrs.METH_GET, '/paginated-items', params={'page_size': 1, 'page': 4}) + response = await http_client.get('/paginated-items', params={'page_size': 1, 'page': 4}) assert response.status == 200 data = await response.json() assert data['results'] == [] class TestCreate: - async def test_create_item(self, database, http): + async def test_create_item(self, database, http_client): item_name = 'Commodo Nibh' item_info = 'Cras Lorem Purus Etiam Venenatis' - response = await http.request(hdrs.METH_POST, '/items', json={'name': item_name, 'info': item_info}) + response = await http_client.post('/items', json={'name': item_name, 'info': item_info}) assert response.status == 201 @@ -135,8 +125,8 @@ async def test_create_item(self, database, http): assert item.name == item_name - async def test_create_item_validation(self, database, http): - response = await http.request(hdrs.METH_POST, '/items') + async def test_create_item_validation(self, database, http_client): + response = await http_client.post('/items') assert response.status == 400 data = await response.json() @@ -145,14 +135,14 @@ async def test_create_item_validation(self, database, http): class TestUpdate: - async def test_update_item(self, database, http): + async def test_update_item(self, database, http_client): item_initial_name = 'Ligula Egestas Fermentum' item_updated_name = 'Cursus Inceptos' item_info = 'Ridiculus Fermentum Quam Porta' item = Item.create(name=item_initial_name, info=item_info) - response = await http.request(hdrs.METH_PATCH, '/items/{}'.format(item.id), json={'name': item_updated_name}) + response = await http_client.patch('/items/{}'.format(item.id), json={'name': item_updated_name}) assert response.status == 200 @@ -160,40 +150,40 @@ async def test_update_item(self, database, http): assert item.id == updated_item.id - async def test_update_item_validation(self, database, http): + async def test_update_item_validation(self, database, http_client): item_initial_name = 'Nibh Lorem Amet Aenean' item_info = 'Ridiculus Fermentum Quam Porta' item = Item.create(name=item_initial_name, info=item_info) - response = await http.request(hdrs.METH_PATCH, '/items/{}'.format(item.id), json={'name': None}) + response = await http_client.patch('/items/{}'.format(item.id), json={'name': None}) assert response.status == 400 data = await response.json() assert 'name' in data['details'] - async def test_update_item_not_found(self, database, http): - response = await http.request(hdrs.METH_PATCH, '/items/11011', json={'name': 'Egestas Fringilla'}) + async def test_update_item_not_found(self, database, http_client): + response = await http_client.patch('/items/11011', json={'name': 'Egestas Fringilla'}) assert response.status == 404 class TestDestroy: - async def test_delete_item(self, database, http): + async def test_delete_item(self, database, http_client): item_name = 'Sit Lorem' item_info = 'Ridiculus Fermentum Quam Porta' item = Item.create(name=item_name, info=item_info) - response = await http.request(hdrs.METH_DELETE, '/items/{}'.format(item.id)) + response = await http_client.delete('/items/{}'.format(item.id)) assert response.status == 204 items = Item.select().where(Item.name == item_name) assert items.count() == 0 - async def test_delete_item_not_found(self, database, http): - response = await http.request(hdrs.METH_DELETE, '/items/111111') + async def test_delete_item_not_found(self, database, http_client): + response = await http_client.delete('/items/111111') assert response.status == 404 diff --git a/tox.ini b/tox.ini index 596a457..dda5ed1 100644 --- a/tox.ini +++ b/tox.ini @@ -9,9 +9,8 @@ ignore = E129,W504 changedir = tests deps = pytest requests -commands = pytest -v -p aiohttp.pytest_plugin {posargs} +commands = pytest {posargs} [pytest] -norecursedirs = dummy-skeleton testpaths = tests addopts = -v -p aiohttp.pytest_plugin