Skip to content

Commit dd3c0c6

Browse files
author
Sune Debel
authored
feature: lens (#86)
1 parent 986807c commit dd3c0c6

15 files changed

+761
-80
lines changed

docs/effectful_but_side_effect_free.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ In functional programming, programs are built by composing functions that have n
55
- `pfun.effect` helps you work with side-effects in functional style.
66

77

8-
If you have some experience with functionl programming, you can probably skip ahead to the section on `pfun.effect.Effect`.
8+
If you have some experience with functional programming, you can probably skip ahead to the section on `pfun.effect.Effect`.
99
## Maybe
1010
The job of the `pfun.maybe.Maybe` type is to help you work with missing values, in much the same way that the built-in `None` type is used. One of the main disadvantages of the `None` type is that you end up with logic for dealing with missing values all over the place, using code like `if foo is not None`.
1111

docs/immutable_objects_and_data.md

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,3 +69,81 @@ assert 'new_key' not in d and d2.get('new_key') == Just('new_value')
6969

7070
It supports the same api as `dict` which the exception of `__setitem__` which will raise an exception, and uses
7171
`pfun.maybe.Maybe` to indicate the presence or absence of a key when using `get`.
72+
73+
## Lens
74+
Working with deeply nested immutable data-structures can be tricky when you want to transform only one member of an object deep inside the nested structure, but want to keep other the remaining data-structure intact, for example in:
75+
76+
```python
77+
d = {
78+
'a': {
79+
'b': {
80+
'c': 'I want to change this...'
81+
}
82+
},
83+
'd': 'but not this'
84+
}
85+
new_d = d.copy()
86+
new_d['a'] = d['a'].copy()
87+
new_d['a']['b'] = d['a']['b'].copy()
88+
new_d['a']['b']['c'] = 'Phew! That took a lot of work!'
89+
```
90+
A _lens_ is a setter function that takes as arguments a value to replace at some path/index
91+
in an object/data-structure, and an object to transform. `pfun.lens` allows you
92+
to easily construct these setter functions by specifying the path/index at
93+
which you want to perform a replacement using normal Python indexing and attribute
94+
access. You use the object returned by `lens()` as a proxy for the object you want
95+
to transform by accessing attributes and indexes on it. The lens will remember this
96+
path, and use it when performing an update. You perform an update by calling the lens
97+
with the value to use as a replacement, and an object on which to perform the replacement:
98+
```python
99+
from pfun import lens
100+
101+
102+
t = lens()['a']['b']['c']
103+
new_d = t('Wow that was a lot easier!')(d)
104+
assert new_d['a']['b']['c'] == 'Wow that was a lot easier!'
105+
```
106+
If you use the `pfun` MyPy plugin, you can give a type as an argument to `lens`, which allows MyPy to check that the operations you make on the lens object are supported by the type you intend to transform:
107+
```python
108+
class Person:
109+
name: str
110+
111+
112+
class User(Person):
113+
organization: Organization
114+
115+
116+
u = lens(User)
117+
118+
# MyPy type error because we misspelled 'organization'
119+
u.organisation('Foo Inc')
120+
121+
# MyPy type error because "User.organization" must a "str"
122+
u.organization(0)
123+
124+
# MyPy type error because "Person" is not a "User"
125+
u.organization('Foo Inc')(Person())
126+
```
127+
Since lenses are just Python callables, you can combine them using the normal
128+
compose operations available in `pfun`:
129+
```python
130+
from pfun import compose
131+
132+
133+
class NamedUser(User):
134+
name: str
135+
136+
137+
u = lens(NamedUser)
138+
set_name = u.name
139+
set_org_name = u.organization.name
140+
141+
new_user = compose(set_name('Bob'), set_org_name('Foo Inc'))(NamedUser())
142+
```
143+
Currently, `lens` supports working with the following types of objects and data-structures:
144+
145+
- Regular Python objects
146+
- `collections.namedtuple` and `typing.NamedTuple` instances
147+
- normal and frozen `dataclasses.dataclass` and `pfun.Immutable`
148+
- `pfun.List`, `tuple` and `list`
149+
- `pfun.Dict` and `dict`

docs/lens_api.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
::: pfun.lens.lens
2+
::: pfun.lens.RootLens
3+
selection:
4+
members: [
5+
'__getattr__',
6+
'__getitem__'
7+
]
8+
::: pfun.lens.Lens
9+
selection:
10+
members: [
11+
'__call__'
12+
]

docs/useful_functions.md

Lines changed: 39 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
## curry
2-
`curry` makes it easy to [curry](https://en.wikipedia.org/wiki/Currying) functions while inferring the resulting type signature with MyPy (if the `pfun` MyPy plugin is enabled).
2+
`curry` makes it easy to [curry](https://en.wikipedia.org/wiki/Currying) functions. If you enable the MyPy plugin, MyPy can also
3+
type check the arguments and return types of curried functions.
34

45
The functions returned by `curry` support both normal and curried call styles:
56

@@ -26,55 +27,60 @@ def f(a, b='b', c='c'):
2627

2728
assert f('a', c='c', b='b') == f(b='b')(a='a') == f(c='c')(a='a')(b='b') == ...
2829
```
30+
To keep things simple, the actual signature of curried functions is simply the original function signature,
31+
which allows you to pass curried functions as arguments to functions that expects callbacks:
2932

30-
To keep things simple, the `pfun` MyPy plugin doesn't infer all possible overloads of curried signatures. Instead, the inferred signature is split into the following argument lists: One argument list for optional arguments and `**kwargs` followed by one argument list for each positional argument. If the uncurried function accepts `*args`, it's added to the last positional argument list of the curried function
31-
32-
In other words, given the following function `f`:
3333
```python
34+
from typing import Callable
35+
36+
from pfun import curry
37+
3438
@curry
35-
def f(pos_1: T, pos_2: T, *args: T, keyword: T = '', **kwargs: T) -> T:
36-
...
37-
```
38-
the MyPy plugin infers the following overloaded signatures:
39+
def f(a: int, b: int) -> int: ...
3940

40-
- **Curried signature without optional arguments**
41-
`(pos_1: T) -> (pos_2: T, *args: T) -> T`
42-
- **Curried signature with optional arguments**
43-
`(*, keyword: T =, **kwargs: T) -> (pos_1: T) -> (pos_2: T, *args: T) -> T`
44-
- **Uncurried signature**
45-
`(pos_1: T, pos_2: T, *args: T, *, keyword: T =, **kwargs: T) -> T`
4641

47-
The reasoning behind this behaviour is that the main use-case for currying is
48-
to pass partially applied functions as arguments to other functions that expect
49-
unary function arguments such as `pfun.effect.Effect.map` or `pfun.effect.Effect.and_then`,
50-
and in by-far most cases, we need the required arguments to be applied last:
42+
def h(g: Callable[[int], int]) - int: ...
43+
44+
h(f(1)) # type safe
45+
```
46+
47+
Because the signature of curried functions is not actually curried, this call will
48+
currently issue a false type error:
5149

5250
```python
53-
import operator as op
54-
from pfun.functions import curry
55-
from pfun.effect import success
51+
@curry
52+
def f(a: int, b: int) -> int: ...
5653

57-
success(2).map(curry(op.add)(2)).run(None)
58-
4
54+
55+
def h(g: Callable[[int], Callable[[int], int]]) -> int: ...
56+
57+
58+
h(f) # false type error
5959
```
6060

61-
If this is not the behaviour you need, you can cast the result of calling a curried function,
62-
or use a `lambda`:
61+
If you need to take curried functions as callback arguments, this is a type-safe
62+
alternative
6363
```python
64-
from typing import cast, Callable
65-
64+
from pfun.functions import Curry, curry
6665

6766
@curry
68-
def only_optional_args(a: str = 'a', b: str = 'b') -> str:
69-
...
67+
def f(a: int, b: int) -> int: ...
7068

71-
# we need to cast here because the MyPy plugin does not infer this signature
72-
f = cast(Callable[[str], str], only_optional_args('c'))
69+
def h(g: Curry[Callable[[int, int], int]]) -> int: ...
7370

74-
# alternatively, use a lambda
75-
f = lambda b: only_optional_args('c', b)
71+
72+
h(f) # type safe
7673
```
74+
Currently the `curry` MyPy plugin doesn't support methods. If you want to return a curried function from a method you can use the following workaround:
75+
76+
```python
77+
from pfun.functions import Curry, curry
7778

79+
80+
class C:
81+
def f(self, x: int) -> Curry[[Callable[[int], int]]]:
82+
return curry(lambda y: x + y)
83+
```
7884
## compose
7985

8086
`compose` makes it easy to compose functions while inferring the resulting type signature with MyPy (if the `pfun` MyPy plugin is enabled).

mkdocs.yml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,10 @@ nav:
88
- 'Guide':
99
- What Is This?: what_is_this.md
1010
- Install: install.md
11-
- Effectful (But Side-Effect Free) Programming: effectful_but_side_effect_free.md
11+
- Immutable Objects And Data Structures: immutable_objects_and_data.md
1212
- Useful Functions: useful_functions.md
13+
- Effectful (But Side-Effect Free) Programming: effectful_but_side_effect_free.md
1314
- Curried And Type-Safe Operators: curried_and_typesafe_operators.md
14-
- Immutable Objects And Data Structures: immutable_objects_and_data.md
1515
- Stack-Safety And Recursion: stack_safety.md
1616
- Property-Based Testing: property_based_testing.md
1717
- Other Resources: other_resources.md
@@ -33,6 +33,7 @@ nav:
3333
- 'pfun.trampoline': trampoline_api.md
3434
- 'pfun.hypothesis_strategies': hypothesis_strategies_api.md
3535
- 'pfun.operator': operator_api.md
36+
- 'pfun.lens': lens_api.md
3637
theme:
3738
name: material
3839
palette:

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "pfun"
3-
version = "0.12.2"
3+
version = "0.12.3"
44
description = ""
55
authors = ["Sune Debel <sad@archii.ai>"]
66
readme = "README.md"

src/pfun/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from .either import Either, Left, Right # noqa
55
from .functions import * # noqa
66
from .immutable import Immutable # noqa
7+
from .lens import * # noqa
78
from .list import List # noqa
89
from .maybe import Just, Maybe, Nothing # noqa
910

src/pfun/functions.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ def __init__(self, f: Callable):
154154
self._f = f # type: ignore
155155

156156
def __repr__(self):
157-
return repr(self._f)
157+
return f'curry({repr(self._f)})'
158158

159159
def __call__(self, *args, **kwargs):
160160
signature = inspect.signature(self._f)
@@ -200,6 +200,23 @@ def decorator(*args, **kwargs):
200200
return decorator
201201

202202

203+
def flip(f: Callable) -> Callable:
204+
"""
205+
Reverse the order of positional arguments of `f`
206+
207+
Example:
208+
>>> f = lambda a, b, c: (a, b, c)
209+
>>> flip(f)('a', 'b', 'c')
210+
('c', 'b', 'a')
211+
212+
Args:
213+
f: Function to flip positional arguments of
214+
Returns:
215+
Function with positional arguments flipped
216+
"""
217+
return curry(lambda *args, **kwargs: f(*reversed(args), **kwargs))
218+
219+
203220
__all__ = [
204221
'curry', 'always', 'compose', 'pipeline', 'identity', 'Unary', 'Predicate'
205222
]

src/pfun/immutable.py

Lines changed: 0 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -29,28 +29,5 @@ def __init_subclass__(
2929
frozen=True, init=init, repr=repr, eq=eq, order=order
3030
)(cls)
3131

32-
def clone(self: T, **kwargs) -> T:
33-
"""
34-
Make a shallow copy of an instance, potentially overwriting
35-
fields given by ``kwargs``
36-
37-
Example:
38-
>>> class A(Immutable):
39-
... a: str
40-
>>> a = A('a')
41-
>>> a2 = a.clone(a='new value')
42-
>>> a2.a
43-
"new value"
44-
45-
Args:
46-
kwargs: fields to overwrite
47-
Return:
48-
New instance of same type with copied and overwritten fields
49-
50-
"""
51-
attrs = self.__dict__.copy()
52-
attrs.update(kwargs)
53-
return type(self)(**attrs) # type: ignore
54-
5532

5633
__all__ = ['Immutable']

0 commit comments

Comments
 (0)