diff --git a/docs/source/autodoc/engineer.rst b/docs/source/autodoc/engineer.rst index 7e5a6f0c..e09dc7d7 100644 --- a/docs/source/autodoc/engineer.rst +++ b/docs/source/autodoc/engineer.rst @@ -7,7 +7,21 @@ ``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`` diff --git a/docs/source/conf.py b/docs/source/conf.py index 2db0a871..f53f6f9c 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -14,6 +14,7 @@ # import sys # sys.path.insert(0, os.path.abspath('.')) +import pkg_resources # -- Project information ----------------------------------------------------- @@ -23,7 +24,7 @@ copyright = '2020, Stefano Pigozzi' author = 'Stefano Pigozzi' # The full version, including alpha/beta/rc tags -release = '6.0.0a12' +release = pkg_resources.get_distribution("royalnet").version # -- General configuration --------------------------------------------------- @@ -69,6 +70,7 @@ html_static_path = ['_static'] intersphinx_mapping = { "python": ("https://docs.python.org/3.8", None), "sqlalchemy": ("https://docs.sqlalchemy.org/en/13/", None), + "async_property": ("https://async-property.readthedocs.io/en/latest/", None), } diff --git a/royalnet/engineer/bullet/casing.py b/royalnet/engineer/bullet/casing.py index ef5df540..03e14e54 100644 --- a/royalnet/engineer/bullet/casing.py +++ b/royalnet/engineer/bullet/casing.py @@ -1,20 +1,5 @@ """ -Casings are parts of the data model that :mod:`royalnet.engineer` uses to build a common interface between -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`. +This module contains the base :class:`.Casing` class. """ from __future__ import annotations @@ -24,7 +9,23 @@ import abc 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): @@ -35,6 +36,6 @@ class Casing(metaclass=abc.ABCMeta): @abc.abstractmethod 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() diff --git a/royalnet/engineer/bullet/exc.py b/royalnet/engineer/bullet/exc.py index e42cd59e..46b01f76 100644 --- a/royalnet/engineer/bullet/exc.py +++ b/royalnet/engineer/bullet/exc.py @@ -1,5 +1,5 @@ """ -The exceptions which can happen in bullets. +This modules contains the exceptions which can happen in bullets. """ from .. import exc @@ -19,11 +19,11 @@ class FrontendError(BulletException): 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): """ - The bot user does not have sufficient permissions to perform a frontend operation. + The bot does not have sufficient permissions to perform an operation. """ diff --git a/royalnet/engineer/bullet/projectiles/_base.py b/royalnet/engineer/bullet/projectiles/_base.py index 6b0cdfd9..9aecbd38 100644 --- a/royalnet/engineer/bullet/projectiles/_base.py +++ b/royalnet/engineer/bullet/projectiles/_base.py @@ -1,3 +1,7 @@ +""" +This module contains the base :class:`.Projectile` class. +""" + from __future__ import annotations import abc diff --git a/royalnet/engineer/bullet/projectiles/message.py b/royalnet/engineer/bullet/projectiles/message.py index 10887148..75140ec6 100644 --- a/royalnet/engineer/bullet/projectiles/message.py +++ b/royalnet/engineer/bullet/projectiles/message.py @@ -1,3 +1,8 @@ +""" +This module contains the projectiles related to messages: :class:`.MessageReceived`, :class:`MessageEdited` and +:class:`MessageDeleted`. +""" + from __future__ import annotations from ._imports import * diff --git a/royalnet/engineer/bullet/projectiles/reaction.py b/royalnet/engineer/bullet/projectiles/reaction.py index 340ed8a2..2fc28974 100644 --- a/royalnet/engineer/bullet/projectiles/reaction.py +++ b/royalnet/engineer/bullet/projectiles/reaction.py @@ -1,3 +1,7 @@ +""" +This module contains the :class:`.Reaction` projectile. +""" + from __future__ import annotations from ._imports import * diff --git a/royalnet/engineer/bullet/projectiles/user.py b/royalnet/engineer/bullet/projectiles/user.py index e7cb1922..22a3d583 100644 --- a/royalnet/engineer/bullet/projectiles/user.py +++ b/royalnet/engineer/bullet/projectiles/user.py @@ -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 ._imports import * diff --git a/royalnet/engineer/command.py b/royalnet/engineer/command.py index 0f8e5afa..02aeedd5 100644 --- a/royalnet/engineer/command.py +++ b/royalnet/engineer/command.py @@ -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 import royalnet.royaltyping as t -# External imports import logging import re -# Internal imports from . import conversation as c from . import sentry as s from . import bullet as b from . import teleporter as tp +from . import exc -# Special global objects log = logging.getLogger(__name__) -# Code -class Command(c.Conversation): +class CommandException(exc.EngineerException): """ - A Command is a :class:`~.conversation.Conversation` which is started by a :class:`~.bullet.Message` having a text - 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. + The base :class:`Exception` of the :mod:`royalnet.engineer.command`\\ module. """ - 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) - - log.debug(f"Initializing Conversation with: {teleported!r}") 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 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 """ - 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 """ - 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): """ - :return: The main name of the Command. + :return: The primary name of the :class:`.FullCommand`. """ return self.names[0] def aliases(self): """ - :return: The aliases (non-main names) of the Command. + :return: The secondary names of the :class:`.FullCommand`. """ return self.names[1:] def __repr__(self): - if (nc := len(self.names)) > 1: - plus = f" + {nc-1} other names" - else: - plus = "" + plus = f" + {nc-1} other names" if (nc := len(self.names)) > 1 else "" return f"<{self.__class__.__qualname__}: {self.name()!r}{plus}>" 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...") projectile: b.Projectile = await _sentry 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): log.debug(f"Returning: {projectile!r} is not a message") return @@ -112,7 +151,7 @@ class Command(c.Conversation): with _sentry.dispenser().lock(self): log.debug(f"Passing args to function: {message_kwargs!r}") - return await super().run( + return await super().__call__( _sentry=_sentry, _proj=projectile, _msg=msg, @@ -123,7 +162,7 @@ class Command(c.Conversation): else: log.debug(f"Passing args to function: {message_kwargs!r}") - return await super().run( + return await super().__call__( _sentry=_sentry, _proj=projectile, _msg=msg, @@ -143,21 +182,28 @@ class Command(c.Conversation): class PartialCommand: """ - A PartialCommand is a :class:`.Command` having an unknown :attr:`~.Command.name` and :attr:`~.Command.pattern` at - the moment of creation. + :class:`.PartialCommand` is a class meant for **building command packs**. - 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): """ - 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 """ - 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 @@ -173,11 +219,20 @@ class PartialCommand: @classmethod 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. - It can still be called in the same way as the previous function! + >>> import royalnet.engineer as engi + + >>> @PartialCommand.new(syntax="") + ... async def ping(*, _sentry: engi.Sentry, _msg: engi.Message, **__): + ... await _msg.reply(text="🏓 Pong!") + + >>> ping + > + + :return: The decorator to wrap around the :attr:`.f` function. """ + def decorator(f: c.ConversationProtocol): partial_command = cls(f=f, *args, **kwargs) log.debug(f"Created: {partial_command!r}") @@ -185,36 +240,42 @@ class PartialCommand: 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 pattern: The pattern to add to the PartialCommand. It is first :meth:`~str.format`\\ ted with the keyword - arguments ``name`` and ``syntax`` and later :func:`~re.compile`\\ d with the - :data:`re.IGNORECASE` flag. - :return: The complete :class:`Command`. + :param names: The :attr:`~.FullCommand.names` that the :class:`.FullCommand` should have. + :param pattern: The pattern to add to the :class:`.PartialCommand`\\ . + It is first :meth:`~str.format`\\ ted with the keyword arguments ``name`` and ``syntax`` + and later :func:`~re.compile`\\ d with the :data:`re.IGNORECASE` flag. + + :return: The completed :class:`.FullCommand`. + + :raises .MissingNameError: If no :attr:`.FullCommand.names` are specified. """ - if names is None: - names = [] + 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: 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)})" log.debug(f"Completed pattern: {name_regex!r}") 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): return f"<{self.__class__.__qualname__} {self.f!r}>" -# Objects exported by this module __all__ = ( - "Command", + "CommandException", + "CommandNameException", + "FullCommand", + "MissingNameError", + "NotAlphanumericNameError", "PartialCommand", ) diff --git a/royalnet/engineer/conversation.py b/royalnet/engineer/conversation.py index b67b360f..dbd72f03 100644 --- a/royalnet/engineer/conversation.py +++ b/royalnet/engineer/conversation.py @@ -1,28 +1,21 @@ -# Module docstring """ -Conversations are wrapper classes that can be applied to functions which await -:class:`~royalnet.engineer.bullet.Bullet`\\ s from a :class:`~royalnet.engineer.sentry.Sentry`. +This module contains :class:`.ConversationProtocol`, the typing stub for conversation functions, and +:class:`.Conversation`, a decorator for functions which should help in debugging conversations. """ -# Special imports from __future__ import annotations import royalnet.royaltyping as t -# External imports import logging -# Internal imports from . import sentry as s - -# Special global objects log = logging.getLogger(__name__) -# Code 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]]: ... @@ -30,15 +23,20 @@ class ConversationProtocol(t.Protocol): 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, *_, **__): 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) """ @@ -46,7 +44,14 @@ class Conversation: @classmethod 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 + :return: The created :class:`Conversation` object. 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]]: log.debug(f"Calling: {self!r}") - return self.run(_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) + return self.f(_sentry=_sentry, **kwargs) def __repr__(self): return f"<{self.__class__.__qualname__} #{id(self)}>" diff --git a/royalnet/engineer/discard.py b/royalnet/engineer/discard.py index 708ad184..a66ef5af 100644 --- a/royalnet/engineer/discard.py +++ b/royalnet/engineer/discard.py @@ -1,3 +1,8 @@ +""" +This module contains the :class:`.Discard` special exception. +""" + + class Discard(BaseException): """ A special exception which should be raised by :class:`~royalnet.engineer.wrench.Wrench`\\ es if a certain object diff --git a/royalnet/engineer/dispenser.py b/royalnet/engineer/dispenser.py index 8262692e..94150598 100644 --- a/royalnet/engineer/dispenser.py +++ b/royalnet/engineer/dispenser.py @@ -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 @@ -10,13 +10,36 @@ import contextlib from .sentry import SentrySource from .conversation import Conversation -from .exc import LockedDispenserError +from .exc import EngineerException from .bullet.projectiles import Projectile 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: + """ + 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): self.sentries: t.List[SentrySource] = [] """ @@ -32,47 +55,48 @@ class Dispenser: 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: await sentry.put(item) @contextlib.contextmanager def sentry(self, *args, **kwargs): """ - A context manager which creates a :class:`.SentrySource` and keeps it in :attr:`.sentries` while it is being - used. + A :func:`~contextlib.contextmanager` which creates a :class:`.SentrySource` and keeps it in :attr:`.sentries` + while it is being used. """ log.debug("Creating a new SentrySource...") sentry = SentrySource(dispenser=self, *args, **kwargs) - log.debug(f"Adding: {sentry}") + log.debug(f"Adding: {sentry!r}") self.sentries.append(sentry) - log.debug(f"Yielding: {sentry}") + log.debug(f"Yielding: {sentry!r}") 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) 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`. """ log.debug(f"Trying to run: {conv!r}") if self._locked_by: 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 " - f"cannot start new conversations.") + raise LockedDispenserError( + 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: state = conv(_sentry=sentry, **kwargs) @@ -83,10 +107,10 @@ class Dispenser: @contextlib.contextmanager 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` - instead. + A locked :class:`.Dispenser` will refuse to :meth:`.run` any new conversations, + raising :exc:`.LockedDispenserError` instead. :param conv: The conversation that requested the lock. @@ -104,4 +128,6 @@ class Dispenser: __all__ = ( "Dispenser", + "DispenserException", + "LockedDispenserError", ) diff --git a/royalnet/engineer/exc.py b/royalnet/engineer/exc.py index 6add7fb1..b3220629 100644 --- a/royalnet/engineer/exc.py +++ b/royalnet/engineer/exc.py @@ -1,4 +1,6 @@ -import pydantic +""" +This module contains the base :class:`.EngineerException` . +""" 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__ = ( "EngineerException", - "WrenchException", - "DeliberateException", - "TeleporterError", - "InTeleporterError", - "OutTeleporterError", - "DispenserException", - "LockedDispenserError", ) diff --git a/royalnet/engineer/sentry.py b/royalnet/engineer/sentry.py index 9d2e90c4..4f4a9349 100644 --- a/royalnet/engineer/sentry.py +++ b/royalnet/engineer/sentry.py @@ -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. """ @@ -22,7 +23,13 @@ log = logging.getLogger(__name__) 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 @@ -32,11 +39,14 @@ class Sentry(metaclass=abc.ABCMeta): @abc.abstractmethod 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 .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. """ raise NotImplementedError() @@ -44,10 +54,12 @@ class Sentry(metaclass=abc.ABCMeta): @abc.abstractmethod async def get(self): """ - Try to get a single :class:`~.bullet.Projectile` from the pipeline, blocking until something is available, but - without handling discards. + Try to get a single :class:`~royalnet.engineer.bullet.projectiles._base.Projectile` from the pipeline, + **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 Exception: If an exception was **raised** in the pipeline. """ @@ -55,10 +67,11 @@ class Sentry(metaclass=abc.ABCMeta): async def wait(self): """ - Try to get a single :class:`~.bullet.Projectile` from the pipeline, blocking until something is available and is - not discarded. + Try to get a single :class:`~.bullet.Projectile` from the pipeline, **blocking** until something is available + and is **not discarded**. :return: The **returned** :class:`~.bullet.Projectile`. + :raises Exception: If an exception was **raised** in the pipeline. """ while True: @@ -72,7 +85,7 @@ class Sentry(metaclass=abc.ABCMeta): 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__() @@ -89,9 +102,11 @@ class Sentry(metaclass=abc.ABCMeta): """ 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 - function accepting a single object as parameter and returning the same or a different one. - :return: A new :class:`.SentryFilter` which includes the filter. + :param wrench: The filter to add to the chain. It can either be a :class:`~royalnet.engineer.wrench.Wrench`, + or a coroutine function accepting a single object as parameter and returning another one. + :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__` """ @@ -106,8 +121,11 @@ class Sentry(metaclass=abc.ABCMeta): .. 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: return self.filter(other) @@ -117,9 +135,9 @@ class Sentry(metaclass=abc.ABCMeta): @abc.abstractmethod 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() diff --git a/royalnet/engineer/teleporter.py b/royalnet/engineer/teleporter.py index f85f5b0e..09afb796 100644 --- a/royalnet/engineer/teleporter.py +++ b/royalnet/engineer/teleporter.py @@ -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 @@ -15,7 +15,29 @@ Value = t.TypeVar("Value") 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: + """ + A :class:`.Teleporter` is a function wrapper which uses :mod:`pydantic` to perform type checking + """ + def __init__(self, f: t.Callable[..., t.Any], validate_input: bool = True, @@ -165,14 +187,14 @@ class Teleporter: :param kwargs: The keyword arguments that should be passed to the 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}") try: return self.InputModel(**kwargs) except pydantic.ValidationError as e: 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: """ @@ -180,14 +202,14 @@ class Teleporter: :param value: The value that should be validated. :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}") try: return self.OutputModel(__root__=value) except pydantic.ValidationError as e: 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 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: """ - Run the teleporter synchronously. + Run the :class:`.Teleporter` synchronously. """ if self.InputModel: log.debug("Validating input...") @@ -226,7 +248,7 @@ class Teleporter: async def _run_async(self, **kwargs) -> t.Awaitable[t.Any]: """ - Run the teleporter asynchronously. + Run the :class:`.Teleporter` asynchronously. """ if self.InputModel: log.debug("Validating input...") @@ -246,5 +268,8 @@ class Teleporter: __all__ = ( + "InTeleporterError", + "OutTeleporterError", "Teleporter", + "TeleporterError", ) diff --git a/royalnet/engineer/wrench.py b/royalnet/engineer/wrench.py index 102594fb..a28728cb 100644 --- a/royalnet/engineer/wrench.py +++ b/royalnet/engineer/wrench.py @@ -12,6 +12,18 @@ from . import discard 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): """ The abstract base class for Wrenches. @@ -67,7 +79,7 @@ class ErrorAll(Wrench): """ 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): @@ -293,15 +305,17 @@ class Check(CheckBase): __all__ = ( - "Wrench", + "Check", "CheckBase", - "Type", - "StartsWith", - "EndsWith", "Choice", + "DeliberateException", + "EndsWith", + "Lambda", "RegexCheck", "RegexMatch", "RegexReplace", - "Lambda", - "Check", + "StartsWith", + "Type", + "Wrench", + "WrenchException", )