├── src └── match_variant │ ├── __init__.py │ ├── _enum.py │ ├── _variant.py │ ├── _maybe.py │ └── _result.py ├── .vscode ├── settings.json └── extensions.json ├── tests ├── test_enum.py ├── test_maybe.py ├── test_result.py └── test_variant.py ├── examples ├── result.py ├── enum.py ├── maybe.py └── roles.py ├── pyproject.toml ├── LICENSE ├── .gitignore └── README.md /src/match_variant/__init__.py: -------------------------------------------------------------------------------- 1 | """Variant algebraic types""" 2 | 3 | from ._enum import Enum as Enum 4 | from ._maybe import Maybe as Maybe 5 | from ._result import Result as Result 6 | from ._result import Trapped as Trapped 7 | from ._result import trap as trap 8 | from ._variant import Variant as Variant 9 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.testing.pytestArgs": [ 3 | "tests" 4 | ], 5 | "python.testing.unittestEnabled": false, 6 | "python.testing.pytestEnabled": true, 7 | "python.analysis.importFormat": "relative", 8 | "python.analysis.diagnosticSeverityOverrides": { 9 | "reportGeneralTypeIssues": "none", 10 | "reportOptionalMemberAccess": "none" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /tests/test_enum.py: -------------------------------------------------------------------------------- 1 | from match_variant import Enum, Maybe 2 | 3 | 4 | class MyEnum(Enum): 5 | a: () = 1 # type: ignore 6 | b: () = 2 # type: ignore 7 | c: (int,) # type: ignore 8 | 9 | 10 | def test_from_value_exists(): 11 | assert MyEnum.from_value(1).unwrap() == MyEnum.a() 12 | 13 | 14 | def test_from_value_no_exist(): 15 | assert isinstance(MyEnum.from_value(99), Maybe.nothing) 16 | -------------------------------------------------------------------------------- /examples/result.py: -------------------------------------------------------------------------------- 1 | import math 2 | import random 3 | 4 | from match_variant import Result, trap 5 | 6 | with trap(ZeroDivisionError) as trapped: 7 | i = random.randint(0, 4) 8 | trapped.ok(1 / i) 9 | 10 | print(trapped.result) 11 | 12 | result = trapped.result 13 | 14 | match result: 15 | case Result.ok(value): 16 | print(f"got {value}") 17 | case Result.error(_): 18 | print("Something went wrong") 19 | 20 | 21 | print(result.to_maybe()) 22 | 23 | print(result.apply(math.sqrt).unwrap()) 24 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations. 3 | // Extension identifier format: ${publisher}.${name}. Example: vscode.csharp 4 | 5 | // List of extensions which should be recommended for users of this workspace. 6 | "recommendations": [ 7 | "ms-python.python", 8 | "ms-python.black-formatter", 9 | "ms-python.isort" 10 | ], 11 | // List of extensions recommended by VS Code that should not be recommended for users of this workspace. 12 | "unwantedRecommendations": [ 13 | 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /examples/enum.py: -------------------------------------------------------------------------------- 1 | from match_variant import Enum, Maybe 2 | 3 | 4 | class HttpStatus(Enum): 5 | ok: () = 200 6 | not_found: () = 404 7 | 8 | 9 | for value in (200, 404, 600): 10 | match HttpStatus.from_value(value): 11 | case Maybe.just(HttpStatus.ok()): 12 | print(f"Request was successful") 13 | case Maybe.just(HttpStatus.not_found()): 14 | print("Request was not found") 15 | case Maybe.just(_): 16 | print(f"Unexpected status code: {value}") 17 | case Maybe.nothing(): 18 | print(f"No idea what we got here") 19 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["flit_core >=3.2,<4"] 3 | build-backend = "flit_core.buildapi" 4 | 5 | [project] 6 | name = "match-variant" 7 | version = "2022.10.0a1" 8 | authors = [{name = "Brett Cannon", email = "brett@python.org"}, {name = "Dusty Phillips", email="dusty@phillips.codes"}] 9 | license = {file = "LICENSE"} 10 | readme = "README.md" 11 | requires-python = ">=3.10" 12 | classifiers = ["License :: OSI Approved :: MIT License"] 13 | dynamic = ["description"] 14 | 15 | [project.urls] 16 | Home = "https://github.com/dusty-phillips/match-variant" 17 | 18 | [project.optional-dependencies] 19 | test = ["pytest"] 20 | 21 | [tool.flit.module] 22 | name = "match_variant" 23 | -------------------------------------------------------------------------------- /tests/test_maybe.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from match_variant import Maybe 4 | 5 | 6 | def test_apply_nothing(): 7 | maybe = Maybe.nothing() 8 | 9 | def no_call_me(): 10 | pytest.fail("I should not be called") 11 | 12 | assert maybe.apply(no_call_me) is maybe 13 | 14 | 15 | def test_apply_just(): 16 | val = "I am a value" 17 | maybe = Maybe.just(val) 18 | assert maybe.apply(str.upper).unwrap() == val.upper() 19 | 20 | 21 | def test_just_unwrap(): 22 | val = "I am a value" 23 | j = Maybe.just(val) 24 | assert j.unwrap() is val 25 | 26 | 27 | def test_nothing_unwrap_raises(): 28 | with pytest.raises(TypeError) as ex: 29 | Maybe.nothing().unwrap() 30 | 31 | assert "nothing" in str(ex.value) 32 | 33 | 34 | def test_nothing_unwrap_default(): 35 | expected = "YAY" 36 | assert Maybe.nothing().unwrap(default=expected) is expected 37 | -------------------------------------------------------------------------------- /examples/maybe.py: -------------------------------------------------------------------------------- 1 | import random 2 | from functools import partial 3 | 4 | from match_variant._maybe import Maybe 5 | 6 | 7 | def get_a_maybe(): 8 | match random.randint(0, 1): 9 | case 0: 10 | return Maybe.nothing() 11 | case 1: 12 | return Maybe.just(random.randint(0, 100)) 13 | 14 | 15 | maybe_value = get_a_maybe() 16 | 17 | match maybe_value: 18 | case Maybe.nothing(): 19 | print("I don't feel like guessing") 20 | case Maybe.just(value): 21 | print(f"I guess {value}") 22 | 23 | 24 | try: 25 | print(maybe_value.unwrap()) 26 | except TypeError: 27 | print("Oops") 28 | 29 | print(maybe_value.unwrap(default="BOO!")) 30 | 31 | match maybe_value.apply(lambda d: d**2).apply(partial(int.__add__, 2)): 32 | case Maybe.just(value): 33 | print(f"Squared plus two: {value}") 34 | case Maybe.nothing(): 35 | print("got nothing to math on") 36 | -------------------------------------------------------------------------------- /examples/roles.py: -------------------------------------------------------------------------------- 1 | from match_variant import Variant 2 | 3 | 4 | class Role(Variant): 5 | anonymous: () 6 | unauthenticated: (str, str) 7 | normal: (str,) 8 | admin: ( 9 | str, 10 | dict[str, bool], 11 | ) 12 | 13 | 14 | anon = Role.anonymous() 15 | needs_to_login = Role.unauthenticated("chris", "bad password") 16 | logged_in = Role.normal("jessie") 17 | super = Role.admin("morgan", {"can_edit": True}) 18 | viewer = Role.admin("alex", {"can_edit": False}) 19 | 20 | 21 | for user in anon, needs_to_login, logged_in, super, viewer: 22 | match user: 23 | case Role.anonymous(): 24 | print("User has not provided credentials") 25 | case Role.unauthenticated(name, pw): 26 | print(f"User {name} needs to log in with {pw}") 27 | case Role.normal(name): 28 | print(f"User {name} is logged in") 29 | case Role.admin(name, {"can_edit": editable}) if editable: 30 | print(f"User {name} can edit") 31 | case Role.admin(name, {"can_edit": editable}) if not editable: 32 | print(f"User {name} can view but not edit") 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Brett Cannon 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 | -------------------------------------------------------------------------------- /src/match_variant/_enum.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from functools import lru_cache 3 | from typing import Any 4 | 5 | from ._maybe import Maybe 6 | from ._variant import Variant 7 | 8 | 9 | class Enum(Variant): 10 | """Variants can model simple enums. 11 | 12 | The Variant class stores any default value assigned to a 13 | field in the special __value__ field. This class has an 14 | lru_cached `from_value` class function to look up the field 15 | given a value. 16 | 17 | Example: 18 | 19 | class HttpStatus(Enum): 20 | ok: () = 200 21 | not_found: () = 404 22 | 23 | match HttpStatus.from_value(200): 24 | case HttpStatus.ok(): 25 | print("got a success response") 26 | case HttpStatus.not_found(): 27 | print("it's not anywhere") 28 | case _: 29 | print("Unexpected status") 30 | """ 31 | 32 | @classmethod 33 | @lru_cache 34 | def from_value(cls, value: Any) -> Maybe: 35 | """ 36 | Given a value, return the variant associated with 37 | that field. 38 | 39 | Returns a Maybe that is set to Maybe.nothing if the 40 | field is not set. 41 | """ 42 | last_class = None 43 | for parent_class in cls.__mro__: 44 | if parent_class == Enum: 45 | break 46 | last_class = parent_class 47 | 48 | for field_name in inspect.get_annotations(last_class): 49 | field = getattr(last_class, field_name) 50 | if hasattr(field, "__value__") and field.__value__ == value: 51 | return Maybe.just(field()) 52 | 53 | return Maybe.nothing() 54 | -------------------------------------------------------------------------------- /tests/test_result.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from match_variant import Result, Trapped, trap 4 | 5 | 6 | def test_apply_error(): 7 | result = Result.error(ValueError("nope")) 8 | 9 | def no_call_me(): 10 | pytest.fail("I should not be called") 11 | 12 | assert result.apply(no_call_me) is result 13 | 14 | 15 | def test_apply_ok(): 16 | val = "I am a value" 17 | result = Result.ok(val) 18 | assert result.apply(str.upper).unwrap() == val.upper() 19 | 20 | 21 | def test_unwrap_ok(): 22 | val = "I am a value" 23 | result = Result.ok(val) 24 | assert result.unwrap() is val 25 | 26 | 27 | def test_unwrap_error_raises(): 28 | my_exception = ValueError("Oops") 29 | with pytest.raises(ValueError) as ex: 30 | Result.error(my_exception).unwrap() 31 | assert ex.value is my_exception 32 | 33 | 34 | def test_trapped_ok(): 35 | val = "some value" 36 | t = Trapped() 37 | t.ok(val) 38 | assert t.result == Result.ok(val) 39 | assert t.result.unwrap() is val 40 | 41 | 42 | def test_trapped_error(): 43 | val = ValueError("some value") 44 | t = Trapped() 45 | t.error(val) 46 | assert t.result == Result.error(val) 47 | with pytest.raises(ValueError) as ex: 48 | t.result.unwrap() 49 | assert ex.value is val 50 | 51 | 52 | def test_trap_ok(): 53 | value = "Hello" 54 | with trap(ValueError) as result: 55 | result.ok(value) 56 | 57 | assert result.result == Result.ok(value) 58 | assert result.result.unwrap() is value 59 | 60 | 61 | def test_trap_exception(): 62 | value = ValueError("Nope") 63 | with trap(ValueError) as result: 64 | raise value 65 | 66 | assert result.result == Result.error(value) 67 | 68 | 69 | def test_trap_exception_multiple(): 70 | value = ValueError("Nope") 71 | with trap(ValueError, KeyError) as result: 72 | raise value 73 | 74 | assert result.result == Result.error(value) 75 | 76 | 77 | def test_trap_no_ok_call(): 78 | with trap(ValueError) as result: 79 | pass 80 | 81 | with pytest.raises(TypeError) as ex: 82 | result.result.unwrap() 83 | 84 | assert "never" in str(ex.value) 85 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /src/match_variant/_variant.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import types 3 | from contextlib import suppress 4 | from typing import Any, NoReturn, Type 5 | 6 | 7 | class VariantMeta(type): 8 | """Placeholder for when we add metaclass features""" 9 | 10 | pass 11 | 12 | 13 | class Variant(metaclass=VariantMeta): 14 | def __init_subclass__(cls) -> None: 15 | """Replace all fields with variants based on the field type annotations""" 16 | annotations = inspect.get_annotations(cls, eval_str=True) 17 | for name, annotation in annotations.items(): 18 | nc: Type[cls] = types.new_class(name, bases=(cls,)) 19 | 20 | nc.__qualname__ = f"{cls.__qualname__}.{name}" 21 | nc.__match_args__: tuple[str, ...] = tuple( 22 | (f"_{index}" for index in range(len(annotation))) 23 | ) 24 | with suppress(AttributeError): 25 | nc.__value__ = getattr(cls, name) 26 | setattr(cls, name, nc) 27 | 28 | def __init__(self, *args: Any) -> None: 29 | """Construct a Variant, ensuring the correct number of args are supplied""" 30 | if len(args) != len(self.__match_args__): 31 | raise TypeError( 32 | f"{type(self).__qualname__}() takes exactly {len(self.__match_args__)} arguments ({len(args)} given)" 33 | ) 34 | 35 | for index, arg in enumerate(args): 36 | setattr(self, f"_{index}", arg) 37 | 38 | def __repr__(self): 39 | """Print a representation of the arguments for the subtype""" 40 | params = ", ".join(repr(getattr(self, arg)) for arg in self.__match_args__) 41 | return f"{type(self).__qualname__}({params})" 42 | 43 | def __eq__(self, other): 44 | """If the instances are the same variant, check they have the same arguments.""" 45 | 46 | if type(self) != type(other): 47 | return NotImplemented 48 | 49 | return all( 50 | getattr(self, arg) == getattr(other, arg) for arg in self.__match_args__ 51 | ) 52 | 53 | def __hash__(self): 54 | """ADT is expected to be immutable, so it can hash.""" 55 | 56 | return hash(tuple(getattr(self, arg) for arg in self.__match_args__)) 57 | 58 | @classmethod 59 | def exhaust(cls, value: NoReturn) -> NoReturn: 60 | """Instruct type checkers to check variant types exhaustively. 61 | 62 | Note, this relies on Typecheckers special casing Variant as they 63 | have done with Enum. 64 | 65 | Example: 66 | 67 | match Maybe.just("Value"): 68 | case Maybe.just(val) 69 | print(f"Just {val}") 70 | case Maybe.nothing: 71 | print("nothing") 72 | case _ as x: 73 | Maybe.exhaust(x) 74 | """ 75 | raise ValueError(f"Unsupported match arm: {value}") 76 | -------------------------------------------------------------------------------- /src/match_variant/_maybe.py: -------------------------------------------------------------------------------- 1 | # type: ignore 2 | from __future__ import annotations 3 | 4 | from typing import Callable, Generic, TypeVar, final 5 | 6 | from ._variant import Variant 7 | 8 | T = TypeVar("T") 9 | U = TypeVar("U") 10 | _RAISE = object() 11 | 12 | 13 | @final 14 | class Maybe(Generic[T], Variant): 15 | """Represent an optional value. 16 | 17 | This is a Variant type that can be either `just(something)` or `nothing`. 18 | 19 | When used with a typechecker, can eliminate bugs caused by missed `is None` 20 | checks. 21 | """ 22 | 23 | just: (T,) 24 | nothing: () 25 | 26 | def apply(self, func: Callable[[T], U]) -> Maybe[U]: 27 | """Apply a function to the contained value. 28 | 29 | If this is a `Maybe.just` variant, return a `Maybe.just` with the given function 30 | applied to the value inside the `Maybe.just`. Otherwise, return `Maybe.nothing` 31 | unchanged. 32 | 33 | Example: 34 | 35 | >>> from basicenum.maybe import Maybe 36 | >>> Maybe.just("hello").apply(str.upper) 37 | 38 | >>> 39 | >>> Maybe.nothing().apply(str.upper) 40 | 41 | >>> 42 | """ 43 | match self: 44 | case Maybe.just(val): 45 | return Maybe.just(func(val)) 46 | case Maybe.nothing(): 47 | return self 48 | 49 | def filter(self, func: Callable[[T], bool]) -> Maybe[T]: 50 | """Filter the contained value. 51 | 52 | If this is a `Maybe.just` variant and the given function returns `True`, return a `Maybe.just` with the unmodified value. 53 | Otherwise, return `Maybe.nothing`. 54 | 55 | Example: 56 | 57 | >>> from basicenum.maybe import Maybe 58 | >>> Maybe.just("hello").filter(lambda x: len(x) > 3) 59 | 60 | >>> 61 | >>> Maybe.just("abc").filter(lambda x: len(x) > 3) 62 | 63 | >>> 64 | >>> Maybe.nothing().filter(lambda x: len(x) > 3) 65 | 66 | """ 67 | match self: 68 | case Maybe.just(val): 69 | if func(val): 70 | return self 71 | return Maybe.nothing() 72 | case Maybe.nothing(): 73 | return self 74 | 75 | def unwrap(self, *, default: T = _RAISE) -> T: 76 | """ 77 | Access the value inside the Maybe.just. 78 | 79 | If this is a Maybe.just value, return the contents of the unwrapped object 80 | 81 | If it is Maybe.nothing and `default` is not provided, this will raise a `TypeError`. 82 | If it is Maybe.nothing and `default` is provided, it will return the provided default value 83 | 84 | Example: 85 | 86 | >>> from basicenum.maybe import Maybe 87 | >>> Maybe.just("hello").unwrap() 88 | 'hello' 89 | >>> Maybe.nothing().unwrap(default="default") 90 | 'default' 91 | >>> Maybe.nothing().unwrap() 92 | Traceback (most recent call last): 93 | File "", line 1, in 94 | File "/Users/dustyphillips/Desktop/Code/basicenum/src/basicenum/maybe.py", line 62, in unwrap 95 | raise TypeError( 96 | TypeError: Attempted to unwrap Maybe.nothing(); can only unwrap Maybe.just(val) 97 | >>> 98 | """ 99 | match self: 100 | case Maybe.just(val): 101 | return val 102 | case Maybe.nothing(): 103 | if default is _RAISE: 104 | raise TypeError( 105 | "Attempted to unwrap Maybe.nothing(); can only unwrap Maybe.just(val)" 106 | ) 107 | return default 108 | 109 | def __iter__(self) -> None: 110 | """Iterate over the Maybe. 111 | 112 | If this is a Maybe.just value, yield the value 113 | 114 | If it is Maybe.nothing, do nothing. 115 | 116 | Example: 117 | 118 | >>> from basicenum.maybe import Maybe 119 | >>> for val in Maybe.just("hello"): print(val) 120 | hello 121 | >>> for val in Maybe.nothing(): print(val) 122 | >>> 123 | """ 124 | match self: 125 | case Maybe.just(val): 126 | yield val 127 | case Maybe.nothing(): 128 | pass 129 | 130 | -------------------------------------------------------------------------------- /src/match_variant/_result.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from contextlib import contextmanager 4 | from typing import Callable, Generic, TypeVar, Union, final 5 | 6 | from ._maybe import Maybe 7 | from ._variant import Variant 8 | 9 | T = TypeVar("T") 10 | E = TypeVar("E", bound=BaseException) 11 | U = TypeVar("U") 12 | 13 | 14 | @final 15 | class Result(Generic[T, E], Variant): 16 | """Model a potential Result. 17 | 18 | Provides an alternative to exception-based python. Used with 19 | the trap context manager, converts exceptions to Result objects 20 | that can be captured and evaluated using match. 21 | """ 22 | 23 | ok: (T,) # type: ignore 24 | error: (E,) # type: ignore 25 | 26 | def apply(self, func: Callable[[T], U]) -> Result[U, E]: 27 | """Apply a function to the contained value. 28 | 29 | If this is a `Result.ok` variant, return a `Result.ok` with the given function 30 | applied to the value inside the `Result.ok`. Otherwise, return `Result.error` 31 | unchanged. 32 | 33 | Example: 34 | 35 | >>> from basicenum.result import Result 36 | >>> Result.ok("hello").apply(str.upper) 37 | 38 | >>> 39 | >>> Result.error(ValueError("oops")).apply(str.upper) 40 | 41 | >>> 42 | """ 43 | match self: 44 | case Result.ok(val): 45 | return Result.ok(func(val)) 46 | case Result.error(_): 47 | return self 48 | 49 | def unwrap(self) -> T: 50 | """ 51 | Access the value inside the Result.ok.just. 52 | 53 | If this is a Result.ok value, return the contents of the unwrapped object 54 | 55 | If it is Result.error, raise the error. 56 | 57 | Example: 58 | 59 | >>> from basicenum.result import Result 60 | >>> Result.ok("hello").unwrap() 61 | 'hello' 62 | >>> Result.error(ValueError("oops")).unwrap() 63 | Traceback (most recent call last): 64 | File "", line 1, in 65 | File "__main__", line 71, in unwrap 66 | raise ex 67 | ValueError: oops 68 | """ 69 | match self: 70 | case Result.ok(val): 71 | return val 72 | case Result.error(ex): 73 | raise ex 74 | 75 | def to_maybe(self) -> Maybe[T]: 76 | """Convert the result to an option, discarding the error if any""" 77 | match self: 78 | case Result.ok(value): 79 | return Maybe.just(value) 80 | case Result.error(_): 81 | return Maybe.nothing() 82 | 83 | 84 | class Trapped(Generic[T, E]): 85 | """Capture a trapped exception or value result. 86 | 87 | See `trap`. 88 | """ 89 | 90 | value: Union[Result[T, E], None] = None 91 | 92 | def ok(self, value: T): 93 | """Set the trapped result to an ok value.""" 94 | self.value = Result.ok(value) 95 | 96 | def error(self, value: E): 97 | """Set the trapped result to an error value. 98 | 99 | Typically called automaticaly by the `trap` contextmanager. 100 | """ 101 | self.value = Result.error(value) 102 | 103 | @property 104 | def result(self) -> Result[T, E]: 105 | """Return the result that was set inside the with statement. 106 | 107 | If an exception was trapped, the Result will be an error. 108 | 109 | If no ok was set inside the with statement, raises TypeError. 110 | """ 111 | if self.value is None: 112 | return Result.error(TypeError("Trapped.ok was never called")) 113 | 114 | return self.value 115 | 116 | 117 | @contextmanager 118 | def trap(*exceptions): 119 | """ 120 | Run a section of code and convert it to a Result type. 121 | 122 | Yields a Trapped object. Typical usage as follows: 123 | 124 | d = {"key": "value"} 125 | with trap(KeyError) as trapped: 126 | trapped.ok(d["key"]) 127 | 128 | match trapped.result: 129 | case Result.ok(val): 130 | print(f"Got a value: {val}") 131 | case Result.error(val): 132 | print(f"Got an error: {val}") 133 | case _ as x: 134 | Result.exhaust(x) 135 | """ 136 | if not (exceptions): 137 | exceptions = (Exception,) 138 | 139 | trapped = Trapped() 140 | try: 141 | yield trapped 142 | except exceptions as ex: 143 | trapped.error(ex) 144 | -------------------------------------------------------------------------------- /tests/test_variant.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from match_variant import Variant 4 | 5 | 6 | class TestVariant(Variant): 7 | option0: () # type: ignore 8 | option1: (str,) # type: ignore 9 | option2: (str, int) # type: ignore 10 | option_list: (list[str],) # type: ignore 11 | 12 | 13 | def test_too_many_args(): 14 | with pytest.raises(TypeError) as ex: 15 | b = TestVariant.option1("Too", "many", "args") 16 | 17 | ex_str = str(ex.value) 18 | assert "1" in ex_str 19 | assert "3" in ex_str 20 | 21 | 22 | def test_adt(): 23 | assert TestVariant.option1.__qualname__ == "TestVariant.option1" 24 | 25 | 26 | def test_match_args(): 27 | assert TestVariant.option0().__match_args__ == () 28 | assert TestVariant.option1("one").__match_args__ == ("_0",) 29 | assert TestVariant.option2("one", 2).__match_args__ == ("_0", "_1") 30 | 31 | 32 | def test_match_no_args(): 33 | match TestVariant.option0(): 34 | case TestVariant.option0(): 35 | pass 36 | case _: 37 | pytest.fail("Should be option0") 38 | 39 | 40 | def test_match_one_arg(): 41 | match TestVariant.option1("boo"): 42 | case TestVariant.option1(val): 43 | assert val == "boo" 44 | case _: 45 | pytest.fail("Should be option1") 46 | 47 | 48 | def test_match_two_args(): 49 | match TestVariant.option2("boo", 2): 50 | case TestVariant.option2(val, val2): 51 | assert val == "boo" 52 | assert val2 == 2 53 | case _: 54 | pytest.fail("Should be option2") 55 | 56 | 57 | def test_repr(): 58 | o = TestVariant.option0() 59 | assert repr(o) == "TestVariant.option0()" 60 | o = TestVariant.option1("one") 61 | assert repr(o) == "TestVariant.option1('one')" 62 | o = TestVariant.option2("one", 2) 63 | assert repr(o) == "TestVariant.option2('one', 2)" 64 | 65 | 66 | @pytest.mark.parametrize( 67 | ["self", "other", "expected"], 68 | [ 69 | pytest.param( 70 | TestVariant.option_list([]), 71 | TestVariant.option_list([]), 72 | True, 73 | id="equal options", 74 | ), 75 | pytest.param( 76 | TestVariant.option0(), TestVariant.option0(), True, id="equal option0" 77 | ), 78 | pytest.param( 79 | TestVariant.option_list([]), 80 | TestVariant.option_list(["something"]), 81 | False, 82 | id="equal variant, unequal value", 83 | ), 84 | pytest.param( 85 | TestVariant.option_list([]), 86 | TestVariant.option0(), 87 | False, 88 | id="left option_list right option0", 89 | ), 90 | pytest.param( 91 | TestVariant.option0(), 92 | TestVariant.option_list([]), 93 | False, 94 | id="right option_list left option0", 95 | ), 96 | pytest.param( 97 | TestVariant.option_list([]), "a string", False, id="different type" 98 | ), 99 | ], 100 | ) 101 | def test_is_eq(self, other, expected): 102 | assert (self == other) is expected 103 | 104 | 105 | def test_hash_identical_val_hashable(): 106 | val = "something" 107 | assert hash(TestVariant.option1(val)) == hash(TestVariant.option1(val)) 108 | 109 | 110 | def test_hash_different_equal_object_values(): 111 | assert hash(TestVariant.option1(("one",))) == hash(TestVariant.option1(("one",))) 112 | 113 | 114 | def test_hashed_same_variant_different_value(): 115 | assert hash(TestVariant.option1("one")) != hash(TestVariant.option1("two")) 116 | 117 | 118 | def test_hash_no_args_is_hashable(): 119 | assert hash(TestVariant.option0()) == hash(TestVariant.option0()) 120 | 121 | 122 | def test_hash_multi_args_is_hashable(): 123 | assert hash(TestVariant.option2("one", 2)) == hash(TestVariant.option2("one", 2)) 124 | 125 | 126 | def test_hash_unhashable_value_fails(): 127 | with pytest.raises(TypeError) as ex: 128 | hash(TestVariant.option1([])) 129 | 130 | assert "unhashable" in str(ex.value) 131 | 132 | 133 | def test_can_add_hashable_to_set(): 134 | assert {TestVariant.option1("one"), TestVariant.option1("one")} == { 135 | TestVariant.option1("one") 136 | } 137 | assert {TestVariant.option0(), TestVariant.option0()} == {TestVariant.option0()} 138 | 139 | 140 | @pytest.mark.parametrize( 141 | ["self", "cls", "expected"], 142 | [ 143 | pytest.param( 144 | TestVariant.option0(), TestVariant.option0, True, id="noargs is class" 145 | ), 146 | pytest.param( 147 | TestVariant.option1("blah"), 148 | TestVariant.option1, 149 | True, 150 | id="withargs is class", 151 | ), 152 | pytest.param( 153 | TestVariant.option0(), 154 | TestVariant.option1, 155 | False, 156 | id="noargs is not with args", 157 | ), 158 | pytest.param(TestVariant.option0(), TestVariant, True, id="noargs is class"), 159 | ], 160 | ) 161 | def test_instance_check(self, cls, expected): 162 | assert isinstance(self, cls) == expected 163 | 164 | 165 | def test_exhaust(): 166 | with pytest.raises(ValueError) as ex: 167 | match TestVariant.option1("Value"): 168 | case TestVariant.option0: 169 | pytest.fail("Should not match nothing on a value") 170 | case _ as x: 171 | TestVariant.exhaust(x) 172 | 173 | ex_str = str(ex.value) 174 | assert "TestVariant.option1" in ex_str 175 | assert "Value" in ex_str 176 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # match-variant 2 | 3 | Variant algebraic datatypes that work with the Python 3.10 `match` statement. 4 | 5 | Python's `match` statement for pattern matching is a delightful innovation, 6 | but it doesn't have the power of similar statements in functional progamming 7 | languages due to Python's lack of a Variant datatype. This package brings 8 | Variant types to the Python language. 9 | 10 | If you are unfamiliar variant types, they are all about representing "this or that" 11 | structures that can be statically analyzed. Common examples include optional 12 | types ("just a value or no value"), result types ("successful value or error 13 | value"), or authentication roles ("anonymous user or normal user or superuser"). 14 | 15 | It may be helpful to think of variants as an Enum where each value can hold 16 | structured data and each type can have a different structure. 17 | 18 | ## Quick example 19 | 20 | Consider a simplification of the `Maybe` type that ships with this package: 21 | 22 | ```python 23 | @final 24 | class Maybe(Generic[T], Variant): 25 | just: (T,) 26 | nothing: () 27 | ``` 28 | 29 | We'll talk more about the specifics of `Maybe` later; for now know that this 30 | class represents an optional value that can be fully typechecked (once 31 | typecheckers catch up). Any one instance of this either has a value, identified 32 | by `just` or no value identified by `maybe` and can be easily tested with the 33 | `match` statement: 34 | 35 | ```python 36 | match get_a_maybe_from_somewhere(): 37 | case Maybe.just(value): 38 | print(f"I got a legitimate {value}") 39 | case Maybe.nothing(): 40 | print("Sorry, I didn't get anything") 41 | ``` 42 | 43 | ## Variant 44 | 45 | The meat of this package is the `Variant` class. Subclass it to create your own 46 | custom variants. Each field on the class must have a type annotation that is a tuple 47 | of the types that variant expects: 48 | 49 | ```python 50 | from match_variant import Variant 51 | 52 | class Role(Variant): 53 | anonymous: () 54 | unauthenticated: (str, str) 55 | normal: (str,) 56 | admin: (str, dict[str, bool],) 57 | ``` 58 | 59 | Any one user can be in exactly one of these four roles. With Python's robust 60 | structured pattern matching, your code can match on it to determine which 61 | role is currently in use, capturing or guarding patterns to adjust the behaviour: 62 | 63 | ```python 64 | class Role(Variant): 65 | anonymous: () 66 | unauthenticated: (str, str) 67 | normal: (str,) 68 | admin: (str, dict[str, bool],) 69 | ``` 70 | 71 | ### Case exhaustion 72 | 73 | Type checkers do not know about this code yet, but we are assuming 74 | they will special-case `Variant`s the same way they do with `enum` 75 | from the standard library. To help them in the future to know that 76 | case exhaustion is desired, call the `exhaust` method in any `Variant` 77 | class: 78 | 79 | ```python 80 | # This "should" fail type checking because not all roles were tested. 81 | match user: 82 | case Role.anonymous(): 83 | print("we only handled anonymous") 84 | case _: 85 | Role.exhaust(user) 86 | ``` 87 | As well as failing static analysis (someday), the `exhaust` method will 88 | raise `ValueError` at runtime if it is called. 89 | 90 | ## `Variant` instances we ship 91 | 92 | We ship a few common variant classes partially as a demo of this 93 | functionality and partially as a convenience for very common 94 | cases. 95 | 96 | ### The `Maybe` Type 97 | 98 | Null, or `None` in Python, has been described as the billion dollar 99 | mistake and current sentiment seems to be that it should be 100 | avoided in favour of optional types. Well, here's your optional 101 | type! 102 | 103 | The maybe class has two variants: `just` and `nothing`, which 104 | represent either a generic value or no value. It also contains a 105 | couple helper functions (we are open to adding others; submit a PR 106 | or issue) to transform or extract the value. 107 | 108 | #### Constructing `Maybe` 109 | 110 | Just use one of the two class constructors defined as attributes 111 | on the `Maybe` class: 112 | 113 | ```python 114 | import random 115 | from match_variant.maybe import Maybe 116 | 117 | def get_a_maybe(): 118 | match random.randint(0, 1): 119 | case 0: 120 | return Maybe.nothing() 121 | case 1: 122 | return Maybe.just("some value") 123 | ``` 124 | 125 | #### Matching on `Maybe` 126 | 127 | Works as expected: 128 | 129 | ```python 130 | match get_a_maybe(): 131 | case Maybe.nothing(): 132 | print("I don't feel like guessing") 133 | case Maybe.just(value): 134 | print(f"I guess {value}") 135 | ``` 136 | 137 | **Gotcha alert:** You need to supply empty parens when 138 | instantiating or matching a Variant that has no value. 139 | 140 | #### Unwrapping a `Maybe` 141 | 142 | For convenience, you can extract the value inside a `Maybe.just` 143 | without a `match` statement. A `TypeError` will be raised if it 144 | receives a `Maybe.nothing` instance: 145 | 146 | ```python 147 | >>> get_a_maybe().unwrap() 148 | 2 149 | >>> get_a_maybe().unwrap() 150 | Traceback (most recent call last): 151 | File "", line 1, in 152 | File "maybe.py", line 77, in unwrap 153 | raise TypeError( 154 | TypeError: Attempted to unwrap Maybe.nothing(); can only unwrap Maybe.just(val) 155 | ``` 156 | 157 | If you don't want an exception, you can supply a default value as a *keyword* argument: 158 | 159 | ```python 160 | get_a_maybe().unwrap(default="BOO!") 161 | ``` 162 | 163 | #### Applying a function to a Maybe 164 | 165 | The `Maybe.apply` function can be used to perform an operation on 166 | the value inside a `Maybe` *if the value is a `Maybe.just`*. If 167 | the value is nothing, then no work is performed. This can lead to 168 | some interesting function chaining applications. 169 | 170 | `Maybe.apply` accepts a single argument: a function or callable. The callable accepts the argument inside the `Maybe.just` and is only called if the `Maybe` is an instance of the `Maybe.just` variant: 171 | 172 | ```python 173 | match maybe_value \ 174 | .apply(lambda d: d ** 2) \ 175 | .apply(partial(int.__add__, 2)): 176 | case Maybe.just(value): 177 | print(f"Squared plus two: {value}") 178 | case Maybe.nothing(): 179 | print("got nothing to math on") 180 | ``` 181 | 182 | ### The `Result` Type 183 | 184 | The `Result` type is similar to `Maybe`, but allows an exception to 185 | be attached to an error variant. A context manager is supplied to 186 | automatically convert exceptions to results. 187 | 188 | The benefit (and drawback) of `Result` is that it forces calling 189 | code to either handle or return the `Result`, whereas there is no 190 | type-safe way to specify that a function will or will not throw 191 | a specific exception. 192 | 193 | Typical usage is with the `trap` context manager: 194 | 195 | ```python 196 | import random 197 | from match_variant import trap, Result 198 | 199 | 200 | with trap(ZeroDivisionError) as trapped: 201 | i = random.randint(0, 4) 202 | trapped.ok(1 / i) 203 | 204 | # Typically `trapped` would be returned in a function. 205 | print(trapped.result) 206 | ``` 207 | 208 | `Result`s can be matched on: 209 | 210 | ```python 211 | match result: 212 | case Result.ok(value): 213 | print(f"got {value}") 214 | case Result.error(_): 215 | print("Something went wrong") 216 | ``` 217 | 218 | `Result` has `apply` and `unwrap` methods similar to `Maybe`: 219 | 220 | 221 | ```python 222 | print(result.apply(math.sqrt).unwrap()) 223 | ``` 224 | 225 | Unlike `Maybe`, `Result.unwrap` does not accept a default argument. If you try 226 | to unwrap a `Result.error`, the original exception is raised. 227 | 228 | Convert a `Result` to a `Maybe` using `Result.to_maybe`: 229 | 230 | ```python 231 | print(result.to_maybe()) 232 | ``` 233 | 234 | ### The `Enum` Type 235 | 236 | You can supply variant fields with a default value, which will be 237 | made available on the `__value__` field for the variant to use ase 238 | you like. One option is to use it as a better-performing 239 | replacement for the `enum` module. As a convenience, we supply the 240 | `Enum` class to work more easily with these types. 241 | 242 | Consider an example `HttpStatus` class: 243 | 244 | ```python 245 | class HttpStatus(Enum): 246 | ok: () = 200 247 | not_found: () = 404 248 | ``` 249 | 250 | `Enum` provides a `from_value` class method to convert values to 251 | instances. Because not all possible values can return an instance, 252 | this function returns a `Maybe`. This works beautifully with the `match` statement's structured typing: 253 | 254 | ```python 255 | for value in (200, 404, 600): 256 | match HttpStatus.from_value(value): 257 | case Maybe.just(HttpStatus.ok()): 258 | print(f"Request was successful") 259 | case Maybe.just(HttpStatus.not_found()): 260 | print("Request was not found") 261 | case Maybe.just(_): 262 | print(f"Unexpected status code: {value}") 263 | case Maybe.nothing(): 264 | print(f"No idea what we got here") 265 | ``` 266 | 267 | # Contributing 268 | 269 | [PRs](https://github.com/dusty-phillips/match-variant/pulls) are more than welcome. 270 | --------------------------------------------------------------------------------