├── .github └── workflows │ ├── integration.yml │ └── publish.yml ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── poltergeist ├── __init__.py ├── decorator.py ├── py.typed └── result.py ├── pyproject.toml └── tests ├── mypy ├── test_decorator.yml ├── test_err.yml ├── test_ok.yml └── test_result.yml ├── test_decorator.py └── test_result.py /.github/workflows/integration.yml: -------------------------------------------------------------------------------- 1 | name: Integration 2 | on: 3 | push: 4 | branches: ["main"] 5 | pull_request: 6 | branches: ["main"] 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | fail-fast: false 12 | matrix: 13 | python-version: ["3.10", "3.11"] 14 | steps: 15 | - name: Check out repository 16 | uses: actions/checkout@v3 17 | - name: Set up Python ${{ matrix.python-version }} 18 | uses: actions/setup-python@v4 19 | with: 20 | python-version: ${{ matrix.python-version }} 21 | - name: Install Poetry 22 | uses: snok/install-poetry@v1 23 | with: 24 | version: "1.2.2" 25 | virtualenvs-create: true 26 | virtualenvs-in-project: true 27 | installer-parallel: true 28 | - name: Install dependencies 29 | run: poetry install --no-interaction --no-root 30 | - name: Install project 31 | run: poetry install --no-interaction 32 | - name: Run checks 33 | run: | 34 | source .venv/bin/activate 35 | make check 36 | - name: Run tests 37 | run: | 38 | source .venv/bin/activate 39 | make test 40 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | on: 3 | release: 4 | types: [created] 5 | jobs: 6 | publish: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | fail-fast: false 10 | matrix: 11 | python-version: ["3.11"] 12 | steps: 13 | - name: Check out repository 14 | uses: actions/checkout@v3 15 | - name: Set up Python ${{ matrix.python-version }} 16 | uses: actions/setup-python@v4 17 | with: 18 | python-version: ${{ matrix.python-version }} 19 | - name: Install Poetry 20 | uses: snok/install-poetry@v1 21 | with: 22 | version: "1.2.2" 23 | virtualenvs-create: true 24 | virtualenvs-in-project: true 25 | installer-parallel: true 26 | - name: Publish 27 | run: | 28 | poetry config pypi-token.pypi ${{ secrets.PYPI_API_TOKEN }} 29 | poetry version $(git describe --tags --abbrev=0) 30 | poetry publish --build 31 | -------------------------------------------------------------------------------- /.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 | *.cover 50 | *.py,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 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | # Poetry 132 | poetry.lock 133 | 134 | # VSCode 135 | .VSCode 136 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Alexander Malyga 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | sources = poltergeist tests 2 | 3 | .PHONY: test lint check 4 | 5 | test: 6 | pytest -vv --cov=poltergeist tests 7 | 8 | lint: 9 | isort $(sources) 10 | black $(sources) 11 | 12 | check: 13 | isort --check --diff $(sources) 14 | black --check --diff $(sources) 15 | mypy -p poltergeist 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # poltergeist 2 | 3 | [![pypi](https://img.shields.io/pypi/v/poltergeist.svg)](https://pypi.python.org/pypi/poltergeist) 4 | [![versions](https://img.shields.io/pypi/pyversions/poltergeist.svg)](https://github.com/alexandermalyga/poltergeist) 5 | 6 | [Rust-like error handling](https://doc.rust-lang.org/book/ch09-00-error-handling.html) in Python, with type-safety in mind. 7 | 8 | ## Installation 9 | 10 | ``` 11 | pip install poltergeist 12 | ``` 13 | 14 | ## Examples 15 | 16 | Use the `@catch` decorator on any function: 17 | 18 | ```python 19 | from poltergeist import catch 20 | 21 | # Handle an exception type potentially raised within the function 22 | @catch(OSError) 23 | def read_text(path: str) -> str: 24 | with open(path) as f: 25 | return f.read() 26 | 27 | # Returns an object of type Result[str, OSError] 28 | result = read_text("my_file.txt") 29 | ``` 30 | 31 | Or wrap errors manually: 32 | 33 | ```python 34 | from poltergeist import Result, Ok, Err 35 | 36 | # Equivalent to the decorated function above 37 | def read_text(path: str) -> Result[str, OSError]: 38 | try: 39 | with open(path) as f: 40 | return Ok(f.read()) 41 | except OSError as e: 42 | return Err(e) 43 | ``` 44 | 45 | Then handle the result in a type-safe way: 46 | 47 | ```python 48 | # Get the Ok value or re-raise the Err exception 49 | content: str = result.unwrap() 50 | 51 | # Get the Ok value or a default if it's an Err 52 | content: str = result.unwrap_or("lorem ipsum") 53 | content: str | None = result.unwrap_or() 54 | 55 | # Get the Ok value or compute it from the exception 56 | content: str = result.unwrap_or_else(lambda e: f"The exception was: {e}") 57 | 58 | # Get the Err exception or None if it's an Ok 59 | err: OSError | None = result.err() 60 | 61 | # Handle the result using structural pattern matching 62 | match result: 63 | case Ok(content): 64 | print("Text in upper:", content.upper()) 65 | case Err(FileNotFoundError() as e): 66 | print("File not found:", e.filename) 67 | ``` 68 | 69 | It's also possible to wrap multiple exception types with the decorator: 70 | 71 | ```python 72 | @catch(OSError, UnicodeDecodeError) 73 | def read_text(path: str) -> str: 74 | with open(path) as f: 75 | return f.read() 76 | ``` 77 | 78 | Or manually: 79 | 80 | ```python 81 | def read_text(path: str) -> Result[str, OSError | UnicodeDecodeError]: 82 | try: 83 | with open(path) as f: 84 | return Ok(f.read()) 85 | except (OSError, UnicodeDecodeError) as e: 86 | return Err(e) 87 | ``` 88 | 89 | There is also an async-compatible decorator: 90 | 91 | ```python 92 | from poltergeist import catch_async 93 | 94 | @catch_async(OSError) 95 | async def read_text(path: str) -> str: 96 | with open(path) as f: 97 | return f.read() 98 | 99 | # Returns an object of type Result[str, OSError] 100 | result = await read_text("my_file.txt") 101 | ``` 102 | 103 | ## Contributing 104 | 105 | Set up the project using [Poetry](https://python-poetry.org/): 106 | 107 | ``` 108 | poetry install 109 | ``` 110 | 111 | Format the code: 112 | 113 | ``` 114 | make lint 115 | ``` 116 | 117 | Run tests: 118 | 119 | ``` 120 | make test 121 | ``` 122 | 123 | Check for typing and format issues: 124 | 125 | ``` 126 | make check 127 | ``` 128 | -------------------------------------------------------------------------------- /poltergeist/__init__.py: -------------------------------------------------------------------------------- 1 | from poltergeist.decorator import catch, catch_async 2 | from poltergeist.result import Err, Ok, Result 3 | 4 | __all__ = ["Err", "Ok", "Result", "catch", "catch_async"] 5 | -------------------------------------------------------------------------------- /poltergeist/decorator.py: -------------------------------------------------------------------------------- 1 | import functools 2 | from collections.abc import Awaitable 3 | from typing import Callable, ParamSpec, TypeVar 4 | 5 | from poltergeist.result import Err, Ok, Result 6 | 7 | _T = TypeVar("_T") 8 | _E = TypeVar("_E", bound=BaseException) 9 | _P = ParamSpec("_P") 10 | 11 | 12 | def catch( 13 | *errors: type[_E], 14 | ) -> Callable[[Callable[_P, _T]], Callable[_P, Result[_T, _E]]]: 15 | def decorator(func: Callable[_P, _T]) -> Callable[_P, Result[_T, _E]]: 16 | @functools.wraps(func) 17 | def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> Result[_T, _E]: 18 | try: 19 | result = func(*args, **kwargs) 20 | except errors as e: 21 | return Err(e) 22 | return Ok(result) 23 | 24 | return wrapper 25 | 26 | return decorator 27 | 28 | 29 | def catch_async( 30 | *errors: type[_E], 31 | ) -> Callable[[Callable[_P, Awaitable[_T]]], Callable[_P, Awaitable[Result[_T, _E]]]]: 32 | def decorator( 33 | func: Callable[_P, Awaitable[_T]] 34 | ) -> Callable[_P, Awaitable[Result[_T, _E]]]: 35 | @functools.wraps(func) 36 | async def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> Result[_T, _E]: 37 | try: 38 | result = await func(*args, **kwargs) 39 | except errors as e: 40 | return Err(e) 41 | return Ok(result) 42 | 43 | return wrapper 44 | 45 | return decorator 46 | -------------------------------------------------------------------------------- /poltergeist/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexandermalyga/poltergeist/6cbec9b87469171ca5a343d98a392c662ed3deb6/poltergeist/py.typed -------------------------------------------------------------------------------- /poltergeist/result.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Callable, Generic, NoReturn, TypeVar, final, overload 2 | 3 | _T = TypeVar("_T", covariant=True) 4 | _E = TypeVar("_E", bound=BaseException, covariant=True) 5 | _D = TypeVar("_D") 6 | 7 | 8 | @final 9 | class Ok(Generic[_T]): 10 | __slots__ = ("_value",) 11 | __match_args__ = ("_value",) 12 | 13 | def __init__(self, value: _T) -> None: 14 | self._value = value 15 | 16 | def __repr__(self) -> str: 17 | return f"Ok({repr(self._value)})" 18 | 19 | def __eq__(self, __o: Any) -> bool: 20 | return isinstance(__o, Ok) and self._value == __o._value 21 | 22 | def err(self) -> None: 23 | return None 24 | 25 | def unwrap(self) -> _T: 26 | return self._value 27 | 28 | @overload 29 | def unwrap_or(self) -> _T: 30 | ... 31 | 32 | @overload 33 | def unwrap_or(self, default: Any) -> _T: 34 | ... 35 | 36 | def unwrap_or(self, default: Any = None) -> Any: 37 | return self.unwrap() 38 | 39 | def unwrap_or_else(self, op: Any) -> _T: 40 | return self.unwrap() 41 | 42 | 43 | @final 44 | class Err(Generic[_E]): 45 | __slots__ = ("_value",) 46 | __match_args__ = ("_value",) 47 | 48 | def __init__(self, value: _E) -> None: 49 | self._value = value 50 | 51 | def __repr__(self) -> str: 52 | return f"Err({repr(self._value)})" 53 | 54 | def __eq__(self, __o: Any) -> bool: 55 | return ( 56 | isinstance(__o, Err) 57 | and type(__o._value) is type(self._value) 58 | and __o._value.args == self._value.args 59 | ) 60 | 61 | def err(self) -> _E: 62 | return self._value 63 | 64 | def unwrap(self) -> NoReturn: 65 | raise self._value 66 | 67 | @overload 68 | def unwrap_or(self) -> None: 69 | ... 70 | 71 | @overload 72 | def unwrap_or(self, default: _D) -> _D: 73 | ... 74 | 75 | def unwrap_or(self, default: Any = None) -> Any: 76 | return default 77 | 78 | def unwrap_or_else(self, op: Callable[[_E], _D]) -> _D: 79 | return op(self._value) 80 | 81 | 82 | Result = Ok[_T] | Err[_E] 83 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "poltergeist" 3 | version = "0.0.0" 4 | description = "Rust-like error handling in Python, with type-safety in mind." 5 | authors = ["Alexander Malyga "] 6 | license = "MIT" 7 | readme = "README.md" 8 | homepage = "https://github.com/alexandermalyga/poltergeist" 9 | repository = "https://github.com/alexandermalyga/poltergeist" 10 | classifiers = [ 11 | "Development Status :: 2 - Pre-Alpha", 12 | "License :: OSI Approved :: MIT License", 13 | "Programming Language :: Python :: 3.10", 14 | "Programming Language :: Python :: 3.11", 15 | "Programming Language :: Python :: 3.12", 16 | "Topic :: Software Development :: Libraries :: Python Modules", 17 | "Typing :: Typed", 18 | "Intended Audience :: Developers", 19 | ] 20 | 21 | [tool.poetry.dependencies] 22 | python = "^3.10" 23 | 24 | [tool.poetry.group.dev.dependencies] 25 | pytest = "^7.4.4" 26 | mypy = "^1.8.0" 27 | black = "^23.12.1" 28 | isort = "^5.13.2" 29 | pytest-mypy-plugins = "^3.0.0" 30 | pytest-cov = "^4.1.0" 31 | pytest-asyncio = "^0.23.3" 32 | 33 | [tool.mypy] 34 | strict = true 35 | 36 | [tool.black] 37 | target_version = ["py310", "py311"] 38 | 39 | [tool.isort] 40 | profile = "black" 41 | 42 | [tool.coverage.report] 43 | exclude_lines = ["pragma: not covered", "@overload"] 44 | 45 | [tool.pytest.ini_options] 46 | asyncio_mode = "auto" 47 | 48 | [build-system] 49 | requires = ["poetry-core"] 50 | build-backend = "poetry.core.masonry.api" 51 | -------------------------------------------------------------------------------- /tests/mypy/test_decorator.yml: -------------------------------------------------------------------------------- 1 | - case: decorator_single_error 2 | main: | 3 | from poltergeist import catch, Result 4 | 5 | @catch(ValueError) 6 | def test(a: int, b: str) -> float | None: ... 7 | 8 | reveal_type(test) # N: Revealed type is "def (a: builtins.int, b: builtins.str) -> Union[poltergeist.result.Ok[Union[builtins.float, None]], poltergeist.result.Err[builtins.ValueError]]" 9 | 10 | - case: decorator_multiple_errors 11 | skip: True # TODO: Enable this test once MyPy properly detects the return type 12 | main: | 13 | from poltergeist import catch, Result 14 | 15 | @catch(ValueError, TypeError) 16 | def test(a: int, b: str) -> float | None: ... 17 | 18 | reveal_type(test) # N: Revealed type is "def (a: builtins.int, b: builtins.str) -> Union[poltergeist.result.Ok[Union[builtins.float, None]], poltergeist.result.Err[Union[builtins.ValueError, builtins.TypeError]]]" 19 | 20 | - case: decorator_invalid_error_type 21 | main: | 22 | from poltergeist import catch, Result 23 | 24 | @catch(123) 25 | def test(a: int, b: str) -> float | None: ... 26 | out: | 27 | main:3: error: Argument 1 to "catch" has incompatible type "int"; expected "type[Never]" [arg-type] 28 | 29 | - case: decorator_with_async_function 30 | main: | 31 | from poltergeist import catch, Result 32 | 33 | @catch(ValueError) 34 | async def test(a: int, b: str) -> float | None: ... 35 | 36 | reveal_type(test) # N: Revealed type is "def (a: builtins.int, b: builtins.str) -> Union[poltergeist.result.Ok[typing.Coroutine[Any, Any, Union[builtins.float, None]]], poltergeist.result.Err[builtins.ValueError]]" 37 | 38 | - case: async_decorator_single_error 39 | main: | 40 | from poltergeist import catch_async, Result 41 | 42 | @catch_async(ValueError) 43 | async def test(a: int, b: str) -> float | None: ... 44 | 45 | reveal_type(test) # N: Revealed type is "def (a: builtins.int, b: builtins.str) -> typing.Awaitable[Union[poltergeist.result.Ok[Union[builtins.float, None]], poltergeist.result.Err[builtins.ValueError]]]" 46 | 47 | - case: async_decorator_multiple_errors 48 | skip: True # TODO: Enable this test once MyPy properly detects the return type 49 | main: | 50 | from poltergeist import catch_async, Result 51 | 52 | @catch_async(ValueError, TypeError) 53 | async def test(a: int, b: str) -> float | None: ... 54 | 55 | reveal_type(test) # N: Revealed type is "def (a: builtins.int, b: builtins.str) -> typing.Awaitable[Union[poltergeist.result.Ok[Union[builtins.float, None]], poltergeist.result.Err[Union[builtins.ValueError, builtins.TypeError]]]]" 56 | 57 | - case: async_decorator_invalid_error_type 58 | main: | 59 | from poltergeist import catch_async, Result 60 | 61 | @catch_async(123) 62 | async def test(a: int, b: str) -> float | None: ... 63 | out: | 64 | main:3: error: Argument 1 to "catch_async" has incompatible type "int"; expected "type[Never]" [arg-type] 65 | 66 | 67 | - case: async_decorator_with_sync_function 68 | main: | 69 | from poltergeist import catch_async, Result 70 | 71 | @catch_async(Exception) 72 | def test(a: int, b: str) -> float | None: ... 73 | out: | 74 | main:3: error: Argument 1 has incompatible type "Callable[[int, str], float | None]"; expected "Callable[[int, str], Awaitable[Never]]" [arg-type] 75 | -------------------------------------------------------------------------------- /tests/mypy/test_err.yml: -------------------------------------------------------------------------------- 1 | - case: err_generic 2 | main: | 3 | from poltergeist import Err 4 | instance: Err[Exception] = Err(123) # E: Argument 1 to "Err" has incompatible type "int"; expected "Exception" [arg-type] 5 | other: Err[int] # E: Type argument "int" of "Err" must be a subtype of "BaseException" [type-var] 6 | 7 | - case: err_err 8 | main: | 9 | from poltergeist import Err 10 | instance: Err[Exception] 11 | reveal_type(instance.err()) # N: Revealed type is "builtins.Exception" 12 | 13 | - case: err_unwrap 14 | main: | 15 | from poltergeist import Err 16 | instance: Err[Exception] 17 | reveal_type(instance.unwrap()) # N: Revealed type is "Never" 18 | 19 | - case: err_unwrap_or 20 | main: | 21 | from poltergeist import Err 22 | instance: Err[Exception] 23 | reveal_type(instance.unwrap_or()) # N: Revealed type is "None" 24 | reveal_type(instance.unwrap_or(123)) # N: Revealed type is "builtins.int" 25 | reveal_type(instance.unwrap_or("abc")) # N: Revealed type is "builtins.str" 26 | 27 | - case: err_unwrap_or_else 28 | main: | 29 | from poltergeist import Err 30 | instance: Err[Exception] 31 | reveal_type(instance.unwrap_or_else(lambda e: e)) # N: Revealed type is "builtins.Exception" 32 | instance.unwrap_or_else(123) # E: Argument 1 to "unwrap_or_else" of "Err" has incompatible type "int"; expected "Callable[[Exception], Never]" [arg-type] 33 | default: str = instance.unwrap_or_else(lambda e: e) # E: Incompatible types in assignment (expression has type "Exception", variable has type "str") [assignment] 34 | -------------------------------------------------------------------------------- /tests/mypy/test_ok.yml: -------------------------------------------------------------------------------- 1 | - case: ok_generic 2 | main: | 3 | from poltergeist import Ok 4 | instance: Ok[str] = Ok(123) # E: Argument 1 to "Ok" has incompatible type "int"; expected "str" [arg-type] 5 | 6 | - case: ok_err 7 | main: | 8 | from poltergeist import Ok 9 | instance: Ok[str] 10 | reveal_type(instance.err()) # N: Revealed type is "None" 11 | 12 | - case: ok_unwrap 13 | main: | 14 | from poltergeist import Ok 15 | instance: Ok[str] 16 | reveal_type(instance.unwrap()) # N: Revealed type is "builtins.str" 17 | 18 | - case: ok_unwrap_or 19 | main: | 20 | from poltergeist import Ok 21 | instance: Ok[str] 22 | reveal_type(instance.unwrap_or()) # N: Revealed type is "builtins.str" 23 | reveal_type(instance.unwrap_or(123)) # N: Revealed type is "builtins.str" 24 | reveal_type(instance.unwrap_or("abc")) # N: Revealed type is "builtins.str" 25 | 26 | - case: ok_unwrap_or_else 27 | main: | 28 | from poltergeist import Ok 29 | instance: Ok[str] 30 | reveal_type(instance.unwrap_or_else(lambda e: e)) # N: Revealed type is "builtins.str" 31 | -------------------------------------------------------------------------------- /tests/mypy/test_result.yml: -------------------------------------------------------------------------------- 1 | - case: result_generic 2 | main: | 3 | from poltergeist import Result, Ok, Err 4 | ok: Result[str, Exception] = Ok(123) # E: Argument 1 to "Ok" has incompatible type "int"; expected "str" [arg-type] 5 | err: Result[str, Exception] = Err(123) # E: Argument 1 to "Err" has incompatible type "int"; expected "Exception" [arg-type] 6 | other: Result[str, int] # E: Type argument "int" of "Result" must be a subtype of "BaseException" [type-var] 7 | 8 | - case: result_generic_covariant 9 | main: | 10 | from abc import ABC, abstractmethod 11 | from typing import Any, Generic, TypeVar 12 | from poltergeist import Err, Result 13 | 14 | FooT = TypeVar("FooT", bound=Result[Any, Exception]) 15 | 16 | class Foo(Generic[FooT], ABC): 17 | @abstractmethod 18 | def run(self) -> FooT: 19 | ... 20 | 21 | class Bar(Foo[Result[Any, ValueError]]): 22 | def run(self) -> Result[Any, ValueError]: 23 | return Err(ValueError("")) 24 | 25 | - case: result_err 26 | main: | 27 | from poltergeist import Result 28 | instance: Result[str, Exception] 29 | reveal_type(instance.err()) # N: Revealed type is "Union[None, builtins.Exception]" 30 | 31 | - case: result_unwrap 32 | main: | 33 | from poltergeist import Result 34 | instance: Result[str, Exception] 35 | reveal_type(instance.unwrap()) # N: Revealed type is "builtins.str" 36 | 37 | - case: result_unwrap_or 38 | main: | 39 | from poltergeist import Result 40 | instance: Result[str, Exception] 41 | reveal_type(instance.unwrap_or()) # N: Revealed type is "Union[builtins.str, None]" 42 | reveal_type(instance.unwrap_or(123)) # N: Revealed type is "Union[builtins.str, builtins.int]" 43 | reveal_type(instance.unwrap_or("abc")) # N: Revealed type is "builtins.str" 44 | 45 | - case: result_unwrap_or_else 46 | main: | 47 | from poltergeist import Result 48 | instance: Result[str, Exception] 49 | reveal_type(instance.unwrap_or_else(lambda e: e)) # N: Revealed type is "Union[builtins.str, builtins.Exception]" 50 | default: str = instance.unwrap_or_else(lambda e: e) # E: Incompatible types in assignment (expression has type "str | Exception", variable has type "str") [assignment] 51 | instance.unwrap_or_else(123) # E: Argument 1 to "unwrap_or_else" of "Err" has incompatible type "int"; expected "Callable[[Exception], Never]" [arg-type] 52 | -------------------------------------------------------------------------------- /tests/test_decorator.py: -------------------------------------------------------------------------------- 1 | import operator 2 | 3 | import pytest 4 | 5 | from poltergeist import Err, Ok, catch, catch_async 6 | 7 | 8 | def test_decorator() -> None: 9 | decorated = catch(ZeroDivisionError)(operator.truediv) 10 | 11 | assert decorated(4, 2) == Ok(2) 12 | 13 | match decorated(4, 0): 14 | case Err(e): 15 | assert type(e) == ZeroDivisionError 16 | assert e.args == ("division by zero",) 17 | case _: 18 | pytest.fail("Should have been Err") 19 | 20 | 21 | def test_decorator_other_error() -> None: 22 | # Only catching instances of ValueError 23 | decorated = catch(ValueError)(operator.truediv) 24 | 25 | assert decorated(4, 2) == Ok(2) 26 | 27 | # ZeroDivisionError should not have been catched 28 | with pytest.raises(ZeroDivisionError): 29 | decorated(4, 0) 30 | 31 | 32 | def test_decorator_multiple_errors() -> None: 33 | decorated = catch(ZeroDivisionError, TypeError)(operator.truediv) 34 | 35 | assert decorated(4, 2) == Ok(2) 36 | 37 | match decorated(4, 0): 38 | case Err(e): 39 | assert type(e) == ZeroDivisionError 40 | assert e.args == ("division by zero",) 41 | case _: 42 | pytest.fail("Should have been Err") 43 | 44 | match decorated("4", 0): 45 | case Err(e): 46 | assert type(e) == TypeError 47 | assert e.args == ("unsupported operand type(s) for /: 'str' and 'int'",) 48 | case _: 49 | pytest.fail("Should have been Err") 50 | 51 | 52 | async def test_async_decorator() -> None: 53 | async def async_div(a: float, b: float) -> float: 54 | return a / b 55 | 56 | decorated = catch_async(ZeroDivisionError)(async_div) 57 | 58 | assert await decorated(4, 2) == Ok(2) 59 | 60 | match await decorated(4, 0): 61 | case Err(e): 62 | assert type(e) == ZeroDivisionError 63 | assert e.args == ("division by zero",) 64 | case _: 65 | pytest.fail("Should have been Err") 66 | 67 | 68 | async def test_async_decorator_other_error() -> None: 69 | async def async_div(a: float, b: float) -> float: 70 | return a / b 71 | 72 | # Only catching instances of ValueError 73 | decorated = catch_async(ValueError)(async_div) 74 | 75 | assert await decorated(4, 2) == Ok(2) 76 | 77 | # ZeroDivisionError should not have been catched 78 | with pytest.raises(ZeroDivisionError): 79 | await decorated(4, 0) 80 | 81 | 82 | async def test_async_decorator_multiple_errors() -> None: 83 | async def async_div(a: float, b: float) -> float: 84 | return a / b 85 | 86 | decorated = catch_async(ZeroDivisionError, TypeError)(async_div) 87 | 88 | assert await decorated(4, 2) == Ok(2) 89 | 90 | match await decorated(4, 0): 91 | case Err(e): 92 | assert type(e) == ZeroDivisionError 93 | assert e.args == ("division by zero",) 94 | case _: 95 | pytest.fail("Should have been Err") 96 | 97 | match await decorated("4", 0): 98 | case Err(e): 99 | assert type(e) == TypeError 100 | assert e.args == ("unsupported operand type(s) for /: 'str' and 'int'",) 101 | case _: 102 | pytest.fail("Should have been Err") 103 | -------------------------------------------------------------------------------- /tests/test_result.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from poltergeist import Err, Ok 4 | 5 | 6 | def test_ok() -> None: 7 | result = Ok("abc") 8 | 9 | match result: 10 | case Ok(v): 11 | assert v == "abc" 12 | case _: 13 | pytest.fail("Should have been Ok") 14 | 15 | assert result.err() is None 16 | 17 | assert result.unwrap() == "abc" 18 | 19 | assert result.unwrap_or() == "abc" 20 | 21 | assert result.unwrap_or("aaa") == "abc" 22 | 23 | assert result.unwrap_or_else(lambda e: str(e)) == "abc" 24 | 25 | 26 | def test_ok_eq() -> None: 27 | result = Ok("abc") 28 | assert result == Ok("abc") 29 | assert result != Ok("aaa") 30 | assert result != Err(Exception("abc")) 31 | assert result != "abc" 32 | 33 | 34 | def test_ok_repr() -> None: 35 | result = Ok("abc") 36 | assert repr(result) == "Ok('abc')" 37 | 38 | 39 | def test_error() -> None: 40 | result = Err(ValueError("abc")) 41 | 42 | match result: 43 | case Err(e): 44 | assert type(e) == ValueError 45 | assert e.args == ("abc",) 46 | case _: 47 | pytest.fail("Should have been Err") 48 | 49 | assert type(result.err()) == ValueError 50 | assert result.err().args == ("abc",) 51 | 52 | with pytest.raises(ValueError) as excinfo: 53 | result.unwrap() 54 | 55 | assert type(excinfo.value) == ValueError 56 | assert excinfo.value.args == ("abc",) 57 | 58 | assert result.unwrap_or() is None 59 | 60 | assert result.unwrap_or("aaa") == "aaa" 61 | 62 | assert result.unwrap_or_else(lambda e: f"Exception is {e}") == "Exception is abc" 63 | 64 | 65 | def test_err_eq() -> None: 66 | result = Err(ValueError("abc")) 67 | assert result == Err(ValueError("abc")) 68 | assert result != Err(ValueError("aaa")) 69 | assert result != Err(ValueError("abc", 1)) 70 | assert result != Err(Exception("abc")) 71 | assert result != Ok("abc") 72 | assert result != "abc" 73 | 74 | 75 | def test_err_repr() -> None: 76 | result = Err(ValueError("Incorrect value!")) 77 | assert repr(result) == "Err(ValueError('Incorrect value!'))" 78 | --------------------------------------------------------------------------------