1
Fork 0
mirror of https://github.com/RYGhub/royalnet.git synced 2024-11-22 19:14:20 +00:00

📔 Document engineer module

This commit is contained in:
Steffo 2021-04-07 13:27:25 +02:00
parent d9c5545033
commit 74a376e65d
Signed by: steffo
GPG key ID: 6965406171929D01
16 changed files with 335 additions and 205 deletions

View file

@ -7,7 +7,21 @@
``bullet`` ``bullet``
---------- ----------
.. automodule:: royalnet.engineer.gunpowder .. automodule:: royalnet.engineer.bullet
``contents``
~~~~~~~~~~~~
.. automodule:: royalnet.engineer.bullet.contents
:imported-members:
``projectiles``
~~~~~~~~~~~~~~~
.. automodule:: royalnet.engineer.bullet.projectiles
:imported-members:
``command`` ``command``

View file

@ -14,6 +14,7 @@
# import sys # import sys
# sys.path.insert(0, os.path.abspath('.')) # sys.path.insert(0, os.path.abspath('.'))
import pkg_resources
# -- Project information ----------------------------------------------------- # -- Project information -----------------------------------------------------
@ -23,7 +24,7 @@ copyright = '2020, Stefano Pigozzi'
author = 'Stefano Pigozzi' author = 'Stefano Pigozzi'
# The full version, including alpha/beta/rc tags # The full version, including alpha/beta/rc tags
release = '6.0.0a12' release = pkg_resources.get_distribution("royalnet").version
# -- General configuration --------------------------------------------------- # -- General configuration ---------------------------------------------------
@ -69,6 +70,7 @@ html_static_path = ['_static']
intersphinx_mapping = { intersphinx_mapping = {
"python": ("https://docs.python.org/3.8", None), "python": ("https://docs.python.org/3.8", None),
"sqlalchemy": ("https://docs.sqlalchemy.org/en/13/", None), "sqlalchemy": ("https://docs.sqlalchemy.org/en/13/", None),
"async_property": ("https://async-property.readthedocs.io/en/latest/", None),
} }

View file

