diff --git a/royalnet/engineer/blueprints/blueprint.py b/royalnet/engineer/blueprints/blueprint.py index 481c68bb..70cffae4 100644 --- a/royalnet/engineer/blueprints/blueprint.py +++ b/royalnet/engineer/blueprints/blueprint.py @@ -56,7 +56,7 @@ class Blueprint(metaclass=abc.ABCMeta): """ raise NotImplementedError() - def requires(self, *fields) -> None: + def requires(self, *fields) -> True: """ Ensure that this blueprint has the specified fields, re-raising the highest priority exception raised between all of them. @@ -85,6 +85,8 @@ class Blueprint(metaclass=abc.ABCMeta): if len(exceptions) > 0: raise max(exceptions, key=lambda e: e.priority) + return True + __all__ = ( "Blueprint", diff --git a/royalnet/engineer/sentry/filter.py b/royalnet/engineer/sentry/filter.py index e3c2601c..b52d6ac0 100644 --- a/royalnet/engineer/sentry/filter.py +++ b/royalnet/engineer/sentry/filter.py @@ -38,84 +38,159 @@ class Filter: return result @staticmethod - def _deco_type(t: type): + def _deco_filter(c: Callable[[Any], bool], *, error: str): + """ + A decorator which checks the condition ``c`` on all objects transiting through the queue: + - If the check **passes**, the object itself is returned; + - If the check **fails**, :exc:`.exc.Discard` is raised, with the object and the ``error`` string as parameters; + - If an error is raised, propagate the error upwards. + + .. warning:: Raising :exc:`.exc.Discard` in ``c`` will automatically cause the object to be discarded, as if + :data:`False` was returned. + + :param c: A function that takes in input an enqueued object and returns either the same object or a new one to + pass to the next filter in the queue. + :param error: The string that :exc:`.exc.Discard` should display if the object is discarded. + """ def decorator(func): @functools.wraps(func) - def decorated(obj): - result: Any = func(obj) - if not isinstance(result, t): - raise exc.Discard(result, f"Not instance of type {t}") - return result + async def decorated(obj): + result: Any = await func(obj) + if c(result): + return result + else: + raise exc.Discard(obj=result, message=error) return decorated return decorator + def filter(self, c: Callable[[Any], bool], error: str) -> Filter: + """ + Check the condition ``c`` on all objects transiting through the queue: + - If the check **passes**, the object goes on to the next filter; + - If the check **fails**, the object is discarded, with ``error`` as reason; + - If an error is raised, propagate the error upwards. + + .. seealso:: :meth:`._deco_filter`, :func:`filter` + + :param c: A function that takes in input an object and performs a check on it, returning either :data:`True` + or :data:`False`. + :param error: The reason for which objects should be discarded. + :return: A new :class:`Filter` with this new condition. + """ + return self.__class__(self._deco_filter(c, error=error)(self.func)) + + @staticmethod + def _deco_map(c: Callable[[Any], object]): + """ + A decorator which applies the function ``c`` on all objects transiting through the queue: + - If the function **returns**, return its return value; + - If the function **raises** an error, it is propagated upwards. + + .. seealso:: :func:`map` + + :param c: A function that takes in input an enqueued object and returns either the same object or something + else. + """ + def decorator(func): + @functools.wraps(func) + async def decorated(obj): + result: Any = await func(obj) + return c(result) + return decorated + return decorator + + def map(self, c: Callable[[Any], bool]) -> Filter: + """ + Apply the function ``c`` on all objects transiting through the queue: + - If the function **returns**, its return value replaces the object in the queue; + - If the function **raises** :exc:`.exc.Discard`, the object is discarded; + - If the function **raises another error**, propagate the error upwards. + + .. seealso:: :meth:`._deco_map`, :func:`filter` + + :param c: A function that takes in input an enqueued object and returns either the same object or something + else. + :return: A new :class:`Filter` with this new condition. + """ + return self.__class__(self._deco_map(c)(self.func)) + def type(self, t: type) -> Filter: """ - :exc:`exc.Discard` all objects that are not an instance of ``t``. + Check if an object passing through the queue :func:`isinstance` of the type ``t``. :param t: The type that objects should be instances of. - :return: A new :class:`Filter` with the new requirements. + :return: A new :class:`Filter` with this new condition. """ - return self.__class__(self._deco_type(t)(self.func)) + return self.filter(lambda o: isinstance(o, t), error=f"Not instance of type {t}") def msg(self) -> Filter: """ - :exc:`exc.Discard` all objects that are not an instance of :class:`.blueprints.Message`. + Check if an object passing through the queue :func:`isinstance` of :class:`.blueprints.Message`. + + :return: A new :class:`Filter` with this new condition. + """ + return self.type(blueprints.Message) + + def requires(self, *fields, + propagate_not_available=False, + propagate_never_available=True) -> Filter: + """ + Test a :class:`.blueprints.Blueprint`'s fields by using its ``.requires()`` method: + - If the :class:`.blueprints.Blueprint` has the appropriate fields, return it; + - If the :class:`.blueprints.Blueprint` doesn't have data for at least one of the fields, the object is + discarded; + - the :class:`.blueprints.Blueprint` never has data for at least one of the fields, + :exc:`.exc.NotAvailableError` is propagated upwards. + + :param fields: The fields to test for. + :param propagate_not_available: If :exc:`.exc.NotAvailableError` should be propagated + instead of discarding the errored object. + :param propagate_never_available: If :exc:`.exc.NeverAvailableError` should be propagated + instead of discarding the errored object. + :return: A new :class:`Filter` with this new condition. + """ + def check(obj): + try: + return obj.requires(*fields) + except exc.NotAvailableError: + if not propagate_not_available: + raise + raise exc.Discard(obj, "Data is not available") + except exc.NeverAvailableError: + if not propagate_never_available: + raise + raise exc.Discard(obj, "Data is never available") + + return self.filter(check, error=".requires() method returned False") + + def field(self) -> Filter: + """ + # TODO :return: A new :class:`Filter` with the new requirements. """ - return self.__class__(self._deco_type(blueprints.Message)(self.func)) + return self.requires(blueprints.Message.text).map(lambda o: o.text()) @staticmethod - def _deco_requires(*fields): + def _deco_startswith(prefix: str): def decorator(func): @functools.wraps(func) def decorated(obj): - result: blueprints.Blueprint = func(obj) - try: - result.requires(*fields) - except exc.NotAvailableError: - raise exc.Discard(result, "Missing data") - except AttributeError: - raise exc.Discard(result, "Missing .requires() method") + result: str = func(obj) + if not result.startswith(prefix): + raise exc.Discard(result, f"Text didn't start with {prefix}") return result return decorated return decorator - def requires(self, *fields) -> Filter: + def startswith(self, prefix: str): """ - Test an object's fields by using its ``.requires()`` method (expecting it to be - :meth:`.blueprints.Blueprint.requires`) and discard everything that does not pass the check. + Check if an object starts with the specified prefix and discard the objects that do not. - :param fields: The fields to test for. + :param prefix: The prefix object should start with. :return: A new :class:`Filter` with the new requirements. """ - return self.__class__(self._deco_requires(*fields)(self.func)) - - @staticmethod - def _deco_text(): - def decorator(func): - @functools.wraps(func) - def decorated(obj): - result: blueprints.Message = func(obj) - try: - text = result.text() - except exc.NotAvailableError: - raise exc.Discard(result, "No text") - except AttributeError: - raise exc.Discard(result, "Missing text method") - return text - return decorated - return decorator - - def text(self) -> Filter: - """ - Get the text of the passed object by using its ``.text()`` method (expecting it to be - :meth:`.blueprints.Message.text`), while discarding all objects that don't have a text. - - :return: A new :class:`Filter` with the new requirements. - """ - return self.__class__(self._deco_text()(self.func)) + return self.__class__(self._deco_startswith(prefix)(self.func)) @staticmethod def _deco_regex(pattern: Pattern): @@ -132,26 +207,13 @@ class Filter: def regex(self, pattern: Pattern): """ - Apply a regex over an object's text (obtained through its ``.text()`` method, expecting it to be - :meth:`.blueprints.Message.text`) and discard the object if it does not match. + Apply a regex over an object and discard the object if it does not match. :param pattern: The pattern that should be matched by the text. :return: A new :class:`Filter` with the new requirements. """ return self.__class__(self._deco_regex(pattern)(self.func)) - @staticmethod - def _deco_choices(*choices): - def decorator(func): - @functools.wraps(func) - def decorated(obj: blueprints.Message): - result = func(obj) - if result not in choices: - raise exc.Discard(result, "Not a valid choice") - return result - return decorated - return decorator - def choices(self, *choices): """ Ensure an object is in the ``choices`` list, discarding the object otherwise. @@ -159,7 +221,7 @@ class Filter: :param choices: The pattern that should be matched by the text. :return: A new :class:`Filter` with the new requirements. """ - return self.__class__(self._deco_choices(*choices)(self.func)) + return self.__class__(self._deco_check(lambda o: o in choices, error="Not a valid choice")(self.func)) __all__ = ( diff --git a/royalnet/engineer/sentry/sentry.py b/royalnet/engineer/sentry/sentry.py index 229d031a..3b1b6514 100644 --- a/royalnet/engineer/sentry/sentry.py +++ b/royalnet/engineer/sentry/sentry.py @@ -27,23 +27,23 @@ class Sentry: def __repr__(self): return f"" - async def get(self, *_, **__) -> Any: - """ - Wait until an :class:`object` leaves the queue, then return it. - - :return: The :class:`object` which entered the queue. - """ - return await self.queue.get() - - async def filter(self): + async def f(self): """ Create a :class:`.filters.Filter` object, which can be configured through its fluent interface. - Remember to call ``.get()`` on the end of the chain. + Remember to call ``.get()`` on the end of the chain to finally get the object. + + To get any object, call: + + .. code-block:: + + await sentry.f().get() + + .. seealso:: :class:`.filters.Filter` :return: The created :class:`.filters.Filter`. """ - return Filter(self.get) + return Filter(self.queue.get) __all__ = (