├── tests ├── __init__.py └── test_actor.py ├── rocat ├── globals.py ├── __init__.py ├── executor.py ├── finder.py ├── message.py ├── ref.py ├── role.py └── actor.py ├── docs └── assets │ └── rocat.jpg ├── .gitignore ├── LICENSE.md └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /rocat/globals.py: -------------------------------------------------------------------------------- 1 | import threading 2 | 3 | 4 | g = threading.local() 5 | -------------------------------------------------------------------------------- /docs/assets/rocat.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chongkong/rocat/HEAD/docs/assets/rocat.jpg -------------------------------------------------------------------------------- /rocat/__init__.py: -------------------------------------------------------------------------------- 1 | from .finder import find 2 | from .executor import ActorExecutor 3 | from .globals import g 4 | from .role import BaseActorRole, DictFieldRole 5 | -------------------------------------------------------------------------------- /rocat/executor.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import threading 3 | 4 | import rocat.globals 5 | 6 | 7 | def _executor_loop(executor): 8 | rocat.globals.g.executor = executor 9 | rocat.globals.g.loop = executor.loop 10 | asyncio.set_event_loop(executor.loop) 11 | executor.loop.run_forever() 12 | 13 | 14 | class ActorExecutor(object): 15 | def __init__(self): 16 | self._loop: asyncio.AbstractEventLoop = asyncio.new_event_loop() 17 | self._thread = threading.Thread(target=_executor_loop, args=[self]) 18 | 19 | @property 20 | def loop(self): 21 | return self._loop 22 | 23 | @property 24 | def thread(self): 25 | return self._thread 26 | 27 | def start(self): 28 | self._thread.start() 29 | -------------------------------------------------------------------------------- /tests/test_actor.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import rocat 3 | 4 | greeter_role = rocat.DictFieldRole('greeter', field='name') 5 | 6 | 7 | @greeter_role.default_route 8 | def greet(ctx, msg): 9 | name = msg.get('name', 'stranger') 10 | ctx.sender.tell(f'Welcome, {name}') 11 | 12 | 13 | @greeter_role.route('john') 14 | def greet_john(ctx, msg): 15 | ctx.sender.tell('How dare you come here, John!') 16 | 17 | 18 | executor = rocat.ActorExecutor() 19 | executor.start() 20 | 21 | async def test(): 22 | greeter = greeter_role.create(executor=executor) 23 | print(await greeter.ask({'name': 'chongkong'})) 24 | print(await greeter.ask({})) 25 | print(await greeter.ask({'name': 'john'})) 26 | 27 | asyncio.get_event_loop().run_until_complete(test()) 28 | -------------------------------------------------------------------------------- /rocat/finder.py: -------------------------------------------------------------------------------- 1 | import rocat.role 2 | import rocat.actor 3 | import rocat.ref 4 | 5 | 6 | # role_name -> actor_name -> actor_ref 7 | _actors_refs = {} 8 | 9 | 10 | def register(role, actor): 11 | _actors_refs.setdefault(role.name, {}) 12 | assert actor.name not in _actors_refs[role.name], f'Actor name {actor.name} exists' 13 | ref = _actors_refs[role.name][actor.name] = actor.create_ref() 14 | return ref 15 | 16 | 17 | def find(role, *, name=None) -> rocat.ref.BaseActorRef: 18 | """ Find actor_ref of given role and actor name """ 19 | if isinstance(role, rocat.role.BaseActorRole): 20 | role = role.name 21 | if role in _actors_refs: 22 | actors_of_role = _actors_refs[role] 23 | if name is None and len(actors_of_role) == 1: 24 | return next(iter(actors_of_role.values())) 25 | elif name is not None and name in actors_of_role: 26 | return actors_of_role[name] 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | 47 | # Translations 48 | *.mo 49 | *.pot 50 | 51 | # Django stuff: 52 | *.log 53 | 54 | # Sphinx documentation 55 | docs/_build/ 56 | 57 | # PyBuilder 58 | target/ 59 | 60 | # Pycharm 61 | .idea 62 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | 2 | The MIT License (MIT) 3 | 4 | Copyright (c) 2017 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /rocat/message.py: -------------------------------------------------------------------------------- 1 | import enum 2 | 3 | 4 | class MsgType(enum.Enum): 5 | TELL = 1 6 | ASK = 2 7 | RPC = 3 8 | ERROR = 4 9 | INTERNAL = 5 10 | INTERNAL_ERROR = 6 11 | TERMINAL = 7 12 | 13 | 14 | class Envelope(object): 15 | __slots__ = ['msg', 'type', 'sender'] 16 | 17 | def __init__(self, msg, *, type, sender): 18 | self.msg = msg 19 | self.type = type 20 | self.sender = sender 21 | 22 | @classmethod 23 | def for_tell(cls, msg, *, sender): 24 | return Envelope(msg, type=MsgType.TELL, sender=sender) 25 | 26 | @classmethod 27 | def for_ask(cls, msg, *, sender): 28 | return Envelope(msg, type=MsgType.ASK, sender=sender) 29 | 30 | @classmethod 31 | def for_rpc(cls, msg, *, sender): 32 | return Envelope(msg, type=MsgType.RPC, sender=sender) 33 | 34 | @classmethod 35 | def for_error(cls, err): 36 | return Envelope(err, type=MsgType.ERROR, sender=None) 37 | 38 | @classmethod 39 | def for_internal(cls, msg, *, sender): 40 | return Envelope(msg, type=MsgType.INTERNAL, sender=sender) 41 | 42 | @classmethod 43 | def for_internal_error(cls, err): 44 | return Envelope(err, type=MsgType.INTERNAL_ERROR, sender=None) 45 | 46 | @classmethod 47 | def for_terminal(cls, msg, *, sender): 48 | return Envelope(msg, type=MsgType.TERMINAL, sender=sender) 49 | 50 | @property 51 | def need_reply(self): 52 | return self.type == MsgType.ASK 53 | 54 | @property 55 | def is_error(self): 56 | return self.type == MsgType.ERROR or self.type == MsgType.INTERNAL_ERROR 57 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # rocat 2 | 3 | 4 | 5 | rocat is a simple asyncio coroutine based actor library. `rocat` is an anagram of `actor`. 6 | Currently on extremely early development stage 7 | 8 | 9 | ## Usage 10 | 11 | In rocat, you define _role_ of the actor, instead of defining actor itself. 12 | _Role_ defines how you'd react to the incoming messages. 13 | You can define role in Flask-like style. 14 | 15 | ```python 16 | import rocat 17 | 18 | greeter_role = rocat.DictFieldRole('greeter', field='name') 19 | 20 | @greeter_role.default_route 21 | def greet(ctx, msg): 22 | name = msg.get('name', 'stranger') 23 | ctx.sender.tell(f'Welcome, {name}') 24 | 25 | @greeter_role.route('john') 26 | def greet_john(ctx, msg): 27 | ctx.sender.tell('How dare you come here, John!') 28 | ``` 29 | 30 | By using `DictFieldRole`, you can route `dict`-type message based on the field value of the given field (`"name"` in this example). 31 | You can create your own `Role` class if you want. 32 | Messages are routed to _action_ functions, which take two arguments: `ActorContext` and message, and return nothing. 33 | Message can have any type, as long as you can handle them properly. 34 | You can also define _action_ function as a coroutine function (using `async def`.) 35 | 36 | Once you have created a role, you need an event loop executor called `ActorExecutor` to run your actor. 37 | 38 | ```python 39 | executor = rocat.ActorExecutor() 40 | executor.start() # This fires a new thread 41 | ``` 42 | 43 | If you're creating actor outside of actor context, (i.e. not from the functions registered to `ActorRole`) 44 | you have to manually specify executor in which the actor would run. 45 | 46 | ```python 47 | greeter = greeter_role.create(executor=executor) 48 | ``` 49 | 50 | After creating an actor, you can freely communicate with your actor. 51 | The basic communication method is to _ask_ an actor some message and wait for the reply. 52 | You have to `await` the reply from the actor. 53 | 54 | ```python 55 | async def test(): 56 | print(await greeter.ask({'name': 'chongkong'})) # would print "Welcome, chongkong" 57 | print(await greeter.ask({})) # would print "Welcome, stranger" 58 | print(await greeter.ask({'name': 'john'})) # would print "How dare you come here, John!" 59 | ``` 60 | -------------------------------------------------------------------------------- /rocat/ref.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import rocat.message 4 | import rocat.actor 5 | import rocat.globals 6 | 7 | 8 | class BaseActorRef(object): 9 | def tell(self, m, *, sender=None): 10 | raise NotImplementedError 11 | 12 | def ask(self, m, *, timeout=None): 13 | raise NotImplementedError 14 | 15 | def error(self, e): 16 | raise NotImplementedError 17 | 18 | 19 | class LocalActorRef(BaseActorRef): 20 | def __init__(self, q, loop): 21 | self._q = q 22 | self._loop = loop 23 | 24 | def _send(self, envel): 25 | self._loop.call_soon_threadsafe(self._q.put_nowait, envel) 26 | 27 | def tell(self, m, *, sender=None): 28 | if sender is None: 29 | sender = _guess_current_sender() 30 | self._send(rocat.message.Envelope.for_tell(m, sender=sender)) 31 | 32 | async def ask(self, m, *, timeout=None): 33 | fut = asyncio.get_event_loop().create_future() 34 | sender = FunctionRef(fut, asyncio.get_event_loop()) 35 | 36 | self._send(rocat.message.Envelope.for_ask(m, sender=sender)) 37 | 38 | if timeout is None: 39 | timeout = _guess_default_timeout() 40 | if timeout > 0: 41 | reply = await asyncio.wait_for(fut, timeout) 42 | else: 43 | reply = await fut 44 | 45 | if reply.is_error: 46 | raise reply.msg 47 | return reply.msg 48 | 49 | def error(self, e): 50 | raise NotImplementedError('You can tell error only when you reply') 51 | 52 | 53 | class FunctionRef(BaseActorRef): 54 | def __init__(self, fut, loop): 55 | self._fut = fut 56 | self._loop = loop 57 | 58 | def _send(self, envel): 59 | self._loop.call_soon_threadsafe(self._try_set_future, envel) 60 | 61 | def _try_set_future(self, result): 62 | if not self._fut.done(): 63 | self._fut.set_result(result) 64 | 65 | def tell(self, m, *, sender=None): 66 | if sender is None: 67 | sender = _guess_current_sender() 68 | self._send(rocat.message.Envelope.for_ask(m, sender=sender)) 69 | 70 | def ask(self, m, *, sender=None, timeout=None): 71 | raise NotImplementedError('You cannot ask back to ask request') 72 | 73 | def error(self, e): 74 | self._send(rocat.message.Envelope.for_error(e)) 75 | 76 | 77 | def _guess_current_sender(): 78 | current_ctx = rocat.actor.ActorContext.current() 79 | if current_ctx is not None: 80 | return current_ctx.sender 81 | 82 | 83 | def _guess_default_timeout(): 84 | current_ctx = rocat.actor.ActorContext.current() 85 | if current_ctx is not None: 86 | return current_ctx.default_timeout or -1 87 | return -1 88 | -------------------------------------------------------------------------------- /rocat/role.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | import rocat 4 | import rocat.actor 5 | import rocat.finder 6 | import rocat.globals 7 | import rocat.ref 8 | 9 | _roles = {} 10 | 11 | 12 | def find_role(role_name): 13 | return _roles.get(role_name) 14 | 15 | 16 | class BaseActorRole(object): 17 | """ActorRole is a blueprint of actor. 18 | You can define a lifecycle hook of the actor, and how you'd react to the 19 | incoming messages by implementing resolve_action""" 20 | 21 | def __init__(self, name, default_timeout=None): 22 | assert name not in _roles, f'Role name "{name}" is already used' 23 | _roles[name] = self 24 | self._name = name 25 | self._default_timeout = default_timeout 26 | self._hooks = {} 27 | 28 | @property 29 | def name(self): 30 | """Unique name of this actor role""" 31 | return self._name 32 | 33 | @property 34 | def default_timeout(self): 35 | """Default timeout seconds when asking to other actors. 36 | None means no timeout is used and wait forever""" 37 | return self._default_timeout 38 | 39 | @property 40 | def hooks(self): 41 | """Return registered hooks""" 42 | return self._hooks 43 | 44 | def _hook_decorator(self, name): 45 | def deco(f): 46 | self._hooks[name] = f 47 | return f 48 | return deco 49 | 50 | def on_created(self): 51 | """Register on_created lifecycle hook""" 52 | return self._hook_decorator('on_created') 53 | 54 | def on_exception(self): 55 | """Register on_exception lifecycle hook""" 56 | return self._hook_decorator('on_exception') 57 | 58 | def before_die(self): 59 | """Register before_die lifecycle hook""" 60 | return self._hook_decorator('before_die') 61 | 62 | def resolve_action(self, m): 63 | raise NotImplementedError 64 | 65 | def create(self, props=None, *, name=None, executor=None) -> rocat.ref.BaseActorRef: 66 | if props is None: 67 | props = {} 68 | if name is None: 69 | name = str(uuid.uuid4()) 70 | if executor is None: 71 | executor = rocat.globals.g.executor 72 | actor = rocat.actor.Actor(self, props, name=name, loop=executor.loop) 73 | actor.start() 74 | return rocat.finder.register(self, actor) 75 | 76 | 77 | class DictFieldRole(BaseActorRole): 78 | def __init__(self, name, field='type', **kwargs): 79 | super().__init__(name, **kwargs) 80 | self._field = field 81 | self._routes = {} 82 | self._default_route = None 83 | 84 | def route(self, field_value: str): 85 | def deco(f): 86 | self._routes[field_value] = f 87 | return f 88 | return deco 89 | 90 | def default_route(self, f): 91 | self._default_route = f 92 | return f 93 | 94 | def resolve_action(self, m): 95 | if isinstance(m, dict): 96 | if self._field in m: 97 | route = m[self._field] 98 | if route in self._routes: 99 | return self._routes[route] 100 | return self._default_route 101 | -------------------------------------------------------------------------------- /rocat/actor.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | from typing import Optional 4 | 5 | import rocat.message 6 | import rocat.role 7 | import rocat.ref 8 | import rocat.finder 9 | 10 | 11 | class ActorContext(object): 12 | contexts = {} 13 | 14 | @classmethod 15 | def current(cls) -> 'ActorContext': 16 | return cls.contexts.get(asyncio.Task.current_task()) 17 | 18 | def __init__(self, actor: Actor, envel: Optional[rocat.message.Envelope]): 19 | self._actor = actor 20 | self._envel = envel 21 | self._task = None 22 | 23 | def __enter__(self): 24 | self._task = asyncio.Task.current_task() 25 | self.contexts[self._task] = self 26 | self._actor.contexts[self._task] = self 27 | return self 28 | 29 | def __exit__(self, exc_type, exc_val, exc_tb): 30 | self.contexts.pop(self._task) 31 | self._actor.contexts.pop(self._task) 32 | 33 | @property 34 | def sender(self): 35 | if self._envel is not None: 36 | return self._envel.sender 37 | 38 | @property 39 | def p(self): 40 | return self._actor.props 41 | 42 | @property 43 | def name(self): 44 | return self._actor.name 45 | 46 | @property 47 | def ref(self): 48 | return self._actor.ref 49 | 50 | @property 51 | def default_timeout(self): 52 | return self._actor.default_timeout 53 | 54 | 55 | class Actor(object): 56 | def __init__(self, role, props, *, name, loop): 57 | self._role = role 58 | self._props = props 59 | self._name = name 60 | self._loop = loop 61 | self._q = asyncio.Queue(loop=loop) 62 | self._contexts = {} 63 | self._logger = logging.Logger(repr(self)) 64 | 65 | def __repr__(self): 66 | return f'{self._role.name}/{self.name}' 67 | 68 | @property 69 | def props(self): 70 | return self._props 71 | 72 | @property 73 | def contexts(self): 74 | return self._contexts 75 | 76 | @property 77 | def name(self): 78 | return self._name 79 | 80 | @property 81 | def ref(self): 82 | return rocat.finder.find(self._role, name=self._name) 83 | 84 | @property 85 | def default_timeout(self): 86 | return self._role.default_timeout 87 | 88 | def start(self): 89 | asyncio.ensure_future(self._main(), loop=self._loop) 90 | 91 | async def _run_hook(self, name, *args): 92 | ctx = ActorContext.current() 93 | if name in self._role.hooks: 94 | ret = self._role.hooks[name](ctx, *args) 95 | if asyncio.iscoroutine(ret): 96 | await ret 97 | 98 | async def _main(self): 99 | with ActorContext(self, None) as ctx: 100 | self._run_hook('on_created') 101 | while True: 102 | envel = await self._q.get() 103 | assert isinstance(envel, rocat.message.Envelope) 104 | if envel.type == rocat.message.MsgType.TERMINAL: 105 | break 106 | asyncio.ensure_future(self._handle_envel(envel), loop=self._loop) 107 | with ActorContext(self, None) as ctx: 108 | self._run_hook('before_die') 109 | 110 | async def _handle_envel(self, envel): 111 | with ActorContext(self, envel) as ctx: 112 | action = self._role.resolve_action(envel.msg) 113 | if action is not None: 114 | try: 115 | ret = action(ctx, envel.msg) 116 | if asyncio.iscoroutine(ret): 117 | await ret 118 | except Exception as e: 119 | self._logger.exception(e) 120 | if envel.need_reply: 121 | ctx.sender.error(e) 122 | self._run_hook('on_exception', e) 123 | else: 124 | self._logger.error(f'No handler for {repr(envel.msg)}') 125 | 126 | def create_ref(self): 127 | return rocat.ref.LocalActorRef(self._q, self._loop) 128 | --------------------------------------------------------------------------------