Skip to content

Commit baf049e

Browse files
committed
IO monad
1 parent 719e4a3 commit baf049e

25 files changed

+907
-430
lines changed

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,18 @@ We follow Semantic Versions since the `0.1.0` release.
55

66
## WIP
77

8+
### Features
9+
10+
- Adds `IO` marker
11+
- Adds `unsafe` module with unsafe functions
12+
- Changes how functions are located inside the project
13+
14+
### Bugfixes
15+
16+
- Fixes container type in `@pipeline`
17+
- Now `is_successful` is public
18+
- Now `raise_exception` is public
19+
820
### Misc
921

1022
- Changes how `str()` function works for container types

README.md

Lines changed: 78 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ Make your functions return something meaningful, typed, and safe!
1212
## Features
1313

1414
- Provides a bunch of primitives to write declarative business logic
15-
- Enforces [Railway Oriented Programming](https://fsharpforfunandprofit.com/rop/)
15+
- Enforces better architecture
1616
- Fully typed with annotations and checked with `mypy`, [PEP561 compatible](https://www.python.org/dev/peps/pep-0561/)
1717
- Pythonic and pleasant to write and to read (!)
1818
- Support functions and coroutines, framework agnostic
@@ -27,12 +27,21 @@ pip install returns
2727
Make sure you know how to get started, [check out our docs](https://returns.readthedocs.io/en/latest/)!
2828

2929

30-
## Why?
30+
## Contents
3131

32-
Consider this code that you can find in **any** `python` project.
32+
- [Result container](#result-container) that let's you to get rid of exceptions
33+
- [IO marker](#io-marker) that marks all impure operations and structures them
34+
35+
36+
## Result container
37+
38+
Please, make sure that you are also aware of
39+
[Railway Oriented Programming](https://fsharpforfunandprofit.com/rop/).
3340

3441
### Straight-forward approach
3542

43+
Consider this code that you can find in **any** `python` project.
44+
3645
```python
3746
import requests
3847

@@ -84,31 +93,25 @@ just to catch the expected exceptions.
8493

8594
Our code will become complex and unreadable with all this mess!
8695

87-
8896
### Pipeline example
8997

90-
9198
```python
9299
import requests
93-
from returns.functions import pipeline, safe
94-
from returns.result import Result
100+
from returns.result import Result, pipeline, safe
95101

96102
class FetchUserProfile(object):
97103
"""Single responsibility callable object that fetches user profile."""
98104

99-
#: You can later use dependency injection to replace `requests`
100-
#: with any other http library (or even a custom service).
101-
_http = requests
102-
103105
@pipeline
104106
def __call__(self, user_id: int) -> Result['UserProfile', Exception]:
105107
"""Fetches UserProfile dict from foreign API."""
106108
response = self._make_request(user_id).unwrap()
107109
return self._parse_json(response)
108110

109111
@safe
112+
@impure
110113
def _make_request(self, user_id: int) -> requests.Response:
111-
response = self._http.get('/api/users/{0}'.format(user_id))
114+
response = requests.get('/api/users/{0}'.format(user_id))
112115
response.raise_for_status()
113116
return response
114117

@@ -141,8 +144,71 @@ And we can clearly see all result patterns that might happen in this particular
141144
- `Failure[JsonDecodeException]`
142145

143146
And we can work with each of them precisely.
147+
It is a good practice to create `enum` classes or `Union` types
148+
with all the possible errors.
149+
150+
151+
## IO marker
152+
153+
But is that all we can improve?
154+
Let's look at `FetchUserProfile` from another angle.
155+
All its methods looks like a regular ones:
156+
it is impossible to tell whether they are pure or impure from the first sight.
157+
158+
It leads to a very important consequence:
159+
*we start to mix pure and impure code together*.
160+
161+
And suffer really bad when testing / reusing it.
162+
Almost everything should be pure by default.
163+
And we should explicitly mark impure parts of the program.
164+
165+
### Explicit IO
166+
167+
Let's refactor it to make our `IO` explicit!
168+
169+
```python
170+
import requests
171+
from returns.io import IO, impure
172+
from returns.result import Result, pipeline, safe
173+
174+
class FetchUserProfile(object):
175+
"""Single responsibility callable object that fetches user profile."""
176+
177+
@pipeline
178+
def __call__(self, user_id: int) -> Result[IO['UserProfile'], Exception]]:
179+
"""Fetches UserProfile dict from foreign API."""
180+
response = self._make_request(user_id).unwrap()
181+
return self._parse_json(response)
182+
183+
@safe
184+
@impure
185+
def _make_request(self, user_id: int) -> requests.Response:
186+
response = requests.get('/api/users/{0}'.format(user_id))
187+
response.raise_for_status()
188+
return response
189+
190+
@safe
191+
def _parse_json(
192+
self,
193+
io_response: IO[requests.Response],
194+
) -> IO['UserProfile']:
195+
return io_response.map(lambda response: response.json())
196+
```
197+
198+
Now we have explicit markers where the `IO` did happen
199+
and these markers cannot be removed.
200+
201+
Whenever we access `FetchUserProfile` we now know
202+
that it does `IO` and might fail.
203+
So, we act accordingly!
204+
205+
## More!
144206

145207
What more? [Go to the docs!](https://returns.readthedocs.io)
208+
Or read these articles:
209+
210+
- [Python exceptions considered an anti-pattern](https://sobolevn.me/2019/02/python-exceptions-considered-an-antipattern)
211+
- [Enforcing Single Responsibility Principle in Python](https://sobolevn.me/2019/03/enforcing-srp)
146212

147213
## License
148214

docs/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ Contents
1818

1919
pages/container.rst
2020
pages/result.rst
21+
pages/io.rst
2122
pages/functions.rst
2223

2324
.. toctree::

docs/pages/functions.rst

Lines changed: 0 additions & 204 deletions
Original file line numberDiff line numberDiff line change
@@ -3,210 +3,6 @@ Helper functions
33

44
We feature several helper functions to make your developer experience better.
55

6-
7-
is_successful
8-
-------------
9-
10-
:func:`is_succesful <returns.functions.is_successful>` is used to
11-
tell whether or not your result is a success.
12-
We treat only treat types that does not throw as a successful ones,
13-
basically: :class:`Success <returns.result.Success>`.
14-
15-
.. code:: python
16-
17-
from returns.result import Success, Failure
18-
from returns.functions import is_successful
19-
20-
is_successful(Success(1))
21-
# => True
22-
23-
is_successful(Failure('text'))
24-
# => False
25-
26-
.. _pipeline:
27-
28-
pipeline
29-
--------
30-
31-
What is a ``pipeline``?
32-
It is a more user-friendly syntax to work with containers
33-
that support both async and regular functions.
34-
35-
Consider this task.
36-
We were asked to create a method
37-
that will connect together a simple pipeline of three steps:
38-
39-
1. We validate passed ``username`` and ``email``
40-
2. We create a new ``Account`` with this data, if it does not exists
41-
3. We create a new ``User`` associated with the ``Account``
42-
43-
And we know that this pipeline can fail in several places:
44-
45-
1. Wrong ``username`` or ``email`` might be passed, so the validation will fail
46-
2. ``Account`` with this ``username`` or ``email`` might already exist
47-
3. ``User`` creation might fail as well,
48-
since it also makes an ``HTTP`` request to another micro-service deep inside
49-
50-
Here's the code to illustrate the task.
51-
52-
.. code:: python
53-
54-
from returns.functions import pipeline
55-
from returns.result import Result, Success, Failure
56-
57-
58-
class CreateAccountAndUser(object):
59-
"""Creates new Account-User pair."""
60-
61-
# TODO: we need to create a pipeline of these methods somehow...
62-
63-
# Protected methods
64-
65-
def _validate_user(
66-
self, username: str, email: str,
67-
) -> Result['UserSchema', str]:
68-
"""Returns an UserSchema for valid input, otherwise a Failure."""
69-
70-
def _create_account(
71-
self, user_schema: 'UserSchema',
72-
) -> Result['Account', str]:
73-
"""Creates an Account for valid UserSchema's. Or returns a Failure."""
74-
75-
def _create_user(
76-
self, account: 'Account',
77-
) -> Result['User', str]:
78-
"""Create an User instance. If user already exists returns Failure."""
79-
80-
Using bind technique
81-
~~~~~~~~~~~~~~~~~~~~
82-
83-
We can implement this feature using a traditional ``bind`` method.
84-
85-
.. code:: python
86-
87-
class CreateAccountAndUser(object):
88-
"""Creates new Account-User pair."""
89-
90-
def __call__(self, username: str, email: str) -> Result['User', str]:
91-
"""Can return a Success(user) or Failure(str_reason)."""
92-
return self._validate_user(username, email).bind(
93-
self._create_account,
94-
).bind(
95-
self._create_user,
96-
)
97-
98-
# Protected methods
99-
# ...
100-
101-
And this will work without any problems.
102-
But, is it easy to read a code like this? **No**, it is not.
103-
104-
What alternative we can provide? ``@pipeline``!
105-
106-
Using pipeline
107-
~~~~~~~~~~~~~~
108-
109-
And here's how we can refactor previous version to be more clear.
110-
111-
.. code:: python
112-
113-
class CreateAccountAndUser(object):
114-
"""Creates new Account-User pair."""
115-
116-
@pipeline
117-
def __call__(self, username: str, email: str) -> Result['User', str]:
118-
"""Can return a Success(user) or Failure(str_reason)."""
119-
user_schema = self._validate_user(username, email).unwrap()
120-
account = self._create_account(user_schema).unwrap()
121-
return self._create_user(account)
122-
123-
# Protected methods
124-
# ...
125-
126-
Let's see how this new ``.unwrap()`` method works:
127-
128-
- if you result is ``Success`` it will return its inner value
129-
- if your result is ``Failure`` it will raise a ``UnwrapFailedError``
130-
131-
And that's where ``@pipeline`` decorator becomes in handy.
132-
It will catch any ``UnwrapFailedError`` during the pipeline
133-
and then return a simple ``Failure`` result.
134-
135-
.. mermaid::
136-
:caption: Pipeline execution.
137-
138-
sequenceDiagram
139-
participant pipeline
140-
participant validation
141-
participant account creation
142-
participant user creation
143-
144-
pipeline->>validation: runs the first step
145-
validation-->>pipeline: returns Failure(validation message) if fails
146-
validation->>account creation: passes Success(UserSchema) if valid
147-
account creation-->>pipeline: return Failure(account exists) if fails
148-
account creation->>user creation: passes Success(Account) if valid
149-
user creation-->>pipeline: returns Failure(http status) if fails
150-
user creation-->>pipeline: returns Success(user) if user is created
151-
152-
See, do notation allows you to write simple yet powerful pipelines
153-
with multiple and complex steps.
154-
And at the same time the produced code is simple and readable.
155-
156-
And that's it!
157-
158-
159-
safe
160-
----
161-
162-
:func:`safe <returns.functions.safe>` is used to convert
163-
regular functions that can throw exceptions to functions
164-
that return :class:`Result <returns.result.Result>` type.
165-
166-
Supports both async and regular functions.
167-
168-
.. code:: python
169-
170-
from returns.functions import safe
171-
172-
@safe
173-
def divide(number: int) -> float:
174-
return number / number
175-
176-
divide(1)
177-
# => Success(1.0)
178-
179-
divide(0)
180-
# => Failure(ZeroDivisionError)
181-
182-
Limitations
183-
~~~~~~~~~~~
184-
185-
There's one limitation in typing
186-
that we are facing right now
187-
due to `mypy issue <https://github.com/python/mypy/issues/3157>`_:
188-
189-
.. code:: python
190-
191-
from returns.functions import safe
192-
193-
@safe
194-
def function(param: int) -> int:
195-
return param
196-
197-
reveal_type(function)
198-
# Actual => def (*Any, **Any) -> builtins.int
199-
# Expected => def (int) -> builtins.int
200-
201-
This effect can be reduced
202-
with the help of `Design by Contract <https://en.wikipedia.org/wiki/Design_by_contract>`_
203-
with these implementations:
204-
205-
- https://github.com/deadpixi/contracts
206-
- https://github.com/orsinium/deal
207-
- https://github.com/Parquery/icontract
208-
209-
2106
compose
2117
-------
2128

0 commit comments

Comments
 (0)