Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 29 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -74,15 +96,15 @@ 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)
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)
Expand Down
42 changes: 31 additions & 11 deletions lupl/compose_router.py
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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()
41 changes: 0 additions & 41 deletions tests/test_compose_router.py

This file was deleted.

117 changes: 117 additions & 0 deletions tests/tests_compose_router/test_compose_router.py
Original file line number Diff line number Diff line change
@@ -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
18 changes: 18 additions & 0 deletions tests/tests_compose_router/test_compose_router_failure.py
Original file line number Diff line number Diff line change
@@ -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)