diff --git a/.circleci/config.yml b/.circleci/config.yml index ee761dd..72bf74d 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -15,9 +15,9 @@ jobs: - run: name: install dependencies command: | - pip install pipenv; - pipenv install; - pipenv install --dev; + pip install pipenv + pipenv install --python `which pypy3` + pipenv install --python `which pypy3` --dev - save_cache: paths: - ./repo @@ -38,9 +38,9 @@ jobs: - run: name: install dependencies command: | - pip install pipenv; - pipenv install; - pipenv install --dev; + pip install pipenv + pipenv install --three + pipenv install --three --dev - save_cache: paths: - ./repo @@ -61,9 +61,9 @@ jobs: - run: name: install dependencies command: | - pip install pipenv; - pipenv install; - pipenv install --dev; + pip install pipenv + pipenv install --three + pipenv install --three --dev - save_cache: paths: - ./repo @@ -84,16 +84,16 @@ jobs: - run: name: install dependencies command: | - pip install pipenv; - pipenv install; - pipenv install --dev; + pip install pipenv + pipenv install --three + pipenv install --three --dev - save_cache: paths: - ./repo key: v1-dependencies-{{ checksum "Pipfile.lock" }} - run: name: run tests - command: pipenv run flake8 eeee tests + command: pipenv run flake8 cl tests workflows: version: 2 test_all: diff --git a/Makefile b/Makefile index 9911653..1408b53 100644 --- a/Makefile +++ b/Makefile @@ -10,7 +10,7 @@ install: ## install dependencies install-dev: install ## install dev dependencies pipenv install --dev -clean: clean-build clean-pyc +clean: clean-build clean-pyc clean-cache clean-build: ## remove build artifacts rm -fr build/ @@ -22,18 +22,24 @@ clean-pyc: ## remove Python file artifacts find . -name '*.pyo' -exec rm -f {} + find . -name '*~' -exec rm -f {} + +clean-cache: ## remove .cache and .pytest_cache + rm -rf .cache + rm -rf .pytest_cache + lint: ## check style with flake8 - pipenv run flake8 eeee tests + pipenv run flake8 test: ## run tests quickly with the default Python pipenv run pytest test-all: ## run tests on every Python version with tox + rm -rf .tox pipenv run tox --skip-missing-interpreters coverage: ## check code coverage quickly with the default Python rm -rf htmlcov - pipenv run coverage run --source eeee -m pytest + pipenv run coverage erase + pipenv run coverage run -m pytest pipenv run coverage report -m pipenv run coverage html diff --git a/docs/_templates/layout.html b/docs/_templates/layout.html index 5b45a2c..cd7dd7c 100644 --- a/docs/_templates/layout.html +++ b/docs/_templates/layout.html @@ -3,12 +3,12 @@ {% block extrabody %} {{ super() }} - + {% endblock %} diff --git a/eeee/event.py b/eeee/event.py index ad9d3be..7035cc7 100644 --- a/eeee/event.py +++ b/eeee/event.py @@ -62,6 +62,7 @@ class Event: :param name: Optional Event name. If empty will be assigned to name of Class. :type name: eeee.event.Event, str + :raises: eeee.exceptions.NamingError """ RETURN_EXCEPTIONS = False @@ -119,16 +120,16 @@ async def publish(self, message: Any, publisher: Union["Publisher", str] = None) :type publisher: eeee.event.Publisher, str :return: List of results from subscribed handlers or None if event is disabled. """ - if self.is_enable: - if publisher is not None: - publisher = Publisher(publisher) - coros = [] - for ps in self.pub_sub: - if ps.publisher is None or (publisher is not None and ps.publisher == publisher): - coros.append(ps.subscriber(message, publisher, event=self.name)) - if coros: - return await asyncio.gather(*coros, return_exceptions=self.RETURN_EXCEPTIONS) - return None + if not self.is_enable: + return None + + publisher = Publisher(publisher) if publisher else publisher + + coros = [] + for ps in self.pub_sub: + if ps.publisher is None or (publisher is not None and ps.publisher == publisher): + coros.append(ps.subscriber(message, publisher, event=self.name)) + return await asyncio.gather(*coros, return_exceptions=self.RETURN_EXCEPTIONS) def subscribe(self, publisher: Union["Publisher", str] = None): """Subscribe decorator integrated within Event object. @@ -279,10 +280,11 @@ def __eq__(self, other): return False @property - def id(self): + def id(self) -> str: """Publisher identification. :return: string ID + :rtype: str """ return self.__id @@ -323,24 +325,11 @@ class Subscriber: """ def __init__(self, handler: Union["Subscriber", callable]): - if isinstance(handler, self.__class__): - self.handler = handler.handler - self.name = handler.name - elif callable(handler): - try: - self.name = handler.__name__ - self.handler = handler - is_coro = iscoroutinefunction(handler) - except AttributeError: - # assume instance of callable class - self.name = handler.__class__.__name__ - self.handler = handler - is_coro = iscoroutinefunction(handler.__call__) - - if not is_coro: - raise exceptions.NotCoroutineError - else: - raise exceptions.NotCallableError + self.name, self.handler = _parse_handler(handler) + + # handler validation + _is_callable(self.handler) + _is_coro(self.handler) self.__template = str(self.__class__) + '{name}' self.__id = self.__template.format(name=self.name) @@ -352,13 +341,66 @@ def __eq__(self, other): return self.id == self.__template.format(name=other) return False - async def __call__(self, *args, **kwargs): - return await self.handler(*args, **kwargs) + async def __call__(self, message, publisher, event): + return await self.handler(message=message, publisher=publisher, event=event) @property def id(self): """Subscriber identification. :return: string ID + :rtype: str """ return self.__id + + +def _parse_handler(handler: Union[callable, object, Subscriber]): + """Parse handler name and body. + + Function accept functions, callable object and instance of Subscriber. + If Subscriber has been given, parser will extract its name and handler. + + :param handler: + :return: + """ + if isinstance(handler, Subscriber): + return handler.name, handler.handler + + try: + name = handler.__name__ + except AttributeError: + # assume instance of callable object + name = handler.__class__.__name__ + + return name, handler + + +def _is_callable(handler: Union[callable, object]): + """Check if handler is callable. + + :param handler: Function or callable object. + :type handler: callable, object + :raises: eeee.exception.NotCallableError + :return: None + """ + if not callable(handler): + raise exceptions.NotCallableError + + +def _is_coro(handler: Union[callable, object]): + """Check if handler is coroutine. + + Class with async __call__ method is considered a coroutine. + + :param handler: Function or callable object. + :type handler: callable, object + :raises: eeee.exceptions.NotCoroutineError + :return: None + """ + try: + is_coro = iscoroutinefunction(handler) or iscoroutinefunction(handler.__call__) + except AttributeError: + is_coro = False + + if not is_coro: + raise exceptions.NotCoroutineError diff --git a/setup.cfg b/setup.cfg index 69d3e1e..6fd7975 100644 --- a/setup.cfg +++ b/setup.cfg @@ -9,19 +9,23 @@ tag = True [flake8] max-line-length = 100 -max-complexity = 10 +max-complexity = 4 exclude = - .git, - .idea, - .tox, - __pycache__, - docs, - build, - dist, - node_modules, + .cache .circleci - *_backup.*, - setup.py, + .git + .idea + .pytest_cache + .tox + __pycache__ + docs + build + dist + htmlcov + node_modules + *.egg-info + *_backup.* + setup.py [tool:pytest] addopts = --verbose diff --git a/tests/test_event.py b/tests/test_event.py index b6d4016..b19725b 100644 --- a/tests/test_event.py +++ b/tests/test_event.py @@ -2,7 +2,7 @@ # -*- coding: utf-8 -*- import unittest -from eeee import Event +from eeee import Event, exceptions __author__ = 'Paweł Zadrożny' __copyright__ = 'Copyright (c) 2018, Pawelzny' @@ -41,3 +41,18 @@ def test_toggle(self): def test_new_event_without_name(self): event = Event() self.assertEqual(event.name, 'Event') + + def test_get_name_from_other_event(self): + optimus = Event('Optimus Prime') + clone = Event(optimus) + + # name has been cloned + self.assertEqual(optimus.name, clone.name) + + # but are not the same + self.assertNotEqual(optimus, clone) + self.assertIsNot(optimus, clone) + + def test_raise_on_bad_naming(self): + with self.assertRaises(exceptions.NamingError): + Event({'name': 'test'}) diff --git a/tests/test_pub_sub.py b/tests/test_pub_sub.py index 5cd1f95..718e962 100644 --- a/tests/test_pub_sub.py +++ b/tests/test_pub_sub.py @@ -150,7 +150,7 @@ def test_publish_to_empty_event(self): with Loop(event.publish('enter the void')) as loop: result = loop.run_until_complete() - self.assertIsNone(result) + self.assertListEqual(result, []) def test_publish_on_disabled_event(self): event = Event('disabled') diff --git a/tests/test_subscriber.py b/tests/test_subscriber.py index dd95760..2f00218 100644 --- a/tests/test_subscriber.py +++ b/tests/test_subscriber.py @@ -47,7 +47,18 @@ def handler(): with self.assertRaises(exceptions.NotCoroutineError): Subscriber(handler) - def test_raise_handler_error(self): + def test_handler_of_wrong_type(self): + with self.assertRaises(exceptions.HandlerError): + Subscriber(tuple) + + def test_not_callable_object_handler(self): + class NotCallable: + name = 'test' + + with self.assertRaises(exceptions.HandlerError): + Subscriber(NotCallable()) + + def test_raise_not_error(self): with self.assertRaises(exceptions.NotCallableError): Subscriber('foo') diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..55dee16 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +import unittest + +from eeee import exceptions +from eeee.event import _is_coro, _is_callable, Subscriber, _parse_handler + +__author__ = 'Paweł Zadrożny' +__copyright__ = 'Copyright (c) 2018, Pawelzny' + + +class TestUtils(unittest.TestCase): + def test_is_coro(self): + async def i_am_coro(): + pass + + self.assertIsNone(_is_coro(i_am_coro)) + + def test_raises_is_not_coro(self): + def i_am_not_coro(): + pass + + with self.assertRaises(exceptions.NotCoroutineError): + _is_coro(i_am_not_coro) + + def test_raises_on_not_callable_obj(self): + class NotCallable: + pass + + with self.assertRaises(exceptions.NotCoroutineError): + _is_coro(NotCallable()) + + def test_is_callable(self): + async def i_am_callable(): + pass + + def i_am_callable_too(): + pass + + class IamCallable: + def __call__(self): + pass + + self.assertIsNone(_is_callable(i_am_callable)) + self.assertIsNone(_is_callable(i_am_callable_too)) + self.assertIsNone(_is_callable(IamCallable())) + + def test_parse_handler_sub(self): + async def some_coro(): + pass + + sub = Subscriber(some_coro) + + name, handler = _parse_handler(sub) + self.assertEqual(name, 'some_coro') + self.assertIs(handler, some_coro) + + def test_parse_handler_coro(self): + async def other_coro(): + pass + + name, handler = _parse_handler(other_coro) + self.assertEqual(name, 'other_coro') + self.assertIs(handler, other_coro) + + def test_parse_handler_callable_obj(self): + class Coro: + def __call__(self): + pass + + coro = Coro() + + name, handler = _parse_handler(coro) + self.assertEqual(name, 'Coro') + self.assertIs(handler, coro) diff --git a/tox.ini b/tox.ini index eafd081..f8205d7 100644 --- a/tox.ini +++ b/tox.ini @@ -1,17 +1,13 @@ [tox] -envlist = - py35 - py36 - pypy35 - flake8 +envlist = py35,py36,pypy35,flake8 [testenv:flake8] basepython = python3.6 commands = pip install pipenv - pipenv install - pipenv install --dev - pipenv run flake8 eeee tests + pipenv install --three + pipenv install --three --dev + pipenv run flake8 [testenv:py35] basepython = python3.5 @@ -20,34 +16,20 @@ basepython = python3.5 basepython = python3.6 [testenv:pypy35] -basepython = python +basepython = pypy3 +commands = + pip install pipenv + pipenv install --python pypy3 + pipenv install --python pypy3 --dev + pipenv run pytest [testenv] changedir = {toxinidir} setenv = - PYTHONPATH = {toxinidir}:{toxinidir}/eeee + PYTHONPATH = {toxinidir}:{toxinidir} passenv = PYTHONPATH commands = pip install pipenv - pipenv install - pipenv install --dev + pipenv install --three + pipenv install --three --dev pipenv run pytest - -[flake8] -max-line-length = 100 -max-complexity = 8 -exclude = - .git - .cache - .pytest_cache - .circleci - .idea - .tox - __pycache__ - docs - build - dist - htmlcov - node_modules, - *.egg-info - setup.py