Skip to content

Commit adc20c5

Browse files
authored
Merge pull request #1 from sobolevn/feature-do-notation
Started working on do-notation
2 parents c7e42f4 + ac26744 commit adc20c5

File tree

11 files changed

+183
-34
lines changed

11 files changed

+183
-34
lines changed

README.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,17 @@ Monads for `python` made simple and safe.
99

1010
- Provides primitives to write declarative business logic
1111
- Fully typed with annotations and checked with `mypy`,
12-
making your code type-safe as well
12+
allowing you to write type-safe code as well
13+
- No operator overloading or other unpythonic stuff that makes your eyes bleed
1314

1415

1516
## Inspirations
1617

1718
This module is heavily based on:
1819

19-
- https://github.com/dry-rb/dry-monads
20-
- https://github.com/dbrattli/OSlash
21-
- https://bitbucket.org/jason_delaat/pymonad
20+
- [dry-rb/dry-monads](https://github.com/dry-rb/dry-monads)
21+
- [Ø](https://github.com/dbrattli/OSlash)
22+
- [pymonad](https://bitbucket.org/jason_delaat/pymonad)
2223

2324

2425
## License

dry_monads/do_notation.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# -*- coding: utf-8 -*-
2+
3+
from functools import wraps
4+
from typing import Callable
5+
6+
from dry_monads.primitives.exceptions import UnwrapFailedError
7+
from dry_monads.primitives.types import MonadType
8+
9+
10+
def do_notation(
11+
function: Callable[..., MonadType],
12+
) -> Callable[..., MonadType]:
13+
"""Decorator to enable 'do-notation' context."""
14+
@wraps(function)
15+
def decorator(*args, **kwargs) -> MonadType:
16+
try:
17+
return function(*args, **kwargs)
18+
except UnwrapFailedError as exc:
19+
return exc.halted_monad
20+
return decorator

dry_monads/either.py

Lines changed: 57 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,93 @@
11
# -*- coding: utf-8 -*-
22

3-
from typing import Callable
3+
from abc import ABCMeta, abstractmethod
4+
from typing import Any, Callable, Generic, NoReturn, TypeVar, Union
45

6+
from typing_extensions import final
7+
8+
from dry_monads.primitives.exceptions import UnwrapFailedError
59
from dry_monads.primitives.monad import Monad, NewValueType, ValueType
610

11+
ErrorType = TypeVar('ErrorType')
12+
713

8-
class Either(Monad[ValueType]):
14+
# That's the most ugly part.
15+
# We need to express `Either` with two type parameters and
16+
# Left and Right with just one parameter.
17+
# And that's how we do it. Any other and more cleaner ways are appreciated.
18+
class Either(Generic[ValueType, ErrorType], metaclass=ABCMeta):
919
"""
1020
Represents a calculation that may either fail or succeed.
1121
1222
An alternative to using exceptions.
1323
'Either' (or its alias 'Result') is an abstract type and should not
1424
be instantiated directly. Instead use 'Right' (or its alias 'Success')
15-
and 'Left' (or its alias 'Failure')
25+
and 'Left' (or its alias 'Failure').
1626
"""
1727

28+
_inner_value: Union[ValueType, ErrorType]
1829

19-
class Left(Either[ValueType]):
30+
@abstractmethod
31+
def unwrap(self) -> ValueType: # pragma: no cover
32+
"""
33+
Custom magic method to unwrap inner value from monad.
34+
35+
Should be redefined for ones that actually have values.
36+
And for ones that raise an exception for no values.
37+
"""
38+
raise NotImplementedError()
39+
40+
41+
@final
42+
class Left(Either[Any, ErrorType], Monad[ErrorType]):
2043
"""
2144
Represents a calculation which has failed.
2245
2346
It should contain an error code or message.
2447
To help with readability you may alternatively use the alias 'Failure'.
2548
"""
2649

27-
def fmap(self, function) -> 'Left[ValueType]':
50+
def __init__(self, inner_value: ErrorType) -> None:
51+
"""
52+
Wraps the given value in the Container.
53+
54+
'value' is any arbitrary value of any type including functions.
55+
"""
56+
self._inner_value = inner_value
57+
58+
def fmap(self, function) -> 'Left[ErrorType]':
2859
"""Returns the 'Left' instance that was used to call the method."""
2960
return Left(self._inner_value)
3061

31-
def bind(self, function) -> 'Left[ValueType]':
62+
def bind(self, function) -> 'Left[ErrorType]':
3263
"""Returns the 'Left' instance that was used to call the method."""
3364
return Left(self._inner_value)
3465

3566
def value_or(self, default_value: NewValueType) -> NewValueType:
3667
"""Returns the value if we deal with 'Right' or default if 'Left'."""
3768
return default_value
3869

70+
def unwrap(self) -> NoReturn:
71+
"""Raises an exception, since it does not have a value inside."""
72+
raise UnwrapFailedError(self)
73+
3974

40-
class Right(Either[ValueType]):
75+
@final
76+
class Right(Either[ValueType, Any], Monad[ValueType]):
4177
"""
4278
Represents a calculation which has succeeded and contains the result.
4379
4480
To help with readability you may alternatively use the alias 'Success'.
4581
"""
4682

83+
def __init__(self, inner_value: ValueType) -> None:
84+
"""
85+
Wraps the given value in the Container.
86+
87+
'value' is any arbitrary value of any type including functions.
88+
"""
89+
self._inner_value = inner_value
90+
4791
def fmap(
4892
self,
4993
function: Callable[[ValueType], NewValueType],
@@ -60,8 +104,8 @@ def fmap(
60104

61105
def bind(
62106
self,
63-
function: Callable[[ValueType], Either[NewValueType]],
64-
) -> Either[NewValueType]:
107+
function: Callable[[ValueType], Either[NewValueType, ErrorType]],
108+
) -> Either[NewValueType, ErrorType]:
65109
"""
66110
Applies 'function' to the result of a previous calculation.
67111
@@ -74,6 +118,10 @@ def value_or(self, default_value: NewValueType) -> ValueType:
74118
"""Returns the value if we deal with 'Right' or default if 'Left'."""
75119
return self._inner_value
76120

121+
def unwrap(self) -> ValueType:
122+
"""Returns the unwrapped value from the inside of this monad."""
123+
return self._inner_value
124+
77125

78126
# Useful aliases for end users:
79127

dry_monads/primitives/exceptions.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# -*- coding: utf-8 -*-
2+
3+
from typing import TYPE_CHECKING
4+
5+
if TYPE_CHECKING: # pragma: no cover
6+
from dry_monads.primitives.types import MonadType # noqa: Z435, F401
7+
8+
9+
class UnwrapFailedError(Exception):
10+
"""Raised when a monad can not be unwrapped into a meaningful value."""
11+
12+
def __init__(self, monad: 'MonadType') -> None:
13+
"""
14+
Saves halted monad in the inner state.
15+
16+
So, this monad can later be unpacked from this exception
17+
and used as a regular value.
18+
"""
19+
super().__init__()
20+
self.halted_monad = monad

dry_monads/primitives/monad.py

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# -*- coding: utf-8 -*-
22

33
from abc import ABCMeta, abstractmethod
4-
from typing import Generic, TypeVar
4+
from typing import Any, Generic, TypeVar
55

66
ValueType = TypeVar('ValueType')
77
NewValueType = TypeVar('NewValueType')
@@ -19,15 +19,7 @@ class Monad(Generic[ValueType], metaclass=ABCMeta):
1919
2020
"""
2121

22-
_inner_value: ValueType
23-
24-
def __init__(self, inner_value: ValueType) -> None:
25-
"""
26-
Wraps the given value in the Container.
27-
28-
'value' is any arbitrary value of any type including functions.
29-
"""
30-
self._inner_value = inner_value
22+
_inner_value: Any
3123

3224
@abstractmethod
3325
def fmap(self, function): # pragma: no cover
@@ -36,7 +28,7 @@ def fmap(self, function): # pragma: no cover
3628
3729
And returns a new functor value.
3830
"""
39-
raise NotImplementedError
31+
raise NotImplementedError()
4032

4133
@abstractmethod
4234
def bind(self, function): # pragma: no cover
@@ -45,12 +37,22 @@ def bind(self, function): # pragma: no cover
4537
4638
And returns a new monad.
4739
"""
48-
raise NotImplementedError
40+
raise NotImplementedError()
4941

5042
@abstractmethod
51-
def value_of(self, default_value):
43+
def value_or(self, default_value): # pragma: no cover
5244
"""Forces to unwrap value from monad or return a default."""
53-
raise NotImplementedError
45+
raise NotImplementedError()
46+
47+
@abstractmethod
48+
def unwrap(self) -> ValueType: # pragma: no cover
49+
"""
50+
Custom magic method to unwrap inner value from monad.
51+
52+
Should be redefined for ones that actually have values.
53+
And for ones that raise an exception for no values.
54+
"""
55+
raise NotImplementedError()
5456

5557
def __str__(self) -> str:
5658
"""Converts to string."""

dry_monads/primitives/types.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# -*- coding: utf-8 -*-
2+
3+
from typing import TYPE_CHECKING, TypeVar, Union
4+
5+
if TYPE_CHECKING: # pragma: no cover
6+
from dry_monads.either import Either # noqa: Z435, F401
7+
from dry_monads.primitives.monad import Monad # noqa: Z435, F401
8+
9+
# We need to have this ugly type because there is no other way around it:
10+
MonadType = TypeVar('MonadType', bound=Union['Monad', 'Either'])

poetry.lock

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,11 @@ authors = ["sobolevn <mail@sobolevn.me>"]
66
license = "MIT"
77

88
[tool.poetry.dependencies]
9-
python = "^3.6"
9+
python = "^3.6 || ^3.7"
10+
typing-extensions = "^3.7"
1011

1112
[tool.poetry.dev-dependencies]
1213
mypy = "^0.660.0"
13-
typing-extensions = "^3.7"
1414
attrs = "^18.2"
1515
wemake-python-styleguide = "^0.7.0"
1616
flake8-pytest = "^1.3"

setup.cfg

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,9 +63,12 @@ line_length = 79
6363
# The mypy configurations: http://bit.ly/2zEl9WI
6464
python_version = 3.6
6565

66+
# We have disabled this checks due to some problems with `mypy` type
67+
# system, it does not look like it will be fixed soon.
68+
# disallow_any_explicit = True
69+
# disallow_any_generics = True
70+
6671
check_untyped_defs = True
67-
disallow_any_explicit = True
68-
disallow_any_generics = True
6972
disallow_untyped_calls = True
7073
ignore_errors = False
7174
ignore_missing_imports = True
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# -*- coding: utf-8 -*-
2+
3+
from dry_monads.do_notation import do_notation
4+
from dry_monads.either import Either, Failure, Success
5+
6+
7+
@do_notation
8+
def _example1(number: int) -> Either[int, str]:
9+
first = Success(1).unwrap()
10+
second = Success(number).unwrap() if number else Failure('E').unwrap()
11+
return Success(first + second)
12+
13+
14+
@do_notation
15+
def _example2(number: int) -> Success[int]:
16+
first: int = Success(1).unwrap()
17+
return Success(first + Failure(number).unwrap())
18+
19+
20+
def test_do_notation_success():
21+
"""Ensures that do notation works well for Success."""
22+
assert _example1(5) == Success(6)
23+
24+
25+
def test_do_notation_failure():
26+
"""Ensures that do notation works well for Failure."""
27+
assert _example1(0) == Failure('E')
28+
assert _example2(0) == Failure(0)

0 commit comments

Comments
 (0)