diff --git a/README.md b/README.md index ae477bc..3aade03 100644 --- a/README.md +++ b/README.md @@ -11,14 +11,15 @@ A collection of potentially generally useful Python utilities. ## Installation -`lupl` is PEP-621-compliant package and available on PyPI. +`lupl` is a [PEP-621](https://peps.python.org/pep-0621/)-compliant package and available on [PyPI](https://pypi.org/project/lupl/). ## Usage ### ComposeRouter -The ComposeRouter class allows to route attributes access for registered methods + +The `ComposeRouter` class allows to route attributes access for registered methods through a functional pipeline constructed from components. -The pipeline is only triggered if a registered method is accessed via the ComposeRouter namespace. +The pipeline is only triggered if a registered method is accessed via the `ComposeRouter` namespace. ```python from lupl import ComposeRouter @@ -36,9 +37,29 @@ print(foo.method(2, 3)) # 6 print(foo.route.method(2, 3)) # 13 ``` +By default, composition in `ComposeRouter` is *left-associative*. + +Associativity can be controlled by setting the `left_associative: bool` kwarg either when creating the ComposeRouter instance or when calling it. + + +```python +class Bar: + route = ComposeRouter(lambda x: x + 1, lambda y: y * 2, left_associative=True) + + @route.register + def method(self, x, y): + return x * y + +bar = Bar() + +print(bar.method(2, 3)) # 6 +print(bar.route.method(2, 3)) # 14 +print(bar.route(left_associative=False).method(2, 3)) # 13 +``` + ### Chunk Iterator -The `lupl.ichunk` generator implements a simple chunk iterator that allows to lazily slice an Iterator into sub-iterators. +The `ichunk` generator implements a simple chunk iterator that allows to lazily slice an Iterator into sub-iterators. ```python from collections.abc import Iterator @@ -54,7 +75,8 @@ print(materialized) # [(0, 1, 2), (3, 4, 5), (6, 7, 8), (9,)] ### Pydantic Tools #### CurryModel -The CurryModel constructor allows to sequentially initialize (curry) a Pydantic model. + +The `CurryModel` constructor allows to sequentially initialize (curry) a Pydantic model. ```python from lupl import CurryModel @@ -74,7 +96,7 @@ model_instance = curried_model(z=("3", 4)) print(model_instance) ``` -CurryModel instances are recursive so it is also possible to do this: +`CurryModel` instances are recursive so it is also possible to do this: ```python curried_model_2 = CurryModel(MyModel) @@ -82,7 +104,7 @@ model_instance_2 = curried_model_2(x="1")(y=2)(z=("3", 4)) print(model_instance_2) ``` -Currying turns a function of arity n into at most n functions of arity 1 and at least 1 function of arity n (and everything in between), so you can also do e.g. this: +Currying turns a function of arity *n* into at most *n* functions of arity 1 and at least 1 function of arity *n* (and everything in between), so you can also do e.g. this: ```python curried_model_3 = CurryModel(MyModel) diff --git a/lupl/compose_router.py b/lupl/compose_router.py index 3bcd58e..29c4549 100644 --- a/lupl/compose_router.py +++ b/lupl/compose_router.py @@ -1,8 +1,9 @@ """ComposeRouter: Utility for routing methods through a functional pipeline.""" from collections.abc import Callable +from typing import Self -from toolz import compose +from toolz import compose, compose_left class ComposeRouter: @@ -26,24 +27,43 @@ def method(self, x, y): print(foo.route.method(2, 3)) # 13 """ - def __init__(self, *components: Callable) -> None: - self._components: tuple[Callable, ...] = components - self._registry: list = [] + def __init__(self, *components: Callable, left_associative: bool = False) -> None: + self.components: tuple[Callable, ...] = components + self.left_associative = left_associative + + self._registry: list[str] = [] def register[F: Callable](self, f: F) -> F: + """Register a method for routing through the component pipeline.""" + self._registry.append(f.__name__) return f - def __get__[T](self, instance: T, owner: type[T]): - class _wrapper: - def __init__(_self, other): - _self.other = other + def __get__[Self](self, instance: Self, owner: type[Self]): + class _BoundRouter: + """_BoundRouter is a heavily closured wrapper for handling compose calls. + + Upon attribute access on the ComposeRouter descriptor, + _BoundRouter acts as an intermediary dispatcher that returns a composed callable + that applies the specified pipeline components to the requested method. + + """ + + def __init__(_self): + _self.left_associative = self.left_associative + + def __call__(_self, *, left_associative: bool): + _self.left_associative = left_associative + return _self def __getattr__(_self, name): if name in self._registry: - method = getattr(_self.other, name) - return compose(*self._components, method) + method = getattr(instance, name) + + if _self.left_associative: + return compose_left(method, *self.components) + return compose(*self.components, method) raise AttributeError(f"Name '{name}' not registered.") - return _wrapper(instance) + return _BoundRouter() diff --git a/tests/test_compose_router.py b/tests/test_compose_router.py deleted file mode 100644 index 586b3fd..0000000 --- a/tests/test_compose_router.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Pytest entry point for lupl.ComposeRouter tests.""" - -from lupl import ComposeRouter -import pytest -from toolz import compose - - -def test_simple_compose_router(): - """Simple base test case for lupl.ComposeRouter. - - Check if the composed route generates the same result - as the _components applied to the result of the method without routing. - """ - _components = [lambda x: x + 1, lambda y: y * 2] - - class Foo: - route = ComposeRouter(*_components) - - @route.register - def method(self, x, y): - return x * y - - foo = Foo() - - no_route = foo.method(2, 3) - assert foo.route.method(2, 3) == compose(*_components)(no_route) - - -def test_simple_compse_router_unregistered_fail(): - _components = [lambda x: x + 1, lambda y: y * 2] - - class Foo: - route = ComposeRouter(*_components) - - def method(self, x, y): - return x * y - - foo = Foo() - - with pytest.raises(AttributeError): - foo.route.method(2, 3) diff --git a/tests/tests_compose_router/test_compose_router.py b/tests/tests_compose_router/test_compose_router.py new file mode 100644 index 0000000..a58d1e2 --- /dev/null +++ b/tests/tests_compose_router/test_compose_router.py @@ -0,0 +1,117 @@ +"""Basic tests for lupl.ComposeRouter""" + +from typing import Any, NamedTuple + +from lupl import ComposeRouter +import pytest + + +class ComposeRouterTestParameter(NamedTuple): + instance: object + args: tuple[Any, ...] + kwargs: dict[str, Any] + expected: Any + expected_route: Any + expected_route_left: Any + + +class TestClass1: + route = ComposeRouter(lambda x: x + 1, lambda y: y * 2) + + @route.register + def method(self, x, y): + return x * y + + +class TestClass2: + route = ComposeRouter(lambda x: x + 1, lambda y: y * 2, left_associative=True) + + @route.register + def method(self, x, y): + return x * y + + +class TestClass3: + route = ComposeRouter(str, lambda x: f"{x}1", int, lambda x: f"{x}1", str) + + @route.register + def method(self): + return 1 + + +class TestClass4: + route = ComposeRouter(int, lambda x: f"{x}1") + + @route.register + def method(self): + return 1 + + +class TestClass5: + route = ComposeRouter(int, lambda x: f"{x}1") + + @route.register + def method(self): + return 1 + + @route.register + def method2(self): + return 2 + + +params = [ + ComposeRouterTestParameter( + instance=TestClass1(), + args=(2, 3), + kwargs={}, + expected=6, + expected_route=13, + expected_route_left=14, + ), + ComposeRouterTestParameter( + instance=TestClass2(), + args=(2, 3), + kwargs={}, + expected=6, + expected_route=14, + expected_route_left=14, + ), + ComposeRouterTestParameter( + instance=TestClass3(), + args=(), + kwargs={}, + expected=1, + expected_route="111", + expected_route_left="111", + ), + ComposeRouterTestParameter( + instance=TestClass4(), + args=(), + kwargs={}, + expected=1, + expected_route=11, + expected_route_left="11", + ), + ComposeRouterTestParameter( + instance=TestClass5(), + args=(), + kwargs={}, + expected=1, + expected_route=11, + expected_route_left="11", + ), +] + + +@pytest.mark.parametrize("param", params) +def test_basic_compose_router(param): + args, kwargs = param.args, param.kwargs + + assert param.instance.method(*args, **kwargs) == param.expected + assert param.instance.route.method(*args, **kwargs) == param.expected_route + assert ( + param.instance.route(left_associative=True).method(*args, **kwargs) + == param.expected_route_left + ) + # repeat to check that left_associative is not altered + assert param.instance.method(*args, **kwargs) == param.expected diff --git a/tests/tests_compose_router/test_compose_router_failure.py b/tests/tests_compose_router/test_compose_router_failure.py new file mode 100644 index 0000000..57164eb --- /dev/null +++ b/tests/tests_compose_router/test_compose_router_failure.py @@ -0,0 +1,18 @@ +"""Basic sad path tests for lupl.ComposeRouter.""" + +from lupl import ComposeRouter +import pytest + + +class TestClassFailure1: + route = ComposeRouter(lambda x: x + 1, lambda y: y * 2) + + def method(self, x, y): + return x * y + + +def test_compose_router_attribute_failure(): + instance = TestClassFailure1() + + with pytest.raises(AttributeError): + instance.route.method(2, 3)