├── src └── safetywrap │ ├── py.typed │ ├── __init__.py │ ├── _interface.py │ └── _impl.py ├── tests ├── __init__.py ├── test_infra.py ├── test_meta.py ├── test_option.py └── test_result.py ├── examples ├── README.md ├── option_examples.py └── result_examples.py ├── tox.ini ├── .vscode └── settings.json ├── bench ├── runner.sh └── sample.py ├── setup.cfg ├── azure-pipelines.yml ├── scripts └── check_ready_to_distribute.py ├── .gitignore ├── Makefile ├── CHANGELOG.md ├── setup.py ├── LICENSE ├── .pylintrc └── README.md /src/safetywrap/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Test modules for result types.""" 2 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | These examples obviously assume safetywrap is installed. Any other required 4 | libraries will be mentioned in the examples. 5 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | basepython = py3 3 | envlist = py36, py37, py38 4 | minversion = 3.6.0 5 | 6 | [testenv] 7 | whitelist_externals = 8 | make 9 | commands = 10 | make test 11 | -------------------------------------------------------------------------------- /src/safetywrap/__init__.py: -------------------------------------------------------------------------------- 1 | """Typesafe python versions of Rust-inspired result types.""" 2 | 3 | __all__ = ("Option", "Result", "Ok", "Err", "Some", "Nothing") 4 | __version__ = "1.5.0" 5 | __version_info__ = tuple(map(int, __version__.split("."))) 6 | 7 | 8 | from ._impl import Option, Result, Ok, Err, Some, Nothing 9 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | 4 | "markdown.extension.toc.githubCompatibility": true, 5 | 6 | "python.formatting.provider": "black", 7 | "python.formatting.blackArgs": ["--line-length", "80"], 8 | 9 | "python.linting.flake8Enabled": true, 10 | "python.linting.flake8Args": [], 11 | "python.linting.pydocstyleEnabled": true, 12 | "python.linting.pydocstyleArgs": [], 13 | "python.linting.pylintEnabled": true, 14 | "python.linting.pylintArgs": [], 15 | "python.linting.mypyEnabled": true, 16 | "python.linting.mypyArgs": [], 17 | } -------------------------------------------------------------------------------- /bench/runner.sh: -------------------------------------------------------------------------------- 1 | # Must have hyperfine installed. 2 | 3 | DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" 4 | 5 | echo "Overall execution time, including interpreter spinup" 6 | echo 7 | 8 | hyperfine "python $DIR/sample.py classical" --min-runs 100 --warmup 50 9 | 10 | hyperfine "python $DIR/sample.py monadic" --min-runs 100 --warmup 50 11 | 12 | echo "Average execution time in seconds within python, excluding interpreter spinup, over 1e6 iterations" 13 | echo 14 | 15 | echo "Classical" 16 | python "$DIR/sample.py" classical timeit 17 | 18 | echo "Monadic" 19 | python "$DIR/sample.py" monadic timeit 20 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [coverage:run] 2 | branch = True 3 | source = safetywrap 4 | 5 | [mypy] 6 | check_untyped_defs = True 7 | follow_imports = silent 8 | ignore_missing_imports = True 9 | show_column_numbers = True 10 | disallow_untyped_calls = True 11 | disallow_untyped_defs = True 12 | disallow_incomplete_defs = True 13 | disallow_untyped_decorators = True 14 | strict_optional = True 15 | warn_redundant_casts = True 16 | warn_return_any = True 17 | warn_unused_ignores = True 18 | 19 | [mypy-tests.*] 20 | disallow_untyped_decorators = False 21 | 22 | [flake8] 23 | max-line-length = 80 24 | per-file-ignores = 25 | **/__init__.py:F401 26 | 27 | [pydocstyle] 28 | add-ignore = D202, D203, D213, D400, D413 29 | 30 | [tool:pytest] 31 | junit_family=xunit2 32 | -------------------------------------------------------------------------------- /tests/test_infra.py: -------------------------------------------------------------------------------- 1 | """Ensure CI & any other infra is set up correctly.""" 2 | 3 | import safetywrap 4 | 5 | 6 | def test_pass() -> None: 7 | """Always pass to verify tests are running.""" 8 | assert True 9 | 10 | 11 | class TestPublicInterface: 12 | """Test the interface to be sure we don't accidentally drop things.""" 13 | 14 | def test_top_level(self) -> None: 15 | """Test the public package interface.""" 16 | exp_attrs = ( 17 | "__version__", 18 | "__version_info__", 19 | "Option", 20 | "Result", 21 | "Ok", 22 | "Err", 23 | "Some", 24 | "Nothing", 25 | ) 26 | assert all(map(lambda attr: bool(getattr(safetywrap, attr)), exp_attrs)) 27 | -------------------------------------------------------------------------------- /azure-pipelines.yml: -------------------------------------------------------------------------------- 1 | # Python package 2 | # Create and test a Python package on multiple Python versions. 3 | # Add steps that analyze code, save the dist with the build record, publish to a PyPI-compatible index, and more: 4 | # https://docs.microsoft.com/azure/devops/pipelines/languages/python 5 | 6 | pool: 7 | vmImage: "ubuntu-latest" 8 | strategy: 9 | matrix: 10 | Python36: 11 | python.version: "3.6" 12 | Python37: 13 | python.version: "3.7" 14 | Python38: 15 | python.version: "3.8" 16 | 17 | steps: 18 | - task: UsePythonVersion@0 19 | inputs: 20 | versionSpec: "$(python.version)" 21 | displayName: "Use Python $(python.version)" 22 | 23 | - script: | 24 | sudo apt-get update && sudo apt-get install python3-venv 25 | condition: startswith(variables['python.version'], '3') 26 | displayName: "install deps" 27 | 28 | - script: | 29 | make lint 30 | displayName: "lint" 31 | 32 | - script: | 33 | make test 34 | displayName: "test" 35 | 36 | - task: PublishCodeCoverageResults@1 37 | inputs: 38 | codeCoverageTool: "Cobertura" 39 | summaryFileLocation: "$(System.DefaultWorkingDirectory)/**/.coverage.xml" 40 | 41 | - task: PublishTestResults@2 42 | inputs: 43 | testResultsFiles: "$(System.DefaultWorkingDirectory)/**/.pytest.xml" 44 | -------------------------------------------------------------------------------- /scripts/check_ready_to_distribute.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Prepare to distribute the package.""" 3 | 4 | from subprocess import Popen, PIPE 5 | from sys import argv 6 | 7 | import safetywrap 8 | 9 | 10 | def check_version(version: str) -> None: 11 | """Ensure the version matches the package.""" 12 | assert version == safetywrap.__version__, "Failed version check." 13 | 14 | 15 | def check_branch() -> None: 16 | """Ensure we are on the master branch.""" 17 | proc = Popen( 18 | ("git", "rev-parse", "--abbrev-ref", "HEAD"), stdout=PIPE, stderr=PIPE 19 | ) 20 | out, err = proc.communicate() 21 | assert proc.returncode == 0, err.decode() 22 | assert out.decode().strip() == "master", "Not on master!" 23 | 24 | 25 | def check_diff() -> None: 26 | """Ensure there is no git diff output with origin/master.""" 27 | proc = Popen(("git", "diff", "origin/master"), stdout=PIPE, stderr=PIPE) 28 | out, err = proc.communicate() 29 | assert proc.returncode == 0, err.decode() 30 | assert out.decode().strip() == "", "There is a diff with origin/master!" 31 | 32 | 33 | def main() -> None: 34 | """Check version, git tag, etc.""" 35 | version = argv[1] 36 | check_version(version) 37 | check_branch() 38 | check_diff() 39 | 40 | 41 | if __name__ == "__main__": 42 | main() 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | .pytest.xml 50 | *.cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | 63 | # Flask stuff: 64 | instance/ 65 | .webassets-cache 66 | 67 | # Scrapy stuff: 68 | .scrapy 69 | 70 | # Sphinx documentation 71 | docs/_build/ 72 | 73 | # PyBuilder 74 | target/ 75 | 76 | # Jupyter Notebook 77 | .ipynb_checkpoints 78 | 79 | # IPython 80 | profile_default/ 81 | ipython_config.py 82 | 83 | # pyenv 84 | .python-version 85 | 86 | # pipenv 87 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 88 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 89 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 90 | # install all needed dependencies. 91 | #Pipfile.lock 92 | 93 | # celery beat schedule file 94 | celerybeat-schedule 95 | 96 | # SageMath parsed files 97 | *.sage.py 98 | 99 | # Environments 100 | .env 101 | .venv 102 | env/ 103 | venv/ 104 | ENV/ 105 | env.bak/ 106 | venv.bak/ 107 | 108 | # Spyder project settings 109 | .spyderproject 110 | .spyproject 111 | 112 | # Rope project settings 113 | .ropeproject 114 | 115 | # mkdocs documentation 116 | /site 117 | 118 | # mypy 119 | .mypy_cache/ 120 | .dmypy.json 121 | dmypy.json 122 | 123 | # Pyre type checker 124 | .pyre/ 125 | 126 | 127 | **/.DS_Store 128 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | VENV = . ./venv/bin/activate; 2 | PKG_DIR = src 3 | TEST_DIR = tests 4 | LINE_LENGTH = 80 5 | SRC_FILES = *.py $(PKG_DIR) $(TEST_DIR) 6 | TEST = pytest \ 7 | --cov-config=setup.cfg \ 8 | --cov-report=xml:.coverage.xml \ 9 | --cov-report=term \ 10 | --cov=safetywrap \ 11 | --doctest-modules \ 12 | --junit-xml=.pytest.xml \ 13 | $(PKG_DIR) \ 14 | $(TEST_DIR) 15 | 16 | .PHONY: bench build clean distribute fmt lint test 17 | 18 | all: fmt lint test 19 | 20 | # venv: venv/bin/activate 21 | venv: setup.py 22 | python3 -m venv venv 23 | $(VENV) pip install -e .[dev] 24 | touch venv 25 | # touch venv/bin/activate 26 | 27 | 28 | venv-clean: 29 | rm -rf venv 30 | 31 | 32 | venv-refresh: venv-clean venv 33 | $(VENV) pip install -e .[dev] 34 | 35 | 36 | venv-update: venv 37 | $(VENV) pip install -e .[dev] 38 | 39 | 40 | build: venv build-clean 41 | $(VENV) python setup.py sdist bdist_wheel 42 | 43 | 44 | build-clean: 45 | rm -rf build dist 46 | rm -rf src/*.egg-info 47 | 48 | clean: 49 | find . -type f -name "*.py[co]" -delete 50 | find . -type d -name "__pycache__" -delete 51 | 52 | # Requires VERSION to be set on the CLI or in an environment variable, 53 | # e.g. make VERSION=1.0.0 distribute 54 | distribute: build 55 | $(VENV) scripts/check_ready_to_distribute.py $(VERSION) 56 | git tag -s "v$(VERSION)" 57 | $(VENV) twine upload -s dist/* 58 | git push --tags 59 | 60 | fmt: venv 61 | $(VENV) black --line-length $(LINE_LENGTH) $(SRC_FILES) 62 | 63 | lint: venv 64 | $(VENV) black --check --line-length $(LINE_LENGTH) $(SRC_FILES) 65 | $(VENV) pydocstyle $(SRC_FILES) 66 | $(VENV) flake8 $(SRC_FILES) 67 | $(VENV) pylint --errors-only $(SRC_FILES) 68 | $(VENV) mypy $(SRC_FILES) 69 | 70 | setup: venv-clean venv 71 | 72 | test: venv 73 | $(VENV) $(TEST) 74 | 75 | tox: venv 76 | TOXENV=$(TOXENV) tox 77 | 78 | test-3.6: 79 | docker run --rm -it --mount type=bind,source="$(PWD)",target="/src" -w "/src" \ 80 | python:3.6 bash -c "make clean && pip install -e .[dev] && $(TEST); make clean" 81 | 82 | test-3.7: 83 | docker run --rm -it --mount type=bind,source="$(PWD)",target="/src" -w "/src" \ 84 | python:3.7 bash -c "make clean && pip install -e .[dev] && $(TEST); make clean" 85 | 86 | test-3.8: 87 | docker run --rm -it --mount type=bind,source="$(PWD)",target="/src" -w "/src" \ 88 | python:3.8 bash -c "make clean && pip install -e .[dev] && $(TEST); make clean" 89 | 90 | test-all-versions: test-3.6 test-3.7 test-3.8 91 | 92 | bench: venv 93 | source venv/bin/activate; bench/runner.sh 94 | 95 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | ## [1.5.0] - 2020-09-23 11 | 12 | ### Added 13 | 14 | - Type inference now allows covariance for `Result` and `Option` wrapped types 15 | - Allows a function of type `Callable[[int], RuntimeError]` to be applied 16 | via flatmap (`.and_then()`) to a result of type `Result[int, Exception]` 17 | - Allows e.g. any of the following to be assigned to a type of 18 | `Result[Number, Exception]`: 19 | - `Result[int, RuntimeError]` 20 | - `Result[float, TypeError]` 21 | - etc. 22 | - This makes `.and_then()`/`.flatmap()`, `.map()`, `.map_err()`, and so on 23 | much more convenient to use in result chains. 24 | 25 | ## [1.4.0] - 2020-03-09 26 | 27 | ### Added 28 | 29 | - New `Option.collect` constructor to create an `Option[Tuple[T, ...]]` 30 | from an iterable of `Option[T]`. If all Options in the iterator are `Some[T]`, 31 | they are collected into a tuple in the resulting `Some`. If any are 32 | `Nothing()`, the result is `Nothing()`. 33 | 34 | ## [1.3.1] - 2020-02-21 35 | 36 | ### Fixed 37 | 38 | - Fix pylint `assignment-from-no-return` warnings for methods that can only 39 | raise, seen when pylint can determine whether a value is an Ok/Err or 40 | Some/Nothing and you try to e.g. `Err(5).expect("no good")`. 41 | 42 | ## [1.3.0] - 2019-01-12 43 | 44 | ### Added 45 | 46 | - Testing in CI for Python 3.8 47 | - `Option.raise_if_nothing(self, msg: str, exc_cls: t.Type[Exception] = RuntimeError) -> T` 48 | added as a semantically friendly alias for `Option.expect`. 49 | 50 | ### Deprecated 51 | 52 | - `Option.raise_if_err` is deprecated in favor of `Option.raise_if_nothing`. 53 | Will be removed in `2.0.0` 54 | 55 | ## [1.2.0] - 2019-01-09 56 | 57 | ### Added 58 | 59 | - `Result.raise_if_err(self, msg: str, exc_cls: t.Type[Exception] = RuntimeError) -> T` 60 | added as a semantically friendly alias for `Result.expect`. 61 | - `Option.raise_if_err(self, msg: str, exc_cls: t.Type[Exception] = RuntimeError) -> T` 62 | added as a semantically friendly alias for `Option.expect`. 63 | 64 | ## [1.1.0] - 2019-01-03 65 | 66 | ### Added 67 | 68 | - `Result.collect(iterable: Iterable[T, E]) -> Result[Tuple[T, ...], E]` added 69 | to collect an iterable of results into a single result, short-circuiting 70 | if any errors are encountered 71 | 72 | ## [1.0.2] - 2019-12-12 73 | 74 | ### Fixed 75 | 76 | - `Result.expect()` and `Result.expect_err()` now appends the stringified 77 | `Err` or `Ok` result to the provided `msg`. 78 | 79 | ## [1.0.1] - 2019-12-09 80 | 81 | ### Fixed 82 | 83 | - All interface methods now, through the magic of dependent imports, specify 84 | that they should return implementation instances. This makes working with 85 | functions specified to return a `Result` or an `Option` much easier (1780999) 86 | 87 | ## [1.0.0] - 2019-05-19 88 | 89 | ### Added 90 | 91 | - Result and Option generic type interfaces 92 | - Ok and Err Result implementations 93 | - Some and Nothing Option implementations 94 | - CI pipeline with Azure pipelines 95 | - Full public interface testing 96 | - Makefile for common operations, including venv setup, linting, formatting, 97 | and testing 98 | - Basic benchmarks for analyzing performance 99 | - Apache license 100 | 101 | [Unreleased]: https://github.com/mplanchard/safetywrap/compare/v1.5.0...HEAD 102 | [1.5.0]: https://github.com/mplanchard/safetywrap/compare/v1.4.0...v1.5.0 103 | [1.4.0]: https://github.com/mplanchard/safetywrap/compare/v1.3.1...v1.4.0 104 | [1.3.1]: https://github.com/mplanchard/safetywrap/compare/v1.3.0...v1.3.1 105 | [1.3.0]: https://github.com/mplanchard/safetywrap/compare/v1.2.0...v1.3.0 106 | [1.2.0]: https://github.com/mplanchard/safetywrap/compare/v1.1.0...v1.2.0 107 | [1.1.0]: https://github.com/mplanchard/safetywrap/compare/v1.0.2...v1.1.0 108 | [1.0.2]: https://github.com/mplanchard/safetywrap/compare/v1.0.1...v1.0.2 109 | [1.0.1]: https://github.com/mplanchard/safetywrap/compare/v1.0.0...v1.0.1 110 | [1.0.0]: https://github.com/mplanchard/safetywrap/compare/f87fa5b1a00af5ef26213e576730039d87f7163b...v1.0.0 111 | -------------------------------------------------------------------------------- /bench/sample.py: -------------------------------------------------------------------------------- 1 | """A benchmark to be run externally. 2 | 3 | Executes a program that might make heavy use of Result/Option types 4 | in one of two ways: classically, with exceptions, or using result types. 5 | 6 | The program checks several data stores (in memory to minimize interference 7 | from slow IO &c.) in order for a key. If it finds it, it gets the value, 8 | adds something to it, and then overwrites the value. 9 | """ 10 | 11 | import sys 12 | import typing as t 13 | 14 | from timeit import timeit 15 | 16 | from safetywrap import Some, Nothing, Ok, Err, Option, Result 17 | 18 | 19 | T = t.TypeVar("T") 20 | 21 | 22 | class ClassicalDataStore: 23 | def __init__(self, values: dict = None) -> None: 24 | self._values = values or {} 25 | 26 | def connect(self, fail: bool = False) -> "ClassicalDataStore": 27 | """'Connect' to the store.""" 28 | if fail: 29 | raise RuntimeError("Failed to connect") 30 | return self 31 | 32 | def get(self, key: str) -> t.Any: 33 | """Return a value from the store.""" 34 | return self._values.get(key) 35 | 36 | def insert(self, key: str, val: T, overwrite: bool = False) -> T: 37 | """Insert the value and return it.""" 38 | if key in self._values and not overwrite: 39 | raise KeyError("Key already exists") 40 | self._values[key] = val 41 | return val 42 | 43 | 44 | class MonadicDataStore: 45 | """Using the monadic types.""" 46 | 47 | def __init__(self, values: dict = None) -> None: 48 | self._values = values or {} 49 | 50 | def connect(self, fail: bool = False) -> Result["MonadicDataStore", str]: 51 | if fail: 52 | return Err("failed to connect") 53 | return Ok(self) 54 | 55 | def get(self, key: str) -> Option[t.Any]: 56 | """Return a value from the store.""" 57 | if key in self._values: 58 | return Some(self._values[key]) 59 | return Nothing() 60 | 61 | def insert( 62 | self, key: str, val: T, overwrite: bool = False 63 | ) -> Result[T, str]: 64 | """Insert the value and return it.""" 65 | if key in self._values and not overwrite: 66 | return Err("Key already exists") 67 | self._values[key] = val 68 | return Ok(val) 69 | 70 | 71 | class Classical: 72 | """Run the program in the classical way.""" 73 | 74 | def __init__(self) -> None: 75 | self._stores = { 76 | 0: ClassicalDataStore(), 77 | 1: ClassicalDataStore(), 78 | 2: ClassicalDataStore(), 79 | 3: ClassicalDataStore({"you": "me"}), 80 | } 81 | 82 | def run(self) -> None: 83 | """Run the program.""" 84 | for store in self._stores.values(): 85 | try: 86 | store = store.connect() 87 | except RuntimeError: 88 | continue 89 | val = store.get("you") 90 | if val is not None: 91 | new_val = val + "et" 92 | try: 93 | inserted = store.insert("you", new_val) 94 | except KeyError: 95 | # oops, need to specify overwrite 96 | inserted = store.insert("you", new_val, overwrite=True) 97 | assert inserted == "meet" 98 | break 99 | else: 100 | raise RuntimeError("Could not get value anywhere.") 101 | 102 | 103 | class Monadic: 104 | """Use the monadic types.""" 105 | 106 | def __init__(self) -> None: 107 | self._stores = { 108 | 0: MonadicDataStore(), 109 | 1: MonadicDataStore(), 110 | 2: MonadicDataStore(), 111 | 3: MonadicDataStore({"you": "me"}), 112 | } 113 | 114 | def run(self) -> None: 115 | """Run the program.""" 116 | for unconnected in self._stores.values(): 117 | connected = unconnected.connect() 118 | if connected.is_err(): 119 | continue 120 | store = connected.unwrap() 121 | inserted = ( 122 | store.get("you") 123 | .ok_or("no such val") 124 | .map(lambda val: str(val + "et")) 125 | .and_then( 126 | lambda val: store.insert("you", val).or_else( 127 | lambda _: store.insert("you", val, overwrite=True) 128 | ) 129 | ) 130 | ) 131 | if inserted.is_ok(): 132 | assert inserted.unwrap() == "meet" 133 | break 134 | else: 135 | raise RuntimeError("Could not get value anywhere") 136 | 137 | 138 | if __name__ == "__main__": 139 | to_run = sys.argv[1].lower() 140 | 141 | switch: t.Dict[str, t.Callable[[], None]] = { 142 | "classical": lambda: Classical().run(), 143 | "monadic": lambda: Monadic().run(), 144 | } 145 | 146 | if to_run not in switch: 147 | raise RuntimeError("No such method: {}".format(to_run)) 148 | 149 | if len(sys.argv) > 2 and sys.argv[2] == "timeit": 150 | # run internal timings 151 | NUMBER = int(1e6) 152 | taken = timeit("switch[to_run]()", globals=globals(), number=NUMBER) 153 | print(taken / NUMBER) 154 | else: 155 | switch[to_run]() 156 | -------------------------------------------------------------------------------- /tests/test_meta.py: -------------------------------------------------------------------------------- 1 | """Test meta-requirements of the implementations.""" 2 | 3 | import typing as t 4 | 5 | import pytest 6 | 7 | from safetywrap._interface import _Option, _Result 8 | from safetywrap import Some, Nothing, Option, Ok, Err, Result 9 | 10 | 11 | class TestInterfaceConformance: 12 | """Ensure the implementations implement and do not extend the interfaces. 13 | 14 | This is a bit of a unique situation, where the usual open-closed 15 | principle does not apply. We want our implementations to conform 16 | EXACTLY to the interface, and not to extend it, since the whole 17 | idea here is that you can treat an Ok() the same as an Err(), 18 | or a Some() the same as a Nothing. 19 | """ 20 | 21 | @staticmethod 22 | def _public_method_names(obj: object) -> t.Tuple[str, ...]: 23 | """Return public method names from an object.""" 24 | return tuple( 25 | sorted( 26 | map( 27 | lambda i: i[0], 28 | filter( 29 | lambda i: not i[0].startswith("_") and callable(i[1]), 30 | obj.__dict__.items(), 31 | ), 32 | ) 33 | ) 34 | ) 35 | 36 | def test_ok_interface(self) -> None: 37 | """"The Ok interface matches Result.""" 38 | assert self._public_method_names(Ok) == self._public_method_names( 39 | _Result 40 | ) 41 | 42 | def test_err_interface(self) -> None: 43 | """The Err interface matches Result.""" 44 | assert self._public_method_names(Err) == self._public_method_names( 45 | _Result 46 | ) 47 | 48 | def test_some_interface(self) -> None: 49 | """The Some interface matches Option.""" 50 | assert self._public_method_names(Some) == self._public_method_names( 51 | _Option 52 | ) 53 | 54 | def test_nothing_interface(self) -> None: 55 | """The Nothing interface matches Option.""" 56 | assert self._public_method_names(Nothing) == self._public_method_names( 57 | _Option 58 | ) 59 | 60 | 61 | class TestNoBaseInstantiations: 62 | """Base types are not instantiable""" 63 | 64 | def test_result_cannot_be_instantiated(self) -> None: 65 | """Result cannot be instantiated""" 66 | with pytest.raises(NotImplementedError): 67 | r: Result[str, str] = Result("a") 68 | assert r 69 | 70 | def test_option_cannot_be_instantiated(self) -> None: 71 | """Option cannot be instantiated""" 72 | with pytest.raises(NotImplementedError): 73 | Option("a") 74 | 75 | 76 | class TestNoConcretesInInterfaces: 77 | """Interfaces contain only abstract methods.""" 78 | 79 | @staticmethod 80 | def assert_not_concrete(kls: t.Type, meth: str) -> None: 81 | """Assert the method on the class is not concrete.""" 82 | with pytest.raises(NotImplementedError): 83 | for num_args in range(10): 84 | try: 85 | getattr(kls, meth)(*map(str, range(num_args))) 86 | except TypeError: 87 | continue 88 | else: 89 | break 90 | 91 | @staticmethod 92 | def filter_meths(cls: t.Type, meth: str) -> bool: 93 | if not callable(getattr(cls, meth)): 94 | return False 95 | if not meth.startswith("_"): 96 | return True 97 | check_magic_methods = ("eq", "init", "iter", "ne", "repr", "str") 98 | 99 | if any(map(lambda m: meth == "__%s__" % m, check_magic_methods)): 100 | return True 101 | 102 | return False 103 | 104 | @pytest.mark.parametrize( 105 | "meth", 106 | filter( 107 | lambda m: TestNoConcretesInInterfaces.filter_meths( 108 | # No idea why it thinks `m` is "object", not "str" 109 | _Result, 110 | m, # type: ignore 111 | ), 112 | _Result.__dict__, 113 | ), 114 | ) 115 | def test_no_concrete_result_methods(self, meth: str) -> None: 116 | """The result interface contains no implementations.""" 117 | self.assert_not_concrete(_Result, meth) 118 | 119 | @pytest.mark.parametrize( 120 | "meth", 121 | filter( 122 | lambda m: TestNoConcretesInInterfaces.filter_meths( 123 | # No idea why it thinks `m` is "object", not "str" 124 | _Option, 125 | m, # type: ignore 126 | ), 127 | _Option.__dict__.keys(), 128 | ), 129 | ) 130 | def test_no_concrete_option_methods(self, meth: str) -> None: 131 | """The option interface contains no implementations.""" 132 | self.assert_not_concrete(_Option, meth) 133 | 134 | 135 | class TestImplementationDetails: 136 | """Some implementation details need to be tested.""" 137 | 138 | def test_nothing_singleton(self) -> None: 139 | """Ensure Nothing() is a singleton.""" 140 | assert Nothing() is Nothing() is Nothing() 141 | 142 | @pytest.mark.parametrize("obj", (Some(1), Nothing(), Ok(1), Err(1))) 143 | def test_all_slotted(self, obj: t.Any) -> None: 144 | """All implementations use __slots__.""" 145 | assert not hasattr(obj, "__dict__") 146 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Setup file for the skelethon.""" 3 | 4 | import typing as t 5 | from os.path import dirname, exists, join, realpath 6 | from setuptools import setup, find_packages 7 | 8 | cwd = dirname(realpath(__file__)) 9 | 10 | ######################################################################## 11 | # Contact Information 12 | ######################################################################## 13 | 14 | URL = "https://www.github.com/mplanchard/safetywrap" 15 | AUTHOR = "Matthew Planchard" 16 | EMAIL = "msplanchard@gmail.com" 17 | 18 | 19 | ######################################################################## 20 | # Package Description 21 | ######################################################################## 22 | 23 | NAME = "safetywrap" 24 | SHORT_DESC = "Rust-inspired typesafe result types" 25 | 26 | with open(join(cwd, "README.md")) as readme: 27 | LONG_DESC = readme.read() 28 | LONG_DESC_CONTENT_TYPE = "text/markdown" 29 | 30 | KEYWORDS = [ 31 | "python", 32 | "rust", 33 | "result", 34 | "option", 35 | "typed", 36 | "types", 37 | "typesafe", 38 | "monad", 39 | "wrapper", 40 | "safety", 41 | ] 42 | CLASSIFIERS = [ 43 | # See https://pypi.python.org/pypi?%3Aaction=list_classifiers for all 44 | # available setup classifiers 45 | # "Development Status :: 1 - Planning", 46 | # 'Development Status :: 2 - Pre-Alpha', 47 | # "Development Status :: 3 - Alpha", 48 | "Development Status :: 4 - Beta", 49 | # 'Development Status :: 5 - Production/Stable', 50 | # 'Development Status :: 6 - Mature', 51 | # 'Framework :: AsyncIO', 52 | # 'Framework :: Flask', 53 | # 'Framework :: Sphinx', 54 | # 'Environment :: Web Environment', 55 | "Intended Audience :: Developers", 56 | # 'Intended Audience :: End Users/Desktop', 57 | # 'Intended Audience :: Science/Research', 58 | # 'Intended Audience :: System Administrators', 59 | # 'License :: Other/Proprietary License', 60 | # 'License :: OSI Approved :: GNU General Public License v3 (GPLv3)', 61 | # 'License :: OSI Approved :: MIT License', 62 | "License :: OSI Approved :: Apache Software License", 63 | "Natural Language :: English", 64 | "Operating System :: POSIX :: Linux", 65 | "Operating System :: MacOS :: MacOS X", 66 | "Operating System :: Microsoft :: Windows", 67 | "Programming Language :: Python", 68 | "Programming Language :: Python :: 3 :: Only", 69 | "Programming Language :: Python :: 3.6", 70 | "Programming Language :: Python :: 3.7", 71 | "Programming Language :: Python :: 3.8", 72 | # 'Programming Language :: Python :: Implementation :: PyPy', 73 | ] 74 | 75 | 76 | ######################################################################## 77 | # Dependency Specification 78 | ######################################################################## 79 | 80 | PYTHON_REQUIRES = ">=3.6" 81 | PACKAGE_DEPENDENCIES: t.Tuple[str, ...] = () 82 | SETUP_DEPENDENCIES: t.Tuple[str, ...] = () 83 | TEST_DEPENDENCIES: t.Tuple[str, ...] = () 84 | EXTRAS_DEPENDENCIES: t.Dict[str, t.Sequence[str]] = { 85 | "dev": ( 86 | TEST_DEPENDENCIES 87 | + ( 88 | "black", 89 | "coverage", 90 | "flake8", 91 | "ipdb", 92 | "ipython", 93 | "mypy", 94 | "pydocstyle", 95 | "pylint", 96 | "pytest", 97 | "pytest-cov", 98 | "tox", 99 | "twine", 100 | "typeguard", 101 | "wheel", 102 | ) 103 | ) 104 | } 105 | 106 | 107 | ######################################################################## 108 | # Package Extras 109 | ######################################################################## 110 | 111 | ENTRY_POINTS: t.Union[str, t.Dict[str, t.Union[str, t.Sequence[str]]]] = {} 112 | PACKAGE_DATA: t.Dict[str, t.Sequence[str]] = {"safetywrap": ["py.typed"]} 113 | 114 | 115 | ######################################################################## 116 | # Setup Logic 117 | ######################################################################## 118 | 119 | PACKAGE_DIR = realpath(dirname(__file__)) 120 | 121 | 122 | REQ_FILE = join(PACKAGE_DIR, "requirements_unfrozen.txt") 123 | if exists(REQ_FILE): 124 | with open(join(PACKAGE_DIR, "requirements.txt")) as reqfile: 125 | for ln in (l.strip() for l in reqfile): # noqa 126 | if ln and not ln.startswith("#"): 127 | PACKAGE_DEPENDENCIES += (ln,) 128 | 129 | 130 | __version__ = "0.0.0" 131 | 132 | with open(join(cwd, "src/{}/__init__.py".format(NAME))) as init_file: 133 | for line in init_file: 134 | # This will set __version__ and __version_info__ variables locally 135 | if line.startswith("__version"): 136 | exec(line) 137 | break 138 | else: 139 | raise RuntimeError("Could not parse version!") 140 | 141 | setup( 142 | author_email=EMAIL, 143 | author=AUTHOR, 144 | classifiers=CLASSIFIERS, 145 | description=SHORT_DESC, 146 | entry_points=ENTRY_POINTS, 147 | extras_require=EXTRAS_DEPENDENCIES, 148 | keywords=KEYWORDS, 149 | long_description_content_type=LONG_DESC_CONTENT_TYPE, 150 | long_description=LONG_DESC, 151 | name=NAME, 152 | package_data=PACKAGE_DATA, 153 | package_dir={"": "src"}, 154 | packages=find_packages(where="src"), 155 | python_requires=PYTHON_REQUIRES, 156 | setup_requires=SETUP_DEPENDENCIES, 157 | tests_require=TEST_DEPENDENCIES, 158 | url=URL, 159 | version=__version__, 160 | ) 161 | -------------------------------------------------------------------------------- /examples/option_examples.py: -------------------------------------------------------------------------------- 1 | """Examples showing how one might use the Option portion of the library.""" 2 | 3 | import typing as t 4 | from hashlib import sha256 5 | 6 | from safetywrap import Option 7 | 8 | 9 | # ###################################################################### 10 | # One: Dealing with Dicts: Possibly Missing Values 11 | # ###################################################################### 12 | # A key that might be missing from a dictionary causes all sorts of 13 | # diversions in your application code. Instead of being able to say, 14 | # try to get this, then do that, then do this, then do that, you have 15 | # to immediately consider: wait, what do I do if the key is absent? 16 | # Option.of() gives us a way to consider what we want to do all in one 17 | # continuous flow, before considering other options. 18 | # ###################################################################### 19 | 20 | 21 | def formatted_payment_amount(payment_details: dict) -> str: 22 | """Get the formatted payment amount from a dictionary. 23 | 24 | In our dictionary, the payment amount is present as pennies. 25 | """ 26 | return ( 27 | # Option.of() takes something that might be null and makes an 28 | # Option out of it: Nothing() if it's null, Some(val) if it's not. 29 | Option.of(payment_details.get("payment_amount")) 30 | # If we got the payment amount, we know it's an int of pennies. 31 | # Let's make it a float of dollars. 32 | .map(lambda val: val / 100) 33 | # Then let's format it into a string! 34 | .map("${:.2f}".format) 35 | # Now we've got our happy path, so all we need to do is figure 36 | # out what to show if our value was missing. In that case, 37 | # let's say we just say $0.00 38 | .unwrap_or("$0.00") 39 | ) 40 | 41 | 42 | def test_formatted_payment_amount_present() -> None: 43 | """It works when it works.""" 44 | assert formatted_payment_amount({"payment_amount": 1000}) == "$10.00" 45 | 46 | 47 | def test_formatted_payment_amount_absent() -> None: 48 | """It works when it works.""" 49 | assert formatted_payment_amount({}) == "$0.00" 50 | 51 | 52 | # ###################################################################### 53 | # Two: Functions that May Return None 54 | # ###################################################################### 55 | # Lots of times, you might have a function that could potentially return 56 | # None. Typically, in these cases, you have to hope and pray that your 57 | # callers will handle the null case appropriately (typing helps with 58 | # this significantly!). In addition, for conscientious callers, this 59 | # adds the burden of adding a new branch to every bit of code that calls 60 | # your function, interrupting the way the code reads to deal with the 61 | # potential null. Returning an Option gives your callers the ability to 62 | # focus on what they want to do, and to only worry about the nulls later. 63 | # ###################################################################### 64 | 65 | 66 | class UsersDB: 67 | """It's pretty much a hard-coded database.""" 68 | 69 | def __init__(self) -> None: 70 | """Set up the users.""" 71 | self._users = { 72 | 1: { 73 | "name": "bob", 74 | # sha256 hashed PW, no salt 75 | "password": ( 76 | "f52ccfb92d96d002c8f933eee1fd80968a1b" 77 | "06271ab1a7a414ea563b2e8e1042" 78 | ), 79 | }, 80 | 2: { 81 | # this is a schemaless DB, and someone forgot to add 82 | # a password for this user! 83 | "name": "webscale McGee", 84 | }, 85 | } 86 | 87 | def get(self, user_id: int) -> Option[t.Dict[str, str]]: 88 | """Get a user from the "database".""" 89 | return Option.of(self._users.get(user_id)) 90 | 91 | 92 | class Users: 93 | """Interact with users.""" 94 | 95 | def authenticate(self, user_id: int, password: str) -> bool: 96 | """Authenticate a user with their username and password.""" 97 | hasher = sha256() 98 | hasher.update(password.encode()) 99 | exp_pw = hasher.hexdigest() 100 | return ( 101 | # First, let's get the user! Since this is an Option, we can 102 | # ignore the error case and just start chaining! 103 | UsersDB().get(user_id) 104 | # If we got the user, we want to check their password. 105 | # Let's assume we're using MongoDB with no schema, so we don't 106 | # even know for sure the password field is going to be set. 107 | # So, we flatmap into another Option-generating function, 108 | # in this case, we will try to get the password 109 | .and_then(lambda user: Option.of(user.get("password"))) 110 | # Now we've got the password, so let's check it to get a bool 111 | .map(lambda hashed_pw: hashed_pw == exp_pw) 112 | # At this point, our happy path is done! We've gotten a bool 113 | # representing whether the password matches. All we need to 114 | # do to finish is unwrap. For any of our Nothing cases ( 115 | # user was not present, password was not present), we can 116 | # just return False. 117 | .unwrap_or(False) 118 | ) 119 | 120 | def test_authenticate_user_present(self) -> None: 121 | """Check that things work when a user is present.""" 122 | assert Users().authenticate(1, "tardigrade") is True 123 | 124 | def test_authenticate_user_absent(self) -> None: 125 | """If a user is absent, we cannot authenticate.""" 126 | assert Users().authenticate(-1, "tardigrade") is False 127 | 128 | def test_authenticate_password_missing(self) -> None: 129 | """If a user has no password, we cannot authenticate.""" 130 | assert Users().authenticate(2, "tardigrade") is False 131 | 132 | def test_authenticate_password_mismatch(self) -> None: 133 | """If the password is wrong, we cannot authenticate.""" 134 | assert Users().authenticate(1, "slime_mold") is False 135 | 136 | # Consider that, using the "normal" approach, to get all of these 137 | # failure cases handled, we would have needed THREE separate 138 | # if/else blocks: one for getting None back from the DB, another 139 | # for if the PW is not present on the user object, and for 140 | # if the PWs don't match. Here, we were able to handle all of it 141 | # by defining our expected success case, and then considering 142 | # a fallback at the very end! 143 | 144 | 145 | if __name__ == "__main__": 146 | # Handling absent dict values inline 147 | test_formatted_payment_amount_present() 148 | test_formatted_payment_amount_absent() 149 | 150 | # The utility of functions that return Options 151 | Users().test_authenticate_user_present() 152 | Users().test_authenticate_user_absent() 153 | Users().test_authenticate_password_missing() 154 | Users().test_authenticate_password_mismatch() 155 | -------------------------------------------------------------------------------- /examples/result_examples.py: -------------------------------------------------------------------------------- 1 | """Examples showing how one might use the Result portion of this library.""" 2 | 3 | import typing as t 4 | 5 | import requests 6 | 7 | from safetywrap import Result, Ok, Err 8 | 9 | 10 | # ###################################################################### 11 | # One: Validation Pipeline 12 | # ###################################################################### 13 | # Sometimes you've got a bunch of validation functions that you would 14 | # like to run on some data, and you want to bail early if any of them 15 | # fails. Particularly when you want to send back some information about 16 | # what failed to validate, you're forced to e.g. return a 2-tuple of 17 | # validation status and a string with info, or to raise a custom 18 | # exception with that data ensconced inside. In either case, you wind 19 | # up having to do a lot of if/else or try/except logic in the calling 20 | # context. The Result type allows you to get rid of all that extra 21 | # boilerplate and get down to what matters: defining a pipeline of 22 | # validation errors with early exiting. 23 | # ###################################################################### 24 | 25 | 26 | class Validator: 27 | """A validator for validating hopefully valid things. 28 | 29 | In this case, let's say we've got a a string we want to validate. 30 | We want the string to be at least X characters long, to not contain 31 | any disallowed characters, to start with a capital letter, to end 32 | with a period, and to contain the substring "shabazz". 33 | """ 34 | 35 | MIN_LEN = 10 36 | DISALLOWED_CHARS = ("^", "_", "O") 37 | MUST_CONTAIN = "shabazz" 38 | 39 | def validated(self, string: str) -> Result[str, str]: 40 | """Return the validated string or any validation error. 41 | 42 | We return a Result, where the Ok value is the validated string, 43 | and the Err value is a descriptive string. 44 | """ 45 | # Because all of our validation methods return Results, we can 46 | # easily chain them. 47 | return ( 48 | self._validate_length(string) 49 | .and_then(self._validate_chars) # and_then == flatmap 50 | .and_then(self._validate_capitalized) 51 | .and_then(self._validate_end_char) 52 | .and_then(self._validate_substring) 53 | # Because we're returning a Result, this is all we need to 54 | # to! We don't even have to figure out if there was an error 55 | # here, because any error would have short-circuited the 56 | # pipeline and will get returned by this method. 57 | ) 58 | # Because we're returning a Result, we are _forcing_ the caller 59 | # to deal with the fact that validation might fail. They only 60 | # way they can get the result back is by calling `.unwrap()` 61 | # or a similar method, checking `is_ok()` first, or otherwise 62 | # continuing to pipeline on it and pass the Result on up the 63 | # chain. 64 | 65 | def _validate_length(self, string: str) -> Result[str, str]: 66 | """Check that all the strings are of the proper length.""" 67 | if len(string) < self.MIN_LEN: 68 | return Err("String is too short") 69 | return Ok(string) 70 | 71 | def _validate_chars(self, string: str) -> Result[str, str]: 72 | """Check that none of the strings have disallowed chars.""" 73 | if set(string).intersection(set(self.DISALLOWED_CHARS)): 74 | return Err("String has disallowed chars") 75 | return Ok(string) 76 | 77 | def _validate_capitalized(self, string: str) -> Result[str, str]: 78 | """Check that the starting character is a capital.""" 79 | if len(string) > 0 and not string[0].isupper(): 80 | return Err("Starting character is not uppercase.") 81 | return Ok(string) 82 | 83 | def _validate_end_char(self, string: str) -> Result[str, str]: 84 | """Check the string ends with a period.""" 85 | if len(string) > 0 and string[-1] != ".": 86 | return Err("String does not end with a period") 87 | return Ok(string) 88 | 89 | def _validate_substring(self, string: str) -> Result[str, str]: 90 | """Check the string has the required substring.""" 91 | if self.MUST_CONTAIN not in string: 92 | return Err(f"String did not contain '{self.MUST_CONTAIN}'") 93 | return Ok(string) 94 | 95 | def test_self(self) -> None: 96 | """Quick test to make sure we're not crazy.""" 97 | goods = ("AshabazzB.", "Abshabazz.") 98 | bads = ("shabazz", "Ab.", "Ashabazz^B.") 99 | assert all(map(lambda g: self.validated(g).is_ok(), goods)) 100 | assert all(map(lambda g: self.validated(g).is_err(), bads)) 101 | print("Validator.test_self: everything as expected!") 102 | 103 | 104 | # ###################################################################### 105 | # Two: Wrangling Exceptions 106 | # ###################################################################### 107 | # It's common in FP-related tutorials to hear exceptions described as 108 | # children throwing tantrums, but it's really worse than that. Calling 109 | # a method that might throw involves either figuring out in detail any 110 | # exception that might be thrown or catching every exception all 111 | # william-nilliam and then dealing with them generically. Doing either 112 | # of the two means that you've got to litter your code with try/except 113 | # blocks, forcing you to consider what the _implementation_ of the thing 114 | # you're using is than what _interface_ you're trying to create. 115 | # Using Result.of can make life easier. 116 | # ###################################################################### 117 | 118 | 119 | class CatFactGetter: 120 | """Do something fraught with error. 121 | 122 | Let's forget them all the possible errors and just care about what 123 | we're trying to do, which is to get a cat fact. 124 | 125 | NOTE: this requires the `requests` library to be installed 126 | """ 127 | 128 | def get_fact(self) -> str: 129 | """Get a cat fact!""" 130 | return ( 131 | # Create a Result from making our GET request. 132 | # Now we can start chaining! 133 | Result.of( 134 | requests.get, "https://cat-fact.herokuapp.com/facts/random" 135 | ) 136 | # Let's first consider the success path. 137 | # If we got a response, it should be JSON, so let's try to parse 138 | .and_then(lambda resp: Result.of(resp.json)) 139 | # If we successfully parsed JSON, we must have a dict, so let's 140 | # grab our cat fact, or a useful message. 141 | .map( 142 | lambda parsed: t.cast( 143 | str, parsed.get("text", "Unexpected cat fact format!") 144 | ) 145 | ) 146 | # From here, all we need to do to consider the error case is 147 | # convert our Err type (which for Result.of() is any exception 148 | # that was raised) into the expected return type, which we 149 | # do by passing the error to `str()` 150 | .unwrap_or_else(str) 151 | ) 152 | 153 | # Note it would also be totally reasonable to return something like 154 | # Result[str, Exception] here! In which case you drop the final 155 | # `.unwrap_or_else()`, and then the caller can decide what to 156 | # do with any errors. 157 | 158 | def get_fact_result(self) -> Result[str, Exception]: 159 | """Return a Result for a cat fact.""" 160 | return ( 161 | Result.of( 162 | requests.get, 163 | "https://cat-fact.herokuapp.com/facts/random", 164 | # this is the default, but sometimes the type checker wants us 165 | # to make it explicit. See python/mypy#3737 for deets. 166 | catch=Exception, 167 | ) 168 | .and_then(lambda resp: Result.of(resp.json)) 169 | .map( 170 | lambda parsed: t.cast( 171 | str, parsed.get("text", "Unexpected cat fact format!") 172 | ) 173 | ) 174 | ) 175 | 176 | def test_get_fact(self) -> None: 177 | """Test getting a cat fact.""" 178 | fact = self.get_fact() 179 | assert isinstance(fact, str) 180 | print(fact) 181 | 182 | def test_get_fact_result(self) -> None: 183 | """Test getting a cat fact as a result! 184 | 185 | Note that here, the caller has to decide what to do with any 186 | potential error in order to get to the cat fact. 187 | """ 188 | fact_res = self.get_fact_result() 189 | fact_str = fact_res.unwrap_or_else(lambda exc: f"ERROR: {str(exc)}") 190 | assert isinstance(fact_str, str) 191 | if fact_res.is_err(): 192 | assert "ERROR" in fact_str 193 | print(fact_str) 194 | 195 | 196 | if __name__ == "__main__": 197 | Validator().test_self() 198 | CatFactGetter().test_get_fact() 199 | CatFactGetter().test_get_fact_result() 200 | -------------------------------------------------------------------------------- /tests/test_option.py: -------------------------------------------------------------------------------- 1 | """Test the Option type.""" 2 | 3 | import typing as t 4 | 5 | import pytest 6 | 7 | from safetywrap import Option, Some, Nothing, Result, Ok, Err 8 | 9 | 10 | def _sq(val: int) -> Option[int]: 11 | """Square the result and return an option.""" 12 | return Some(val ** 2) 13 | 14 | 15 | def _nothing(_: int) -> Option[int]: 16 | """Just return nothing.""" 17 | return Nothing() 18 | 19 | 20 | class TestOptionConstructors: 21 | """Test option constructors.""" 22 | 23 | @pytest.mark.parametrize( 24 | "val, exp", 25 | ( 26 | (5, Some(5)), 27 | (None, Nothing()), 28 | ("", Some("")), 29 | (False, Some(False)), 30 | ({}, Some({})), 31 | ([], Some([])), 32 | ), 33 | ) 34 | def test_of(self, val: t.Any, exp: Option) -> None: 35 | """Option.of() returns an Option from an Optional.""" 36 | assert Option.of(val) == exp 37 | 38 | @pytest.mark.parametrize( 39 | "predicate, val, exp", 40 | ( 41 | (lambda x: x is True, True, Some(True)), 42 | (lambda x: x is True, False, Nothing()), 43 | (lambda x: x > 0, 1, Some(1)), 44 | (lambda x: x > 0, -2, Nothing()), 45 | ), 46 | ) 47 | def test_some_if( 48 | self, predicate: t.Callable, val: t.Any, exp: Option 49 | ) -> None: 50 | """Test constructing based on some predicate.""" 51 | assert Option.some_if(predicate, val) == exp 52 | 53 | @pytest.mark.parametrize( 54 | "predicate, val, exp", 55 | ( 56 | (lambda x: x is True, True, Nothing()), 57 | (lambda x: x is True, False, Some(False)), 58 | (lambda x: x > 0, 1, Nothing()), 59 | (lambda x: x > 0, -2, Some(-2)), 60 | ), 61 | ) 62 | def test_nothing_if( 63 | self, predicate: t.Callable, val: t.Any, exp: Option 64 | ) -> None: 65 | """Test constructing based on some predicate.""" 66 | assert Option.nothing_if(predicate, val) == exp 67 | 68 | @pytest.mark.parametrize( 69 | "options, exp", 70 | ( 71 | ([Some(1), Some(2), Some(3)], Some((1, 2, 3))), 72 | ([Some(1), Nothing(), Some(3)], Nothing()), 73 | ([Nothing()], Nothing()), 74 | ([Some(1)], Some((1,))), 75 | ((Some(1), Some(2), Some(3)), Some((1, 2, 3))), 76 | ), 77 | ) 78 | def test_collect(self, options: t.Iterable[Option], exp: Option) -> None: 79 | """Test constructing from an iterable of options.""" 80 | assert Option.collect(options) == exp 81 | 82 | 83 | class TestOption: 84 | """Test the option type.""" 85 | 86 | @pytest.mark.parametrize( 87 | "left, right, exp", 88 | ( 89 | (Some(2), Nothing(), Nothing()), 90 | (Nothing(), Some(2), Nothing()), 91 | (Some(1), Some(2), Some(2)), 92 | (Nothing(), Nothing(), Nothing()), 93 | ), 94 | ) 95 | def test_and( 96 | self, left: Option[int], right: Option[int], exp: Option[int] 97 | ) -> None: 98 | """Returns Some() if both options are Some().""" 99 | assert left.and_(right) == exp 100 | 101 | @pytest.mark.parametrize( 102 | "left, right, exp", 103 | ( 104 | (Some(2), Nothing(), Some(2)), 105 | (Nothing(), Some(2), Some(2)), 106 | (Some(1), Some(2), Some(1)), 107 | (Nothing(), Nothing(), Nothing()), 108 | ), 109 | ) 110 | def test_or( 111 | self, left: Option[int], right: Option[int], exp: Option[int] 112 | ) -> None: 113 | """Returns Some() if either or both is Some().""" 114 | assert left.or_(right) == exp 115 | 116 | @pytest.mark.parametrize( 117 | "left, right, exp", 118 | ( 119 | (Some(2), Nothing(), Some(2)), 120 | (Nothing(), Some(2), Some(2)), 121 | (Some(1), Some(2), Nothing()), 122 | (Nothing(), Nothing(), Nothing()), 123 | ), 124 | ) 125 | def test_xor( 126 | self, left: Option[int], right: Option[int], exp: Option[int] 127 | ) -> None: 128 | """Returns Some() IFF only one option is Some().""" 129 | assert left.xor(right) == exp 130 | 131 | @pytest.mark.parametrize( 132 | "start, first, second, exp", 133 | ( 134 | (Some(2), _sq, _sq, Some(16)), 135 | (Some(2), _sq, _nothing, Nothing()), 136 | (Some(2), _nothing, _sq, Nothing()), 137 | (Nothing(), _sq, _sq, Nothing()), 138 | ), 139 | ) 140 | def test_and_then( 141 | self, 142 | start: Option[int], 143 | first: t.Callable[[int], Option[int]], 144 | second: t.Callable[[int], Option[int]], 145 | exp: Option[int], 146 | ) -> None: 147 | """Chains option-generating functions if results are `Some`.""" 148 | assert start.and_then(first).and_then(second) == exp 149 | 150 | @pytest.mark.parametrize( 151 | "start, fn, exp", 152 | ( 153 | (Some("one"), lambda: Some("one else"), Some("one")), 154 | (Nothing(), lambda: Some("one else"), Some("one else")), 155 | (Nothing(), lambda: Nothing(), Nothing()), 156 | ), 157 | ) 158 | def test_or_else( 159 | self, 160 | start: Option[str], 161 | fn: t.Callable[[], Option[str]], 162 | exp: Option[str], 163 | ) -> None: 164 | """Chains option-generating functions if results are `None`.""" 165 | assert start.or_else(fn) == exp 166 | 167 | @pytest.mark.parametrize("method", ("expect", "raise_if_nothing")) 168 | @pytest.mark.parametrize("exc_cls", (None, IOError)) 169 | def test_expect_and_aliases_raising( 170 | self, method: str, exc_cls: t.Type[Exception] 171 | ) -> None: 172 | """Can specify exception msg/cls if value is not Some().""" 173 | exp_exc: t.Type[Exception] = exc_cls if exc_cls else RuntimeError 174 | kwargs = {"exc_cls": exc_cls} if exc_cls else {} 175 | msg = "not what I expected" 176 | 177 | with pytest.raises(exp_exc) as exc_info: 178 | getattr(Nothing(), method)(msg, **kwargs) 179 | 180 | assert msg in str(exc_info.value) 181 | 182 | @pytest.mark.parametrize("method", ("expect", "raise_if_nothing")) 183 | def test_expect_and_aliases_not_raising(self, method: str) -> None: 184 | """Expecting on a Some() returns the value.""" 185 | assert getattr(Some("hello"), method)("not what I expected") == "hello" 186 | 187 | def test_raise_if_err(self) -> None: 188 | """This method is deprecated.""" 189 | with pytest.deprecated_call(): 190 | assert Some("hello").raise_if_err("error") == "hello" 191 | with pytest.deprecated_call(): 192 | with pytest.raises(RuntimeError): 193 | Nothing().raise_if_err("error") 194 | 195 | @pytest.mark.parametrize( 196 | "start, exp", 197 | ((Nothing(), Nothing()), (Some(3), Nothing()), (Some(4), Some(4))), 198 | ) 199 | def test_filter(self, start: Option[int], exp: Option[int]) -> None: 200 | """A satisfied predicate returns `Some()`, otherwise `None()`.""" 201 | 202 | def is_even(val: int) -> bool: 203 | return val % 2 == 0 204 | 205 | assert start.filter(is_even) == exp 206 | 207 | @pytest.mark.parametrize("opt, exp", ((Nothing(), True), (Some(1), False))) 208 | def test_is_nothing(self, opt: Option[int], exp: bool) -> None: 209 | """"Nothings() are nothing, Some()s are not.""" 210 | assert opt.is_nothing() is exp 211 | 212 | @pytest.mark.parametrize("opt, exp", ((Nothing(), False), (Some(1), True))) 213 | def test_is_some(self, opt: Option[int], exp: bool) -> None: 214 | """"Nothings() are nothing, Some()s are not.""" 215 | assert opt.is_some() is exp 216 | 217 | @pytest.mark.parametrize("opt, exp", ((Nothing(), ()), (Some(5), (5,)))) 218 | def test_iter(self, opt: Option[int], exp: t.Tuple[int, ...]) -> None: 219 | """Iterating on a Some() yields the Some(); on a None() nothing.""" 220 | assert tuple(opt.iter()) == tuple(iter(opt)) == exp 221 | 222 | @pytest.mark.parametrize( 223 | "opt, exp", ((Some("hello"), Some(5)), (Nothing(), Nothing())) 224 | ) 225 | def test_map(self, opt: Option[str], exp: Option[int]) -> None: 226 | """Maps fn() onto `Some()` to make a new option, or ignores None().""" 227 | assert opt.map(len) == exp 228 | 229 | @pytest.mark.parametrize("opt, exp", ((Some("hello"), 5), (Nothing(), -1))) 230 | def test_map_or(self, opt: Option[str], exp: Option[int]) -> None: 231 | """Maps fn() onto `Some()` & return the value, or return a default.""" 232 | assert opt.map_or(-1, lambda s: len(s)) == exp 233 | 234 | @pytest.mark.parametrize("opt, exp", ((Some("hello"), 5), (Nothing(), -1))) 235 | def test_map_or_else(self, opt: Option[str], exp: Option[int]) -> None: 236 | """Maps fn() onto `Some()` & return the value, or return a default.""" 237 | assert opt.map_or_else(lambda: -1, lambda s: len(s)) == exp 238 | 239 | @pytest.mark.parametrize( 240 | "opt, exp", ((Some(2), Ok(2)), (Nothing(), Err("oh no"))) 241 | ) 242 | def test_ok_or(self, opt: Option[str], exp: Result[str, int]) -> None: 243 | """Map `Some(t)` to `Ok(t)`, or `Nothing()` to an `Err()`.""" 244 | assert opt.ok_or("oh no") == exp 245 | 246 | @pytest.mark.parametrize( 247 | "opt, exp", ((Some(2), Ok(2)), (Nothing(), Err("oh no"))) 248 | ) 249 | def test_ok_or_else(self, opt: Option[str], exp: Result[str, int]) -> None: 250 | """Map `Some(t)` to `Ok(t)`, or `Nothing()` to an `Err()`.""" 251 | assert opt.ok_or_else(lambda: "oh no") == exp 252 | 253 | def test_unwrap_raising(self) -> None: 254 | """Unwraping a Nothing() raises an error.""" 255 | with pytest.raises(RuntimeError): 256 | Nothing().unwrap() 257 | 258 | def test_unwrap_success(self) -> None: 259 | """Unwrapping a Some() returns the wrapped value.""" 260 | assert Some("thing").unwrap() == "thing" 261 | 262 | @pytest.mark.parametrize("opt, exp", ((Some(2), 2), (Nothing(), 42))) 263 | def test_unwrap_or(self, opt: Option[int], exp: int) -> None: 264 | """Unwraps a `Some()` or returns a default.""" 265 | assert opt.unwrap_or(42) == exp 266 | 267 | @pytest.mark.parametrize("opt, exp", ((Some(2), 2), (Nothing(), 42))) 268 | def test_unwrap_or_else(self, opt: Option[int], exp: int) -> None: 269 | """Unwraps a `Some()` or returns a default.""" 270 | assert opt.unwrap_or_else(lambda: 42) == exp 271 | 272 | @pytest.mark.parametrize( 273 | "inst, other, eq", 274 | ( 275 | (Some(1), Some(1), True), 276 | (Some(1), Some(2), False), 277 | (Some(1), Nothing(), False), 278 | (Some(1), 1, False), 279 | (Nothing(), Nothing(), True), 280 | (Nothing(), Some(1), False), 281 | (Nothing(), None, False), 282 | ), 283 | ) 284 | def test_equality_inequality( 285 | self, inst: t.Any, other: t.Any, eq: bool 286 | ) -> None: 287 | """Test equality and inequality of results.""" 288 | assert (inst == other) is eq 289 | assert (inst != other) is not eq 290 | 291 | def test_stringify(self) -> None: 292 | """Repr and str representations are equivalent.""" 293 | assert repr(Some(1)) == str(Some(1)) == "Some(1)" 294 | assert repr(Nothing()) == str(Nothing()) == "Nothing()" 295 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /tests/test_result.py: -------------------------------------------------------------------------------- 1 | """Test the Result type.""" 2 | 3 | import typing as t 4 | 5 | import pytest 6 | 7 | from safetywrap import Ok, Err, Result, Some, Nothing, Option 8 | 9 | 10 | def _sq(val: int) -> Result[int, int]: 11 | """Square a value.""" 12 | return Ok(val ** 2) 13 | 14 | 15 | def _err(val: int) -> Result[int, int]: 16 | """Return an error.""" 17 | return Err(val) 18 | 19 | 20 | def _raises(exc: t.Type[Exception]) -> None: 21 | raise exc 22 | 23 | 24 | class TestResultConstructors: 25 | """Test Result constructors.""" 26 | 27 | @pytest.mark.parametrize( 28 | "fn, exp", 29 | ( 30 | (lambda: 5, Ok(5)), 31 | (lambda: _raises(TypeError), Err(TypeError)), 32 | (Nothing, Ok(Nothing())), 33 | ), 34 | ) 35 | def test_of(self, fn: t.Callable, exp: Result) -> None: 36 | """Test getting a result from a callable.""" 37 | if exp.is_err(): 38 | assert isinstance(Result.of(fn).unwrap_err(), exp.unwrap_err()) 39 | else: 40 | assert Result.of(fn) == exp 41 | 42 | def test_of_with_args(self) -> None: 43 | """Test getting a result from a callable with args.""" 44 | assert Result.of(lambda x: bool(x > 0), 1).unwrap() is True 45 | 46 | def test_of_with_kwargs(self) -> None: 47 | """Test getting a result from a callable with args.""" 48 | 49 | def foo(a: int, b: str = None) -> t.Optional[str]: 50 | return b 51 | 52 | assert Result.of(foo, 1, b="a").unwrap() == "a" 53 | 54 | @pytest.mark.parametrize( 55 | "iterable, exp", 56 | ( 57 | ((Ok(1), Ok(2), Ok(3)), Ok((1, 2, 3))), 58 | ((Ok(1), Err("no"), Ok(3)), Err("no")), 59 | (iter([Ok(1), Ok(2)]), Ok((1, 2))), 60 | ([Err("no")], Err("no")), 61 | ([Ok(1)], Ok((1,))), 62 | ([], Ok(())), 63 | ), 64 | ) 65 | def test_collect( 66 | self, iterable: t.Iterable[Result[int, str]], exp: Result[int, str] 67 | ) -> None: 68 | """Test collecting an iterable of results into a single result.""" 69 | assert Result.collect(iterable) == exp 70 | 71 | def test_collect_short_circuits(self) -> None: 72 | """Ensure collect does not iterate after an err is reached.""" 73 | until_err: t.List[Result[int, str]] = [Ok(1), Ok(2), Err("no")] 74 | 75 | def _iterable() -> t.Iterable[Result[int, str]]: 76 | yield from until_err 77 | # If we continue iterating after the err, we will raise a 78 | # runtime Error. 79 | assert False, "Result.collect() did not short circuit on err!" 80 | 81 | assert Result.collect(_iterable()) == Err("no") 82 | 83 | @pytest.mark.parametrize( 84 | "predicate, val, exp", 85 | ( 86 | (lambda x: x is True, True, Ok(True)), 87 | (lambda x: x is True, False, Err(False)), 88 | (lambda x: x > 0, 1, Ok(1)), 89 | (lambda x: x > 0, -2, Err(-2)), 90 | ), 91 | ) 92 | def test_ok_if( 93 | self, predicate: t.Callable, val: t.Any, exp: Result 94 | ) -> None: 95 | """Test constructing based on some predicate.""" 96 | assert Result.ok_if(predicate, val) == exp 97 | 98 | @pytest.mark.parametrize( 99 | "predicate, val, exp", 100 | ( 101 | (lambda x: x is True, True, Err(True)), 102 | (lambda x: x is True, False, Ok(False)), 103 | (lambda x: x > 0, 1, Err(1)), 104 | (lambda x: x > 0, -2, Ok(-2)), 105 | ), 106 | ) 107 | def test_err_if( 108 | self, predicate: t.Callable, val: t.Any, exp: Result 109 | ) -> None: 110 | """Test constructing based on some predicate.""" 111 | assert Result.err_if(predicate, val) == exp 112 | 113 | 114 | class TestResult: 115 | """Test the result type.""" 116 | 117 | @pytest.mark.parametrize( 118 | "left, right, exp", 119 | ( 120 | (Ok(2), Err("late error"), Err("late error")), 121 | (Err("early error"), Ok(2), Err("early error")), 122 | (Err("early error"), Err("late error"), Err("early error")), 123 | (Ok(2), Ok(3), Ok(3)), 124 | ), 125 | ) 126 | def test_and( 127 | self, 128 | left: Result[int, str], 129 | right: Result[int, str], 130 | exp: Result[int, str], 131 | ) -> None: 132 | """Test that `and` returns an alternative for `Ok` values.""" 133 | assert left.and_(right) == exp 134 | 135 | def test_and_multichain(self) -> None: 136 | """.and() calls can be chained indefinitely.""" 137 | assert Ok(2).and_(Ok(3)).and_(Ok(4)).and_(Ok(5)) == Ok(5) 138 | 139 | @pytest.mark.parametrize( 140 | "start, first, second, exp", 141 | ( 142 | (Ok(2), _sq, _sq, Ok(16)), 143 | (Ok(2), _sq, _err, Err(4)), 144 | (Ok(2), _err, _sq, Err(2)), 145 | (Ok(2), _err, _err, Err(2)), 146 | (Err(3), _sq, _sq, Err(3)), 147 | ), 148 | ) 149 | def test_and_then( 150 | self, 151 | start: Result[int, int], 152 | first: t.Callable[[int], Result[int, int]], 153 | second: t.Callable[[int], Result[int, int]], 154 | exp: Result[int, int], 155 | ) -> None: 156 | """Test that and_then chains result-generating functions.""" 157 | assert start.and_then(first).and_then(second) == exp 158 | 159 | def test_and_then_covariance(self) -> None: 160 | """Covariant errors are acceptable for flatmapping.""" 161 | 162 | class MyInt(int): 163 | """A subclass of int.""" 164 | 165 | def process_int(i: int) -> Result[MyInt, RuntimeError]: 166 | return Err(RuntimeError(f"it broke {i}")) 167 | 168 | start: Result[int, Exception] = Ok(5) 169 | # We can flatmap w/a function that takes any covariant type of 170 | # int or Exception. The result remains the original exception type, 171 | # since we cannot guarantee narrowing to the covariant type. 172 | flatmapped: Result[int, Exception] = start.and_then(process_int) 173 | assert flatmapped 174 | 175 | def test_flatmap(self) -> None: 176 | """Flatmap is an alias for and_then""" 177 | ok: Result[int, int] = Ok(2) 178 | err: Result[int, int] = Err(2) 179 | assert ok.flatmap(_sq) == Ok(4) 180 | assert err.flatmap(_sq) == Err(2) 181 | 182 | @pytest.mark.parametrize( 183 | "start, first, second, exp", 184 | ( 185 | (Ok(2), _sq, _sq, Ok(2)), 186 | (Ok(2), _err, _sq, Ok(2)), 187 | (Err(3), _sq, _err, Ok(9)), 188 | (Err(3), _err, _err, Err(3)), 189 | ), 190 | ) 191 | def test_or_else( 192 | self, 193 | start: Result[int, int], 194 | first: t.Callable[[int], Result[int, int]], 195 | second: t.Callable[[int], Result[int, int]], 196 | exp: Result[int, int], 197 | ) -> None: 198 | """Test that and_then chains result-generating functions.""" 199 | assert start.or_else(first).or_else(second) == exp 200 | 201 | @pytest.mark.parametrize( 202 | "start, exp", ((Ok(2), Nothing()), (Err("err"), Some("err"))) 203 | ) 204 | def test_err(self, start: Result[int, str], exp: Option[str]) -> None: 205 | """Test converting a result to an option.""" 206 | assert start.err() == exp 207 | 208 | @pytest.mark.parametrize("exc_cls", (None, IOError)) 209 | def test_expect_raising(self, exc_cls: t.Type[Exception]) -> None: 210 | """Test expecting a value to be Ok().""" 211 | exp_exc: t.Type[Exception] = exc_cls if exc_cls else RuntimeError 212 | kwargs = {"exc_cls": exc_cls} if exc_cls else {} 213 | input_val = 2 214 | msg = "not what I expected" 215 | 216 | with pytest.raises(exp_exc) as exc_info: 217 | Err(input_val).expect(msg, **kwargs) 218 | 219 | assert msg in str(exc_info.value) 220 | assert str(input_val) in str(exc_info.value) 221 | 222 | @pytest.mark.parametrize("exc_cls", (None, IOError)) 223 | def test_raise_if_err_raising(self, exc_cls: t.Type[Exception]) -> None: 224 | """Test raise_if_err for Err() values.""" 225 | exp_exc: t.Type[Exception] = exc_cls if exc_cls else RuntimeError 226 | kwargs = {"exc_cls": exc_cls} if exc_cls else {} 227 | input_val = 2 228 | msg = "not what I expected" 229 | 230 | with pytest.raises(exp_exc) as exc_info: 231 | Err(input_val).raise_if_err(msg, **kwargs) 232 | 233 | assert msg in str(exc_info.value) 234 | assert str(input_val) in str(exc_info.value) 235 | 236 | def test_expect_ok(self) -> None: 237 | """Expecting an Ok() value returns the value.""" 238 | assert Ok(2).expect("err") == 2 239 | 240 | def test_raise_if_err_ok(self) -> None: 241 | """Raise_if_err returns the value when given an Ok() value.""" 242 | assert Ok(2).raise_if_err("err") == 2 243 | 244 | @pytest.mark.parametrize("exc_cls", (None, IOError)) 245 | def test_expect_err_raising(self, exc_cls: t.Type[Exception]) -> None: 246 | """Test expecting a value to be Ok().""" 247 | exp_exc: t.Type[Exception] = exc_cls if exc_cls else RuntimeError 248 | kwargs = {"exc_cls": exc_cls} if exc_cls else {} 249 | msg = "not what I expected" 250 | 251 | with pytest.raises(exp_exc) as exc_info: 252 | Ok(2).expect_err(msg, **kwargs) 253 | 254 | assert msg in str(exc_info.value) 255 | 256 | def test_expect_err_err(self) -> None: 257 | """Expecting an Ok() value returns the value.""" 258 | assert Err(2).expect_err("err") == 2 259 | 260 | def test_is_err(self) -> None: 261 | """Err() returns true for is_err().""" 262 | assert Err(1).is_err() 263 | assert not Ok(1).is_err() 264 | 265 | def test_is_ok(self) -> None: 266 | """Ok() returns true for is_ok().""" 267 | assert Ok(1).is_ok() 268 | assert not Err(1).is_ok() 269 | 270 | @pytest.mark.parametrize("start, exp", ((Ok(1), (1,)), (Err(1), ()))) 271 | def test_iter( 272 | self, start: Result[int, int], exp: t.Tuple[int, ...] 273 | ) -> None: 274 | """iter() returns a 1-member iterator on Ok(), 0-member for Err().""" 275 | assert tuple(start.iter()) == exp 276 | 277 | @pytest.mark.parametrize( 278 | "start, exp", ((Ok(2), Ok(4)), (Err("foo"), Err("foo"))) 279 | ) 280 | def test_map(self, start: Result[int, str], exp: Result[int, str]) -> None: 281 | """.map() will map onto Ok() and ignore Err().""" 282 | assert start.map(lambda x: int(x ** 2)) == exp 283 | 284 | def test_map_covariance(self) -> None: 285 | """The input type to the map fn is covariant.""" 286 | 287 | class MyStr(str): 288 | """Subclass of str.""" 289 | 290 | def to_mystr(string: str) -> MyStr: 291 | return MyStr(string) if not isinstance(string, MyStr) else string 292 | 293 | start: Result[str, str] = Ok("foo") 294 | # We can assign the result to [str, str] even though we know it's 295 | # actually a MyStr, since MyStr is covariant with str 296 | end: Result[str, str] = start.map(to_mystr) 297 | assert end == Ok(MyStr("foo")) 298 | 299 | @pytest.mark.parametrize( 300 | "start, exp", ((Ok("foo"), Ok("foo")), (Err(2), Err("2"))) 301 | ) 302 | def test_map_err( 303 | self, start: Result[str, int], exp: Result[str, str] 304 | ) -> None: 305 | """.map_err() will map onto Err() and ignore Ok().""" 306 | assert start.map_err(str) == exp 307 | 308 | @pytest.mark.parametrize( 309 | "start, exp", ((Ok(1), Some(1)), (Err(1), Nothing())) 310 | ) 311 | def test_ok(self, start: Result[int, int], exp: Option[int]) -> None: 312 | """.ok() converts a result to an Option.""" 313 | assert start.ok() == exp 314 | 315 | @pytest.mark.parametrize( 316 | "left, right, exp", 317 | ( 318 | (Ok(5), Ok(6), Ok(5)), 319 | (Ok(5), Err(6), Ok(5)), 320 | (Err(5), Ok(6), Ok(6)), 321 | (Err(5), Err(6), Err(6)), 322 | ), 323 | ) 324 | def test_or( 325 | self, 326 | left: Result[int, str], 327 | right: Result[int, str], 328 | exp: Result[int, str], 329 | ) -> None: 330 | """.or_() returns the first available non-err value.""" 331 | assert left.or_(right) == exp 332 | 333 | def test_or_multichain(self) -> None: 334 | """.or_() calls can be chained indefinitely.""" 335 | err: Result[int, int] = Err(5) 336 | assert err.or_(Err(6)).or_(Err(7)).or_(Ok(8)) == Ok(8) 337 | 338 | def test_unwrap_is_ok(self) -> None: 339 | """.unwrap() returns an ok() value.""" 340 | assert Ok(5).unwrap() == 5 341 | 342 | def test_unwrap_is_err(self) -> None: 343 | """.unwrap() raises for an error value.""" 344 | with pytest.raises(RuntimeError): 345 | Err(5).unwrap() 346 | 347 | def test_unwrap_err_is_ok(self) -> None: 348 | """.unwrap_err() raises for an ok value.""" 349 | with pytest.raises(RuntimeError): 350 | Ok(5).unwrap_err() 351 | 352 | def test_unwrap_err_is_err(self) -> None: 353 | """.unwrap_err() returns an error value.""" 354 | assert Err(5).unwrap_err() == 5 355 | 356 | @pytest.mark.parametrize("start, alt, exp", ((Ok(5), 6, 5), (Err(5), 6, 6))) 357 | def test_unwrap_or( 358 | self, start: Result[int, int], alt: int, exp: int 359 | ) -> None: 360 | """.unwrap_or() provides the default if the result is Err().""" 361 | assert start.unwrap_or(alt) == exp 362 | 363 | @pytest.mark.parametrize( 364 | "start, fn, exp", 365 | ((Ok(5), lambda i: i + 2, 5), (Err(5), lambda i: i + 2, 7)), 366 | ) 367 | def test_unwrap_or_else( 368 | self, start: Result[int, int], fn: t.Callable[[int], int], exp: int 369 | ) -> None: 370 | """Calculates a result from Err() value if present.""" 371 | assert start.unwrap_or_else(fn) == exp 372 | 373 | @pytest.mark.parametrize( 374 | "inst, other, eq", 375 | ( 376 | (Ok(1), Ok(1), True), 377 | (Ok(1), Ok(2), False), 378 | (Ok(1), Err(1), False), 379 | (Ok(1), 1, False), 380 | (Err(1), Err(1), True), 381 | (Err(1), Err(2), False), 382 | (Err(1), Ok(1), False), 383 | (Err(1), 1, False), 384 | ), 385 | ) 386 | def test_equality_inequality( 387 | self, inst: t.Any, other: t.Any, eq: bool 388 | ) -> None: 389 | """Test equality and inequality of results.""" 390 | assert (inst == other) is eq 391 | assert (inst != other) is not eq 392 | 393 | def test_stringify(self) -> None: 394 | """Repr and str representations are equivalent.""" 395 | assert repr(Ok(1)) == str(Ok(1)) == "Ok(1)" 396 | assert repr(Err(1)) == str(Err(1)) == "Err(1)" 397 | -------------------------------------------------------------------------------- /src/safetywrap/_interface.py: -------------------------------------------------------------------------------- 1 | """Result and Option interfaces.""" 2 | 3 | import typing as t 4 | 5 | if t.TYPE_CHECKING: 6 | from ._impl import Option, Result # pylint: disable=unused-import 7 | 8 | # pylint: disable=invalid-name 9 | 10 | T = t.TypeVar("T", covariant=True) 11 | E = t.TypeVar("E", covariant=True) 12 | U = t.TypeVar("U") 13 | F = t.TypeVar("F") 14 | 15 | ExcType = t.TypeVar("ExcType", bound=Exception) 16 | 17 | 18 | class _Result(t.Generic[T, E]): 19 | """Standard wrapper for results.""" 20 | 21 | __slots__ = () 22 | 23 | def __init__(self, result: t.Union[T, E]) -> None: 24 | """Results may not be instantiated directly.""" 25 | raise NotImplementedError( 26 | "Results are only for type hinting and may not be instantiated " 27 | "directly. Please use Ok() or Err() instead." 28 | ) 29 | 30 | # ------------------------------------------------------------------ 31 | # Constructors 32 | # ------------------------------------------------------------------ 33 | 34 | # See https://github.com/python/mypy/issues/3737 for issue with 35 | # specifying a default type. However, type hinting of uses of this 36 | # method should still work just fine. 37 | @staticmethod 38 | def of( 39 | fn: t.Callable[..., T], 40 | *args: t.Any, 41 | catch: t.Type[ExcType] = Exception, # type: ignore 42 | **kwargs: t.Any 43 | ) -> "Result[T, ExcType]": 44 | """Call `fn` and wrap its result in an `Ok()`. 45 | 46 | If an exception is intercepted, return `Err(exception)`. By 47 | default, any `Exception` will be intercepted. If you specify 48 | `exc_type`, only that exception will be intercepted. 49 | """ 50 | raise NotImplementedError 51 | 52 | @staticmethod 53 | def collect( 54 | iterable: t.Iterable["Result[U, F]"], 55 | ) -> "Result[t.Tuple[U, ...], F]": 56 | """Convert an iterable of Results into a Result of an iterable. 57 | 58 | Given some iterable of type Iterable[Result[T, E]], try to collect 59 | all Ok values into a tuple of type Tuple[T, ...]. If any of the 60 | iterable items are Errs, short-circuit and return Err of type 61 | Result[E]. 62 | """ 63 | raise NotImplementedError 64 | 65 | @staticmethod 66 | def err_if(predicate: t.Callable[[U], bool], value: U) -> "Result[U, U]": 67 | """Return Err(val) if predicate(val) is True, otherwise Ok(val).""" 68 | raise NotImplementedError 69 | 70 | @staticmethod 71 | def ok_if(predicate: t.Callable[[U], bool], value: U) -> "Result[U, U]": 72 | """Return Ok(val) if predicate(val) is True, otherwise Err(val).""" 73 | raise NotImplementedError 74 | 75 | # ------------------------------------------------------------------ 76 | # Methods 77 | # ------------------------------------------------------------------ 78 | 79 | def and_(self, res: "Result[U, E]") -> "Result[U, E]": 80 | """Return `res` if self is `Ok`, otherwise return `self`.""" 81 | raise NotImplementedError 82 | 83 | def or_(self, res: "Result[T, F]") -> "Result[T, F]": 84 | """Return `res` if self is `Err`, otherwise `self`.""" 85 | raise NotImplementedError 86 | 87 | def and_then(self, fn: t.Callable[[T], "Result[U, E]"]) -> "Result[U, E]": 88 | """Call `fn` if Ok, or ignore an error. Alias of `flatmap`. 89 | 90 | This can be used to chain functions that return results. 91 | """ 92 | raise NotImplementedError 93 | 94 | def flatmap(self, fn: t.Callable[[T], "Result[U, E]"]) -> "Result[U, E]": 95 | """Call `fn` if Ok, or ignore an error. Alias of `and_then` 96 | 97 | This can be used to chain functions that return results. 98 | """ 99 | raise NotImplementedError 100 | 101 | def or_else(self, fn: t.Callable[[E], "Result[T, F]"]) -> "Result[T, F]": 102 | """Return `self` if `Ok`, or call `fn` with `self` if `Err`.""" 103 | raise NotImplementedError 104 | 105 | def err(self) -> "Option[E]": 106 | """Return Err value if result is Err.""" 107 | raise NotImplementedError 108 | 109 | def ok(self) -> "Option[T]": 110 | """Return OK value if result is Ok.""" 111 | raise NotImplementedError 112 | 113 | def expect(self, msg: str, exc_cls: t.Type[Exception] = RuntimeError) -> T: 114 | """Return `Ok` value or raise an error with the specified message. 115 | 116 | The raised exception class may be specified with the `exc_cls` 117 | keyword argument. 118 | 119 | The underlying error will be stringified and appended to the 120 | provided message. 121 | """ 122 | raise NotImplementedError 123 | 124 | def raise_if_err( 125 | self, msg: str, exc_cls: t.Type[Exception] = RuntimeError 126 | ) -> T: 127 | """Return `Ok` value or raise an error with the specified message. 128 | 129 | The raised exception class may be specified with the `exc_cls` 130 | keyword argument. 131 | 132 | The underlying error will be stringified and appended to the 133 | provided message. 134 | 135 | Alias of `expect`. 136 | """ 137 | raise NotImplementedError 138 | 139 | def expect_err( 140 | self, msg: str, exc_cls: t.Type[Exception] = RuntimeError 141 | ) -> E: 142 | """Return `Err` value or raise an error with the specified message. 143 | 144 | The raised exception class may be specified with the `exc_cls` 145 | keyword argument. 146 | 147 | The underlying result will be stringified and appended to the 148 | provided message. 149 | """ 150 | raise NotImplementedError 151 | 152 | def is_err(self) -> bool: 153 | """Returl whether the result is an Err.""" 154 | raise NotImplementedError 155 | 156 | def is_ok(self) -> bool: 157 | """Return whether the result is OK.""" 158 | raise NotImplementedError 159 | 160 | def iter(self) -> t.Iterator[T]: 161 | """Return a one-item iterator whose sole member is the result if `Ok`. 162 | 163 | If the result is `Err`, the iterator will contain no items. 164 | """ 165 | raise NotImplementedError 166 | 167 | def map(self, fn: t.Callable[[T], U]) -> "Result[U, E]": 168 | """Map a function onto an okay result, or ignore an error.""" 169 | raise NotImplementedError 170 | 171 | def map_err(self, fn: t.Callable[[E], F]) -> "Result[T, F]": 172 | """Map a function onto an error, or ignore a success.""" 173 | raise NotImplementedError 174 | 175 | def unwrap(self) -> T: 176 | """Return an Ok result, or throw an error if an Err.""" 177 | raise NotImplementedError 178 | 179 | def unwrap_err(self) -> E: 180 | """Return an Ok result, or throw an error if an Err.""" 181 | raise NotImplementedError 182 | 183 | def unwrap_or(self, alternative: U) -> t.Union[T, U]: 184 | """Return the `Ok` value, or `alternative` if `self` is `Err`.""" 185 | raise NotImplementedError 186 | 187 | def unwrap_or_else(self, fn: t.Callable[[E], U]) -> t.Union[T, U]: 188 | """Return the `Ok` value, or the return from `fn`.""" 189 | raise NotImplementedError 190 | 191 | def __iter__(self) -> t.Iterator[T]: 192 | """Return a one-item iterator whose sole member is the result if `Ok`. 193 | 194 | If the result is `Err`, the iterator will contain no items. 195 | """ 196 | raise NotImplementedError 197 | 198 | def __eq__(self, other: t.Any) -> bool: 199 | """Compare two results. They are equal if their values are equal.""" 200 | raise NotImplementedError 201 | 202 | def __ne__(self, other: t.Any) -> bool: 203 | """Compare two results. They are equal if their values are equal.""" 204 | raise NotImplementedError 205 | 206 | def __str__(self) -> str: 207 | """Return string value of result.""" 208 | raise NotImplementedError 209 | 210 | def __repr__(self) -> str: 211 | """Return repr for result.""" 212 | raise NotImplementedError 213 | 214 | 215 | class _Option(t.Generic[T]): 216 | """A value that may be `Some` or `Nothing`.""" 217 | 218 | __slots__ = () 219 | 220 | def __init__(self, value: t.Optional[T]) -> None: 221 | """Options may not be instantiated directly.""" 222 | raise NotImplementedError( 223 | "Options may not be instantiated directly. Use Some() or " 224 | "Nothing() instead." 225 | ) 226 | 227 | # ------------------------------------------------------------------ 228 | # Constructors 229 | # ------------------------------------------------------------------ 230 | 231 | @staticmethod 232 | def of(value: t.Optional[T]) -> "Option[T]": 233 | """Construct an _Option[T] from an Optional[T].""" 234 | raise NotImplementedError 235 | 236 | @staticmethod 237 | def nothing_if(predicate: t.Callable[[U], bool], value: U) -> "Option[U]": 238 | """Return Nothing() if predicate(val) is True, else Some(val).""" 239 | raise NotImplementedError 240 | 241 | @staticmethod 242 | def some_if(predicate: t.Callable[[U], bool], value: U) -> "Option[U]": 243 | """Return Some(val) if predicate(val) is True, else Nothing().""" 244 | raise NotImplementedError 245 | 246 | @staticmethod 247 | def collect(options: t.Iterable["Option[T]"]) -> "Option[t.Tuple[T, ...]]": 248 | """Collect a series of Options into single Option. 249 | 250 | If all options are `Some[T]`, the result is `Some[Tuple[T]]`. If 251 | any options are `Nothing`, the result is `Nothing`. 252 | """ 253 | raise NotImplementedError 254 | 255 | # ------------------------------------------------------------------ 256 | # Methods 257 | # ------------------------------------------------------------------ 258 | 259 | def and_(self, alternative: "Option[U]") -> "Option[U]": 260 | """Return `Nothing` if `self` is `Nothing`, or the `alternative`.""" 261 | raise NotImplementedError 262 | 263 | def or_(self, alternative: "Option[T]") -> "Option[T]": 264 | """Return option if it is `Some`, or the `alternative`.""" 265 | raise NotImplementedError 266 | 267 | def xor(self, alternative: "Option[T]") -> "Option[T]": 268 | """Return Some IFF exactly one of `self`, `alternative` is `Some`.""" 269 | raise NotImplementedError 270 | 271 | def and_then(self, fn: t.Callable[[T], "Option[U]"]) -> "Option[U]": 272 | """Return `Nothing`, or call `fn` with the `Some` value.""" 273 | raise NotImplementedError 274 | 275 | def flatmap(self, fn: t.Callable[[T], "Option[U]"]) -> "Option[U]": 276 | """Return `Nothing`, or call `fn` with the `Some` value.""" 277 | raise NotImplementedError 278 | 279 | def or_else(self, fn: t.Callable[[], "Option[T]"]) -> "Option[T]": 280 | """Return option if it is `Some`, or calculate an alternative.""" 281 | raise NotImplementedError 282 | 283 | def expect(self, msg: str, exc_cls: t.Type[Exception] = RuntimeError) -> T: 284 | """Unwrap and yield a `Some`, or throw an exception if `Nothing`. 285 | 286 | The exception class may be specified with the `exc_cls` keyword 287 | argument. 288 | """ 289 | raise NotImplementedError 290 | 291 | def raise_if_err( 292 | self, msg: str, exc_cls: t.Type[Exception] = RuntimeError 293 | ) -> T: # noqa: D401 294 | """DEPRECATED: Use `raise_if_nothing` or `expect`. 295 | 296 | Unwrap and yield a `Some`, or throw an exception if `Nothing`. 297 | 298 | The exception class may be specified with the `exc_cls` keyword 299 | argument. 300 | 301 | Alias of `expect`. 302 | """ 303 | raise NotImplementedError 304 | 305 | def raise_if_nothing( 306 | self, msg: str, exc_cls: t.Type[Exception] = RuntimeError 307 | ) -> T: 308 | """Unwrap and yield a `Some`, or throw an exception if `Nothing`. 309 | 310 | The exception class may be specified with the `exc_cls` keyword 311 | argument. 312 | 313 | Alias of `expect`. 314 | """ 315 | raise NotImplementedError 316 | 317 | def filter(self, predicate: t.Callable[[T], bool]) -> "Option[T]": 318 | """Return `Nothing`, or an option determined by the predicate. 319 | 320 | If `self` is `Some`, call `predicate` with the wrapped value and 321 | return: 322 | 323 | * `self` (`Some(t)` where `t` is the wrapped value) if the predicate 324 | is `True` 325 | * `Nothing` if the predicate is `False` 326 | """ 327 | raise NotImplementedError 328 | 329 | def is_nothing(self) -> bool: 330 | """Return whether the option is `Nothing`.""" 331 | raise NotImplementedError 332 | 333 | def is_some(self) -> bool: 334 | """Return whether the option is a `Some` value.""" 335 | raise NotImplementedError 336 | 337 | def iter(self) -> t.Iterator[T]: 338 | """Return an iterator over the possibly contained value.""" 339 | raise NotImplementedError 340 | 341 | def map(self, fn: t.Callable[[T], U]) -> "Option[U]": 342 | """Apply `fn` to the contained value if any.""" 343 | raise NotImplementedError 344 | 345 | def map_or(self, default: U, fn: t.Callable[[T], U]) -> U: 346 | """Apply `fn` to contained value, or return the default.""" 347 | raise NotImplementedError 348 | 349 | def map_or_else( 350 | self, default: t.Callable[[], U], fn: t.Callable[[T], U] 351 | ) -> U: 352 | """Apply `fn` to contained value, or compute a default.""" 353 | raise NotImplementedError 354 | 355 | def ok_or(self, err: F) -> "Result[T, F]": 356 | """Transform an option into a `Result`. 357 | 358 | Maps `Some(v)` to `Ok(v)` or `None` to `Err(err)`. 359 | """ 360 | raise NotImplementedError 361 | 362 | def ok_or_else(self, err_fn: t.Callable[[], E]) -> "Result[T, E]": 363 | """Transform an option into a `Result`. 364 | 365 | Maps `Some(v)` to `Ok(v)` or `None` to `Err(err_fn())`. 366 | """ 367 | raise NotImplementedError 368 | 369 | def unwrap(self) -> T: 370 | """Return `Some` value, or raise an error.""" 371 | raise NotImplementedError 372 | 373 | def unwrap_or(self, default: U) -> t.Union[T, U]: 374 | """Return the contained value or `default`.""" 375 | raise NotImplementedError 376 | 377 | def unwrap_or_else(self, fn: t.Callable[[], U]) -> t.Union[T, U]: 378 | """Return the contained value or calculate a default.""" 379 | raise NotImplementedError 380 | 381 | def __iter__(self) -> t.Iterator[T]: 382 | """Iterate over the contained value if present.""" 383 | raise NotImplementedError 384 | 385 | def __eq__(self, other: t.Any) -> bool: 386 | """Options are equal if their values are equal.""" 387 | raise NotImplementedError 388 | 389 | def __ne__(self, other: t.Any) -> bool: 390 | """Options are equal if their values are equal.""" 391 | raise NotImplementedError 392 | 393 | def __str__(self) -> str: 394 | """Return a string representation of the Option.""" 395 | raise NotImplementedError 396 | 397 | def __repr__(self) -> str: 398 | """Return a string representation of the Option.""" 399 | raise NotImplementedError 400 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | 3 | # A comma-separated list of package or module names from where C extensions may 4 | # be loaded. Extensions are loading into the active Python interpreter and may 5 | # run arbitrary code. 6 | extension-pkg-whitelist= 7 | 8 | # Add files or directories to the blacklist. They should be base names, not 9 | # paths. 10 | ignore=CVS 11 | 12 | # Add files or directories matching the regex patterns to the blacklist. The 13 | # regex matches against base names, not paths. 14 | ignore-patterns= 15 | 16 | # Python code to execute, usually for sys.path manipulation such as 17 | # pygtk.require(). 18 | #init-hook= 19 | 20 | # Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the 21 | # number of processors available to use. 22 | jobs=1 23 | 24 | # Control the amount of potential inferred values when inferring a single 25 | # object. This can help the performance when dealing with large functions or 26 | # complex, nested conditions. 27 | limit-inference-results=100 28 | 29 | # List of plugins (as comma separated values of python modules names) to load, 30 | # usually to register additional checkers. 31 | load-plugins= 32 | 33 | # Pickle collected data for later comparisons. 34 | persistent=yes 35 | 36 | # Specify a configuration file. 37 | #rcfile= 38 | 39 | # When enabled, pylint would attempt to guess common misconfiguration and emit 40 | # user-friendly hints instead of false-positive error messages. 41 | suggestion-mode=yes 42 | 43 | # Allow loading of arbitrary C extensions. Extensions are imported into the 44 | # active Python interpreter and may run arbitrary code. 45 | unsafe-load-any-extension=no 46 | 47 | 48 | [MESSAGES CONTROL] 49 | 50 | # Only show warnings with the listed confidence levels. Leave empty to show 51 | # all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED. 52 | confidence= 53 | 54 | # Disable the message, report, category or checker with the given id(s). You 55 | # can either give multiple identifiers separated by comma (,) or put this 56 | # option multiple times (only on the command line, not in the configuration 57 | # file where it should appear only once). You can also use "--disable=all" to 58 | # disable everything first and then reenable specific checks. For example, if 59 | # you want to run only the similarities checker, you can use "--disable=all 60 | # --enable=similarities". If you want to run only the classes checker, but have 61 | # no Warning level messages displayed, use "--disable=all --enable=classes 62 | # --disable=W". 63 | disable=bad-continuation, 64 | duplicate-code, 65 | no-self-use, 66 | too-few-public-methods 67 | 68 | # Enable the message, report, category or checker with the given id(s). You can 69 | # either give multiple identifier separated by comma (,) or put this option 70 | # multiple time (only on the command line, not in the configuration file where 71 | # it should appear only once). See also the "--disable" option for examples. 72 | enable=c-extension-no-member 73 | 74 | 75 | [REPORTS] 76 | 77 | # Python expression which should return a note less than 10 (10 is the highest 78 | # note). You have access to the variables errors warning, statement which 79 | # respectively contain the number of errors / warnings messages and the total 80 | # number of statements analyzed. This is used by the global evaluation report 81 | # (RP0004). 82 | evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) 83 | 84 | # Template used to display messages. This is a python new-style format string 85 | # used to format the message information. See doc for all details. 86 | #msg-template= 87 | 88 | # Set the output format. Available formats are text, parseable, colorized, json 89 | # and msvs (visual studio). You can also give a reporter class, e.g. 90 | # mypackage.mymodule.MyReporterClass. 91 | output-format=text 92 | 93 | # Tells whether to display a full report or only the messages. 94 | reports=no 95 | 96 | # Activate the evaluation score. 97 | score=yes 98 | 99 | 100 | [REFACTORING] 101 | 102 | # Maximum number of nested blocks for function / method body 103 | max-nested-blocks=5 104 | 105 | # Complete name of functions that never returns. When checking for 106 | # inconsistent-return-statements if a never returning function is called then 107 | # it will be considered as an explicit return statement and no message will be 108 | # printed. 109 | never-returning-functions=sys.exit 110 | 111 | 112 | [LOGGING] 113 | 114 | # Format style used to check logging format string. `old` means using % 115 | # formatting, while `new` is for `{}` formatting. 116 | logging-format-style=old 117 | 118 | # Logging modules to check that the string format arguments are in logging 119 | # function parameter format. 120 | logging-modules=logging 121 | 122 | 123 | [SPELLING] 124 | 125 | # Limits count of emitted suggestions for spelling mistakes. 126 | max-spelling-suggestions=4 127 | 128 | # Spelling dictionary name. Available dictionaries: none. To make it working 129 | # install python-enchant package.. 130 | spelling-dict= 131 | 132 | # List of comma separated words that should not be checked. 133 | spelling-ignore-words= 134 | 135 | # A path to a file that contains private dictionary; one word per line. 136 | spelling-private-dict-file= 137 | 138 | # Tells whether to store unknown words to indicated private dictionary in 139 | # --spelling-private-dict-file option instead of raising a message. 140 | spelling-store-unknown-words=no 141 | 142 | 143 | [MISCELLANEOUS] 144 | 145 | # List of note tags to take in consideration, separated by a comma. 146 | notes=FIXME, 147 | XXX, 148 | TODO 149 | 150 | 151 | [TYPECHECK] 152 | 153 | # List of decorators that produce context managers, such as 154 | # contextlib.contextmanager. Add to this list to register other decorators that 155 | # produce valid context managers. 156 | contextmanager-decorators=contextlib.contextmanager 157 | 158 | # List of members which are set dynamically and missed by pylint inference 159 | # system, and so shouldn't trigger E1101 when accessed. Python regular 160 | # expressions are accepted. 161 | generated-members= 162 | 163 | # Tells whether missing members accessed in mixin class should be ignored. A 164 | # mixin class is detected if its name ends with "mixin" (case insensitive). 165 | ignore-mixin-members=yes 166 | 167 | # Tells whether to warn about missing members when the owner of the attribute 168 | # is inferred to be None. 169 | ignore-none=yes 170 | 171 | # This flag controls whether pylint should warn about no-member and similar 172 | # checks whenever an opaque object is returned when inferring. The inference 173 | # can return multiple potential results while evaluating a Python object, but 174 | # some branches might not be evaluated, which results in partial inference. In 175 | # that case, it might be useful to still emit no-member and other checks for 176 | # the rest of the inferred objects. 177 | ignore-on-opaque-inference=yes 178 | 179 | # List of class names for which member attributes should not be checked (useful 180 | # for classes with dynamically set attributes). This supports the use of 181 | # qualified names. 182 | ignored-classes=optparse.Values,thread._local,_thread._local 183 | 184 | # List of module names for which member attributes should not be checked 185 | # (useful for modules/projects where namespaces are manipulated during runtime 186 | # and thus existing member attributes cannot be deduced by static analysis. It 187 | # supports qualified module names, as well as Unix pattern matching. 188 | ignored-modules= 189 | 190 | # Show a hint with possible names when a member name was not found. The aspect 191 | # of finding the hint is based on edit distance. 192 | missing-member-hint=yes 193 | 194 | # The minimum edit distance a name should have in order to be considered a 195 | # similar match for a missing member name. 196 | missing-member-hint-distance=1 197 | 198 | # The total number of similar names that should be taken in consideration when 199 | # showing a hint for a missing member. 200 | missing-member-max-choices=1 201 | 202 | 203 | [VARIABLES] 204 | 205 | # List of additional names supposed to be defined in builtins. Remember that 206 | # you should avoid defining new builtins when possible. 207 | additional-builtins= 208 | 209 | # Tells whether unused global variables should be treated as a violation. 210 | allow-global-unused-variables=yes 211 | 212 | # List of strings which can identify a callback function by name. A callback 213 | # name must start or end with one of those strings. 214 | callbacks=cb_, 215 | _cb 216 | 217 | # A regular expression matching the name of dummy variables (i.e. expected to 218 | # not be used). 219 | dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ 220 | 221 | # Argument names that match this expression will be ignored. Default to name 222 | # with leading underscore. 223 | ignored-argument-names=_.*|^ignored_|^unused_ 224 | 225 | # Tells whether we should check for unused import in __init__ files. 226 | init-import=no 227 | 228 | # List of qualified module names which can have objects that can redefine 229 | # builtins. 230 | redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io 231 | 232 | 233 | [FORMAT] 234 | 235 | # Expected format of line ending, e.g. empty (any line ending), LF or CRLF. 236 | expected-line-ending-format= 237 | 238 | # Regexp for a line that is allowed to be longer than the limit. 239 | ignore-long-lines=^\s*(# )??$ 240 | 241 | # Number of spaces of indent required inside a hanging or continued line. 242 | indent-after-paren=4 243 | 244 | # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 245 | # tab). 246 | indent-string=' ' 247 | 248 | # Maximum number of characters on a single line. 249 | max-line-length=100 250 | 251 | # Maximum number of lines in a module. 252 | max-module-lines=1000 253 | 254 | # List of optional constructs for which whitespace checking is disabled. `dict- 255 | # separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}. 256 | # `trailing-comma` allows a space between comma and closing bracket: (a, ). 257 | # `empty-line` allows space-only lines. 258 | no-space-check=trailing-comma, 259 | dict-separator 260 | 261 | # Allow the body of a class to be on the same line as the declaration if body 262 | # contains single statement. 263 | single-line-class-stmt=no 264 | 265 | # Allow the body of an if to be on the same line as the test if there is no 266 | # else. 267 | single-line-if-stmt=no 268 | 269 | 270 | [SIMILARITIES] 271 | 272 | # Ignore comments when computing similarities. 273 | ignore-comments=yes 274 | 275 | # Ignore docstrings when computing similarities. 276 | ignore-docstrings=yes 277 | 278 | # Ignore imports when computing similarities. 279 | ignore-imports=no 280 | 281 | # Minimum lines number of a similarity. 282 | min-similarity-lines=4 283 | 284 | 285 | [BASIC] 286 | 287 | # Naming style matching correct argument names. 288 | argument-naming-style=snake_case 289 | 290 | # Regular expression matching correct argument names. Overrides argument- 291 | # naming-style. 292 | #argument-rgx= 293 | 294 | # Naming style matching correct attribute names. 295 | attr-naming-style=snake_case 296 | 297 | # Regular expression matching correct attribute names. Overrides attr-naming- 298 | # style. 299 | #attr-rgx= 300 | 301 | # Bad variable names which should always be refused, separated by a comma. 302 | bad-names=foo, 303 | bar, 304 | baz, 305 | toto, 306 | tutu, 307 | tata 308 | 309 | # Naming style matching correct class attribute names. 310 | class-attribute-naming-style=any 311 | 312 | # Regular expression matching correct class attribute names. Overrides class- 313 | # attribute-naming-style. 314 | #class-attribute-rgx= 315 | 316 | # Naming style matching correct class names. 317 | class-naming-style=PascalCase 318 | 319 | # Regular expression matching correct class names. Overrides class-naming- 320 | # style. 321 | #class-rgx= 322 | 323 | # Naming style matching correct constant names. 324 | const-naming-style=UPPER_CASE 325 | 326 | # Regular expression matching correct constant names. Overrides const-naming- 327 | # style. 328 | #const-rgx= 329 | 330 | # Minimum line length for functions/classes that require docstrings, shorter 331 | # ones are exempt. 332 | docstring-min-length=-1 333 | 334 | # Naming style matching correct function names. 335 | function-naming-style=snake_case 336 | 337 | # Regular expression matching correct function names. Overrides function- 338 | # naming-style. 339 | #function-rgx= 340 | 341 | # Good variable names which should always be accepted, separated by a comma. 342 | good-names=i, 343 | j, 344 | k, 345 | ex, 346 | log, 347 | Run, 348 | T, 349 | E, 350 | U, 351 | F, 352 | fn, 353 | _ 354 | 355 | # Include a hint for the correct naming format with invalid-name. 356 | include-naming-hint=no 357 | 358 | # Naming style matching correct inline iteration names. 359 | inlinevar-naming-style=any 360 | 361 | # Regular expression matching correct inline iteration names. Overrides 362 | # inlinevar-naming-style. 363 | #inlinevar-rgx= 364 | 365 | # Naming style matching correct method names. 366 | method-naming-style=snake_case 367 | 368 | # Regular expression matching correct method names. Overrides method-naming- 369 | # style. 370 | #method-rgx= 371 | 372 | # Naming style matching correct module names. 373 | module-naming-style=snake_case 374 | 375 | # Regular expression matching correct module names. Overrides module-naming- 376 | # style. 377 | #module-rgx= 378 | 379 | # Colon-delimited sets of names that determine each other's naming style when 380 | # the name regexes allow several styles. 381 | name-group= 382 | 383 | # Regular expression which should only match function or class names that do 384 | # not require a docstring. 385 | no-docstring-rgx=^_ 386 | 387 | # List of decorators that produce properties, such as abc.abstractproperty. Add 388 | # to this list to register other decorators that produce valid properties. 389 | # These decorators are taken in consideration only for invalid-name. 390 | property-classes=abc.abstractproperty 391 | 392 | # Naming style matching correct variable names. 393 | variable-naming-style=snake_case 394 | 395 | # Regular expression matching correct variable names. Overrides variable- 396 | # naming-style. 397 | #variable-rgx= 398 | 399 | 400 | [STRING] 401 | 402 | # This flag controls whether the implicit-str-concat-in-sequence should 403 | # generate a warning on implicit string concatenation in sequences defined over 404 | # several lines. 405 | check-str-concat-over-line-jumps=no 406 | 407 | 408 | [IMPORTS] 409 | 410 | # Allow wildcard imports from modules that define __all__. 411 | allow-wildcard-with-all=no 412 | 413 | # Analyse import fallback blocks. This can be used to support both Python 2 and 414 | # 3 compatible code, which means that the block might have code that exists 415 | # only in one or another interpreter, leading to false positives when analysed. 416 | analyse-fallback-blocks=no 417 | 418 | # Deprecated modules which should not be used, separated by a comma. 419 | deprecated-modules=optparse,tkinter.tix 420 | 421 | # Create a graph of external dependencies in the given file (report RP0402 must 422 | # not be disabled). 423 | ext-import-graph= 424 | 425 | # Create a graph of every (i.e. internal and external) dependencies in the 426 | # given file (report RP0402 must not be disabled). 427 | import-graph= 428 | 429 | # Create a graph of internal dependencies in the given file (report RP0402 must 430 | # not be disabled). 431 | int-import-graph= 432 | 433 | # Force import order to recognize a module as part of the standard 434 | # compatibility libraries. 435 | known-standard-library= 436 | 437 | # Force import order to recognize a module as part of a third party library. 438 | known-third-party=enchant 439 | 440 | 441 | [CLASSES] 442 | 443 | # List of method names used to declare (i.e. assign) instance attributes. 444 | defining-attr-methods=__init__, 445 | __new__, 446 | setUp 447 | 448 | # List of member names, which should be excluded from the protected access 449 | # warning. 450 | exclude-protected=_asdict, 451 | _fields, 452 | _replace, 453 | _source, 454 | _make 455 | 456 | # List of valid names for the first argument in a class method. 457 | valid-classmethod-first-arg=cls 458 | 459 | # List of valid names for the first argument in a metaclass class method. 460 | valid-metaclass-classmethod-first-arg=cls 461 | 462 | 463 | [DESIGN] 464 | 465 | # Maximum number of arguments for function / method. 466 | max-args=5 467 | 468 | # Maximum number of attributes for a class (see R0902). 469 | max-attributes=7 470 | 471 | # Maximum number of boolean expressions in an if statement. 472 | max-bool-expr=5 473 | 474 | # Maximum number of branch for function / method body. 475 | max-branches=12 476 | 477 | # Maximum number of locals for function / method body. 478 | max-locals=15 479 | 480 | # Maximum number of parents for a class (see R0901). 481 | max-parents=7 482 | 483 | # Maximum number of public methods for a class (see R0904). 484 | max-public-methods=20 485 | 486 | # Maximum number of return / yield for function / method body. 487 | max-returns=6 488 | 489 | # Maximum number of statements in function / method body. 490 | max-statements=50 491 | 492 | # Minimum number of public methods for a class (see R0903). 493 | min-public-methods=2 494 | 495 | 496 | [EXCEPTIONS] 497 | 498 | # Exceptions that will emit a warning when being caught. Defaults to 499 | # "BaseException, Exception". 500 | overgeneral-exceptions=BaseException, 501 | Exception 502 | -------------------------------------------------------------------------------- /src/safetywrap/_impl.py: -------------------------------------------------------------------------------- 1 | """Implementations of Ok, Err, Some, and None.""" 2 | 3 | import typing as t 4 | import warnings 5 | from functools import reduce 6 | 7 | from ._interface import _Option, _Result 8 | 9 | 10 | T = t.TypeVar("T", covariant=True) 11 | E = t.TypeVar("E", covariant=True) 12 | U = t.TypeVar("U") 13 | F = t.TypeVar("F") 14 | 15 | ExcType = t.TypeVar("ExcType", bound=Exception) 16 | 17 | WrappedFunc = t.Callable[..., t.Any] 18 | WrappedFn = t.TypeVar("WrappedFn", bound=WrappedFunc) 19 | 20 | Args = t.TypeVar("Args") 21 | Kwargs = t.TypeVar("Kwargs") 22 | 23 | # pylint: disable=super-init-not-called 24 | 25 | 26 | # pylint: disable=abstract-method 27 | class Result(_Result[T, E]): 28 | """Base implementation for Result types.""" 29 | 30 | __slots__ = () 31 | 32 | @staticmethod 33 | def of( 34 | fn: t.Callable[..., T], 35 | *args: t.Any, 36 | catch: t.Type[ExcType] = Exception, # type: ignore 37 | **kwargs: t.Any, 38 | ) -> "Result[T, ExcType]": 39 | """Call `fn` and wrap its result in an `Ok()`. 40 | 41 | If an exception is intercepted, return `Err(exception)`. By 42 | default, any `Exception` will be intercepted. If you specify 43 | `exc_type`, only that exception will be intercepted. 44 | """ 45 | try: 46 | return Ok(fn(*args, **kwargs)) 47 | except catch as exc: # pylint: disable=broad-except 48 | return Err(exc) 49 | 50 | @staticmethod 51 | def collect( 52 | iterable: t.Iterable["Result[U, F]"], 53 | ) -> "Result[t.Tuple[U, ...], F]": 54 | """Collect an iterable of Results into a Result of an iterable. 55 | 56 | Given some iterable of type Iterable[Result[T, E]], try to collect 57 | all Ok values into a tuple of type Tuple[T, ...]. If any of the 58 | iterable items are Errs, short-circuit and return Err of type 59 | Result[E]. 60 | 61 | Example: 62 | ```py 63 | 64 | >>> assert Result.collect([Ok(1), Ok(2), Ok(3)]) == Ok((1, 2, 3)) 65 | >>> assert Result.collect([Ok(1), Err("no"), Ok(3)]) == Err("no") 66 | 67 | ``` 68 | 69 | Note that in order to satisfy the type checker, you'll probably 70 | need to use this in a context where the type of the result is 71 | hinted, either by a variable annotation or a return type. 72 | """ 73 | # Non-functional code here to enable true short-circuiting. 74 | ok_vals: t.Tuple[U, ...] = () 75 | for result in iterable: 76 | if result.is_err(): 77 | return result.map(lambda _: ()) 78 | ok_vals += (result.unwrap(),) 79 | return Ok(ok_vals) 80 | 81 | @staticmethod 82 | def err_if(predicate: t.Callable[[U], bool], value: U) -> "Result[U, U]": 83 | """Return Err(val) if predicate(val) is True, otherwise Ok(val).""" 84 | if predicate(value): 85 | return Err(value) 86 | return Ok(value) 87 | 88 | @staticmethod 89 | def ok_if(predicate: t.Callable[[U], bool], value: U) -> "Result[U, U]": 90 | """Return Ok(val) if predicate(val) is True, otherwise Err(val).""" 91 | if predicate(value): 92 | return Ok(value) 93 | return Err(value) 94 | 95 | 96 | class Option(_Option[T]): 97 | """Base implementation for Option types.""" 98 | 99 | __slots__ = () 100 | 101 | @staticmethod 102 | def of(value: t.Optional[T]) -> "Option[T]": 103 | """Construct an Option[T] from an Optional[T]. 104 | 105 | If the value is None, Nothing() is returned. If the value is 106 | not None, Some(value) is returned. 107 | """ 108 | if value is None: 109 | return Nothing() 110 | return Some(value) 111 | 112 | @staticmethod 113 | def nothing_if(predicate: t.Callable[[U], bool], value: U) -> "Option[U]": 114 | """Return Nothing() if predicate(val) is True, else Some(val).""" 115 | if predicate(value): 116 | return Nothing() 117 | return Some(value) 118 | 119 | @staticmethod 120 | def some_if(predicate: t.Callable[[U], bool], value: U) -> "Option[U]": 121 | """Return Some(val) if predicate(val) is True, else Nothing().""" 122 | if predicate(value): 123 | return Some(value) 124 | return Nothing() 125 | 126 | @staticmethod 127 | def collect(options: t.Iterable["Option[T]"]) -> "Option[t.Tuple[T, ...]]": 128 | """Collect a series of Options into single Option. 129 | 130 | If all options are `Some[T]`, the result is `Some[Tuple[T]]`. If 131 | any options are `Nothing`, the result is `Nothing`. 132 | """ 133 | accumulator: Option[t.Tuple[T, ...]] = Some(()) 134 | try: 135 | return reduce( 136 | lambda acc, i: acc.map(lambda somes: (*somes, i.unwrap())), 137 | options, 138 | accumulator, 139 | ) 140 | except RuntimeError: 141 | return Nothing() 142 | 143 | 144 | # pylint: enable=abstract-method 145 | 146 | 147 | class Ok(Result[T, E]): 148 | """Standard wrapper for results.""" 149 | 150 | __slots__ = ("_value",) 151 | 152 | def __init__(self, result: T) -> None: 153 | """Wrap a result.""" 154 | self._value: T = result 155 | 156 | def and_(self, res: "Result[U, E]") -> "Result[U, E]": 157 | """Return `res` if the result is `Ok`, otherwise return `self`.""" 158 | return res 159 | 160 | def or_(self, res: "Result[T, F]") -> "Result[T, F]": 161 | """Return `res` if the result is `Err`, otherwise `self`.""" 162 | return t.cast(Result[T, F], self) 163 | 164 | def and_then(self, fn: t.Callable[[T], "Result[U, E]"]) -> "Result[U, E]": 165 | """Call `fn` if Ok, or ignore an error. 166 | 167 | This can be used to chain functions that return results. 168 | """ 169 | return fn(self._value) 170 | 171 | def flatmap(self, fn: t.Callable[[T], "Result[U, E]"]) -> "Result[U, E]": 172 | """Call `fn` if Ok, or ignore an error. 173 | 174 | This can be used to chain functions that return results. 175 | """ 176 | return self.and_then(fn) 177 | 178 | def or_else(self, fn: t.Callable[[E], "Result[T, F]"]) -> "Result[T, F]": 179 | """Return `self` if `Ok`, or call `fn` with `self` if `Err`.""" 180 | return t.cast(Result[T, F], self) 181 | 182 | def err(self) -> Option[E]: 183 | """Return Err value if result is Err.""" 184 | return Nothing() 185 | 186 | def ok(self) -> Option[T]: 187 | """Return OK value if result is Ok.""" 188 | return Some(self._value) 189 | 190 | def expect(self, msg: str, exc_cls: t.Type[Exception] = RuntimeError) -> T: 191 | """Return `Ok` value or raise an error with the specified message. 192 | 193 | The raised exception class may be specified with the `exc_cls` 194 | keyword argument. 195 | """ 196 | return self._value 197 | 198 | def raise_if_err( 199 | self, msg: str, exc_cls: t.Type[Exception] = RuntimeError 200 | ) -> T: 201 | """Return `Ok` value or raise an error with the specified message. 202 | 203 | The raised exception class may be specified with the `exc_cls` 204 | keyword argument. 205 | 206 | Alias for `Ok.expect`. 207 | """ 208 | return self.expect(msg, exc_cls=exc_cls) 209 | 210 | def expect_err( 211 | self, msg: str, exc_cls: t.Type[Exception] = RuntimeError 212 | ) -> E: 213 | """Return `Err` value or raise an error with the specified message. 214 | 215 | The raised exception class may be specified with the `exc_cls` 216 | keyword argument. 217 | """ 218 | raise exc_cls(f"{msg}: {self._value}") 219 | # Hack: pylint will warn that you're assigning from a function 220 | # that doesn't return if there isn't at least one return statement 221 | # in a function 222 | return self._value # pylint: disable=unreachable 223 | 224 | def is_err(self) -> bool: 225 | """Returl whether the result is an Err.""" 226 | return False 227 | 228 | def is_ok(self) -> bool: 229 | """Return whether the result is OK.""" 230 | return True 231 | 232 | def iter(self) -> t.Iterator[T]: 233 | """Return a one-item iterator whose sole member is the result if `Ok`. 234 | 235 | If the result is `Err`, the iterator will contain no items. 236 | """ 237 | return iter(self) 238 | 239 | def map(self, fn: t.Callable[[T], U]) -> "Result[U, E]": 240 | """Map a function onto an okay result, or ignore an error.""" 241 | return Ok(fn(self._value)) 242 | 243 | def map_err(self, fn: t.Callable[[E], F]) -> "Result[T, F]": 244 | """Map a function onto an error, or ignore a success.""" 245 | return t.cast(Result[T, F], self) 246 | 247 | def unwrap(self) -> T: 248 | """Return an Ok result, or throw an error if an Err.""" 249 | return self._value 250 | 251 | def unwrap_err(self) -> E: 252 | """Return an Ok result, or throw an error if an Err.""" 253 | raise RuntimeError(f"Tried to unwrap_err {self}!") 254 | # Hack: pylint will warn that you're assigning from a function 255 | # that doesn't return if there isn't at least one return statement 256 | # in a function 257 | return self._value # pylint: disable=unreachable 258 | 259 | def unwrap_or(self, alternative: U) -> t.Union[T, U]: 260 | """Return the `Ok` value, or `alternative` if `self` is `Err`.""" 261 | return self._value 262 | 263 | def unwrap_or_else(self, fn: t.Callable[[E], U]) -> t.Union[T, U]: 264 | """Return the `Ok` value, or the return from `fn`.""" 265 | return self._value 266 | 267 | def __iter__(self) -> t.Iterator[T]: 268 | """Return a one-item iterator whose sole member is the result if `Ok`. 269 | 270 | If the result is `Err`, the iterator will contain no items. 271 | """ 272 | yield self._value 273 | 274 | def __eq__(self, other: t.Any) -> bool: 275 | """Compare two results. They are equal if their values are equal.""" 276 | if not isinstance(other, Result): 277 | return False 278 | if not other.is_ok(): 279 | return False 280 | eq: bool = self._value == other.unwrap() 281 | return eq 282 | 283 | def __ne__(self, other: t.Any) -> bool: 284 | """Compare two results. They are equal if their values are equal.""" 285 | return not self == other 286 | 287 | def __str__(self) -> str: 288 | """Return string value of result.""" 289 | return f"{self.__class__.__name__}({repr(self._value)})" 290 | 291 | def __repr__(self) -> str: 292 | """Return repr for result.""" 293 | return self.__str__() 294 | 295 | 296 | class Err(Result[T, E]): 297 | """Standard wrapper for results.""" 298 | 299 | __slots__ = ("_value",) 300 | 301 | def __init__(self, result: E) -> None: 302 | """Wrap a result.""" 303 | self._value = result 304 | 305 | def and_(self, res: "Result[U, E]") -> "Result[U, E]": 306 | """Return `res` if the result is `Ok`, otherwise return `self`.""" 307 | return t.cast(Result[U, E], self) 308 | 309 | def or_(self, res: "Result[T, F]") -> "Result[T, F]": 310 | """Return `res` if the result is `Err`, otherwise `self`.""" 311 | return res 312 | 313 | def and_then(self, fn: t.Callable[[T], "Result[U, E]"]) -> "Result[U, E]": 314 | """Call `fn` if Ok, or ignore an error. 315 | 316 | This can be used to chain functions that return results. 317 | """ 318 | return t.cast(Result[U, E], self) 319 | 320 | def flatmap(self, fn: t.Callable[[T], "Result[U, E]"]) -> "Result[U, E]": 321 | """Call `fn` if Ok, or ignore an error. 322 | 323 | This can be used to chain functions that return results. 324 | """ 325 | return t.cast(Result[U, E], self.and_then(fn)) 326 | 327 | def or_else(self, fn: t.Callable[[E], "Result[T, F]"]) -> "Result[T, F]": 328 | """Return `self` if `Ok`, or call `fn` with `self` if `Err`.""" 329 | return fn(self._value) 330 | 331 | def err(self) -> Option[E]: 332 | """Return Err value if result is Err.""" 333 | return Some(self._value) 334 | 335 | def ok(self) -> Option[T]: 336 | """Return OK value if result is Ok.""" 337 | return Nothing() 338 | 339 | def expect(self, msg: str, exc_cls: t.Type[Exception] = RuntimeError) -> T: 340 | """Return `Ok` value or raise an error with the specified message. 341 | 342 | The raised exception class may be specified with the `exc_cls` 343 | keyword argument. 344 | """ 345 | raise exc_cls(f"{msg}: {self._value}") 346 | # Hack: pylint will warn that you're assigning from a function 347 | # that doesn't return if there isn't at least one return statement 348 | # in a function 349 | return self._value # pylint: disable=unreachable 350 | 351 | def raise_if_err( 352 | self, msg: str, exc_cls: t.Type[Exception] = RuntimeError 353 | ) -> T: 354 | """Return `Ok` value or raise an error with the specified message. 355 | 356 | The raised exception class may be specified with the `exc_cls` 357 | keyword argument. 358 | 359 | Alias for `Err.expect`. 360 | """ 361 | return self.expect(msg, exc_cls=exc_cls) 362 | 363 | def expect_err( 364 | self, msg: str, exc_cls: t.Type[Exception] = RuntimeError 365 | ) -> E: 366 | """Return `Err` value or raise an error with the specified message. 367 | 368 | The raised exception class may be specified with the `exc_cls` 369 | keyword argument. 370 | """ 371 | return self._value 372 | 373 | def is_err(self) -> bool: 374 | """Returl whether the result is an Err.""" 375 | return True 376 | 377 | def is_ok(self) -> bool: 378 | """Return whether the result is OK.""" 379 | return False 380 | 381 | def iter(self) -> t.Iterator[T]: 382 | """Return a one-item iterator whose sole member is the result if `Ok`. 383 | 384 | If the result is `Err`, the iterator will contain no items. 385 | """ 386 | return iter(self) 387 | 388 | def map(self, fn: t.Callable[[T], U]) -> "Result[U, E]": 389 | """Map a function onto an okay result, or ignore an error.""" 390 | return t.cast(Result[U, E], self) 391 | 392 | def map_err(self, fn: t.Callable[[E], F]) -> "Result[T, F]": 393 | """Map a function onto an error, or ignore a success.""" 394 | return Err(fn(self._value)) 395 | 396 | def unwrap(self) -> T: 397 | """Return an Ok result, or throw an error if an Err.""" 398 | raise RuntimeError(f"Tried to unwrap {self}!") 399 | # Hack: pylint will warn that you're assigning from a function 400 | # that doesn't return if there isn't at least one return statement 401 | # in a function 402 | return self._value # pylint: disable=unreachable 403 | 404 | def unwrap_err(self) -> E: 405 | """Return an Ok result, or throw an error if an Err.""" 406 | return self._value 407 | 408 | def unwrap_or(self, alternative: U) -> t.Union[T, U]: 409 | """Return the `Ok` value, or `alternative` if `self` is `Err`.""" 410 | return alternative 411 | 412 | def unwrap_or_else(self, fn: t.Callable[[E], U]) -> t.Union[T, U]: 413 | """Return the `Ok` value, or the return from `fn`.""" 414 | return fn(self._value) 415 | 416 | def __iter__(self) -> t.Iterator[T]: 417 | """Return a one-item iterator whose sole member is the result if `Ok`. 418 | 419 | If the result is `Err`, the iterator will contain no items. 420 | """ 421 | _: t.Tuple[T, ...] = () 422 | yield from _ 423 | 424 | def __eq__(self, other: t.Any) -> bool: 425 | """Compare two results. They are equal if their values are equal.""" 426 | if not isinstance(other, Result): 427 | return False 428 | if not other.is_err(): 429 | return False 430 | eq: bool = self._value == other.unwrap_err() 431 | return eq 432 | 433 | def __ne__(self, other: t.Any) -> bool: 434 | """Compare two results. They are equal if their values are equal.""" 435 | return not self == other 436 | 437 | def __str__(self) -> str: 438 | """Return string value of result.""" 439 | return f"{self.__class__.__name__}({repr(self._value)})" 440 | 441 | def __repr__(self) -> str: 442 | """Return repr for result.""" 443 | return self.__str__() 444 | 445 | 446 | class Some(Option[T]): 447 | """A value that may be `Some` or `Nothing`.""" 448 | 449 | __slots__ = ("_value",) 450 | 451 | def __init__(self, value: T) -> None: 452 | """Wrap value in a `Some()`.""" 453 | # not sure why pylint things _value is not in __slots__ 454 | self._value = value # pylint: disable=assigning-non-slot 455 | 456 | def and_(self, alternative: Option[U]) -> Option[U]: 457 | """Return `Nothing` if `self` is `Nothing`, or the `alternative`.""" 458 | return alternative 459 | 460 | def or_(self, alternative: Option[T]) -> Option[T]: 461 | """Return option if it is `Some`, or the `alternative`.""" 462 | return self 463 | 464 | def xor(self, alternative: Option[T]) -> Option[T]: 465 | """Return Some IFF exactly one of `self`, `alternative` is `Some`.""" 466 | return ( 467 | t.cast(Option[T], self) if alternative.is_nothing() else Nothing() 468 | ) 469 | 470 | def and_then(self, fn: t.Callable[[T], Option[U]]) -> Option[U]: 471 | """Return `Nothing`, or call `fn` with the `Some` value.""" 472 | return fn(self._value) 473 | 474 | def flatmap(self, fn: t.Callable[[T], Option[U]]) -> Option[U]: 475 | """Return `Nothing`, or call `fn` with the `Some` value.""" 476 | return t.cast(Option[U], self.and_then(fn)) 477 | 478 | def or_else(self, fn: t.Callable[[], Option[T]]) -> Option[T]: 479 | """Return option if it is `Some`, or calculate an alternative.""" 480 | return self 481 | 482 | def expect(self, msg: str, exc_cls: t.Type[Exception] = RuntimeError) -> T: 483 | """Unwrap and yield a `Some`, or throw an exception if `Nothing`. 484 | 485 | The exception class may be specified with the `exc_cls` keyword 486 | argument. 487 | """ 488 | return self._value 489 | 490 | def raise_if_err( 491 | self, msg: str, exc_cls: t.Type[Exception] = RuntimeError 492 | ) -> T: # noqa: D401 493 | """DEPRECATED: Use `raise_if_nothing` or `expect`. 494 | 495 | Unwrap and yield a `Some`, or throw an exception if `Nothing`. 496 | 497 | The exception class may be specified with the `exc_cls` keyword 498 | argument. 499 | 500 | Alias of `Some.expect`. 501 | """ 502 | warnings.warn( 503 | "Option.raise_if_err() is deprecated. " 504 | "Use raise_if_nothing() or expect() instead", 505 | DeprecationWarning, 506 | ) 507 | return self.expect(msg, exc_cls=exc_cls) 508 | 509 | def raise_if_nothing( 510 | self, msg: str, exc_cls: t.Type[Exception] = RuntimeError 511 | ) -> T: 512 | """Unwrap and yield a `Some`, or throw an exception if `Nothing`. 513 | 514 | The exception class may be specified with the `exc_cls` keyword 515 | argument. 516 | 517 | Alias of `Some.expect`. 518 | """ 519 | return self.expect(msg, exc_cls=exc_cls) 520 | 521 | def filter(self, predicate: t.Callable[[T], bool]) -> Option[T]: 522 | """Return `Nothing`, or an option determined by the predicate. 523 | 524 | If `self` is `Some`, call `predicate` with the wrapped value and 525 | return: 526 | 527 | * `self` (`Some(t)` where `t` is the wrapped value) if the predicate 528 | is `True` 529 | * `Nothing` if the predicate is `False` 530 | """ 531 | if predicate(self._value): 532 | return self 533 | return Nothing() 534 | 535 | def is_nothing(self) -> bool: 536 | """Return whether the option is `Nothing`.""" 537 | return False 538 | 539 | def is_some(self) -> bool: 540 | """Return whether the option is a `Some` value.""" 541 | return True 542 | 543 | def iter(self) -> t.Iterator[T]: 544 | """Return an iterator over the possibly contained value.""" 545 | return iter(self) 546 | 547 | def map(self, fn: t.Callable[[T], U]) -> Option[U]: 548 | """Apply `fn` to the contained value if any.""" 549 | return Some(fn(self._value)) 550 | 551 | def map_or(self, default: U, fn: t.Callable[[T], U]) -> U: 552 | """Apply `fn` to contained value, or return the default.""" 553 | return fn(self._value) 554 | 555 | def map_or_else( 556 | self, default: t.Callable[[], U], fn: t.Callable[[T], U] 557 | ) -> U: 558 | """Apply `fn` to contained value, or compute a default.""" 559 | return fn(self._value) 560 | 561 | def ok_or(self, err: F) -> Result[T, F]: 562 | """Transform an option into a `Result`. 563 | 564 | Maps `Some(v)` to `Ok(v)` or `None` to `Err(err)`. 565 | """ 566 | res: Result[T, F] = Ok(self._value) 567 | return res 568 | 569 | def ok_or_else(self, err_fn: t.Callable[[], E]) -> Result[T, E]: 570 | """Transform an option into a `Result`. 571 | 572 | Maps `Some(v)` to `Ok(v)` or `None` to `Err(err_fn())`. 573 | """ 574 | res: Result[T, E] = Ok(self._value) 575 | return res 576 | 577 | def unwrap(self) -> T: 578 | """Return `Some` value, or raise an error.""" 579 | return self._value 580 | 581 | def unwrap_or(self, default: U) -> t.Union[T, U]: 582 | """Return the contained value or `default`.""" 583 | return self._value 584 | 585 | def unwrap_or_else(self, fn: t.Callable[[], U]) -> t.Union[T, U]: 586 | """Return the contained value or calculate a default.""" 587 | return self._value 588 | 589 | def __iter__(self) -> t.Iterator[T]: 590 | """Iterate over the contained value if present.""" 591 | yield self._value 592 | 593 | def __eq__(self, other: t.Any) -> bool: 594 | """Options are equal if their values are equal.""" 595 | if not isinstance(other, Some): 596 | return False 597 | eq: bool = self._value == other.unwrap() 598 | return eq 599 | 600 | def __ne__(self, other: t.Any) -> bool: 601 | """Options are equal if their values are equal.""" 602 | return not self == other 603 | 604 | def __str__(self) -> str: 605 | """Represent the Some() as a string.""" 606 | return f"Some({repr(self._value)})" 607 | 608 | def __repr__(self) -> str: 609 | """Return a string representation of the Some().""" 610 | return self.__str__() 611 | 612 | 613 | class Nothing(Option[T]): 614 | """A value that may be `Some` or `Nothing`.""" 615 | 616 | __slots__ = ("_value",) 617 | 618 | _instance = None 619 | 620 | def __init__(self, _: None = None) -> None: 621 | """Create a Nothing().""" 622 | if self._instance is None: 623 | # The singleton is being instantiated the first time 624 | # not sure why pylint things _value is not in __slots__ 625 | self._value = None # pylint: disable=assigning-non-slot 626 | 627 | def __new__(cls, _: None = None) -> "Nothing[T]": 628 | """Ensure we are a singleton.""" 629 | if cls._instance is None: 630 | # Create the instance 631 | inst = super().__new__(cls) 632 | # And instantiate it 633 | cls.__init__(inst) 634 | # Then assign it to the class' _instance var, so no other 635 | # instances can be created 636 | cls._instance = inst 637 | return t.cast("Nothing[T]", cls._instance) 638 | 639 | def and_(self, alternative: Option[U]) -> Option[U]: 640 | """Return `Nothing` if `self` is `Nothing`, or the `alternative`.""" 641 | return t.cast(Option[U], self) 642 | 643 | def or_(self, alternative: Option[T]) -> Option[T]: 644 | """Return option if it is `Some`, or the `alternative`.""" 645 | return alternative 646 | 647 | def xor(self, alternative: Option[T]) -> Option[T]: 648 | """Return Some IFF exactly one of `self`, `alternative` is `Some`.""" 649 | return alternative if alternative.is_some() else self 650 | 651 | def and_then(self, fn: t.Callable[[T], Option[U]]) -> Option[U]: 652 | """Return `Nothing`, or call `fn` with the `Some` value.""" 653 | return t.cast(Option[U], self) 654 | 655 | def flatmap(self, fn: t.Callable[[T], Option[U]]) -> Option[U]: 656 | """Return `Nothing`, or call `fn` with the `Some` value.""" 657 | return t.cast(Option[U], self.and_then(fn)) 658 | 659 | def or_else(self, fn: t.Callable[[], Option[T]]) -> Option[T]: 660 | """Return option if it is `Some`, or calculate an alternative.""" 661 | return fn() 662 | 663 | def expect(self, msg: str, exc_cls: t.Type[Exception] = RuntimeError) -> T: 664 | """Unwrap and yield a `Some`, or throw an exception if `Nothing`. 665 | 666 | The exception class may be specified with the `exc_cls` keyword 667 | argument. 668 | """ 669 | raise exc_cls(msg) 670 | # Hack: pylint will warn that you're assigning from a function 671 | # that doesn't return if there isn't at least one return statement 672 | # in a function 673 | return self._value # pylint: disable=unreachable 674 | 675 | def raise_if_err( 676 | self, msg: str, exc_cls: t.Type[Exception] = RuntimeError 677 | ) -> T: # noqa: D401 678 | """DEPRECATED: Use `raise_if_nothing` or `expect`. 679 | 680 | Unwrap and yield a `Some`, or throw an exception if `Nothing`. 681 | 682 | The exception class may be specified with the `exc_cls` keyword 683 | argument. 684 | 685 | Alias of `Nothing.expect`. 686 | """ 687 | warnings.warn( 688 | "Option.raise_if_err() is deprecated. " 689 | "Use raise_if_nothing() or expect() instead", 690 | DeprecationWarning, 691 | ) 692 | return self.expect(msg, exc_cls=exc_cls) 693 | 694 | def raise_if_nothing( 695 | self, msg: str, exc_cls: t.Type[Exception] = RuntimeError 696 | ) -> T: 697 | """Unwrap and yield a `Some`, or throw an exception if `Nothing`. 698 | 699 | The exception class may be specified with the `exc_cls` keyword 700 | argument. 701 | 702 | Alias of `Nothing.expect`. 703 | """ 704 | return self.expect(msg, exc_cls=exc_cls) 705 | 706 | def filter(self, predicate: t.Callable[[T], bool]) -> Option[T]: 707 | """Return `Nothing`, or an option determined by the predicate. 708 | 709 | If `self` is `Some`, call `predicate` with the wrapped value and 710 | return: 711 | 712 | * `self` (`Some(t)` where `t` is the wrapped value) if the predicate 713 | is `True` 714 | * `Nothing` if the predicate is `False` 715 | """ 716 | return self 717 | 718 | def is_nothing(self) -> bool: 719 | """Return whether the option is `Nothing`.""" 720 | return True 721 | 722 | def is_some(self) -> bool: 723 | """Return whether the option is a `Some` value.""" 724 | return False 725 | 726 | def iter(self) -> t.Iterator[T]: 727 | """Return an iterator over the possibly contained value.""" 728 | return iter(self) 729 | 730 | def map(self, fn: t.Callable[[T], U]) -> Option[U]: 731 | """Apply `fn` to the contained value if any.""" 732 | return t.cast(Option[U], self) 733 | 734 | def map_or(self, default: U, fn: t.Callable[[T], U]) -> U: 735 | """Apply `fn` to contained value, or return the default.""" 736 | return default 737 | 738 | def map_or_else( 739 | self, default: t.Callable[[], U], fn: t.Callable[[T], U] 740 | ) -> U: 741 | """Apply `fn` to contained value, or compute a default.""" 742 | return default() 743 | 744 | def ok_or(self, err: F) -> Result[T, F]: 745 | """Transform an option into a `Result`. 746 | 747 | Maps `Some(v)` to `Ok(v)` or `None` to `Err(err)`. 748 | """ 749 | res: Result[T, F] = Err(err) 750 | return res 751 | 752 | def ok_or_else(self, err_fn: t.Callable[[], E]) -> Result[T, E]: 753 | """Transform an option into a `Result`. 754 | 755 | Maps `Some(v)` to `Ok(v)` or `None` to `Err(err_fn())`. 756 | """ 757 | res: Result[T, E] = Err(err_fn()) 758 | return res 759 | 760 | def unwrap(self) -> T: 761 | """Return `Some` value, or raise an error.""" 762 | raise RuntimeError("Tried to unwrap Nothing") 763 | # Hack: pylint will warn that you're assigning from a function 764 | # that doesn't return if there isn't at least one return statement 765 | # in a function 766 | return self._value # pylint: disable=unreachable 767 | 768 | def unwrap_or(self, default: U) -> t.Union[T, U]: 769 | """Return the contained value or `default`.""" 770 | return default 771 | 772 | def unwrap_or_else(self, fn: t.Callable[[], U]) -> t.Union[T, U]: 773 | """Return the contained value or calculate a default.""" 774 | return fn() 775 | 776 | def __iter__(self) -> t.Iterator[T]: 777 | """Iterate over the contained value if present.""" 778 | _: t.Tuple[T, ...] = () 779 | yield from _ 780 | 781 | def __eq__(self, other: t.Any) -> bool: 782 | """Options are equal if their values are equal.""" 783 | if not isinstance(other, Nothing): 784 | return False 785 | return True 786 | 787 | def __ne__(self, other: t.Any) -> bool: 788 | """Options are equal if their values are equal.""" 789 | return not self == other 790 | 791 | def __str__(self) -> str: 792 | """Return a string representation of Nothing().""" 793 | return "Nothing()" 794 | 795 | def __repr__(self) -> str: 796 | """Return a string representation of Nothing().""" 797 | return self.__str__() 798 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # safetywrap 2 | 3 | [![Build Status](https://dev.azure.com/msplanchard/safetywrap/_apis/build/status/mplanchard.safetywrap?branchName=master)](https://dev.azure.com/msplanchard/safetywrap/_build/latest?definitionId=3&branchName=master) 4 | [![coverage report](https://img.shields.io/azure-devops/coverage/msplanchard/safetywrap/3)](https://dev.azure.com/msplanchard/safetywrap/_build?definitionId=3) 5 | 6 | Fully typesafe, Rust-inspired wrapper types for Python values 7 | 8 | ## Summary 9 | 10 | This library provides two main wrappers: `Result` and `Option`. These types 11 | allow you to specify typesafe code that effectively handles errors or 12 | absent data, without resorting to deeply nested if-statements and lots 13 | of try-except blocks. 14 | 15 | This is accomplished by allowing you to operate on an `Option` or `Result` 16 | in a sort of quantum superposition, where an `Option` could be `Some` or 17 | `Nothing` or a `Result` could be `Ok` or `Err`. In either case, all of the 18 | methods on the type work just the same, allowing you to handle both cases 19 | elegantly. 20 | 21 | A `Result[T, E]` may be an instance of `Ok[T]` or `Err[E]`, while an `Option[T]` 22 | may be an instance of `Some[T]` or `Nothing`. Either way, you get to treat 23 | them just the same until you really need to get the wrapped value. 24 | 25 | So, rather than this: 26 | 27 | ```py 28 | for something in "value", None: 29 | if something is not None: 30 | val = something.upper() 31 | else: 32 | val = "DEFAULT" 33 | print(val) 34 | ``` 35 | 36 | You can do this: 37 | 38 | ```py 39 | for something in Some("value"), Nothing(): 40 | print(something.map(str.upper).unwrap_or("DEFAULT")) 41 | ``` 42 | 43 | And rather than this: 44 | 45 | ```py 46 | for jsondata in '{"value": "myvalue"}', '{badjson': 47 | try: 48 | config = capitalize_keys(json.loads(jsondata)) 49 | except Exception: 50 | config = get_default_config() 51 | print(config["value"]) 52 | ``` 53 | 54 | You can do this: 55 | 56 | ```py 57 | for jsondata in '{"value": "myvalue"}', '{badjson': 58 | print( 59 | Result.of(json.loads, jsondata) 60 | .map(capitalize_keys) 61 | .unwrap_or_else(get_default_config)["value"] 62 | ) 63 | ``` 64 | 65 | These two examples are super minimal samples of how using these typesafe 66 | wrappers can make things easier to write and reason about. Please see the 67 | [Examples](#examples) section for more, and [Usage](#usage) for the full 68 | suite of offered functionality. 69 | 70 | These types are heavily influenced by the [Result][rust-result] and 71 | [Option][rust-option] types in Rust. 72 | 73 | Thorough type specifications for mypy or your favorite python type-checker 74 | are provided, so that you can decorate function inputs and outputs as 75 | returning `Result` and `Option` types and get useful feedback when supplying 76 | arguments or passing return values. 77 | 78 | ### Sponsorship 79 | 80 | This project was developed for and is graciously sponsored by my employer, 81 | [Bestow, Inc.](https://hellobestow.com/). At Bestow, we aim to democratize life 82 | insurance by providing simple, easy coverage, purchasable online in five minutes 83 | with no doctors' visits and no hassles. 84 | 85 | We're pretty much always hiring great developers, so if you'd like to work 86 | with us, please check out [our careers page](https://hellobestow.com/careers/)! 87 | 88 | ## Table of Contents 89 | 90 | - [safetywrap](#safetywrap) 91 | - [Summary](#summary) 92 | - [Sponsorship](#sponsorship) 93 | - [Table of Contents](#table-of-contents) 94 | - [Examples](#examples) 95 | - [Get an enum member by its value, returning the member or None](#get-an-enum-member-by-its-value-returning-the-member-or-none) 96 | - [Get an enum member by its value, returning an Option](#get-an-enum-member-by-its-value-returning-an-option) 97 | - [Serialize a dict that may be missing keys, using default values](#serialize-a-dict-that-may-be-missing-keys-using-default-values) 98 | - [Make an HTTP request, and if the status code is 200, convert the body to JSON and return the `data` key. If there is an error or the `data` key does not exist, return an error string](#make-an-http-request-and-if-the-status-code-is-200-convert-the-body-to-json-and-return-the-data-key-if-there-is-an-error-or-the-data-key-does-not-exist-return-an-error-string) 99 | - [Usage](#usage) 100 | - [Result[T, E]](#resultt-e) 101 | - [Result Constructors](#result-constructors) 102 | - [Ok](#ok) 103 | - [Err](#err) 104 | - [Result.of](#resultof) 105 | - [Result.collect](#resultcollect) 106 | - [Result.err_if](#resulterr_if) 107 | - [Result.ok_if](#resultok_if) 108 | - [Result Methods](#result-methods) 109 | - [Result.and_](#resultand_) 110 | - [Result.or_](#resultor_) 111 | - [Result.and_then](#resultand_then) 112 | - [Result.flatmap](#resultflatmap) 113 | - [Result.or_else](#resultor_else) 114 | - [Result.err](#resulterr) 115 | - [Result.ok](#resultok) 116 | - [Result.expect](#resultexpect) 117 | - [Result.raise_if_err](#resultraise_if_err) 118 | - [Result.expect_err](#resultexpect_err) 119 | - [Result.is_err](#resultis_err) 120 | - [Result.is_ok](#resultis_ok) 121 | - [Result.iter](#resultiter) 122 | - [Result.map](#resultmap) 123 | - [Result.map_err](#resultmap_err) 124 | - [Result.unwrap](#resultunwrap) 125 | - [Result.unwrap_err](#resultunwrap_err) 126 | - [Result.unwrap_or](#resultunwrap_or) 127 | - [Result.unwrap_or_else](#resultunwrap_or_else) 128 | - [Result Magic Methods](#result-magic-methods) 129 | - [Option[T]](#optiont) 130 | - [Option Constructors](#option-constructors) 131 | - [Some](#some) 132 | - [Nothing](#nothing) 133 | - [Option.of](#optionof) 134 | - [Option.nothing_if](#optionnothing_if) 135 | - [Option.some_if](#optionsome_if) 136 | - [Option.collect](#optioncollect) 137 | - [Option Methods](#option-methods) 138 | - [Option.and_](#optionand_) 139 | - [Option.or_](#optionor_) 140 | - [Option.xor](#optionxor) 141 | - [Option.and_then](#optionand_then) 142 | - [Option.flatmap](#optionflatmap) 143 | - [Option.or_else](#optionor_else) 144 | - [Option.expect](#optionexpect) 145 | - [Option.raise_if_nothing](#optionraise_if_nothing) 146 | - [Option.filter](#optionfilter) 147 | - [Option.is_nothing](#optionis_nothing) 148 | - [Option.is_some](#optionis_some) 149 | - [Option.iter](#optioniter) 150 | - [Option.map](#optionmap) 151 | - [Option.map_or](#optionmap_or) 152 | - [Option.map_or_else](#optionmap_or_else) 153 | - [Option.ok_or](#optionok_or) 154 | - [Option.ok_or_else](#optionok_or_else) 155 | - [Option.unwrap](#optionunwrap) 156 | - [Option.unwrap_or](#optionunwrap_or) 157 | - [Option.unwrap_or_else](#optionunwrap_or_else) 158 | - [Option Magic Methods](#option-magic-methods) 159 | - [Performance](#performance) 160 | - [Results](#results) 161 | - [Discussion](#discussion) 162 | - [Contributing](#contributing) 163 | 164 | ## Examples 165 | 166 | In general, these examples build from simple to complex. See [Usage](#usage) 167 | below for the full API specification. 168 | 169 | ### Get an enum member by its value, returning the member or None 170 | 171 | ```py 172 | import typing as t 173 | from enum import Enum 174 | 175 | from result_types import Option, Result, Some 176 | 177 | T = t.TypeVar("T", bound=Enum) 178 | 179 | def enum_member_for_val(enum: t.Type[T], value: t.Any) -> t.Optional[t.Any]: 180 | """Return Some(enum_member) or Nothing().""" 181 | # Enums throw a `ValueError` if the value isn't present, so 182 | # we'll either have `Ok(enum_member)` or `Err(ValueError)`. 183 | # We unwrap and return the member if it's Ok, otherwise, we just 184 | # return None 185 | return Result.of(enum, value).unwrap_or(None) 186 | ``` 187 | 188 | ### Get an enum member by its value, returning an Option 189 | 190 | ```py 191 | import typing as t 192 | from enum import Enum 193 | 194 | from result_types import Option, Result, Some 195 | 196 | T = t.TypeVar("T", bound=Enum) 197 | 198 | def enum_member_for_val(enum: t.Type[T], value: t.Any) -> Option[T]: 199 | """Return Some(enum_member) or Nothing().""" 200 | # Enums throw a `ValueError` if the value isn't present, so 201 | # we'll either have `Ok(enum_member)` or `Err(ValueError)`. 202 | # Calling `ok()` on a `Result` returns an `Option` 203 | return Result.of(enum, value).ok() 204 | ``` 205 | 206 | ### Serialize a dict that may be missing keys, using default values 207 | 208 | ```py 209 | import json 210 | from result_types import Result 211 | 212 | def serialize(data: t.Dict[str, t.Union[int, str, float]]) -> str: 213 | """Serialize the data. 214 | 215 | Absent keys are "[absent]", rather than null. This allows us to maintain 216 | information about whether a key was present or actually set to None. 217 | """ 218 | keys = ("first", "second", "third", "fourth") 219 | # We can even use Result to catch any JSON serialization errors, so that 220 | # this function will _always_ return a string! 221 | Result.of( 222 | json.dumps, 223 | # Result.of() will intercept the KeyError and return an Err. We use 224 | # `unwrap_or()` to discard the error and return the "[absent]" string 225 | # instead; if the key was present, the Result was Ok, and we just 226 | # return that value. 227 | {k: Result.of(lambda: data[k]).unwrap_or("[absent]") for k in keys} 228 | ).unwrap_or("Could not serialize JSON from data!") 229 | ``` 230 | 231 | ### Make an HTTP request, and if the status code is 200, convert the body to JSON and return the `data` key. If there is an error or the `data` key does not exist, return an error string 232 | 233 | ```py 234 | from functools import partial 235 | 236 | import requests 237 | from requests import Response 238 | from result_types import Option, Result 239 | 240 | 241 | def get_data(url: str) -> str: 242 | """Get the data!""" 243 | # We need to do manual type assignment sometimes when the code 244 | # we're wrapping does not provide types. 245 | # If the wrapped function raises any Exception, `res` will be 246 | # Err(Exception). Otherwise it will be `Ok(Response)`. 247 | res: Result[Response, Exception] = Result.of(requests.get, url) 248 | return ( 249 | # We start as a `Result[Response, Exception]` 250 | res 251 | # And if we were an Err, map to a `Result[Response, str]` 252 | .map_err(str) 253 | # If we were Ok, and_then (aka flatmap) to a new `Result[Response, str]` 254 | .and_then(lambda res: ( 255 | # Our return value starts as a `Result[Response, Response]` 256 | Result.ok_if(lambda r: r.status_code == 200, res).map_err( 257 | # So we map it to a `Result[Response, str]` 258 | lambda r: str(f"Bad status code: {r.status_code}") 259 | ) 260 | )) 261 | # We are now a `Result[Response, str]`, where we are only Ok if 262 | # our status code was 200. 263 | # Now we transition to a `Result[dict, str]` 264 | .and_then(lambda res: Result.of(res.json).map_err(str)) 265 | # And to a `Result[Option[str], str]` 266 | .map(lambda js: Option.of(js.get("data")).map(str)) 267 | # And to a `Result[str, str]` 268 | .and_then(lambda data: data.ok_or("No data key in JSON!")) 269 | # If we are an error, convert us to an Ok with the error string 270 | .or_else(Ok) 271 | # And now we get either the Ok string or the Err string! 272 | .unwrap() 273 | ) 274 | ``` 275 | 276 | ## Usage 277 | 278 | ### Result[T, E] 279 | 280 | A Result represents some value that may either be in an `Ok` state or 281 | an `Err` state. 282 | 283 | #### Result Constructors 284 | 285 | ##### Ok 286 | 287 | `Ok(value: T) -> Result[T, E]` 288 | 289 | Construct an `Ok` Result directly with the value. 290 | 291 | Example: 292 | 293 | ```py 294 | def check_value_not_negative(val: int) -> Result[int, str]: 295 | """Check that a value is not negative, or return an Err.""" 296 | if val >= 0: 297 | return Ok(val) 298 | return Err(f"{val} is negative!") 299 | ``` 300 | 301 | ##### Err 302 | 303 | `Err(value: E) -> Result[T, E]` 304 | 305 | Construct an `Err` Result directly with the value. 306 | 307 | Example: 308 | 309 | ```py 310 | def check_value_not_negative(val: int) -> Result[int, str]: 311 | """Check that a value is not negative, or return an Err.""" 312 | if val >= 0: 313 | return Ok(val) 314 | return Err(f"{val} is negative!") 315 | ``` 316 | 317 | ##### Result.of 318 | 319 | `Result.of(fn: Callable[..., T], *args: t.Any, catch: t.Type[E], **kwargs) -> Result[T, E]` 320 | 321 | Call a function with the provided arguments. If no error is thrown, return 322 | `Ok(result)`. Otherwise, return `Err(exception)`. By default, `Exception` 323 | is caught, but different error types may be provided with the `catch` 324 | keyword argument. 325 | 326 | The type of `E` MUST be `Exception` or one of its subclasses. 327 | 328 | This constructor is designed to be useful in wrapping other APIs, builtin 329 | functions, etc. 330 | 331 | Note that due to a bug in mypy (see https://github.com/python/mypy/issues/3737), 332 | sometimes you need to explicitly specify the `catch` keyword argument, 333 | even if you're setting it to its default (`Exception`). This does not 334 | happen consistently, but when it does, you will see mypy thinking 335 | that the type of the `Result` is `Result[SomeType, ]`. 336 | 337 | Example: 338 | 339 | ```py 340 | import json 341 | 342 | def parse_json(string: str) -> Result[dict, Exception]: 343 | """Parse a JSON object into a dict.""" 344 | return Result.of(json.loads, string) 345 | ``` 346 | 347 | ##### Result.collect 348 | 349 | `Result.collect(iterable: Iterable[T, E]) -> Result[Tuple[T, ...], E]` 350 | 351 | Convert an iterable of Results into a single Result. If all Results were 352 | Ok, Ok values are collected into a Tuple in the final Result. If any Results 353 | were Err, the Err result is returned directly. 354 | 355 | Example: 356 | 357 | ```py 358 | assert Result.collect([Ok(1), Ok(2), Ok(3)]) == Ok((1, 2, 3)) 359 | assert Result.collect([Ok(1), Err("no"), Ok(3)]) == Err("no") 360 | ``` 361 | 362 | ##### Result.err_if 363 | 364 | `Result.err_if(predicate: t.Callable[[T], bool], value: T) -> Result[T, T]` 365 | 366 | Run a predicate on some value, and return `Err(val)` if the predicate returns 367 | True, or `Ok(val)` if the predicate returns False. 368 | 369 | Example: 370 | 371 | ```py 372 | from requests import Response 373 | 374 | def checked_response(response: Response) -> Result[Response, Response]: 375 | """Try to get a response from the server.""" 376 | return Result.err_if(lambda r: r.status_code >= 300, response) 377 | ``` 378 | 379 | ##### Result.ok_if 380 | 381 | `Result.ok_if(predicate: t.Callable[[T], bool], value: T) -> Result[T, T]` 382 | 383 | Run a predicate on some value, and return `Ok(val)` if the predicate returns 384 | True, or `Err(val)` if the predicate returns False. 385 | 386 | Example: 387 | 388 | ```py 389 | def checked_data(data: dict) -> Result[dict, dict]: 390 | """Check if data has expected keys.""" 391 | expected_keys = ("one", "two", "three") 392 | return Result.ok_if(lambda d: all(k in d for k in expected_keys), data) 393 | ``` 394 | 395 | #### Result Methods 396 | 397 | ##### Result.and_ 398 | 399 | `Result.and_(self, res: Result[U, E]) -> Result[U, E]` 400 | 401 | If this Result is `Ok`, return `res`. If this result is `Err`, return this 402 | Result. This can be used to short circuit a chain of Results on encountering 403 | the first error. 404 | 405 | Example: 406 | 407 | ```py 408 | assert Ok(5).and_(Ok(6)) == Ok(6) 409 | assert Err(1).and_(Ok(6)) == Err(1) 410 | assert Err(1).and_(Err(2)).and_(Ok(5)) == Err(1) 411 | assert Ok(5).and_(Err(1)).and_(Ok(6)) == Err(1) 412 | ``` 413 | 414 | ##### Result.or_ 415 | 416 | `Result.or_(self, res: Result[T, F]) -> Result[T, F]` 417 | 418 | If this Result is `Err`, return `res`. Otherwise, return this Result. 419 | 420 | Example: 421 | 422 | ```py 423 | assert Err(1).or_(Ok(5)) == Ok(5) 424 | assert Err(1).or_(Err(2)) == Err(2) 425 | assert Ok(5).or_(Ok(6)) == Ok(5) 426 | assert Ok(5).or_(Err(1)) == Ok(5) 427 | ``` 428 | 429 | ##### Result.and_then 430 | 431 | `Result.and_then(self, fn: t.Callable[[T], Result[U, E]]) -> Result[U, E]` 432 | 433 | If this Result is `Ok`, call the provided function with the wrapped value of 434 | this Result and return the Result of that function. This allows easily 435 | chaining multiple Result-generating calls together to yield a final 436 | Result. This method is an alias of [`Result.flatmap`](#resultflatmap) 437 | 438 | Example: 439 | 440 | ```py 441 | assert Ok(5).and_then(lambda val: Ok(val + 1)) == Ok(6) 442 | assert Err(1).and_then(lambda val: Ok(val + 1)) == Err(1) 443 | ``` 444 | 445 | ##### Result.flatmap 446 | 447 | `Result.flatmap(self, fn: t.Callable[[T], Result[U, E]]) -> Result[U, E]` 448 | 449 | If this Result is `Ok`, call the provided function with the wrapped value of 450 | this Result and return the Result of that function. This allows easily 451 | chaining multiple Result-generating calls together to yield a final 452 | Result. This method is an alias of [`Result.and_then`](#resultand_then) 453 | 454 | Example: 455 | 456 | ```py 457 | assert Ok(5).flatmap(lambda val: Ok(val + 1)) == Ok(6) 458 | assert Err(1).flatmap(lambda val: Ok(val + 1)) == Err(1) 459 | ``` 460 | 461 | ##### Result.or_else 462 | 463 | `Result.or_else(self, fn: t.Callable[[E], Result[T, F]]) -> Result[T, F])` 464 | 465 | If this result is `Err`, call the provided function with the wrapped error 466 | value of this Result and return the Result of that function. This allows 467 | easily handling potential errors in a way that still returns a final Result. 468 | 469 | Example: 470 | 471 | ```py 472 | assert Ok(5).or_else(Ok) == Ok(5) 473 | assert Err(1).or_else(Ok) == Ok(1) 474 | ``` 475 | 476 | ##### Result.err 477 | 478 | `Result.err(self) -> Option[E]` 479 | 480 | Convert this Result into an Option, returning Some(err_val) if this Result 481 | is `Err`, or Nothing() if this Result is `Ok`. 482 | 483 | Example: 484 | 485 | ```py 486 | assert Ok(5).err() == Nothing() 487 | assert Err(1).err() == Some(1) 488 | ``` 489 | 490 | ##### Result.ok 491 | 492 | `Result.ok(self) -> Option[T]` 493 | 494 | Convert this Result into an Option, returning `Some(val)` if this Result is 495 | `Ok`, or `Nothing()` if this result is `Err`. 496 | 497 | Example: 498 | 499 | ```py 500 | assert Ok(5).ok() == Some(5) 501 | assert Err(1).ok() == Nothing() 502 | ``` 503 | 504 | ##### Result.expect 505 | 506 | `Result.expect(self, msg: str, exc_cls: t.Type[Exception] = RuntimeError) -> T` 507 | 508 | Return the wrapped value if this Result is `Ok`. Otherwise, raise an error, 509 | instantiated with the provided message and the stringified error value. 510 | By default, a `RuntimeError` is raised, but an alternative error may be 511 | provided using the `exc_cls` keyword argument. This method is an alias for 512 | [`Result.raise_if_err`](#resultraise_if_err). 513 | 514 | Example: 515 | 516 | ```py 517 | import pytest 518 | 519 | with pytest.raises(RuntimeError) as exc: 520 | Err(5).expect("Bad value") 521 | assert str(exc.value) == "Bad value: 5" 522 | 523 | assert Ok(1).expect("Bad value") == 1 524 | ``` 525 | 526 | ##### Result.raise_if_err 527 | 528 | `Result.raise_if_err(self, msg: str, exc_cls: t.Type[Exception] = RuntimeError) -> T` 529 | 530 | Return the wrapped value if this Result is `Ok`. Otherwise, raise an error, 531 | instantiated with the provided message and the stringified error value. 532 | By default, a `RuntimeError` is raised, but an alternative error may be 533 | provided using the `exc_cls` keyword argument. This method is an alias for 534 | [`Result.expect`](#resultexpect). 535 | 536 | Example: 537 | 538 | ```py 539 | import pytest 540 | 541 | with pytest.raises(RuntimeError) as exc: 542 | Err(5).raise_if_err("Bad value") 543 | assert str(exc.value) == "Bad value: 5" 544 | 545 | assert Ok(1).raise_if_err("Bad value") == 1 546 | ``` 547 | 548 | ##### Result.expect_err 549 | 550 | `Result.expect_err(self, msg: str, exc_cls: t.Type[Exception] = RuntimeError) -> E` 551 | 552 | Return the wrapped value if this Result is `Err`. Otherwise, raise an error, 553 | instantiated with the provided message and the stringified Ok value. 554 | By default, a `RuntimeError` is raised, but an alternative error may be 555 | provided using the `exc_cls` keyword argument. 556 | 557 | Example: 558 | 559 | ```py 560 | import pytest 561 | 562 | with pytest.raises(RuntimeError) as exc: 563 | Ok(5).expect_err("Unexpected good value") 564 | assert str(exc.value) == "Unexpected good value: 5" 565 | 566 | assert Err(1).expect_err("Unexpected good value") == 1 567 | ``` 568 | 569 | ##### Result.is_err 570 | 571 | `Result.is_err(self) -> bool` 572 | 573 | Return True if this Result is `Err`, or `False` if this Result is `Ok`. 574 | 575 | Example: 576 | 577 | ```py 578 | assert Err(1).is_err() is True 579 | assert Ok(1).is_err() is False 580 | ``` 581 | 582 | ##### Result.is_ok 583 | 584 | `Result.is_ok(self) -> bool` 585 | 586 | Return True if this Result is `Ok`, or `False` if this Result is `Err`. 587 | 588 | Example: 589 | 590 | ```py 591 | assert Ok(1).is_err() is True 592 | assert Err(1).is_err() is False 593 | ``` 594 | 595 | ##### Result.iter 596 | 597 | `Result.iter(self) -> Iterator[T]` 598 | 599 | Return an iterator with length 1 over the wrapped value if this Result is `Ok`. 600 | Otherwise, return a 0-length iterator. 601 | 602 | Example: 603 | 604 | ```py 605 | assert tuple(Ok(1).iter()) == (1,) 606 | assert tuple(Err(1).iter()) == () 607 | ``` 608 | 609 | ##### Result.map 610 | 611 | `Result.map(self, fn: t.Callable[[T], U]) -> Result[U, E]` 612 | 613 | If this Result is `Ok`, apply the provided function to the wrapped value, 614 | and return a new `Ok` Result with the result of the function. If this Result 615 | is `Err`, do not apply the function and return this Result unchanged. 616 | 617 | **Warning:** no error checking is performed while applying the provided 618 | function, and exceptions applying the function are not caught. If you need 619 | to map with error handling, consider using `and_then` (aka `flatmap`) in 620 | conjunction with the `Result.of` constructor, e.g. 621 | `assert Ok(0).and_then(partial(Result.of, lambda i: 10 / i)) == Err(ZeroDivisionError('division by zero'))` 622 | 623 | Example: 624 | 625 | ```py 626 | assert Ok(1).map(str) == Ok("1") 627 | assert Err(1).map(str) == Err(1) 628 | ``` 629 | 630 | ##### Result.map_err 631 | 632 | `Result.map_err(self, fn: t.Callable[[E], F]) -> Result[T, F]` 633 | 634 | If this Result is `Err`, apply the provided function to the wrapped value, 635 | and return a new `Err` Result with the result of the function. If this Result 636 | is `Ok`, do not apply the function and return this Result unchanged. 637 | 638 | **Warning:** no error checking is performed while applying the provided 639 | function, and exceptions applying the function are not caught. 640 | 641 | Example: 642 | 643 | ```py 644 | assert Err(1).map_err(lambda i: i + 1) == Err(2) 645 | assert Ok(1).map_err(lambda i: i + 1) == Ok(1) 646 | ``` 647 | 648 | ##### Result.unwrap 649 | 650 | `Result.unwrap(self) -> T` 651 | 652 | If this Result is `Ok`, return the wrapped value. If this Result is `Err`, 653 | throw a `RuntimeError`. 654 | 655 | Example: 656 | 657 | ```py 658 | import pytest 659 | 660 | assert Ok(1).unwrap() == 1 661 | 662 | with pytest.raises(RuntimeError): 663 | Err(1).unwrap() 664 | ``` 665 | 666 | ##### Result.unwrap_err 667 | 668 | `Result.unwrap_err(self) -> E` 669 | 670 | If this Result is `Err`, return the wrapped value. If this Result is `Ok`, 671 | throw a `RuntimeError`. 672 | 673 | Example: 674 | 675 | ```py 676 | import pytest 677 | 678 | assert Err(1).unwrap() == 1 679 | 680 | with pytest.raises(RuntimeError): 681 | Ok(1).unwrap() 682 | ``` 683 | 684 | ##### Result.unwrap_or 685 | 686 | `Result.unwrap_or(self, alternative: U) -> t.Union[T, U]` 687 | 688 | If this Result is `Ok`, return the wrapped value. Otherwise, if this Result 689 | is `Err`, return the provided alternative. 690 | 691 | Example: 692 | 693 | ```py 694 | assert Ok(1).unwrap_or(5) == 1 695 | assert Err(1).unwrap_or(5) == 5 696 | ``` 697 | 698 | ##### Result.unwrap_or_else 699 | 700 | `Result.unwrap_or_else(self, fn: t.Callable[[E], U]) -> t.Union[T, U]` 701 | 702 | If this Result is `Ok`, return the wrapped value. Otherwise, if this Result 703 | is `Err`, call the supplied function with the wrapped error value and return 704 | the result. 705 | 706 | Example: 707 | 708 | ```py 709 | assert Ok(1).unwrap_or_else(str) == 1 710 | assert Err(1).unwrap_or_else(str) == "1" 711 | ``` 712 | 713 | #### Result Magic Methods 714 | 715 | ##### Result.__iter__ 716 | 717 | `Result.__iter__(self) -> t.Iterator[T]` 718 | 719 | Implement the iterator protocol, allowing iteration over the results of 720 | [`Result.iter`](#resultiter). If this Result is `Ok`, return an iterator 721 | of length 1 containing the wrapped value. Otherwise, if this Result is `Err`, 722 | return a 0-length iterator. 723 | 724 | Example: 725 | 726 | ```py 727 | # Can be passed to methods that take iterators 728 | assert tuple(Ok(1)) == (1,) 729 | assert tuple(Err(1)) == () 730 | 731 | # Can be used in `for in` constructs, including comprehensions 732 | assert [val for val in Ok(5)] == [5] 733 | assert [val for val in Err(5)] == [] 734 | 735 | 736 | # More for-in usage. 737 | for val in Ok(5): 738 | pass 739 | assert val == 5 740 | 741 | val = None 742 | for val in Err(1): 743 | pass 744 | assert val is None 745 | ``` 746 | 747 | ##### Result.__eq__ 748 | 749 | `Result.__eq__(self, other: Any) -> bool` 750 | 751 | Enable equality checking using `==`. 752 | 753 | Compare the Result with `other`. Return True if `other` is the same type of 754 | Result with the same wrapped value. Otherwise, return False. 755 | 756 | Example: 757 | 758 | ```py 759 | assert (Ok(5) == Ok(5)) is True 760 | assert (Ok(5) == Ok(6)) is False 761 | assert (Ok(5) == Err(5)) is False 762 | assert (Ok(5) == 5) is False 763 | ``` 764 | 765 | ##### Result.__ne__ 766 | 767 | `Result.__ne__(self, other: Any) -> bool` 768 | 769 | Enable inequality checking using `!=`. 770 | 771 | Compare the Result with `other`. Return False if `other` is the same type of 772 | Result with the same wrapped value. Otherwise, return True. 773 | 774 | Example: 775 | 776 | ```py 777 | assert (Ok(5) != Ok(5)) is False 778 | assert (Ok(5) != Ok(6)) is True 779 | assert (Ok(5) != Err(5)) is True 780 | assert (Ok(5) != 5) is True 781 | ``` 782 | 783 | ##### Result.__str__ 784 | 785 | `Result.__str__(self) -> str` 786 | 787 | Enable useful stringification via `str()`. 788 | 789 | Example: 790 | 791 | ```py 792 | assert str(Ok(5)) == "Ok(5)" 793 | assert str(Err(5)) == "Err(5)" 794 | ``` 795 | 796 | ##### Result.__repr__ 797 | 798 | `Result.__repr__(self) -> str` 799 | 800 | Enable useful stringification via `repr()`. 801 | 802 | Example: 803 | 804 | ```py 805 | assert repr(Ok(5)) == "Ok(5)" 806 | assert repr(Err(5)) == "Err(5)" 807 | ``` 808 | 809 | ### Option[T] 810 | 811 | An Option represents either `Some` value or `Nothing`. 812 | 813 | #### Option Constructors 814 | 815 | ##### Some 816 | 817 | `Some(value: T) -> Option[T]` 818 | 819 | Construct a `Some` Option directly with a value. 820 | 821 | Example: 822 | 823 | ```py 824 | def file_contents(path: str) -> Option[str]: 825 | """Return the file contents or Nothing.""" 826 | try: 827 | with open(path) as f: 828 | return Some(f.read()) 829 | except IOError: 830 | return Nothing() 831 | ``` 832 | 833 | ##### Nothing 834 | 835 | `Nothing() -> Option[T]` 836 | 837 | Construct a `Nothing` Option directly with a value. 838 | 839 | Note: as an implementation detail, `Nothing` is implemented as a singleton, 840 | to avoid instantiation time for any `Nothing` created after the first. 841 | However since this is an implementation detail, `Nothing` Options should 842 | still be compared with `==` rather than `is`. 843 | 844 | Example: 845 | 846 | ```py 847 | def file_contents(path: str) -> Option[str]: 848 | """Return the file contents or Nothing.""" 849 | try: 850 | with open(path) as f: 851 | return Some(f.read()) 852 | except IOError: 853 | return Nothing() 854 | ``` 855 | 856 | ##### Option.of 857 | 858 | `Option.of(value: t.Optional[T]) -> Option[T]` 859 | 860 | Convert an optional value into an Option. If the value is not `None`, return 861 | `Some(value)`. Otherwise, if the value is `None`, return `Nothing()`. 862 | 863 | Example: 864 | 865 | ```py 866 | assert Option.of(None) == Nothing() 867 | assert Option.of({}.get("a")) == Nothing() 868 | assert Option.of("a") == Some("a") 869 | assert Option.of({"a": "b"}) == Some("b") 870 | ``` 871 | 872 | ##### Option.nothing_if 873 | 874 | `Option.nothing_if(predicate: t.Callable[[T], bool], value: T) -> Option[T]` 875 | 876 | Call the provided predicate function with the provided value. If the predicate 877 | returns True, return `Nothing()`. If the predicate returns False, return 878 | `Some(value)`. 879 | 880 | Example: 881 | 882 | ```py 883 | assert Option.nothing_if(lambda val: val.startswith("_"), "_private") == Nothing() 884 | assert Option.nothing_if(lambda val: val.startswith("_"), "public") == Some("public") 885 | ``` 886 | 887 | ##### Option.some_if 888 | 889 | `Option.some_if(predicate: t.Callable[[T], bool], value: T) -> Option[T]` 890 | 891 | Call the provided predicate function with the provided value. If the predicate 892 | returns True, return `Some(value)`. If the predicate returns False, return 893 | `Nothing()`. 894 | 895 | Example: 896 | 897 | ```py 898 | assert Option.some_if(bool, [1, 2, 3]) == Some([1, 2, 3]) 899 | assert Option.some_if(bool, []) == Nothing() 900 | ``` 901 | 902 | ##### Option.collect 903 | 904 | `Option.collect(options: t.Iterable[Option[T]]) -> Option[t.Tuple[T, ...]]` 905 | 906 | Collect a series of Options into single Option. 907 | 908 | If all options are `Some[T]`, the result is `Some[Tuple[T, ...j]]`. If 909 | any options are `Nothing`, the result is `Nothing`. 910 | 911 | Example: 912 | 913 | ```py 914 | assert Option.collect([Some(1), Some(2), Some(3)]) == Some((1, 2, 3)) 915 | assert Option.collect([Some(1), Nothing(), Some(3)]) == Nothing() 916 | ``` 917 | 918 | #### Option Methods 919 | 920 | ##### Option.and_ 921 | 922 | `Option.and_(alternative: Option[U]) -> Option[U]` 923 | 924 | If this Option is `Nothing`, return it unchanged. Otherwise, if this Option 925 | is `Some`, return the provided `alternative` Option. 926 | 927 | Example: 928 | 929 | ```py 930 | assert Some(1).and_(Some(2)) == Some(2) 931 | assert Nothing().and_(Some(2)) == Nothing() 932 | assert Some(1).and_(Nothing()) == Nothing() 933 | assert Nothing().and_(Nothing()) == Nothing() 934 | assert Some(1).and_(Nothing()).and_(Some(2)) == Nothing() 935 | ``` 936 | 937 | ##### Option.or_ 938 | 939 | `Option.or_(alternative: Option[T]) -> Option[T]` 940 | 941 | If this Option is `Nothing`, return the provided `alternative` Option. 942 | Otherwise, if this Option is `Some`, return it unchanged. 943 | 944 | Example: 945 | 946 | ```py 947 | assert Some(1).or_(Some(2)) == Some(1) 948 | assert Some(1).or_(Nothing()) == Some(1) 949 | assert Nothing().or_(Some(1)) == Some(1) 950 | assert Nothing().or_(Nothing()) == Nothing() 951 | ``` 952 | 953 | ##### Option.xor 954 | 955 | `Option.xor(alternative: Option[T]) -> Option[T]` 956 | 957 | Exclusive or. Return `Some` Option iff (if and only if) exactly one of 958 | this Option and hte provided `alternative` are Some. Otherwise, return 959 | `Nothing`. 960 | 961 | Example: 962 | 963 | ```py 964 | assert Some(1).xor(Nothing()) == Some(1) 965 | assert Nothing().xor(Some(1)) == Some(1) 966 | assert Some(1).xor(Some(2)) == Nothing() 967 | assert Nothing().xor(Nothing()) == Nothing() 968 | ``` 969 | 970 | ##### Option.and_then 971 | 972 | `Option.and_then(self, fn: t.Callable[[T], Option[U]]) -> Option[U]` 973 | 974 | If this Option is `Some`, call the provided, Option-returning function with 975 | the contained value and return whatever Option it returns. If this Option 976 | is `Nothing`, return it unchanged. This method is an alias for 977 | [`Option.flatmap`](#optionflatmap) 978 | 979 | Example: 980 | 981 | ```py 982 | assert Some(1).and_then(lambda i: Some(i + 1)) == Some(2) 983 | assert Nothing().and_then(lambda i: Some(i + 1)) == Nothing() 984 | ``` 985 | 986 | ##### Option.flatmap 987 | 988 | `Option.flatmap(self, fn: t.Callable[[T], Option[U]]) -> Option[U]` 989 | 990 | If this Option is `Some`, call the provided, Option-returning function with 991 | the contained value and return whatever Option it returns. If this Option 992 | is `Nothing`, return it unchanged. This method is an alias for 993 | [`Option.and_then`](#optionand_then) 994 | 995 | Example: 996 | 997 | ```py 998 | assert Some(1).flatmap(Some) == Some(1) 999 | assert Nothing().flatmap(Some) == Nothing() 1000 | ``` 1001 | 1002 | ##### Option.or_else 1003 | 1004 | `Option.or_else(self, fn: t.Callable[[], Option[T]]) -> Option[T]` 1005 | 1006 | If this Option is `Nothing`, call the provided, Option-returning function 1007 | and return whatever Option it returns. If this Option is `Some`, return it 1008 | unchanged. 1009 | 1010 | Example: 1011 | 1012 | ```py 1013 | assert Nothing().or_else(lambda: Some(1)) == Some(1) 1014 | assert Some(1).or_else(lambda: Some(2)) == Some(1) 1015 | ``` 1016 | 1017 | ##### Option.expect 1018 | 1019 | `Option.expect(self, msg: str, exc_cls: t.Type[Exception] = RuntimeError) -> T` 1020 | 1021 | If this Option is `Some`, return the wrapped value. Otherwise, if this 1022 | Option is `Nothing`, raise an error instantiated with the provided message. 1023 | By default, a `RuntimeError` is raised, but a custom exception class may be 1024 | provided via the `exc_cls` keyword argument. This method is an alias 1025 | of [`Option.raise_if_nothing`](#optionraise_if_nothing). 1026 | 1027 | Example: 1028 | 1029 | ```py 1030 | import pytest 1031 | 1032 | with pytest.raises(RuntimeError) as exc: 1033 | Nothing().expect("Nothing here") 1034 | assert str(exc.value) == "Nothing here" 1035 | 1036 | assert Some(1).expect("Nothing here") == 1 1037 | ``` 1038 | 1039 | ##### Option.raise_if_nothing 1040 | 1041 | `Option.raise_if_nothing(self, msg: str, exc_cls: t.Type[Exception] = RuntimeError) -> T` 1042 | 1043 | If this Option is `Some`, return the wrapped value. Otherwise, if this 1044 | Option is `Nothing`, raise an error instantiated with the provided message. 1045 | By default, a `RuntimeError` is raised, but a custom exception class may be 1046 | provided via the `exc_cls` keyword argument. This method is an alias 1047 | of [`Option.expect`](#optionexpect). 1048 | 1049 | Example: 1050 | 1051 | ```py 1052 | import pytest 1053 | 1054 | with pytest.raises(RuntimeError) as exc: 1055 | Nothing().raise_if_nothing("Nothing here") 1056 | assert str(exc.value) == "Nothing here" 1057 | 1058 | assert Some(1).raise_if_nothing("Nothing here") == 1 1059 | ``` 1060 | 1061 | ##### Option.filter 1062 | 1063 | `Option.filter(self, predicate: t.Callable[[T], bool]) -> Option[T]` 1064 | 1065 | If this Option is `Some`, call the provided predicate function with the wrapped 1066 | value. If the predicate returns True, return `Some` containing the wrapped 1067 | value of this Option. If the predicate returns False, return `Nothing`. If 1068 | this Option is `Nothing`, return it unchanged. 1069 | 1070 | Example: 1071 | 1072 | ```py 1073 | def is_even(val: int) -> bool: 1074 | """Return whether the value is even.""" 1075 | return val % 2 == 0 1076 | 1077 | assert Some(2).filter(is_even) == Some(2) 1078 | assert Some(1).filter(is_even) == Nothing() 1079 | assert Nothing().filter(is_even) == Nothing() 1080 | ``` 1081 | 1082 | ##### Option.is_nothing 1083 | 1084 | `Option.is_nothing(self) -> bool` 1085 | 1086 | If this Option is `Nothing`, return True. Otherwise, if this Option is 1087 | `Some`, return False. 1088 | 1089 | Example: 1090 | 1091 | ```py 1092 | assert Nothing().is_nothing() is True 1093 | assert Some(1).is_nothing() is False 1094 | ``` 1095 | 1096 | ##### Option.is_some 1097 | 1098 | `Option.is_some(self) -> bool` 1099 | 1100 | If this Option is `Some`. Otherwise, if this Option is `Nothing`, return False. 1101 | 1102 | Example: 1103 | 1104 | ```py 1105 | assert Some(1).is_some() is True 1106 | assert Nothing().is_some() is False 1107 | ``` 1108 | 1109 | ##### Option.iter 1110 | 1111 | `Option.iter(self) -> t.Iterator[T]` 1112 | 1113 | If this Option is `Some`, return an iterator of length one over the wrapped 1114 | value. Otherwise, if this Option is `Nothing`, return a 0-length iterator. 1115 | 1116 | Example: 1117 | 1118 | ```py 1119 | assert tuple(Some(1).iter()) == (1,) 1120 | assert tuple(Nothing().iter()) == () 1121 | ``` 1122 | 1123 | ##### Option.map 1124 | 1125 | `Option.map(self, fn: t.Callable[[T], U]) -> Option[U]` 1126 | 1127 | If this Option is `Some`, apply the provided function to the wrapped value, 1128 | and return `Some` wrapping the result of the function. If this Option is 1129 | `Nothing`, return this Option unchanged. 1130 | 1131 | Example: 1132 | 1133 | ```py 1134 | assert Some(1).map(str) == Some("1") 1135 | assert Nothing().map(str) == Nothing() 1136 | assert Some(1).map(str).map(lambda x: x + "a").map(str.upper) == Some("1A") 1137 | ``` 1138 | 1139 | ##### Option.map_or 1140 | 1141 | `Option.map_or(self, default: U, fn: t.Callable[[T], U]) -> U` 1142 | 1143 | If this Option is `Some`, apply the provided function to the wrapped value 1144 | and return the result. If this Option is `Nothing`, return the provided 1145 | default value. 1146 | 1147 | Example: 1148 | 1149 | ```py 1150 | assert Some(1).map_or("no value", str) == "1" 1151 | assert Nothing().map_or("no value", str) == "no value" 1152 | ``` 1153 | 1154 | ##### Option.map_or_else 1155 | 1156 | `Option.map_or_else(self, default: t.Callable[[], U], fn: t.Callable[[T], U]) -> U` 1157 | 1158 | If this Option is `Some`, apply the provided function to the wrapped value and 1159 | return the result. If this Option is `Nothing`, call the provided default 1160 | function with no arguments and return the result. 1161 | 1162 | Example: 1163 | 1164 | ```py 1165 | from datetime import datetime, date 1166 | 1167 | assert Some("2005-08-28").map_or_else( 1168 | date.today, 1169 | lambda t: datetime.strptime(t, "%Y-%m-%d").date() 1170 | ) == datetime(2005, 8, 28).date() 1171 | 1172 | assert Nothing().map_or_else( 1173 | date.today, 1174 | lambda t: datetime.strptime(t, "%Y-%m-%d").date() 1175 | ) == date.today() 1176 | ``` 1177 | 1178 | ##### Option.ok_or 1179 | 1180 | `Option.ok_or(self, err: E) -> Result[T, E]` 1181 | 1182 | If this Option is `Some`, return an `Ok` Result wrapping the contained 1183 | value. Otherwise, return an `Err` result wrapping the provided error. 1184 | 1185 | Example: 1186 | 1187 | ```py 1188 | assert Some(1).ok_or("no value!") == Ok(1) 1189 | assert Nothing().ok_or("no value!") == Err("no value!") 1190 | ``` 1191 | 1192 | ##### Option.ok_or_else 1193 | 1194 | `Option.ok_or_else(self, err_fn: t.Callable[[], E]) -> Result[T, E]` 1195 | 1196 | If this Option is `Some`, return an `Ok` Result wrapping the contained 1197 | value. Otherwise, call the provided `err_fn` and wrap its return value 1198 | in an `Err` Result. 1199 | 1200 | Example: 1201 | 1202 | ```py 1203 | from functools import partial 1204 | 1205 | def make_err_msg(msg: str) -> str: 1206 | """Make an error message with some starting text.""" 1207 | return f"[MY_APP_ERROR] -- {msg}" 1208 | 1209 | assert Some(1).ok_or_else(partial(make_err_msg, "no value!")) == Ok(1) 1210 | assert Nothing().ok_or_else(partial(make_err_msg, "no value!")) == Err( 1211 | "[MY_APP_ERROR] -- no value!" 1212 | ) 1213 | ``` 1214 | 1215 | ##### Option.unwrap 1216 | 1217 | `Option.unwrap(self) -> T` 1218 | 1219 | If this Option is `Some`, return the wrapped value. Otherwise, raise a 1220 | `RuntimeError`. 1221 | 1222 | Example: 1223 | 1224 | ```py 1225 | import pytest 1226 | 1227 | assert Some(1).unwrap() == 1 1228 | 1229 | with pytest.raises(RuntimeError): 1230 | Nothing().unwrap() 1231 | ``` 1232 | 1233 | ##### Option.unwrap_or 1234 | 1235 | `Option.unwrap_or(self, default: U) -> t.Union[T, U]` 1236 | 1237 | If this Option is `Some`, return the wrapped value. Otherwise, return the 1238 | provided default. 1239 | 1240 | Example: 1241 | 1242 | ```py 1243 | assert Some(1),unwrap_or(-1) == 1 1244 | assert Nothing().unwrap_or(-1) == -1 1245 | ``` 1246 | 1247 | ##### Option.unwrap_or_else 1248 | 1249 | `Option.unwrap_or_else(self, fn: t.Callable[[], U]) -> t.Union[T, U]` 1250 | 1251 | If this Option is `Some`, return the wrapped value. Otherwise, return the 1252 | result of the provided function. 1253 | 1254 | Example: 1255 | 1256 | ```py 1257 | from datetime import date 1258 | 1259 | assert Some(date(2001, 1, 1)).unwrap_or_else(date.today) == date(2001, 1, 1) 1260 | assert Nothing().unwrap_or_else(date.today) == date.today() 1261 | ``` 1262 | 1263 | #### Option Magic Methods 1264 | 1265 | ##### Option.__iter__ 1266 | 1267 | `Option.__iter__(self) -> t.Iterator[T]` 1268 | 1269 | Implement the iterator protocol, allowing iteration over the results of 1270 | [`Option.iter`](#optioniter). If this Option is `Ok`, return an iterator 1271 | of length 1 containing the wrapped value. Otherwise, if this Option is `Nothing`, 1272 | return a 0-length iterator. 1273 | 1274 | Example: 1275 | 1276 | ```py 1277 | # Can be passed to methods that take iterators 1278 | assert tuple(Some(1)) == (1,) 1279 | assert tuple(Nothing()j) == () 1280 | 1281 | # Can be used in `for in` constructs, including comprehensions 1282 | assert [val for val in Some(1)] == [1] 1283 | assert [val for val in Nothing()] == [] 1284 | 1285 | 1286 | # More for-in usage. 1287 | for val in Some(1): 1288 | pass 1289 | assert val == 1 1290 | 1291 | val = None 1292 | for val in Nothing(): 1293 | pass 1294 | assert val is None 1295 | ``` 1296 | 1297 | ##### Option.__eq__ 1298 | 1299 | `Option.__eq__(self, other: Any) -> bool` 1300 | 1301 | Enable equality checking using `==`. 1302 | 1303 | Compare this Option with `other`. Return True if `other` is the same type of 1304 | Option with the same wrapped value. Otherwise, return False. 1305 | 1306 | Example: 1307 | 1308 | ```py 1309 | assert (Some(1) == Some(1)) is True 1310 | assert (Some(1) == Some(2)) is False 1311 | assert (Some(1) == Nothing()) is False 1312 | assert (Some(1) == 1) is False 1313 | ``` 1314 | 1315 | ##### Option.__ne__ 1316 | 1317 | `Option.__ne__(self, other: Any) -> bool` 1318 | 1319 | Enable inequality checking using `!=`. 1320 | 1321 | Compare the Option with `other`. Return False if `other` is the same type of 1322 | Option with the same wrapped value. Otherwise, return True. 1323 | 1324 | Example: 1325 | 1326 | ```py 1327 | assert (Some(1) != Some(1)) is False 1328 | assert (Some(1) != Some(2)) is True 1329 | assert (Some(1) != Nothing()) is True 1330 | assert (Some(1) != 1) is True 1331 | ``` 1332 | 1333 | ##### Option.__str__ 1334 | 1335 | `Option.__str__(self) -> str` 1336 | 1337 | Enable useful stringification via `str()`. 1338 | 1339 | Example: 1340 | 1341 | ```py 1342 | assert str(Some(1)) == "Some(1)" 1343 | assert str(Nothing()) == "Nothing()" 1344 | ``` 1345 | 1346 | ##### Option.__repr__ 1347 | 1348 | `Option.__repr__(self) -> str` 1349 | 1350 | Enable useful stringification via `repr()`. 1351 | 1352 | Example: 1353 | 1354 | ```py 1355 | assert repr(Some(1)) == "Some(1)" 1356 | assert repr(Nothing()) == "Nothing()" 1357 | ``` 1358 | 1359 | ## Performance 1360 | 1361 | Benchmarks may be run with `make bench`. Benchmarking utilities are provided 1362 | in [`bench/`](/bench). 1363 | 1364 | Currently, the [`sample.py`](/bench/sample.py) benchmark defines two data 1365 | stores, one using classical python error handling (or lack thereof), and 1366 | the other using this library's wrapper types. Some simple operations 1367 | are performed using each data store for comparison. 1368 | 1369 | [`runner.sh`](/bench/runner.sh) runs the benchmarks two ways. First, it uses 1370 | [hyperfine] to run the benchmarks as a normal python script 100 times and 1371 | display information about the run time. It then uses python's builtin 1372 | [timeit](https://docs.python.org/3/library/timeit.html) module to measure 1373 | the code execution time in isolation over one million runs, without the 1374 | added overhead of spinning up the interpreter to parse and run the script. 1375 | 1376 | ### Results 1377 | 1378 | The `Result` and `Option` wrapper types add minimal overhead to 1379 | execution time, which will not be noticeable for most real-world workloads. 1380 | However, care should be taken if using these types in "hot paths." 1381 | 1382 | Run in isolation, the sample code using `Result` and `Option` types is 1383 | about six times slower than builtin exception handling: 1384 | 1385 | | Method | Number of Executions | Average Execution Time | Relative to Classical | 1386 | | --------- | -------------------- | ---------------------- | --------------------- | 1387 | | Classical | 1,000,000 (1E6) | 3.79E-6 s (3.79 μs) | 1x | 1388 | | Wrapper | 1,000,000 (1E6) | 2.31E-5 s (23.1 μs) | 6.09x | 1389 | 1390 | When run as part of a Python script, there is no significant difference 1391 | between using code with these wrapper types versus code that uses builtin 1392 | exception handling and nested if statements. 1393 | 1394 | | Method | Number of Executions | Average Execution Time | Relative to Classical | 1395 | | --------- | -------------------- | ---------------------- | --------------------- | 1396 | | Classical | 100 | 32.2 ms | 1x | 1397 | | Wrapper | 100 | 32.5 ms | 1.01x | 1398 | 1399 | ### Discussion 1400 | 1401 | Care has been taken to make the wrapper types in this library as performant 1402 | as possible. All types use `__slots__` to avoid allocating a dictionary for 1403 | instance variables, and wrapper variants (e.g. `Ok` and `Err` for `Result`) 1404 | are implemented as separate subclasses of `Result` rather than a shared 1405 | class in order to avoid needing to perform if/else branching or `isinstance()` 1406 | checks, which are notoriously slow in Python. 1407 | 1408 | That being said, using these types _is_ doing more than the builtin error 1409 | handling! Instances are being constructed and methods are being accessed. 1410 | Both of these are relatively quick in Python, but definitely not quicker 1411 | than doing nothing, so this library will probably never be quite as performant 1412 | as raw exception handling. That being said, that is not its aim! The goal 1413 | is to be as quick as possible, preferably within striking distance of 1414 | regular old idiomatic python, while providing significantly more ergonomics 1415 | and type safety around handling errors and absent data. 1416 | 1417 | ## Contributing 1418 | 1419 | Contributions are welcome! To get started, you'll just need a local install 1420 | of Python 3. 1421 | 1422 | Once you've forked and cloned the repo, you can run: 1423 | 1424 | - `make test` - run tests using your local interpreter 1425 | - `make fmt` - format code using [black](https://github.com/python/black) 1426 | - `make lint` - check code with a variety of analysis tools 1427 | - `make bench` - run benchmarks 1428 | 1429 | See the [`Makefile`](Makefile) for other commands. 1430 | 1431 | The CI system requires that `make lint` and `make test` run successfully 1432 | (exit status of 0) in order to merge code. 1433 | 1434 | `result_types` is compatible with Python >= 3.6. You can run against 1435 | all supported python versions with `make test-all-versions`. This requires 1436 | that `docker` be installed on your local system. Alternatively, if you 1437 | have all required Python versions installed, you may run `make tox` to 1438 | run against your local interpreters. 1439 | 1440 | [hyperfine]: https://github.com/sharkdp/hyperfine 1441 | [rust-result]: https://doc.rust-lang.org/std/result/ 1442 | [rust-option]: https://doc.rust-lang.org/std/option/ 1443 | --------------------------------------------------------------------------------