@ -1,20 +1,5 @@
""" """
Casings are parts of the data model that :mod:`royalnet.engineer` uses to build a common interface between This module contains the base :class:`.Casing` class.
different applications (implemented by individual *PDAs*).
They exclusively use coroutine functions to access data, as it may be required to fetch it from a remote location before
it is available.
**All** coroutine functions can have three different results:
- :exc:`.exc.CasingException` is raised, meaning that something went wrong during the data retrieval.
- :exc:`.exc.NotSupportedError` is raised, meaning that the frontend does not support the feature the requested data
is about (asking for :meth:`.Message.reply_to` in an IRC frontend, for example).
- :data:`None` is returned, meaning that there is no data in that field (if a message is not a reply to anything,
:meth:`Message.reply_to` will be :data:`None`.
- The data is returned.
To instantiate a new :class:`Bullet` from a bullet, you should use the methods of :attr:`.Bullet.mag`.
""" """
from __future__ import annotations from __future__ import annotations
@ -24,7 +9,23 @@ import abc
class Casing(metaclass=abc.ABCMeta): class Casing(metaclass=abc.ABCMeta):
""" """
The abstract base class for :mod:`~royalnet.engineer.casing` models. :class:`.Casing`\\ s are parts of the data model that :mod:`royalnet.engineer` uses to build a common interface
between different applications (*PDA implementations*).
They use :func:`~async_property.async_property` to represent data, as it may be required to fetch it from a remote
location before it is available.
**All** their methods can have three different results:
- :exc:`.exc.CasingException` is raised, meaning that something went wrong during the data retrieval.
- :exc:`.exc.NotSupportedError` is raised, meaning that the frontend does not support the feature the requested
data is about (asking for :meth:`~royalnet.engineer.bullet.contents.message.Message.reply_to` in an SMS
implementation, for example).
- :data:`None` is returned, meaning that there is no data in that field (if a message is not a reply to anything,
:meth:`Message.reply_to` will be :data:`None`.
- The data is returned.
""" """
def __init__(self): def __init__(self):
@ -35,6 +36,6 @@ class Casing(metaclass=abc.ABCMeta):
@abc.abstractmethod @abc.abstractmethod
def __hash__(self) -> int: def __hash__(self) -> int:
""" """
:return: A value that uniquely identifies the object in this Python interpreter process. :return: A :class:`int` value that uniquely identifies the object in this Python interpreter process.
""" """
raise NotImplementedError() raise NotImplementedError()

View file

@ -1,5 +1,5 @@
""" """
The exceptions which can happen in bullets. This modules contains the exceptions which can happen in bullets.
""" """
from .. import exc from .. import exc
@ -19,11 +19,11 @@ class FrontendError(BulletException):
class NotSupportedError(FrontendError, NotImplementedError): class NotSupportedError(FrontendError, NotImplementedError):
""" """
The requested property isn't available on the current frontend. The requested property isn't available on the current implementation.
""" """
class ForbiddenError(FrontendError): class ForbiddenError(FrontendError):
""" """
The bot user does not have sufficient permissions to perform a frontend operation. The bot does not have sufficient permissions to perform an operation.
""" """

View file

@ -1,3 +1,7 @@
"""
This module contains the base :class:`.Projectile` class.
"""
from __future__ import annotations from __future__ import annotations
import abc import abc

View file

@ -1,3 +1,8 @@
"""
This module contains the projectiles related to messages: :class:`.MessageReceived`, :class:`MessageEdited` and
:class:`MessageDeleted`.
"""
from __future__ import annotations from __future__ import annotations
from ._imports import * from ._imports import *

View file

@ -1,3 +1,7 @@
"""
This module contains the :class:`.Reaction` projectile.
"""
from __future__ import annotations from __future__ import annotations
from ._imports import * from ._imports import *

View file

@ -1,3 +1,8 @@
"""
This module contains the projectiles related to user actions, such as :class:`.UserJoined`, :class:`.UserLeft` and
:class:`.UserUpdate`.
"""
from __future__ import annotations from __future__ import annotations
from ._imports import * from ._imports import *

View file

@ -1,90 +1,129 @@
# Module docstring """
This module contains the :class:`.FullCommand` and :class:`.PartialCommand` classes, and all
:class:`.CommandException`\\ s.
""" """
"""
# Special imports
from __future__ import annotations from __future__ import annotations
import royalnet.royaltyping as t import royalnet.royaltyping as t
# External imports
import logging import logging
import re import re
# Internal imports
from . import conversation as c from . import conversation as c
from . import sentry as s from . import sentry as s
from . import bullet as b from . import bullet as b
from . import teleporter as tp from . import teleporter as tp
from . import exc
# Special global objects
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
# Code class CommandException(exc.EngineerException):
class Command(c.Conversation):
""" """
A Command is a :class:`~.conversation.Conversation` which is started by a :class:`~.bullet.Message` having a text The base :class:`Exception` of the :mod:`royalnet.engineer.command`\\ module.
matching the :attr:`.pattern`; the named capture groups of the pattern are then passed as keyword arguments to
:attr:`.f`.
.. info:: Usually you don't directly instantiate commands, you define :class:`.PartialCommand`\\ s in packs which
are later completed by a runner.
""" """
def __init__(self, f: c.ConversationProtocol, *, names: t.List[str] = None, pattern: re.Pattern, lock: bool = True):
class CommandNameException(CommandException):
"""
An :class:`Exception` raised because the :attr:`.FullCommand.names` were invalid.
"""
class MissingNameError(ValueError, CommandNameException):
"""
:class:`.FullCommand`\\ s must have at least one name, but no names were passed.
"""
class NotAlphanumericNameError(ValueError, CommandNameException):
"""
All :attr:`.FullCommand.names` should be alphanumeric (a-z 0-9).
.. seealso:: :meth:`str.isalnum`
"""
class FullCommand(c.Conversation):
"""
A :class:`.FullCommand` is a :class:`~royalnet.engineer.conversation.Conversation` which is started by a
:class:`~royalnet.engineer.projectiles.Projectile` having a text matching the :attr:`.pattern`;
the named capture groups of the pattern are then passed as teleported keyword arguments to :attr:`.f`.
.. note:: If you're creating a command pack, you shouldn't instantiate directly :class:`.FullCommand` objects, but
you should use :class:`.PartialCommand`\\ s instead.
"""
def __init__(self, f: c.ConversationProtocol, *, names: t.List[str], pattern: re.Pattern, lock: bool = True):
""" """
Create a new :class:`.Command` . Instantiate a new :class:`.FullCommand`\\ .
""" """
log.debug(f"Teleporting function: {f!r}")
self.plain_f = f
"""
A reference to the unteleported function :attr:`.f`\\ .
"""
teleported = tp.Teleporter(f, validate_input=True, validate_output=False) teleported = tp.Teleporter(f, validate_input=True, validate_output=False)
log.debug(f"Initializing Conversation with: {teleported!r}")
super().__init__(teleported) super().__init__(teleported)
self.names: t.List[str] = names if names else [] if len(names) < 1:
raise MissingNameError(f"Passed 'names' list is empty", names)
self.names: t.List[str] = names
""" """
The names of the command, as a :class:`list` of :class:`str`. The names of the command, as a :class:`list` of :class:`str`.
The first element of the list is the "main" name, and will be displayed in help messages. The first element of the list is the primary :attr:`.name`, and will be displayed in help messages.
""" """
self.pattern: re.Pattern = pattern self.pattern: re.Pattern = pattern
""" """
The pattern that should be matched by the command. The pattern that should be matched by the text of the first
:class:`~royalnet.engineer.bullet.projectiles.message.MessageReceived` by the
:class:`~royalnet.engineer.conversation.Conversation`\\ .
""" """
self.lock: bool = lock self.lock: bool = lock
""" """
If calling this command should :meth:`~royalnet.engineer.dispenser.Dispenser.lock` the dispenser. If calling this command should :meth:`~royalnet.engineer.dispenser.Dispenser.lock` the calling dispenser.
Locked dispensers cannot run any other :class:`~royalnet.engineer.conversation.Conversation`\\ s but the one
which locked it.
""" """
def name(self): def name(self):
""" """
:return: The main name of the Command. :return: The primary name of the :class:`.FullCommand`.
""" """
return self.names[0] return self.names[0]
def aliases(self): def aliases(self):
""" """
:return: The aliases (non-main names) of the Command. :return: The secondary names of the :class:`.FullCommand`.
""" """
return self.names[1:] return self.names[1:]
def __repr__(self): def __repr__(self):
if (nc := len(self.names)) > 1: plus = f" + {nc-1} other names" if (nc := len(self.names)) > 1 else ""
plus = f" + {nc-1} other names"
else:
plus = ""
return f"<{self.__class__.__qualname__}: {self.name()!r}{plus}>" return f"<{self.__class__.__qualname__}: {self.name()!r}{plus}>"
async def run(self, *, _sentry: s.Sentry, **base_kwargs) -> t.Optional[c.ConversationProtocol]: async def run(self, *, _sentry: s.Sentry, **base_kwargs) -> t.Optional[c.ConversationProtocol]:
"""
Run the command as if it was a conversation.
:param _sentry: The :class:`~royalnet.engineer.sentry.Sentry` to use for the conversation.
:param base_kwargs: Keyword arguments to pass to the wrapped function :attr:`.f` .
:return: The result of the wrapped function :attr:`.f` , or :data:`None` if the first projectile received does
not satisfy the requirements of the command.
"""
log.debug(f"Awaiting a bullet...") log.debug(f"Awaiting a bullet...")
projectile: b.Projectile = await _sentry projectile: b.Projectile = await _sentry
log.debug(f"Received: {projectile!r}") log.debug(f"Received: {projectile!r}")
log.debug(f"Ensuring a message was received: {projectile!r}") log.debug(f"Ensuring the projectile is a MessageReceived: {projectile!r}")
if not isinstance(projectile, b.MessageReceived): if not isinstance(projectile, b.MessageReceived):
log.debug(f"Returning: {projectile!r} is not a message") log.debug(f"Returning: {projectile!r} is not a message")
return return
@ -112,7 +151,7 @@ class Command(c.Conversation):
with _sentry.dispenser().lock(self): with _sentry.dispenser().lock(self):
log.debug(f"Passing args to function: {message_kwargs!r}") log.debug(f"Passing args to function: {message_kwargs!r}")
return await super().run( return await super().__call__(
_sentry=_sentry, _sentry=_sentry,
_proj=projectile, _proj=projectile,
_msg=msg, _msg=msg,
@ -123,7 +162,7 @@ class Command(c.Conversation):
else: else:
log.debug(f"Passing args to function: {message_kwargs!r}") log.debug(f"Passing args to function: {message_kwargs!r}")
return await super().run( return await super().__call__(
_sentry=_sentry, _sentry=_sentry,
_proj=projectile, _proj=projectile,
_msg=msg, _msg=msg,
@ -143,21 +182,28 @@ class Command(c.Conversation):
class PartialCommand: class PartialCommand:
""" """
A PartialCommand is a :class:`.Command` having an unknown :attr:`~.Command.name` and :attr:`~.Command.pattern` at :class:`.PartialCommand` is a class meant for **building command packs**.
the moment of creation.
They can specified later using :meth:`.complete`. It provides mostly the same interface as a :class:`.FullCommand`, but some fields are unknown until the command is
registered in a PDA.
The missing fields currently are:
- :attr:`~.FullCommand.name`
- :attr:`~.FullCommand.pattern`
At the moment of registration in a PDA, :meth:`.complete` is called, converting it in a :class:`.FullCommand`.
""" """
def __init__(self, f: c.ConversationProtocol, syntax: str, lock: bool = True): def __init__(self, f: c.ConversationProtocol, syntax: str, lock: bool = True):
""" """
Create a new :class:`.PartialCommand` . Instantiate a new :class:`.PartialCommand`\\ .
.. seealso:: :meth:`.new` .. seealso:: The corresponding decorator form, :meth:`.new`.
""" """
self.f: c.ConversationProtocol = f self.f: c.ConversationProtocol = f
""" """
The function to pass to :attr:`.c.Conversation.f`. The function to pass to be called when the command is executed.
""" """
self.syntax: str = syntax self.syntax: str = syntax
@ -173,11 +219,20 @@ class PartialCommand:
@classmethod @classmethod
def new(cls, *args, **kwargs): def new(cls, *args, **kwargs):
""" """
A decorator that instantiates a new :class:`Conversation` object using the decorated function. Decorator factory for creating new :class:`.PartialCommand` with the decorator syntax::
:return: The created :class:`Conversation` object. >>> import royalnet.engineer as engi
It can still be called in the same way as the previous function!
>>> @PartialCommand.new(syntax="")
... async def ping(*, _sentry: engi.Sentry, _msg: engi.Message, **__):
... await _msg.reply(text="🏓 Pong!")
>>> ping
<PartialCommand <function ping at ...>>
:return: The decorator to wrap around the :attr:`.f` function.
""" """
def decorator(f: c.ConversationProtocol): def decorator(f: c.ConversationProtocol):
partial_command = cls(f=f, *args, **kwargs) partial_command = cls(f=f, *args, **kwargs)
log.debug(f"Created: {partial_command!r}") log.debug(f"Created: {partial_command!r}")
@ -185,36 +240,42 @@ class PartialCommand:
return decorator return decorator
def complete(self, *, names: t.List[str] = None, pattern: str) -> Command: def complete(self, *, names: t.List[str], pattern: str) -> FullCommand:
""" """
Complete the :class:`.PartialCommand` with names and a pattern, creating a :class:`Command` object. Complete the :class:`.PartialCommand` with the missing fields, creating a :class:`.FullCommand` object.
:param names: The names of the command. See :attr:`.Command.names` . :param names: The :attr:`~.FullCommand.names` that the :class:`.FullCommand` should have.
:param pattern: The pattern to add to the PartialCommand. It is first :meth:`~str.format`\\ ted with the keyword :param pattern: The pattern to add to the :class:`.PartialCommand`\\ .
arguments ``name`` and ``syntax`` and later :func:`~re.compile`\\ d with the It is first :meth:`~str.format`\\ ted with the keyword arguments ``name`` and ``syntax``
:data:`re.IGNORECASE` flag. and later :func:`~re.compile`\\ d with the :data:`re.IGNORECASE` flag.
:return: The complete :class:`Command`.
:return: The completed :class:`.FullCommand`.
:raises .MissingNameError: If no :attr:`.FullCommand.names` are specified.
""" """
if names is None:
names = []
if len(names) < 1: if len(names) < 1:
raise ValueError("Commands must have at least one name.") raise MissingNameError(f"Passed 'names' list is empty", names)
for name in names: for name in names:
if not name.isalnum(): if not name.isalnum():
raise ValueError(f"Name is not alphanumeric: {name!r}") raise NotAlphanumericNameError(f"Name is not alphanumeric", name)
log.debug(f"")
name_regex = f"(?:{'|'.join(names)})" name_regex = f"(?:{'|'.join(names)})"
log.debug(f"Completed pattern: {name_regex!r}") log.debug(f"Completed pattern: {name_regex!r}")
pattern: re.Pattern = re.compile(pattern.format(name=name_regex, syntax=self.syntax), re.IGNORECASE) pattern: re.Pattern = re.compile(pattern.format(name=name_regex, syntax=self.syntax), re.IGNORECASE)
return Command(f=self.f, names=names, pattern=pattern, lock=self.lock) return FullCommand(f=self.f, names=names, pattern=pattern, lock=self.lock)
def __repr__(self): def __repr__(self):
return f"<{self.__class__.__qualname__} {self.f!r}>" return f"<{self.__class__.__qualname__} {self.f!r}>"
# Objects exported by this module
__all__ = ( __all__ = (
"Command", "CommandException",
"CommandNameException",
"FullCommand",
"MissingNameError",
"NotAlphanumericNameError",
"PartialCommand", "PartialCommand",
) )

View file

@ -1,28 +1,21 @@
# Module docstring
""" """
Conversations are wrapper classes that can be applied to functions which await This module contains :class:`.ConversationProtocol`, the typing stub for conversation functions, and
:class:`~royalnet.engineer.bullet.Bullet`\\ s from a :class:`~royalnet.engineer.sentry.Sentry`. :class:`.Conversation`, a decorator for functions which should help in debugging conversations.
""" """
# Special imports
from __future__ import annotations from __future__ import annotations
import royalnet.royaltyping as t import royalnet.royaltyping as t
# External imports
import logging import logging
# Internal imports
from . import sentry as s from . import sentry as s
# Special global objects
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
# Code
class ConversationProtocol(t.Protocol): class ConversationProtocol(t.Protocol):
""" """
Typing annotation for Conversation functions. Typing stub for :class:`.Conversation`\\ -compatible functions.
""" """
def __call__(self, *, _sentry: s.Sentry, **kwargs) -> t.Awaitable[t.Optional[ConversationProtocol]]: def __call__(self, *, _sentry: s.Sentry, **kwargs) -> t.Awaitable[t.Optional[ConversationProtocol]]:
... ...
@ -30,15 +23,20 @@ class ConversationProtocol(t.Protocol):
class Conversation: class Conversation:
""" """
The base class for Conversations. It does nothing on its own except providing better debug information. :class:`.Conversation`\\ s are functions which await
:class:`~royalnet.engineer.bullet.projectiles._base.Projectile`\\ s incoming from a
:class:`~royalnet.engineer.sentry.Sentry` .
This class is callable ( :meth:`.__call__` ), and can be used in place of plain functions to have better debug
information.
""" """
def __init__(self, f: ConversationProtocol, *_, **__): def __init__(self, f: ConversationProtocol, *_, **__):
self.f: ConversationProtocol = f self.f: ConversationProtocol = f
""" """
The function that was wrapped by this conversation. The function that is wrapped by this class.
It can be called by calling this object as it was a function:: It can be called by calling this object as if it was a function::
my_conv(_sentry=_sentry, _msg=msg) my_conv(_sentry=_sentry, _msg=msg)
""" """
@ -46,7 +44,14 @@ class Conversation:
@classmethod @classmethod
def new(cls, *args, **kwargs): def new(cls, *args, **kwargs):
""" """
A decorator that instantiates a new :class:`Conversation` object using the decorated function. Decorator factory for creating new :class:`.PartialCommand` with the decorator syntax::
>>> @Conversation.new()
... def my_conv(*, _sentry: s.Sentry, **__):
... pass
>>> my_conv
<Conversation #1234>
:return: The created :class:`Conversation` object. :return: The created :class:`Conversation` object.
It can still be called in the same way as the previous function! It can still be called in the same way as the previous function!
@ -59,16 +64,7 @@ class Conversation:
def __call__(self, *, _sentry: s.Sentry, **kwargs) -> t.Awaitable[t.Optional[ConversationProtocol]]: def __call__(self, *, _sentry: s.Sentry, **kwargs) -> t.Awaitable[t.Optional[ConversationProtocol]]:
log.debug(f"Calling: {self!r}") log.debug(f"Calling: {self!r}")
return self.run(_sentry=_sentry, **kwargs) return self.f(_sentry=_sentry, **kwargs)
async def run(self, *, _sentry: s.Sentry, **kwargs) -> t.Optional[ConversationProtocol]:
"""
The coroutine function that generates the coroutines returned by :meth:`.__call__` .
It is a :class:`Conversation` itself.
"""
log.debug(f"Running: {self!r}")
return await self.f(_sentry=_sentry, **kwargs)
def __repr__(self): def __repr__(self):
return f"<{self.__class__.__qualname__} #{id(self)}>" return f"<{self.__class__.__qualname__} #{id(self)}>"

View file

@ -1,3 +1,8 @@
"""
This module contains the :class:`.Discard` special exception.
"""
class Discard(BaseException): class Discard(BaseException):
""" """
A special exception which should be raised by :class:`~royalnet.engineer.wrench.Wrench`\\ es if a certain object A special exception which should be raised by :class:`~royalnet.engineer.wrench.Wrench`\\ es if a certain object

View file

@ -1,5 +1,5 @@
""" """
Dispensers instantiate sentries and dispatch events in bulk to the whole group. This module contains the :class:`.Dispenser` class.
""" """
from __future__ import annotations from __future__ import annotations
@ -10,13 +10,36 @@ import contextlib
from .sentry import SentrySource from .sentry import SentrySource
from .conversation import Conversation from .conversation import Conversation
from .exc import LockedDispenserError from .exc import EngineerException
from .bullet.projectiles import Projectile from .bullet.projectiles import Projectile
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
class DispenserException(EngineerException):
"""
The base class for errors in :mod:`royalnet.engineer.dispenser`\\ .
"""
class LockedDispenserError(DispenserException):
"""
The :class:`.Dispenser` couldn't start a new :class:`~royalnet.engineer.conversation.Conversation` as it is
currently :attr:`.Dispenser.lock`\\ ed.
"""
def __init__(self, locked_by, *args):
super().__init__(*args)
self.locked_by = locked_by
class Dispenser: class Dispenser:
"""
A :class:`.Dispenser` is an object which instantiates multiple :class:`~royalnet.engineer.sentry.Sentry` and
multiplexes and distributes incoming :class:`~royalnet.engineer.bullet.projectiles._base.Projectile`\\ s to them.
They usually represent a single "conversation channel" with the bot: either a chat channel, or an user.
"""
def __init__(self): def __init__(self):
self.sentries: t.List[SentrySource] = [] self.sentries: t.List[SentrySource] = []
""" """
@ -32,47 +55,48 @@ class Dispenser:
async def put(self, item: Projectile) -> None: async def put(self, item: Projectile) -> None:
""" """
Insert a new projectile in the queues of all the running sentries. Insert a new :class:`~royalnet.engineer.bullet.projectiles._base.Projectile` in the queues of all the
running :attr:`.sentries`.
:param item: The projectile to insert. :param item: The :class:`~royalnet.engineer.bullet.projectiles._base.Projectile` to insert.
""" """
log.debug(f"Putting {item}...") log.debug(f"Putting {item!r}...")
for sentry in self.sentries: for sentry in self.sentries:
await sentry.put(item) await sentry.put(item)
@contextlib.contextmanager @contextlib.contextmanager
def sentry(self, *args, **kwargs): def sentry(self, *args, **kwargs):
""" """
A context manager which creates a :class:`.SentrySource` and keeps it in :attr:`.sentries` while it is being A :func:`~contextlib.contextmanager` which creates a :class:`.SentrySource` and keeps it in :attr:`.sentries`
used. while it is being used.
""" """
log.debug("Creating a new SentrySource...") log.debug("Creating a new SentrySource...")
sentry = SentrySource(dispenser=self, *args, **kwargs) sentry = SentrySource(dispenser=self, *args, **kwargs)
log.debug(f"Adding: {sentry}") log.debug(f"Adding: {sentry!r}")
self.sentries.append(sentry) self.sentries.append(sentry)
log.debug(f"Yielding: {sentry}") log.debug(f"Yielding: {sentry!r}")
yield sentry yield sentry
log.debug(f"Removing from the sentries list: {sentry}") log.debug(f"Removing from the sentries list: {sentry!r}")
self.sentries.remove(sentry) self.sentries.remove(sentry)
async def run(self, conv: Conversation, **kwargs) -> None: async def run(self, conv: Conversation, **kwargs) -> None:
""" """
Run the passed conversation. Run a :class:`~royalnet.engineer.conversation.Conversation`\\ .
:param conv: The conversation to run. :param conv: The :class:`~royalnet.engineer.conversation.Conversation` to run.
:raises .LockedDispenserError: If the dispenser is currently :attr:`.locked_by` a :class:`.Conversation`. :raises .LockedDispenserError: If the dispenser is currently :attr:`.locked_by` a :class:`.Conversation`.
""" """
log.debug(f"Trying to run: {conv!r}") log.debug(f"Trying to run: {conv!r}")
if self._locked_by: if self._locked_by:
log.debug(f"Dispenser is locked by {self._locked_by!r}, refusing to run {conv!r}") log.debug(f"Dispenser is locked by {self._locked_by!r}, refusing to run {conv!r}")
raise LockedDispenserError(self._locked_by, f"The Dispenser is currently locked by {self._locked_by!r} and " raise LockedDispenserError(
f"cannot start new conversations.") f"The Dispenser is currently locked and cannot start any new Conversation.", self._locked_by)
log.debug(f"Running: {conv}") log.debug(f"Running: {conv!r}")
with self.sentry() as sentry: with self.sentry() as sentry:
state = conv(_sentry=sentry, **kwargs) state = conv(_sentry=sentry, **kwargs)
@ -83,10 +107,10 @@ class Dispenser:
@contextlib.contextmanager @contextlib.contextmanager
def lock(self, conv: Conversation): def lock(self, conv: Conversation):
""" """
Lock the dispenser while this :func:`~contextlib.contextmanager` is in scope. Lock the :class:`.Dispenser` while this :func:`~contextlib.contextmanager` is in scope.
A locked dispenser will refuse to :meth:`.run` any new conversations, raising :exc:`.exc.LockedDispenserError` A locked :class:`.Dispenser` will refuse to :meth:`.run` any new conversations,
instead. raising :exc:`.LockedDispenserError` instead.
:param conv: The conversation that requested the lock. :param conv: The conversation that requested the lock.
@ -104,4 +128,6 @@ class Dispenser:
__all__ = ( __all__ = (
"Dispenser", "Dispenser",
"DispenserException",
"LockedDispenserError",
) )

View file

@ -1,4 +1,6 @@
import pydantic """
This module contains the base :class:`.EngineerException` .
"""
class EngineerException(Exception): class EngineerException(Exception):
@ -7,58 +9,6 @@ class EngineerException(Exception):
""" """
class WrenchException(EngineerException):
"""
The base class for errors in :mod:`royalnet.engineer.wrench`.
"""
class DeliberateException(WrenchException):
"""
This exception was deliberately raised by :class:`royalnet.engineer.wrench.ErrorAll`.
"""
class TeleporterError(EngineerException, pydantic.ValidationError):
"""
The base class for errors in :mod:`royalnet.engineer.teleporter`.
"""
class InTeleporterError(TeleporterError):
"""
The input parameters validation failed.
"""
class OutTeleporterError(TeleporterError):
"""
The return value validation failed.
"""
class DispenserException(EngineerException):
"""
The base class for errors in :mod:`royalnet.engineer.dispenser`.
"""
class LockedDispenserError(DispenserException):
"""
The dispenser couldn't start a new conversation as it is currently locked.
"""
def __init__(self, locked_by, *args):
super().__init__(*args)
self.locked_by = locked_by
__all__ = ( __all__ = (
"EngineerException", "EngineerException",
"WrenchException",
"DeliberateException",
"TeleporterError",
"InTeleporterError",
"OutTeleporterError",
"DispenserException",
"LockedDispenserError",
) )

View file

@ -1,5 +1,6 @@
""" """
Sentries are asynchronous receivers for events (usually :class:`bullet.Projectile`) incoming from Dispensers. This module contains the :class:`.Sentry` class and its descendents :class:`SentryFilter` and :class:`SentrySource`\\ .
They support event filtering through Wrenches and coroutine functions. They support event filtering through Wrenches and coroutine functions.
""" """
@ -22,7 +23,13 @@ log = logging.getLogger(__name__)
class Sentry(metaclass=abc.ABCMeta): class Sentry(metaclass=abc.ABCMeta):
""" """
The abstract object representing a node of the pipeline. A :class:`.Sentry` is an asynchronous receiver for :class:`~royalnet.engineer.bullet.projectiles._base.Projectile`
incoming from :class:`~royalnet.engineer.dispenser.Dispenser`\\ s.
Sentries can be chained together to form a filtering pipeline, starting with a :class:`.SentrySource` followed by
zero or more :class:`.SentryFilter`\\ s.
This abstract base class represents a single node of the pipeline.
""" """
@abc.abstractmethod @abc.abstractmethod
@ -32,11 +39,14 @@ class Sentry(metaclass=abc.ABCMeta):
@abc.abstractmethod @abc.abstractmethod
def get_nowait(self): def get_nowait(self):
""" """
Try to get a single :class:`~.bullet.Projectile` from the pipeline, without blocking or handling discards. Try to get a single :class:`~royalnet.engineer.bullet.projectiles._base.Projectile` from the pipeline,
**without blocking** or **handling :class:`~royalnet.engineer.discard.Discard`\\ s** .
:return: The **returned** :class:`~royalnet.engineer.bullet.projectiles._base.Projectile`.
:return: The **returned** :class:`~.bullet.Projectile`.
:raises asyncio.QueueEmpty: If the queue is empty. :raises asyncio.QueueEmpty: If the queue is empty.
:raises .discard.Discard: If the object was **discarded** by the pipeline. :raises .discard.Discard: If the object was **:class:`~royalnet.engineer.discard.Discard`\\ ed** by
the pipeline.
:raises Exception: If an exception was **raised** in the pipeline. :raises Exception: If an exception was **raised** in the pipeline.
""" """
raise NotImplementedError() raise NotImplementedError()
@ -44,10 +54,12 @@ class Sentry(metaclass=abc.ABCMeta):
@abc.abstractmethod @abc.abstractmethod
async def get(self): async def get(self):
""" """
Try to get a single :class:`~.bullet.Projectile` from the pipeline, blocking until something is available, but Try to get a single :class:`~royalnet.engineer.bullet.projectiles._base.Projectile` from the pipeline,
without handling discards. **blocking** until something is available, but
**without handling :class:`~royalnet.engineer.discard.Discard`\\ s**.
:return: The **returned** :class:`~royalnet.engineer.bullet.projectiles._base.Projectile`.
:return: The **returned** :class:`~.bullet.Projectile`.
:raises .discard.Discard: If the object was **discarded** by the pipeline. :raises .discard.Discard: If the object was **discarded** by the pipeline.
:raises Exception: If an exception was **raised** in the pipeline. :raises Exception: If an exception was **raised** in the pipeline.
""" """
@ -55,10 +67,11 @@ class Sentry(metaclass=abc.ABCMeta):
async def wait(self): async def wait(self):
""" """
Try to get a single :class:`~.bullet.Projectile` from the pipeline, blocking until something is available and is Try to get a single :class:`~.bullet.Projectile` from the pipeline, **blocking** until something is available
not discarded. and is **not discarded**.
:return: The **returned** :class:`~.bullet.Projectile`. :return: The **returned** :class:`~.bullet.Projectile`.
:raises Exception: If an exception was **raised** in the pipeline. :raises Exception: If an exception was **raised** in the pipeline.
""" """
while True: while True:
@ -72,7 +85,7 @@ class Sentry(metaclass=abc.ABCMeta):
def __await__(self): def __await__(self):
""" """
Awaiting an object implementing :class:`.SentryInterface` corresponds to awaiting :meth:`.wait`. Awaiting an object implementing :class:`.Sentry` corresponds to awaiting :meth:`.wait`.
""" """
return self.get().__await__() return self.get().__await__()
@ -89,9 +102,11 @@ class Sentry(metaclass=abc.ABCMeta):
""" """
Chain a new filter to the pipeline. Chain a new filter to the pipeline.
:param wrench: The filter to add to the chain. It can either be a :class:`.wrench.Wrench`, or a coroutine :param wrench: The filter to add to the chain. It can either be a :class:`~royalnet.engineer.wrench.Wrench`,
function accepting a single object as parameter and returning the same or a different one. or a coroutine function accepting a single object as parameter and returning another one.
:return: A new :class:`.SentryFilter` which includes the filter. :return: The resulting :class:`.SentryFilter`\\ .
:raises TypeError: If the right side operator is neither a :class:`~royalnet.engineer.wrench.Wrench` or a
coroutine function.
.. seealso:: :meth:`.__or__` .. seealso:: :meth:`.__or__`
""" """
@ -106,8 +121,11 @@ class Sentry(metaclass=abc.ABCMeta):
.. code-block:: .. code-block::
await (sentry | wrench.Type(Message) | wrench.Sync(lambda o: o.text)) await (sentry | wrench.Type(engi.MessageReceived) | wrench.Lambda(lambda o: o.text))
:return: The resulting :class:`.SentryFilter`\\ .
:raises TypeError: If the right side operator is neither a :class:`~royalnet.engineer.wrench.Wrench` or a
coroutine function.
""" """
try: try:
return self.filter(other) return self.filter(other)
@ -117,9 +135,9 @@ class Sentry(metaclass=abc.ABCMeta):
@abc.abstractmethod @abc.abstractmethod
def dispenser(self) -> Dispenser: def dispenser(self) -> Dispenser:
""" """
Get the :class:`.Dispenser` that created this Sentry. Get the :class:`~royalnet.engineer.dispenser.Dispenser` that created this :class:`.Sentry`.
:return: The :class:`.Dispenser` object. :return: The :class:`~royalnet.engineer.dispenser.Dispenser` object.
""" """
raise NotImplementedError() raise NotImplementedError()

View file

@ -1,5 +1,5 @@
""" """
The teleporter uses :mod:`pydantic` to validate function parameters and return values. This module contains the :class:`.Teleporter` class and its exceptions.
""" """
from __future__ import annotations from __future__ import annotations
@ -15,7 +15,29 @@ Value = t.TypeVar("Value")
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
class TeleporterError(exc.EngineerException, pydantic.ValidationError):
"""
The base class for errors in :mod:`royalnet.engineer.teleporter`.
"""
class InTeleporterError(TeleporterError):
"""
The input parameters validation failed.
"""
class OutTeleporterError(TeleporterError):
"""
The return value validation failed.
"""
class Teleporter: class Teleporter:
"""
A :class:`.Teleporter` is a function wrapper which uses :mod:`pydantic` to perform type checking
"""
def __init__(self, def __init__(self,
f: t.Callable[..., t.Any], f: t.Callable[..., t.Any],
validate_input: bool = True, validate_input: bool = True,
@ -165,14 +187,14 @@ class Teleporter:
:param kwargs: The keyword arguments that should be passed to the model. :param kwargs: The keyword arguments that should be passed to the model.
:return: The created model. :return: The created model.
:raises .exc.InTeleporterError: If the kwargs fail the validation. :raises .InTeleporterError: If the kwargs fail the validation.
""" """
log.debug(f"Teleporting in: {kwargs!r}") log.debug(f"Teleporting in: {kwargs!r}")
try: try:
return self.InputModel(**kwargs) return self.InputModel(**kwargs)
except pydantic.ValidationError as e: except pydantic.ValidationError as e:
log.error(f"Teleport in failed: {e!r}") log.error(f"Teleport in failed: {e!r}")
raise exc.InTeleporterError(errors=e.raw_errors, model=e.model) raise InTeleporterError(errors=e.raw_errors, model=e.model)
def teleport_out(self, value: Value) -> pydantic.BaseModel: def teleport_out(self, value: Value) -> pydantic.BaseModel:
""" """
@ -180,14 +202,14 @@ class Teleporter:
:param value: The value that should be validated. :param value: The value that should be validated.
:return: The created model. :return: The created model.
:raises .exc.OutTeleporterError: If the value fails the validation. :raises .OutTeleporterError: If the value fails the validation.
""" """
log.debug(f"Teleporting out: {value!r}") log.debug(f"Teleporting out: {value!r}")
try: try:
return self.OutputModel(__root__=value) return self.OutputModel(__root__=value)
except pydantic.ValidationError as e: except pydantic.ValidationError as e:
log.error(f"Teleport out failed: {e!r}") log.error(f"Teleport out failed: {e!r}")
raise exc.OutTeleporterError(errors=e.raw_errors, model=e.model) raise OutTeleporterError(errors=e.raw_errors, model=e.model)
@staticmethod @staticmethod
def _split_kwargs(**kwargs) -> t.Tuple[t.Dict[str, t.Any], t.Dict[str, t.Any]]: def _split_kwargs(**kwargs) -> t.Tuple[t.Dict[str, t.Any], t.Dict[str, t.Any]]:
@ -212,7 +234,7 @@ class Teleporter:
def _run(self, **kwargs) -> t.Any: def _run(self, **kwargs) -> t.Any:
""" """
Run the teleporter synchronously. Run the :class:`.Teleporter` synchronously.
""" """
if self.InputModel: if self.InputModel:
log.debug("Validating input...") log.debug("Validating input...")
@ -226,7 +248,7 @@ class Teleporter:
async def _run_async(self, **kwargs) -> t.Awaitable[t.Any]: async def _run_async(self, **kwargs) -> t.Awaitable[t.Any]:
""" """
Run the teleporter asynchronously. Run the :class:`.Teleporter` asynchronously.
""" """
if self.InputModel: if self.InputModel:
log.debug("Validating input...") log.debug("Validating input...")
@ -246,5 +268,8 @@ class Teleporter:
__all__ = ( __all__ = (
"InTeleporterError",
"OutTeleporterError",
"Teleporter", "Teleporter",
"TeleporterError",
) )

View file

@ -12,6 +12,18 @@ from . import discard
from . import exc from . import exc
class WrenchException(exc.EngineerException):
"""
The base class for errors in :mod:`royalnet.engineer.wrench`.
"""
class DeliberateException(WrenchException):
"""
This exception was deliberately raised by :class:`.ErrorAll`.
"""
class Wrench(metaclass=abc.ABCMeta): class Wrench(metaclass=abc.ABCMeta):
""" """
The abstract base class for Wrenches. The abstract base class for Wrenches.
@ -67,7 +79,7 @@ class ErrorAll(Wrench):
""" """
async def filter(self, obj: t.Any) -> t.Any: async def filter(self, obj: t.Any) -> t.Any:
raise exc.DeliberateException("ErrorAll received an object") raise DeliberateException("ErrorAll received an object")
class CheckBase(Wrench, metaclass=abc.ABCMeta): class CheckBase(Wrench, metaclass=abc.ABCMeta):
@ -293,15 +305,17 @@ class Check(CheckBase):
__all__ = ( __all__ = (
"Wrench", "Check",
"CheckBase", "CheckBase",
"Type",
"StartsWith",
"EndsWith",
"Choice", "Choice",
"DeliberateException",
"EndsWith",
"Lambda",
"RegexCheck", "RegexCheck",
"RegexMatch", "RegexMatch",
"RegexReplace", "RegexReplace",
"Lambda", "StartsWith",
"Check", "Type",
"Wrench",
"WrenchException",
) )