├── tests ├── __init__.py ├── random_int_generator.py ├── test_option.py ├── test_monad.py ├── test_either.py ├── test_try.py └── test_future.py ├── .coveragerc ├── logo.jpg ├── setup.cfg ├── docs ├── _static │ └── logo.jpg ├── map_and_flatmap.rst ├── index.rst ├── future.rst ├── monads.rst ├── either.rst ├── options.rst ├── try.rst ├── make.bat ├── Makefile └── conf.py ├── tox.ini ├── .github ├── ISSUE_TEMPLATE │ ├── Custom.md │ ├── Feature_request.md │ └── Bug_report.md ├── CODE_OF_CONDUCT.md ├── ISSUE_TEMPLATE.md └── workflows │ ├── update_readme_coverage.yml │ └── pull_request.yml ├── pytest.ini ├── pyeffects ├── __version__.py ├── __init__.py ├── Monad.py ├── Option.py ├── Either.py ├── Try.py └── Future.py ├── .gitignore ├── Pipfile ├── Makefile ├── README.md ├── setup.py ├── LICENSE └── Pipfile.lock /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = pyeffects/packages/* -------------------------------------------------------------------------------- /logo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vickumar1981/pyeffects/HEAD/logo.jpg -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = 1 3 | 4 | [metadata] 5 | license_file = LICENSE 6 | -------------------------------------------------------------------------------- /docs/_static/logo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vickumar1981/pyeffects/HEAD/docs/_static/logo.jpg -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py38 3 | 4 | [testenv] 5 | 6 | commands = 7 | python setup.py test 8 | -------------------------------------------------------------------------------- /tests/random_int_generator.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | 4 | def random_int(): 5 | return int(random.random() * 100) 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/Custom.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Request for Help 3 | about: Guidance on using pyEffects. 4 | 5 | --- 6 | 7 | -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | Be cordial or be on your way. 2 | 3 | https://www.kennethreitz.org/essays/be-cordial-or-be-on-your-way 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/Feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | 5 | --- 6 | 7 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | pythonpath = . 3 | addopts = -p no:warnings --doctest-modules 4 | doctest_optionflags= NORMALIZE_WHITESPACE ELLIPSIS -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | Summary. 2 | 3 | ## Expected Result 4 | 5 | What you expected. 6 | 7 | ## Actual Result 8 | 9 | What happened instead. 10 | 11 | ## Reproduction Steps 12 | 13 | ```python 14 | import pyeffects.Option.* 15 | 16 | ``` 17 | 18 | -------------------------------------------------------------------------------- /pyeffects/__version__.py: -------------------------------------------------------------------------------- 1 | __title__ = "pyeffects" 2 | __description__ = "Monads for Python. Side-effect explicitly." 3 | __url__ = "https://github.com/vickumar1981/pyeffects" 4 | __version__ = "1.00.5" 5 | __build__ = 0x010005 6 | __author__ = "Vic Kumar" 7 | __author_email__ = "vickumar@gmail.com" 8 | __license__ = "Apache 2.0" 9 | __copyright__ = "Copyright 2020 Vic Kumar" 10 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/Bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | 5 | --- 6 | 7 | Summary. 8 | 9 | ## Expected Result 10 | 11 | What you expected. 12 | 13 | ## Actual Result 14 | 15 | What happened instead. 16 | 17 | ## Reproduction Steps 18 | 19 | ```python 20 | import pyeffects.Option.* 21 | 22 | ``` 23 | 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | pyeffects.egg-info/ 3 | htmlcov/ 4 | .coverage 5 | MANIFEST 6 | coverage.xml 7 | nosetests.xml 8 | junit-report.xml 9 | pylint.txt 10 | .cache/ 11 | cover/ 12 | build/ 13 | docs/_build 14 | requests.egg-info/ 15 | *.pyc 16 | *.swp 17 | *.egg 18 | env/ 19 | .venv/ 20 | .eggs/ 21 | .tox/ 22 | .pytest_cache/ 23 | .vscode/ 24 | .eggs/ 25 | dist 26 | 27 | /.mypy_cache/ 28 | 29 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | name = "pypi" 3 | url = "https://pypi.org/simple" 4 | verify_ssl = true 5 | 6 | [dev-packages] 7 | alabaster = "*" 8 | codecov = "*" 9 | docutils = "*" 10 | detox = "*" 11 | black = "*" 12 | more-itertools = "<6.0" 13 | pytest = "*" 14 | pytest-mock = "*" 15 | pytest-cov = "*" 16 | pytest-xdist = "*" 17 | readme-renderer = "*" 18 | sphinx = "*" 19 | tox = "*" 20 | mypy = "*" 21 | typing_extensions = "*" 22 | 23 | [packages] 24 | black = "*" 25 | 26 | [requires] 27 | python_version = "3.8" 28 | -------------------------------------------------------------------------------- /pyeffects/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | pyEffects Library 5 | ~~~~~~~~~~~~~~~~~ 6 | Monads for Python. Side-effect explicitly. 7 | 8 | Handle your side-effects in Python like a boss. Implements functional types for Either, Option, Try, and Future. 9 | 10 | Basic usage: 11 | >>> from pyeffects.Option import * 12 | >>> val = Some(5).map(lambda v: v * v) 13 | >>> val 14 | Some(25) 15 | >>> val.is_defined() 16 | True 17 | >>> val.get() 18 | 25 19 | 20 | :copyright: (c) 2020 by Vic Kumar. 21 | :license: Apache 2.0, see LICENSE for more details. 22 | """ 23 | 24 | __version__ = "1.00.5" 25 | -------------------------------------------------------------------------------- /docs/map_and_flatmap.rst: -------------------------------------------------------------------------------- 1 | Map and Flat Map 2 | ================ 3 | 4 | `map` takes in a function (A -> B) where A and B are any type. 5 | 6 | For example: 7 | >>> from pyeffects.Option import * 8 | >>> Some("abc").map(lambda s: len(s)) 9 | Some(3) 10 | 11 | .. image:: https://porizi.files.wordpress.com/2014/02/map.png 12 | 13 | `flat_map` takes in a function (A -> M[B]) where A and B are any types and M is the container. 14 | 15 | For example: 16 | >>> from pyeffects.Option import * 17 | >>> Some("abc").flat_map(lambda s: Some(len(s))) 18 | Some(3) 19 | 20 | .. image:: https://porizi.files.wordpress.com/2014/02/flatmap.png 21 | 22 | `flat_map` is a more general version of the `map` function. 23 | 24 | Both operations return a container of type B. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: docs 2 | init: 3 | pip install pipenv --upgrade 4 | pipenv install --dev 5 | 6 | test: 7 | # This runs all of the tests 8 | pipenv run mypy pyeffects/*.py 9 | tox 10 | 11 | ci: 12 | pipenv run mypy pyeffects/*.py 13 | pipenv run py.test --junitxml=pytest.xml --cov-report=term-missing:skip-covered --cov=pyeffects tests/ | tee pytest-coverage.txt 14 | 15 | 16 | test-readme: 17 | @pipenv run python setup.py check --restructuredtext --strict && ([ $$? -eq 0 ] && echo "README.rst and HISTORY.rst ok") || echo "Invalid markup in README.rst or HISTORY.rst!" 18 | 19 | black: 20 | pipenv run black --check pyeffects tests 21 | 22 | coverage: 23 | pipenv run py.test --cov-config .coveragerc --verbose --cov-report term --cov-report xml --cov=pyeffects tests 24 | 25 | coverhtml: 26 | pipenv run py.test --cov-config .coveragerc --verbose --cov-report term --cov-report html --cov=pyeffects tests 27 | 28 | 29 | publish: 30 | pip install 'twine>=1.5.0' 31 | python setup.py sdist bdist_wheel 32 | twine upload dist/* 33 | rm -fr build dist .egg pyeffects.egg-info 34 | 35 | docs: 36 | cd docs && make html 37 | @echo "\033[95m\n\nBuild successful! View the docs homepage at docs/_build/html/index.html.\n\033[0m" 38 | 39 | clean: 40 | rm -Rf .coverage htmlcov/ .tox .pytest_cache/ .eggs/ pyeffects.egg-info/ build/ dist/ 41 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | 2 | pyEffects: Monads for Python™ 3 | ============================= 4 | 5 | 6 | Handle your side-effects in Python like a boss. Implements functional types for Either, Option, Try, and Future. 7 | 8 | ---------------- 9 | 10 | .. toctree:: 11 | :maxdepth: 2 12 | 13 | monads 14 | map_and_flatmap 15 | options 16 | try 17 | either 18 | future 19 | 20 | ---------------- 21 | 22 | **Using Option**:: 23 | 24 | >>> from pyeffects.Option import * 25 | >>> val = Some(5).map(lambda v: v * v) 26 | >>> val 27 | Some(25) 28 | >>> val.is_defined() 29 | True 30 | >>> val.get() 31 | 25 32 | 33 | **Using Try**:: 34 | 35 | >>> from pyeffects.Try import * 36 | >>> val = Success(5).map(lambda v: v * v) 37 | >>> val 38 | Success(25) 39 | >>> val.is_success() 40 | True 41 | >>> val.get() 42 | 25 43 | 44 | **Using Either**:: 45 | 46 | >>> from pyeffects.Either import * 47 | >>> val = Right(5).map(lambda v: v * v) 48 | >>> val 49 | Right(25) 50 | >>> val.is_right() 51 | True 52 | >>> val.right() 53 | 25 54 | 55 | **Using Future**:: 56 | 57 | >>> from pyeffects.Future import * 58 | >>> val = Future.of(5).map(lambda v: v * v) 59 | >>> val 60 | Future(Success(25)) 61 | >>> val.on_complete(lambda v: print(v)) 62 | Success(25) 63 | >>> val.get() 64 | 25 -------------------------------------------------------------------------------- /.github/workflows/update_readme_coverage.yml: -------------------------------------------------------------------------------- 1 | name: Update Coverage on Readme 2 | on: 3 | push: 4 | branches: 5 | - master 6 | jobs: 7 | update-coverage-on-readme: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v3 11 | with: 12 | persist-credentials: false # otherwise, the token used is the GITHUB_TOKEN, instead of your personal token 13 | fetch-depth: 0 # otherwise, you will failed to push refs to dest repo 14 | 15 | - name: Install pyenv 16 | run: | 17 | curl https://pyenv.run | bash 18 | 19 | - name: Install dependencies 20 | run: | 21 | make init 22 | 23 | - name: Run tests and coverage 24 | run: | 25 | make ci 26 | 27 | - name: Coverage comment 28 | uses: MishaKav/pytest-coverage-comment@main 29 | with: 30 | pytest-coverage-path: ./pytest-coverage.txt 31 | junitxml-path: ./pytest.xml 32 | 33 | - name: Update Readme with Coverage Html 34 | run: | 35 | sed -i '//,//c\\n\${{ steps.summary_report.outputs.content }}\n' ./README.md 36 | 37 | - name: Commit & Push changes to Readme 38 | uses: actions-js/push@master 39 | with: 40 | message: Update coverage on Readme 41 | github_token: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /pyeffects/Monad.py: -------------------------------------------------------------------------------- 1 | from typing import Callable, Generic, TypeVar 2 | 3 | A = TypeVar("A", covariant=True) 4 | B = TypeVar("B") 5 | 6 | 7 | class Monad(Generic[A]): 8 | value: A 9 | biased: bool 10 | 11 | @staticmethod 12 | def of(x: B) -> "Monad[B]": 13 | raise NotImplementedError("of method needs to be implemented") 14 | 15 | def flat_map(self, f: Callable[[A], "Monad[B]"]) -> "Monad[B]": 16 | raise NotImplementedError("flat_map method needs to be implemented") 17 | 18 | def map(self, func: Callable[[A], B]) -> "Monad[B]": 19 | if not hasattr(func, "__call__"): 20 | raise TypeError("map expects a callable") 21 | 22 | def wrapped(x: A) -> "Monad[B]": # type: ignore 23 | return self.of(func(x)) 24 | 25 | return self.flat_map(wrapped) 26 | 27 | def foreach(self, func: Callable[[A], B]) -> None: 28 | if not hasattr(func, "__call__"): 29 | raise TypeError("foreach expects a callable") 30 | self.map(func) 31 | 32 | def get(self) -> A: 33 | if self.biased: 34 | return self.value 35 | raise TypeError("get cannot be called on this class") 36 | 37 | def get_or_else(self, v: A) -> A: # type: ignore 38 | if self.biased: 39 | return self.value 40 | else: 41 | return v 42 | 43 | def or_else_supply(self, func: Callable[[], A]) -> A: 44 | if not hasattr(func, "__call__"): 45 | raise TypeError("or_else_supply expects a callable") 46 | if self.biased: 47 | return self.value 48 | else: 49 | return func() 50 | 51 | def or_else(self, other: "Monad[A]") -> "Monad[A]": 52 | if not isinstance(other, Monad): 53 | raise TypeError("or_else can only be chained with other Monad classes") 54 | if self.biased: 55 | return self 56 | else: 57 | return other 58 | 59 | 60 | def identity(value): 61 | return value 62 | -------------------------------------------------------------------------------- /.github/workflows/pull_request.yml: -------------------------------------------------------------------------------- 1 | name: Pull Request 2 | 3 | on: 4 | - pull_request 5 | 6 | permissions: 7 | contents: read 8 | pull-requests: write 9 | 10 | concurrency: 11 | group: test-${{ github.head_ref || github.run_id }} 12 | cancel-in-progress: true 13 | 14 | jobs: 15 | build: 16 | runs-on: ubuntu-latest 17 | strategy: 18 | fail-fast: false 19 | matrix: 20 | python-version: ["3.8", "3.9", "3.10"] 21 | 22 | steps: 23 | - uses: actions/checkout@v3 24 | - name: Set up Python ${{ matrix.python-version }} 25 | uses: actions/setup-python@v4 26 | with: 27 | python-version: ${{ matrix.python-version }} 28 | 29 | - name: Install pyenv 30 | run: | 31 | curl https://pyenv.run | bash 32 | 33 | - name: Install dependencies 34 | run: | 35 | make init 36 | 37 | - name: Run tests and coverage 38 | run: | 39 | make ci 40 | 41 | - name: Coverage comment 42 | uses: MishaKav/pytest-coverage-comment@main 43 | with: 44 | pytest-coverage-path: ./pytest-coverage.txt 45 | junitxml-path: ./pytest.xml 46 | 47 | - name: Check the output coverage 48 | run: | 49 | echo "Coverage Percantage - ${{ steps.coverageComment.outputs.coverage }}" 50 | echo "Coverage Color - ${{ steps.coverageComment.outputs.color }}" 51 | echo "Coverage Html - ${{ steps.coverageComment.outputs.coverageHtml }}" 52 | echo "Summary Report - ${{ steps.coverageComment.outputs.summaryReport }}" 53 | 54 | echo "Coverage Warnings - ${{ steps.coverageComment.outputs.warnings }}" 55 | 56 | echo "Coverage Errors - ${{ steps.coverageComment.outputs.errors }}" 57 | echo "Coverage Failures - ${{ steps.coverageComment.outputs.failures }}" 58 | echo "Coverage Skipped - ${{ steps.coverageComment.outputs.skipped }}" 59 | echo "Coverage Tests - ${{ steps.coverageComment.outputs.tests }}" 60 | echo "Coverage Time - ${{ steps.coverageComment.outputs.time }}" 61 | echo "Not Success Test Info - ${{ steps.coverageComment.outputs.notSuccessTestInfo }}" 62 | -------------------------------------------------------------------------------- /tests/test_option.py: -------------------------------------------------------------------------------- 1 | from pyeffects.Option import * 2 | from pyeffects.Monad import identity 3 | from pyeffects.Try import Try 4 | from .random_int_generator import random_int 5 | 6 | 7 | class TestOption: 8 | @staticmethod 9 | def _sq_int(v): 10 | return Option.of(v * v) 11 | 12 | @staticmethod 13 | def _dbl_int(v): 14 | return Option.of(v + v) 15 | 16 | def test_option_of_none_is_empty(self): 17 | empty_option = Option.of(None) 18 | assert empty_option.is_empty() and empty_option is empty 19 | 20 | def test_option_right_identity(self): 21 | value = random_int() 22 | assert Option.of(value).flat_map(identity) == value 23 | 24 | def test_option_left_identity(self): 25 | value = random_int() 26 | assert ( 27 | Option.of(value).flat_map(self._sq_int).get() == self._sq_int(value).get() 28 | ) 29 | 30 | def test_option_associativity(self): 31 | value = random_int() 32 | value1 = Option.of(value).flat_map( 33 | lambda v1: self._sq_int(v1).flat_map(lambda v2: self._dbl_int(v2)) 34 | ) 35 | value2 = Option.of(value).flat_map(self._sq_int).flat_map(self._dbl_int) 36 | assert value1.get() == value2.get() 37 | 38 | def test_empty_option_flat_maps_to_empty(self): 39 | assert empty.flat_map(lambda v: Option.of(v)).is_empty() 40 | 41 | def test_option_flat_map_requires_callable(self): 42 | result = Try.of(lambda: Some(random_int()).flat_map(random_int())) 43 | assert result.is_failure() and isinstance(result.error(), TypeError) 44 | 45 | def test_option_repr(self): 46 | assert str(Some(random_int())).startswith("Some") 47 | assert str(empty).startswith("Empty") 48 | 49 | def test_some_equality(self): 50 | value = random_int() 51 | assert Some(value) == Some(value) 52 | 53 | def test_some_inequality(self): 54 | value = random_int() 55 | assert Some(value) != Some(value + 1) 56 | 57 | def test_empty_equality(self): 58 | assert Empty() == Empty() 59 | 60 | def test_option_type_inequality(self): 61 | assert Some(random_int()) != Empty() 62 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Logo](logo.jpg) 2 | 3 | # pyEffects 4 | 5 | 6 | 7 | [![PyPI version](https://badge.fury.io/py/pyeffects.svg)](https://badge.fury.io/py/pyeffects) [![Documentation Status](https://readthedocs.org/projects/pyeffects/badge/?version=latest)](https://pyeffects.readthedocs.io/en/latest/?badge=latest) 8 | [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://github.com/vickumar1981/pyeffects/blob/master/LICENSE) 9 | 10 | Monads for Python. Side-effect explicitly. 11 | 12 | Handle your side-effects in Python like a boss. Implements functional types for Either, Option, Try, and Future. 13 | 14 | For more detailed information, please refer to the [API Documentation](https://pyeffects.readthedocs.io/en/latest/ "API Documentation"). 15 | 16 | --- 17 | ### 1. Install 18 | 19 | `pip install pyeffects` 20 | 21 | --- 22 | ### 2. Using Option 23 | ```python 24 | >>> from pyeffects.Option import * 25 | >>> val = Some(5).map(lambda v: v * v) 26 | >>> val 27 | Some(25) 28 | >>> val.is_defined() 29 | True 30 | >>> val.get() 31 | 25 32 | 33 | ``` 34 | 35 | --- 36 | ### 3. Using Try 37 | ```python 38 | >>> from pyeffects.Try import * 39 | >>> val = Success(5).map(lambda v: v * v) 40 | >>> val 41 | Success(25) 42 | >>> val.is_success() 43 | True 44 | >>> val.get() 45 | 25 46 | 47 | ``` 48 | 49 | --- 50 | ### 4. Using Either 51 | ```python 52 | >>> from pyeffects.Either import * 53 | >>> val = Right(5).map(lambda v: v * v) 54 | >>> val 55 | Right(25) 56 | >>> val.is_right() 57 | True 58 | >>> val.right() 59 | 25 60 | ``` 61 | 62 | --- 63 | ### 5. Using Future 64 | ```python 65 | >>> from pyeffects.Future import * 66 | >>> val = Future.of(5).map(lambda v: v * v) 67 | >>> val 68 | Future(Success(25)) 69 | >>> val.on_complete(lambda v: print(v)) 70 | Success(25) 71 | >>> val.get() 72 | 25 73 | ``` 74 | 75 | --- 76 | ### 6. Reporting an Issue 77 | 78 | Please report any issues or bugs to the [Github issues page](https://github.com/vickumar1981/pyeffects/issues). 79 | 80 | --- 81 | ### 7. License 82 | 83 | This project is licensed under the [Apache 2 License](https://github.com/vickumar1981/pyeffects/blob/master/LICENSE). 84 | -------------------------------------------------------------------------------- /docs/future.rst: -------------------------------------------------------------------------------- 1 | 2 | Using Futures 3 | ============= 4 | 5 | 6 | Using the Future monad 7 | 8 | ---------------- 9 | 10 | The Future monad represents the result of an asynchronous computation that runs on another thread. 11 | 12 | We use `Future.run` to run a function asynchronously. Let's look at an example: 13 | 14 | >>> import time 15 | >>> from pyeffects.Future import * 16 | >>> import time 17 | >>> def delayed_result(): 18 | ... time.sleep(0.1) 19 | ... return 100 20 | ... 21 | >>> result = Future.run(delayed_result).map(lambda v: v + 1) 22 | >>> result.is_done() 23 | False 24 | >>> time.sleep(0.2) 25 | >>> result.is_done() 26 | True 27 | >>> result.get() 28 | 101 29 | 30 | Initially, the `Future` has no value, and runs your function in the background on another thread. Once the function 31 | completes, the return value is supplied to the `Future` and can be retrieved using the `get()` method. 32 | 33 | We can also create an immediate future from a value: 34 | 35 | >>> from pyeffects.Future.import * 36 | >>> val = Future.of(5).map(lambda v: v * v) 37 | >>> val 38 | Future(Success(25)) 39 | 40 | ---------------- 41 | 42 | **map and flat_map**: `Future` can also use `flat_map` and `map` functions to chain operations. 43 | 44 | >>> from pyeffects.Future import * 45 | >>> hello = Future.of("Hello") 46 | >>> world = Future.of("World") 47 | >>> hello.flat_map(lambda h: world.map(lambda w: h + " " + w + "!")) 48 | Future(Success(Hello World!)) 49 | 50 | If we try to map or flat_map on a `Future` that has failed, we get back a failed `Future` 51 | 52 | >>> from pyeffects.Future import * 53 | >>> hello = Future.of("Hello") 54 | >>> failed = Future.run(lambda: int("Hello World")) # Can't convert a string to int 55 | >>> hello.flat_map(lambda h: world.map(lambda w: h + " " + w + "!")) 56 | Future(Failure(invalid literal for int() with base 10: 'Hello World')) 57 | 58 | ---------------- 59 | 60 | **on_complete** can be used when we want to call a function when the future has completed. 61 | 62 | >>> from pyeffects.Future import * 63 | >>> Future.of("Hello World!").on_complete(lambda s: print(s)) 64 | Success(Hello World!) -------------------------------------------------------------------------------- /docs/monads.rst: -------------------------------------------------------------------------------- 1 | 2 | What is a Monad? 3 | ================ 4 | 5 | 6 | What is a `Monad `_ and when to use it? 7 | 8 | ---------------- 9 | 10 | `A Monad is a container. `_ 11 | 12 | A Monad contains elements, but instead of operating on those elements directly, the container has properties 13 | and functions that allow us to work with the elements inside of it. These are often called higher-order functions, 14 | because they take another function as an input parameter. Typically, a monad has two very important higher-order 15 | functions called `map` and `flat_map`. 16 | 17 | `More on map and flat_map `_ 18 | 19 | .. image:: https://porizi.files.wordpress.com/2014/02/monad-transformations.png 20 | 21 | Often, a complex tasks has several steps, and this can result in very complex code. We might have callbacks, 22 | multiple functions that pass results back and forth, conditional trees, or other kinds of sticky patterns, 23 | that result in greater complexity and more time spent debugging. 24 | 25 | We often want to take a complex problem and break it down into smaller tasks, and then put 26 | those tasks back together to solve a problem. We can reduce the complexity by breaking down a problem, 27 | and letting each individual part of the process take care of its own responsibility, and then passing 28 | the result onto the next part in the chain. We want to break down our problem into a number of small 29 | functions, and then compose those functions together into one function that represents the entire task. 30 | 31 | Composing functions is the reason why we want to use monads. We can create a container that can perform 32 | actions on its contents, and then use the container as a way to manage those contents and perform the 33 | actions we need, in the order we need to, encapsulating our operations via function composition. 34 | Instead of handling the objects and outputs of functions, we put the objects into a container, and then 35 | write up a manifest of all the functions we need done to it. Like an assembly line, we put the 36 | raw materials in one end and get the finished product from the other end. 37 | 38 | When we create a Monad, we’re essentially building an assembly line out of individual assembly line 39 | components (functions) that can handle raw materials of various types. When we execute our code, 40 | we’re feeding raw materials (values) into the assembly line and watching it operate, 41 | eventually spitting out a finished product. 42 | -------------------------------------------------------------------------------- /tests/test_monad.py: -------------------------------------------------------------------------------- 1 | from pyeffects.Option import * 2 | from pyeffects.Try import * 3 | from .random_int_generator import random_int 4 | 5 | 6 | class TestMonad: 7 | def test_monad_pure_not_implemented(self): 8 | err = Try.of(lambda: Monad.of(random_int())) 9 | assert err.is_failure() and isinstance(err.error(), NotImplementedError) 10 | 11 | def test_monad_map(self): 12 | value = Some(random_int()) 13 | assert value.map(lambda v: v + v).get() == value.get() * 2 14 | 15 | def test_get_on_empty_monad_throws_type_error(self): 16 | err = Try.of(lambda: empty.get()) 17 | assert err.is_failure() and isinstance(err.error(), TypeError) 18 | 19 | def test_monad_get_or_else_with_success(self): 20 | value = random_int() 21 | assert Some(value).get_or_else(random_int()) == value 22 | 23 | def test_monad_get_or_else_with_failure(self): 24 | value = random_int() 25 | assert empty.get_or_else(value) == value 26 | 27 | def test_monad_or_else_supply_with_success(self): 28 | value = random_int() 29 | assert Some(value).or_else_supply(lambda: random_int()) == value 30 | 31 | def test_monad_or_else_suplly_with_failure(self): 32 | value = random_int() 33 | assert empty.or_else_supply(lambda: value) == value 34 | 35 | def test_monad_or_else_with_success(self): 36 | value = random_int() 37 | result = Some(value).or_else(Some(random_int())) 38 | assert result.get() == value 39 | 40 | def test_monad_or_else_with_failure(self): 41 | value = random_int() 42 | result = empty.or_else(Some(value)) 43 | assert result.get() == value 44 | 45 | def test_monad_foreach_is_none(self): 46 | result = Some(random_int()).foreach(lambda r: random_int()) 47 | assert result is None 48 | 49 | def test_monad_map_requires_callable(self): 50 | result = Try.of(lambda: Some(random_int()).map(random_int())) 51 | assert result.is_failure() and isinstance(result.error(), TypeError) 52 | 53 | def test_monad_foreach_requires_callable(self): 54 | result = Try.of(lambda: Some(random_int()).foreach(random_int())) 55 | assert result.is_failure() and isinstance(result.error(), TypeError) 56 | 57 | def test_monad_or_else_supply_requires_callable(self): 58 | result = Try.of(lambda: Some(random_int()).or_else_supply(random_int())) 59 | assert result.is_failure() and isinstance(result.error(), TypeError) 60 | 61 | def test_monad_or_else_requires_other_monad(self): 62 | result = Try.of(lambda: Some(random_int()).or_else(random_int())) 63 | assert result.is_failure() and isinstance(result.error(), TypeError) 64 | -------------------------------------------------------------------------------- /docs/either.rst: -------------------------------------------------------------------------------- 1 | 2 | Using Either 3 | ============ 4 | 5 | 6 | Using the `Either Type `_ 7 | 8 | ---------------- 9 | 10 | `Either `_ is a monad that represents either one thing or another thing. 11 | 12 | An `Either` has two subclasses that hold values: `Left` and `Right`. We can think of `Try` as a type of 13 | `Either` where the `Left` value is always a `Failure`. Either is a more generic version of Try, where the 14 | left value can be anything, and not just a `Failure` class. 15 | 16 | We can create an `Either` in three ways: 17 | 18 | >>> from pyeffects.Either import * 19 | >>> Either.of(123) 20 | Right(123) 21 | >>> Right("abc") 22 | Right(abc) 23 | >>> Left("abc") 24 | Left(abc) 25 | 26 | We can also check if an `Either` is `Left` or `Right`. 27 | 28 | >>> from pyeffects.Either import * 29 | >>> v = Right("abc") 30 | >>> v.is_right() 31 | True 32 | >>> v.is_left() 33 | False 34 | 35 | Let's say we want to convert a string to an integer, and then double the value if it is a valid integer. 36 | We can map successful values as `Right` and invalid values to `Left`. 37 | 38 | >>> from pyeffects.Either import * 39 | >>> from pyeffects.Try import * 40 | >>> def to_int(s): 41 | ... val = Try.of(lambda: int(s)) 42 | ... return Right(val) if val.is_success() else Left("age is invalid") 43 | ... 44 | >>> to_int("5") 45 | Right(5) 46 | >>> value = to_int("not an integer") 47 | >>> value 48 | Left(age is invalid) 49 | >>> value.is_right() 50 | False 51 | >>> value.is_left() 52 | True 53 | >>> to_int("not an integer").map(lambda v: v * 2) # Failure does not map 54 | Left(age is invalid) 55 | >>> to_int("5").map(lambda v: v * 2) # Map success and double value 56 | Right(10) 57 | 58 | ---------------- 59 | 60 | **map and flat_map**: Either is a monad and we can use `flat_map` and `map` with them. 61 | 62 | >>> from pyeffects.Either import * 63 | >>> hello = Right("Hello") 64 | >>> world = Right("World") 65 | >>> hello.flat_map(lambda h: world.map(lambda w: h + " " + w + "!")) 66 | Right(Hello World!) 67 | 68 | If we try to map or flat_map on a `Left`, we get back a `Left`. 69 | 70 | >>> from pyeffects.Either import * 71 | >>> left_value = Left("Hello") 72 | >>> left_value.flat_map(lambda v: Right(len(v))) 73 | Left(Hello) 74 | 75 | ---------------- 76 | 77 | **left and right**: We can retrieve the value of an `Either` by using `left` and `right`, for `Left` and `Right` values. 78 | 79 | >>> from pyeffects.Either import * 80 | >>> left_value = Left(123) 81 | >>> right_value = Right("abc") 82 | >>> left_value.left() 83 | 123 84 | >>> right_value.right() 85 | 'abc' -------------------------------------------------------------------------------- /docs/options.rst: -------------------------------------------------------------------------------- 1 | 2 | Using Option 3 | ============ 4 | 5 | 6 | Using the `Option class `_ 7 | 8 | ---------------- 9 | 10 | A simple monad might be a container that holds either one element or zero elements. 11 | 12 | This is the `Option class. `_ `Option` has two subclasses: `Some` and 13 | `Empty`. If the `Option` contains a value, it is `Some(value)`, otherwise it is `Empty`. 14 | 15 | We can create an `Option` in three ways: 16 | 17 | >>> from pyeffects.Option import * 18 | >>> Option.of(123) 19 | Some(123) 20 | >>> Some("abc") 21 | Some(abc) 22 | >>> empty 23 | Empty() 24 | 25 | We can also check if an `Option` is empty of not. 26 | 27 | >>> from pyeffects.Option import * 28 | >>> v = Some("abc") 29 | >>> v.is_defined() 30 | True 31 | >>> v.is_empty() 32 | False 33 | 34 | ---------------- 35 | 36 | **map and flat_map**: Options are monads, so we can use `flat_map` and `map` with them. 37 | 38 | >>> from pyeffects.Option import * 39 | >>> hello = Some("Hello") 40 | >>> world = Some("World") 41 | >>> hello.flat_map(lambda h: world.map(lambda w: h + " " + w + "!")) 42 | Some(Hello World!) 43 | 44 | If we try to map or flat_map on an `Empty`, we get back an `Empty`, 45 | 46 | >>> from pyeffects.Option import * 47 | >>> hello = Some("Hello") 48 | >>> world = empty 49 | >>> hello.flat_map(lambda h: world.map(lambda w: h + " " + w + "!")) 50 | Empty() 51 | 52 | ---------------- 53 | 54 | **get and get_or_else**: We can retrieve the value of an `Option` by using `get`. However, this is unsafe. 55 | If the `Option` is `Empty`, it will throw an exception. 56 | 57 | >>> from pyeffects.Option import * 58 | >>> v = empty 59 | >>> v.get() 60 | TypeError: get cannot be called on this class 61 | 62 | Because of this, we try to avoid using .get wherever possible. Instead, we can use `get_or_else`. This 63 | returns the contents of the Option if available, or a default value if it’s not. 64 | 65 | >>> from pyeffects.Option import * 66 | >>> v1 = Some("Hello") 67 | >>> v2 = empty 68 | >>> v1.get_or_else("World") 69 | 'Hello' 70 | >>> v2.get_or_else("World") 71 | 'World' 72 | 73 | ---------------- 74 | 75 | **or_else_supply and or_else**: We can also supply a value if our 'Option' is empty using a function or another `Option`, 76 | using `or_else_supply` and `or_else` respectively. 77 | 78 | >>> from pyeffects.Option import * 79 | >>> empty.or_else_supply(lambda: "Hello World!") 80 | 'Hello World!' 81 | >>> empty.or_else(Some("Hello World!")) 82 | Some(Hello World!) 83 | 84 | ---------------- 85 | 86 | **foreach** can be used when we want to apply a function, but unlike with `map`, we don't care about the return value. 87 | 88 | >>> from pyeffects.Option import * 89 | >>> Some("Hello World!").foreach(lambda s: print(s)) 90 | 'Hello World!' 91 | -------------------------------------------------------------------------------- /tests/test_either.py: -------------------------------------------------------------------------------- 1 | from pyeffects.Either import * 2 | from pyeffects.Monad import identity 3 | from pyeffects.Try import Try 4 | from .random_int_generator import random_int 5 | 6 | 7 | class TestEither: 8 | @staticmethod 9 | def _sq_int(v): 10 | return Right(v * v) 11 | 12 | @staticmethod 13 | def _dbl_int(v): 14 | return Right(v + v) 15 | 16 | def test_either_is_right_by_default(self): 17 | assert Either.of(random_int()).is_right() 18 | 19 | def test_left_either_properties(self): 20 | value = Left(random_int()) 21 | assert value.is_left() and not value.is_right() 22 | 23 | def test_right_either_properties(self): 24 | value = Right(random_int()) 25 | assert value.is_right() and not value.is_left() 26 | 27 | def test_either_right_identity(self): 28 | value = random_int() 29 | assert Right(value).flat_map(identity) == value 30 | 31 | def test_either_left_identity(self): 32 | value = random_int() 33 | assert Right(value).flat_map(self._sq_int).get() == self._sq_int(value).get() 34 | 35 | def test_either_associativity(self): 36 | value = random_int() 37 | value1 = Right(value).flat_map( 38 | lambda v1: self._sq_int(v1).flat_map(lambda v2: self._dbl_int(v2)) 39 | ) 40 | value2 = Right(value).flat_map(self._sq_int).flat_map(self._dbl_int) 41 | assert value1.get() == value2.get() 42 | 43 | def test_right_value_equals_get(self): 44 | value = random_int() 45 | assert Right(value).right() == Right(value).get() 46 | 47 | def test_left_either_flat_maps_is_left(self): 48 | value = random_int() 49 | result = Left(value).flat_map(lambda v: Right(v)) 50 | assert result.is_left() and result.left() == value 51 | 52 | def test_left_maps_does_not_map(self): 53 | value = random_int() 54 | assert Left(value).map(lambda v: v * 2).left() == value 55 | 56 | def test_left_map_requires_callable(self): 57 | result = Try.of(lambda: Left(random_int()).map(random_int())) 58 | assert result.is_failure() and isinstance(result.error(), TypeError) 59 | 60 | def test_either_flat_map_requires_callable(self): 61 | result = Try.of(lambda: Right(random_int()).flat_map(random_int())) 62 | assert result.is_failure() and isinstance(result.error(), TypeError) 63 | 64 | def test_either_repr(self): 65 | assert str(Right(random_int())).startswith("Right") 66 | assert str(Left(random_int())).startswith("Left") 67 | 68 | def test_left_equality(self): 69 | value = random_int() 70 | assert Left(value) == Left(value) 71 | 72 | def test_left_inequality(self): 73 | value = random_int() 74 | assert Left(value) != Left(value + 1) 75 | 76 | def test_right_equality(self): 77 | value = random_int() 78 | assert Right(value) == Right(value) 79 | 80 | def test_right_inequality(self): 81 | value = random_int() 82 | assert Right(value) != Right(value + 1) 83 | 84 | def test_either_type_inequality(self): 85 | value = random_int() 86 | assert Right(value) != Left(value) 87 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | from codecs import open 6 | 7 | from setuptools import setup 8 | from setuptools.command.test import test as TestCommand 9 | 10 | here = os.path.abspath(os.path.dirname(__file__)) 11 | 12 | 13 | class PyTest(TestCommand): 14 | user_options = [('pytest-args=', 'a', "Arguments to pass into py.test")] 15 | 16 | def initialize_options(self): 17 | TestCommand.initialize_options(self) 18 | try: 19 | from multiprocessing import cpu_count 20 | self.pytest_args = ['-n', str(cpu_count())] 21 | except (ImportError, NotImplementedError): 22 | self.pytest_args = ['-n', '1'] 23 | 24 | def finalize_options(self): 25 | TestCommand.finalize_options(self) 26 | self.test_args = [] 27 | self.test_suite = True 28 | 29 | def run_tests(self): 30 | import pytest 31 | 32 | errno = pytest.main(self.pytest_args) 33 | sys.exit(errno) 34 | 35 | 36 | # 'setup.py publish' shortcut. 37 | if sys.argv[-1] == 'publish': 38 | os.system('python setup.py sdist bdist_wheel') 39 | os.system('twine upload dist/*') 40 | sys.exit() 41 | 42 | packages = ['pyeffects'] 43 | 44 | requires = [] 45 | test_requirements = [ 46 | 'pytest-cov', 47 | 'pytest-mock', 48 | 'pytest-xdist', 49 | 'pytest>=3' 50 | ] 51 | 52 | about = {} 53 | with open(os.path.join(here, 'pyeffects', '__version__.py'), 'r', 'utf-8') as f: 54 | exec(f.read(), about) 55 | 56 | with open('README.md', 'r', 'utf-8') as f: 57 | readme = f.read() 58 | 59 | setup( 60 | name=about['__title__'], 61 | version=about['__version__'], 62 | description=about['__description__'], 63 | long_description=readme, 64 | long_description_content_type='text/markdown', 65 | author=about['__author__'], 66 | author_email=about['__author_email__'], 67 | url=about['__url__'], 68 | packages=packages, 69 | package_data={'': ['LICENSE', 'NOTICE'], 'pyeffects': ['*.pem']}, 70 | package_dir={'pyeffects': 'pyeffects'}, 71 | include_package_data=True, 72 | python_requires=">=3.8", 73 | install_requires=requires, 74 | license=about['__license__'], 75 | zip_safe=False, 76 | classifiers=[ 77 | 'Development Status :: 5 - Production/Stable', 78 | 'Intended Audience :: Developers', 79 | 'Natural Language :: English', 80 | 'License :: OSI Approved :: Apache Software License', 81 | 'Programming Language :: Python', 82 | 'Programming Language :: Python :: 2', 83 | 'Programming Language :: Python :: 2.7', 84 | 'Programming Language :: Python :: 3', 85 | 'Programming Language :: Python :: 3.5', 86 | 'Programming Language :: Python :: 3.6', 87 | 'Programming Language :: Python :: 3.7', 88 | 'Programming Language :: Python :: 3.8', 89 | 'Programming Language :: Python :: Implementation :: CPython', 90 | 'Programming Language :: Python :: Implementation :: PyPy' 91 | ], 92 | cmdclass={'test': PyTest}, 93 | tests_require=test_requirements, 94 | extras_require={}, 95 | project_urls={ 96 | 'Source': 'https://github.com/vickumar1981/pyeffects', 97 | }, 98 | ) 99 | -------------------------------------------------------------------------------- /pyeffects/Option.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | pyeffects.Option 5 | ~~~~~~~~~~~~---- 6 | 7 | This module implements the Option, Some, and Empty classes. 8 | """ 9 | from typing import Callable, TypeVar 10 | from .Monad import Monad 11 | 12 | A = TypeVar("A", covariant=True) 13 | B = TypeVar("B") 14 | 15 | 16 | class Option(Monad[A]): 17 | @staticmethod 18 | def of(value: B) -> "Option[B]": 19 | """Constructs a :class:`Option