├── .github ├── dependabot.yml └── workflows │ └── ci.yml ├── .gitignore ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.md ├── RELEASING.md ├── codecov.yml ├── docs ├── .pages ├── README.md └── maybe.md ├── pyproject.toml ├── requirements-dev.txt ├── setup.cfg ├── setup.py ├── src └── maybe │ ├── __init__.py │ ├── maybe.py │ └── py.typed ├── tests ├── test_maybe.py ├── test_pattern_matching.py └── type-checking │ └── test_maybe.yml └── tox.ini /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # See https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 2 | version: 2 3 | updates: 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: "monthly" 8 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | --- 2 | on: 3 | push: 4 | branches: 5 | - master 6 | pull_request: 7 | 8 | name: CI 9 | 10 | jobs: 11 | test: 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | python: 16 | - version: '3.12' 17 | - version: '3.11' 18 | - version: '3.10' 19 | - version: '3.9' 20 | exclude-pattern-matching: true 21 | - version: '3.8' 22 | exclude-pattern-matching: true 23 | name: Python ${{ matrix.python.version }} 24 | steps: 25 | # Check out code 26 | - uses: actions/checkout@v4 27 | 28 | # Python 29 | - name: Setup python ${{ matrix.python.version }} 30 | uses: actions/setup-python@v5 31 | with: 32 | python-version: ${{ matrix.python.version }} 33 | cache: pip 34 | cache-dependency-path: requirements-dev.txt 35 | 36 | - name: Install dev dependencies 37 | run: pip install --root-user-action=ignore --upgrade pip --requirement requirements-dev.txt 38 | 39 | # Install library 40 | - name: Install maybe 41 | run: pip install --root-user-action=ignore --editable .[result] 42 | 43 | # Tests 44 | - name: Run tests (excluding pattern matching) 45 | if: ${{ matrix.python.exclude-pattern-matching }} 46 | run: pytest --ignore=tests/test_pattern_matching.py 47 | - name: Run tests (including pattern matching) 48 | if: ${{ ! matrix.python.exclude-pattern-matching }} 49 | run: pytest 50 | 51 | # Linters 52 | - name: Run flake8 (excluding pattern matching) 53 | if: ${{ matrix.python.exclude-pattern-matching }} 54 | run: flake8 --extend-exclude tests/test_pattern_matching.py 55 | - name: Run flake8 (including pattern matching) 56 | if: ${{ ! matrix.python.exclude-pattern-matching }} 57 | run: flake8 58 | - name: Run mypy 59 | run: mypy 60 | 61 | # Packaging 62 | - name: Build packages 63 | run: | 64 | pip install --root-user-action=ignore --upgrade build pip setuptools wheel 65 | python -m build 66 | 67 | # Coverage 68 | - name: Upload coverage to codecov.io 69 | uses: codecov/codecov-action@v4 70 | if: matrix.python == '3.9' 71 | with: 72 | token: ${{ secrets.CODECOV_TOKEN }} 73 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/python 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=python 3 | 4 | ### Python ### 5 | # Byte-compiled / optimized / DLL files 6 | __pycache__/ 7 | *.py[cod] 8 | *$py.class 9 | 10 | # Distribution / packaging 11 | .Python 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 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 | cover/ 54 | 55 | # IPython 56 | profile_default/ 57 | ipython_config.py 58 | 59 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 60 | __pypackages__/ 61 | 62 | # Environments 63 | .env 64 | .venv 65 | env/ 66 | venv/ 67 | ENV/ 68 | env.bak/ 69 | venv.bak/ 70 | 71 | # Spyder project settings 72 | .spyderproject 73 | .spyproject 74 | 75 | # Rope project settings 76 | .ropeproject 77 | 78 | # mkdocs documentation 79 | /site 80 | 81 | # mypy 82 | .mypy_cache/ 83 | .dmypy.json 84 | dmypy.json 85 | 86 | # Pyre type checker 87 | .pyre/ 88 | 89 | # pytype static type analyzer 90 | .pytype/ 91 | 92 | # Cython debug symbols 93 | cython_debug/ 94 | 95 | # PyCharm 96 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 97 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 98 | # and can be added to the global gitignore or merged into this file. For a more nuclear 99 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 100 | .idea/ 101 | 102 | ### Python Patch ### 103 | 104 | # ruff 105 | .ruff_cache/ 106 | 107 | # LSP config files 108 | pyrightconfig.json 109 | 110 | # End of https://www.toptal.com/developers/gitignore/api/python 111 | # Created by https://www.toptal.com/developers/gitignore/api/vim 112 | # Edit at https://www.toptal.com/developers/gitignore?templates=vim 113 | 114 | ### Vim ### 115 | # Swap 116 | [._]*.s[a-v][a-z] 117 | !*.svg # comment out if you don't need vector files 118 | [._]*.sw[a-p] 119 | [._]s[a-rt-v][a-z] 120 | [._]ss[a-gi-z] 121 | [._]sw[a-p] 122 | 123 | # Session 124 | Session.vim 125 | Sessionx.vim 126 | 127 | # Temporary 128 | .netrwhist 129 | *~ 130 | # Auto-generated tag files 131 | tags 132 | # Persistent undo 133 | [._]*.un~ 134 | 135 | # End of https://www.toptal.com/developers/gitignore/api/vim 136 | # Created by https://www.toptal.com/developers/gitignore/api/visualstudiocode 137 | # Edit at https://www.toptal.com/developers/gitignore?templates=visualstudiocode 138 | 139 | ### VisualStudioCode ### 140 | .vscode/ 141 | # !.vscode/settings.json 142 | # !.vscode/tasks.json 143 | # !.vscode/launch.json 144 | # !.vscode/extensions.json 145 | # !.vscode/*.code-snippets 146 | 147 | # Local History for Visual Studio Code 148 | .history/ 149 | 150 | # Built Visual Studio Code Extensions 151 | *.vsix 152 | 153 | ### VisualStudioCode Patch ### 154 | # Ignore all local history of files 155 | .history 156 | .ionide 157 | 158 | # End of https://www.toptal.com/developers/gitignore/api/visualstudiocode 159 | # Created by https://www.toptal.com/developers/gitignore/api/jetbrains 160 | # Edit at https://www.toptal.com/developers/gitignore?templates=jetbrains 161 | 162 | ### JetBrains ### 163 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 164 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 165 | 166 | # User-specific stuff 167 | .idea/**/workspace.xml 168 | .idea/**/tasks.xml 169 | .idea/**/usage.statistics.xml 170 | .idea/**/dictionaries 171 | .idea/**/shelf 172 | 173 | # AWS User-specific 174 | .idea/**/aws.xml 175 | 176 | # Generated files 177 | .idea/**/contentModel.xml 178 | 179 | # Sensitive or high-churn files 180 | .idea/**/dataSources/ 181 | .idea/**/dataSources.ids 182 | .idea/**/dataSources.local.xml 183 | .idea/**/sqlDataSources.xml 184 | .idea/**/dynamic.xml 185 | .idea/**/uiDesigner.xml 186 | .idea/**/dbnavigator.xml 187 | 188 | # Gradle 189 | .idea/**/gradle.xml 190 | .idea/**/libraries 191 | 192 | # Gradle and Maven with auto-import 193 | # When using Gradle or Maven with auto-import, you should exclude module files, 194 | # since they will be recreated, and may cause churn. Uncomment if using 195 | # auto-import. 196 | # .idea/artifacts 197 | # .idea/compiler.xml 198 | # .idea/jarRepositories.xml 199 | # .idea/modules.xml 200 | # .idea/*.iml 201 | # .idea/modules 202 | # *.iml 203 | # *.ipr 204 | 205 | # CMake 206 | cmake-build-*/ 207 | 208 | # Mongo Explorer plugin 209 | .idea/**/mongoSettings.xml 210 | 211 | # File-based project format 212 | *.iws 213 | 214 | # IntelliJ 215 | out/ 216 | 217 | # mpeltonen/sbt-idea plugin 218 | .idea_modules/ 219 | 220 | # JIRA plugin 221 | atlassian-ide-plugin.xml 222 | 223 | # Cursive Clojure plugin 224 | .idea/replstate.xml 225 | 226 | # SonarLint plugin 227 | .idea/sonarlint/ 228 | 229 | # Crashlytics plugin (for Android Studio and IntelliJ) 230 | com_crashlytics_export_strings.xml 231 | crashlytics.properties 232 | crashlytics-build.properties 233 | fabric.properties 234 | 235 | # Editor-based Rest Client 236 | .idea/httpRequests 237 | 238 | # Android studio 3.1+ serialized cache file 239 | .idea/caches/build_file_checksums.ser 240 | 241 | ### JetBrains Patch ### 242 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 243 | 244 | # *.iml 245 | # modules.xml 246 | # .idea/misc.xml 247 | # *.ipr 248 | 249 | # Sonarlint plugin 250 | # https://plugins.jetbrains.com/plugin/7973-sonarlint 251 | .idea/**/sonarlint/ 252 | 253 | # SonarQube Plugin 254 | # https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin 255 | .idea/**/sonarIssues.xml 256 | 257 | # Markdown Navigator plugin 258 | # https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced 259 | .idea/**/markdown-navigator.xml 260 | .idea/**/markdown-navigator-enh.xml 261 | .idea/**/markdown-navigator/ 262 | 263 | # Cache file creation bug 264 | # See https://youtrack.jetbrains.com/issue/JBR-2257 265 | .idea/$CACHE_FILE$ 266 | 267 | # CodeStream plugin 268 | # https://plugins.jetbrains.com/plugin/12206-codestream 269 | .idea/codestream.xml 270 | 271 | # Azure Toolkit for IntelliJ plugin 272 | # https://plugins.jetbrains.com/plugin/8053-azure-toolkit-for-intellij 273 | .idea/**/azureSettings.xml 274 | 275 | # End of https://www.toptal.com/developers/gitignore/api/jetbrains 276 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (C) 2023 rustedpy maintainers and contributors 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | of the Software, and to permit persons to whom the Software is furnished to do 8 | so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include src/maybe/py.typed 2 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # phony trick from https://keleshev.com/my-book-writing-setup/ 2 | .PHONY: phony 3 | 4 | # "True" if running Python < (3, 10); "False" otherwise. 5 | PYTHON_PRE_310 := $(shell python -c "import sys; print(sys.version_info < (3, 10))") 6 | 7 | install: phony 8 | @echo Installing dependencies... 9 | python -m pip install --require-virtualenv -r requirements-dev.txt 10 | python -m pip install --require-virtualenv -e .[result] 11 | 12 | lint: phony lint-flake lint-mypy 13 | 14 | lint-flake: 15 | ifeq ($(PYTHON_PRE_310), True) 16 | @# Python <3.10 doesn't support pattern matching. 17 | flake8 --extend-exclude tests/test_pattern_matching.py 18 | else 19 | flake8 20 | endif 21 | 22 | lint-mypy: phony 23 | mypy 24 | 25 | test: phony 26 | pytest -vv 27 | 28 | docs: phony 29 | lazydocs \ 30 | --overview-file README.md \ 31 | --src-base-url https://github.com/rustedpy/maybe/blob/main/ \ 32 | ./src/maybe 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Maybe 2 | 3 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 4 | [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/rustedpy-maybe?logo=python&logoColor=white)](https://pypi.org/project/rustedpy-maybe/) 5 | [![PyPI](https://img.shields.io/pypi/v/rustedpy-maybe)](https://pypi.org/project/rustedpy-maybe/) 6 | [![GitHub Workflow Status (branch)](https://img.shields.io/github/actions/workflow/status/rustedpy/maybe/ci.yml?branch=master)](https://github.com/rustedpy/maybe/actions/workflows/ci.yml?query=branch%3Amaster) 7 | [![Checked with mypy](https://www.mypy-lang.org/static/mypy_badge.svg)](http://mypy-lang.org/) 8 | [![Coverage](https://codecov.io/gh/rustedpy/maybe/branch/master/graph/badge.svg)](https://codecov.io/gh/rustedpy/maybe) 9 | 10 | A simple Maybe (Option) type for Python 3 [inspired by Rust]( 11 | https://doc.rust-lang.org/std/option/), fully type annotated. 12 | 13 | ## Installation 14 | 15 | Latest release: 16 | 17 | ```sh 18 | pip install rustedpy-maybe 19 | ``` 20 | 21 | Latest GitHub `master` branch version: 22 | 23 | ```sh 24 | pip install git+https://github.com/rustedpy/maybe 25 | ``` 26 | 27 | There are no dependencies outside of the Python standard library. However, if 28 | you wish to use the `Result` conversion methods (see examples in the next 29 | section), you will need to install the `result` extra. 30 | 31 | In this case, rather than installing via one of the commands above, you can 32 | install the package with the `result` extra either from the latest release: 33 | 34 | ```sh 35 | pip install rustedpy-maybe[result] 36 | ``` 37 | 38 | or from the GitHub `master` branch: 39 | 40 | ```sh 41 | pip install git+https://github.com/rustedpy/maybe[result] 42 | ``` 43 | 44 | ## Summary 45 | 46 | **Experimental. API subject to change.** 47 | 48 | The idea is that a possible value can be either `Some(value)` or `Nothing()`, 49 | with a way to differentiate between the two. `Some` and `Nothing` are both 50 | classes encapsulating a possible value. 51 | 52 | Example usage: 53 | 54 | ```python 55 | from maybe import Nothing, Some 56 | 57 | o = Some('yay') 58 | n = Nothing() 59 | assert o.unwrap_or_else(str.upper) == 'yay' 60 | assert n.unwrap_or_else(lambda: 'default') == 'default' 61 | ``` 62 | 63 | There are some methods that support conversion from a `Maybe` to a `Result` type 64 | in the [result library](https://github.com/rustedpy/result/). If you wish to 65 | leverage these methods, you must install the `result` extra as described in the 66 | installation section. 67 | 68 | Example usage: 69 | 70 | ```python 71 | from maybe import Nothing, Some 72 | from result import Ok, Err 73 | 74 | o = Some('yay') 75 | n = Nothing() 76 | assert o.ok_or('error') == Ok('yay') 77 | assert o.ok_or_else(lambda: 'error') == Ok('yay') 78 | assert n.ok_or('error') == Err('error') 79 | assert n.ok_or_else(lambda: 'error') == Err('error') 80 | ``` 81 | 82 | ## Contributing 83 | 84 | These steps should work on any Unix-based system (Linux, macOS, etc) with Python 85 | and `make` installed. On Windows, you will need to refer to the Python 86 | documentation (linked below) and reference the `Makefile` for commands to run 87 | from the non-unix shell you're using on Windows. 88 | 89 | 1. Setup and activate a virtual environment. See [Python docs][pydocs-venv] for 90 | more information about virtual environments and setup. 91 | 1. Run `make install` to install dependencies 92 | 1. Switch to a new git branch and make your changes 93 | 1. Test your changes: 94 | - `make test` 95 | - `make lint` 96 | - You can also start a Python REPL and import `maybe` 97 | 1. Update documentation 98 | - Edit any relevant docstrings, markdown files 99 | - Run `make docs` 100 | 1. Add an entry to the [changelog](./CHANGELOG.md) 101 | 1. Git commit all your changes and create a new PR. 102 | 103 | [pydocs-venv]: https://docs.python.org/3/library/venv.html 104 | 105 | ## License 106 | 107 | MIT License 108 | -------------------------------------------------------------------------------- /RELEASING.md: -------------------------------------------------------------------------------- 1 | # Release process 2 | 3 | 1) Export the necessary environment variables: 4 | ``` 5 | # Examples: '0.8.0', '0.8.0rc1', '0.8.0b1' 6 | export VERSION={VERSION BEING RELEASED} 7 | 8 | # See `gpg -k` 9 | export GPG={YOUR GPG} 10 | ``` 11 | 12 | 2) Update version numbers to match the version being released: 13 | ``` 14 | vim -p src/maybe/__init__.py CHANGELOG.md 15 | ``` 16 | 17 | 3) Update diff link in CHANGELOG.md ([see example][diff-link-update-pr-example]): 18 | ``` 19 | vim CHANGELOG.md 20 | ``` 21 | 22 | 4) Do a signed commit and signed tag of the release: 23 | ``` 24 | git add src/maybe/__init__.py CHANGELOG.md 25 | git commit -S${GPG} -m "Release v${VERSION}" 26 | git tag -u ${GPG} -m "Release v${VERSION}" v${VERSION} 27 | ``` 28 | 29 | 5) Build source and binary distributions: 30 | ``` 31 | rm -rf ./dist 32 | python3 -m build 33 | ``` 34 | 35 | 6) Upload package to PyPI: 36 | ``` 37 | twine upload dist/rustedpy-maybe-${VERSION}.tar.gz dist/rustedpy_maybe-${VERSION}-*.whl 38 | git push 39 | git push --tags 40 | ``` 41 | 42 | 7) Optionally check the new version is published correctly 43 | - https://github.com/rustedpy/maybe/tags 44 | - https://pypi.org/project/maybe/#history 45 | 46 | 8) Update version number to next dev version (for example after `v0.9.0` this should be set to `0.10.0.dev0`: 47 | ``` 48 | vim -p src/maybe/__init__.py 49 | ``` 50 | 51 | [diff-link-update-pr-example]: https://github.com/rustedpy/result/pull/77/files 52 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | --- 2 | coverage: 3 | precision: 2 4 | round: nearest 5 | range: "80...100" 6 | status: 7 | project: 8 | default: 9 | target: 85% 10 | threshold: 3% 11 | comment: 12 | layout: "diff, flags, files" 13 | behavior: default 14 | require_changes: true 15 | -------------------------------------------------------------------------------- /docs/.pages: -------------------------------------------------------------------------------- 1 | title: API Reference 2 | nav: 3 | - Overview: README.md 4 | - ... 5 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # API Overview 4 | 5 | ## Modules 6 | 7 | - [`maybe`](./maybe.md#module-maybe) 8 | 9 | ## Classes 10 | 11 | - [`maybe.Nothing`](./maybe.md#class-nothing): An object that indicates no inner value is present 12 | - [`maybe.Some`](./maybe.md#class-some): An object that indicates some inner value is present 13 | - [`maybe.UnwrapError`](./maybe.md#class-unwraperror): Exception raised from ``.unwrap_<...>`` and ``.expect_<...>`` calls. 14 | 15 | ## Functions 16 | 17 | - [`maybe.is_nothing`](./maybe.md#function-is_nothing): A typeguard to check if a maybe is a `Nothing`. 18 | - [`maybe.is_some`](./maybe.md#function-is_some): A typeguard to check if a maybe is a `Some`. 19 | 20 | 21 | --- 22 | 23 | _This file was automatically generated via [lazydocs](https://github.com/ml-tooling/lazydocs)._ 24 | -------------------------------------------------------------------------------- /docs/maybe.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | # module `maybe` 6 | 7 | 8 | 9 | 10 | **Global Variables** 11 | --------------- 12 | - **SomeNothing** 13 | 14 | --- 15 | 16 | 17 | 18 | ## function `is_some` 19 | 20 | ```python 21 | is_some(maybe: 'Maybe[T]') → TypeGuard[Some[T]] 22 | ``` 23 | 24 | A typeguard to check if a maybe is a `Some`. 25 | 26 | Usage: 27 | 28 | ```plain 29 | >>> r: Maybe[int, str] = get_a_maybe() 30 | >>> if is_some(r): 31 | ... r # r is of type Some[int] 32 | ... elif is_nothing(r): 33 | ... r # r is of type Nothing[str] 34 | ``` 35 | 36 | 37 | --- 38 | 39 | 40 | 41 | ## function `is_nothing` 42 | 43 | ```python 44 | is_nothing(maybe: 'Maybe[T]') → TypeGuard[Nothing] 45 | ``` 46 | 47 | A typeguard to check if a maybe is a `Nothing`. 48 | 49 | Usage: 50 | 51 | ```plain 52 | >>> r: Maybe[int, str] = get_a_maybe() 53 | >>> if is_some(r): 54 | ... r # r is of type Some[int] 55 | ... elif is_nothing(r): 56 | ... r # r is of type Nothing[str] 57 | ``` 58 | 59 | 60 | --- 61 | 62 | 63 | 64 | ## class `Some` 65 | An object that indicates some inner value is present 66 | 67 | 68 | 69 | ### method `__init__` 70 | 71 | ```python 72 | __init__(value: 'T') → None 73 | ``` 74 | 75 | 76 | 77 | 78 | 79 | 80 | --- 81 | 82 | #### property some_value 83 | 84 | Return the inner value. 85 | 86 | 87 | 88 | --- 89 | 90 | 91 | 92 | ### method `and_then` 93 | 94 | ```python 95 | and_then(op: 'Callable[[T], Maybe[U]]') → Maybe[U] 96 | ``` 97 | 98 | There is a contained value, so return the maybe of `op` with the original value passed in 99 | 100 | --- 101 | 102 | 103 | 104 | ### method `expect` 105 | 106 | ```python 107 | expect(_message: 'str') → T 108 | ``` 109 | 110 | Return the value. 111 | 112 | --- 113 | 114 | 115 | 116 | ### method `is_nothing` 117 | 118 | ```python 119 | is_nothing() → Literal[False] 120 | ``` 121 | 122 | 123 | 124 | 125 | 126 | --- 127 | 128 | 129 | 130 | ### method `is_some` 131 | 132 | ```python 133 | is_some() → Literal[True] 134 | ``` 135 | 136 | 137 | 138 | 139 | 140 | --- 141 | 142 | 143 | 144 | ### method `map` 145 | 146 | ```python 147 | map(op: 'Callable[[T], U]') → Some[U] 148 | ``` 149 | 150 | There is a contained value, so return `Some` with original value mapped to a new value using the passed in function. 151 | 152 | --- 153 | 154 | 155 | 156 | ### method `map_or` 157 | 158 | ```python 159 | map_or(_default: 'object', op: 'Callable[[T], U]') → U 160 | ``` 161 | 162 | There is a contained value, so return the original value mapped to a new value using the passed in function. 163 | 164 | --- 165 | 166 | 167 | 168 | ### method `map_or_else` 169 | 170 | ```python 171 | map_or_else(_default_op: 'object', op: 'Callable[[T], U]') → U 172 | ``` 173 | 174 | There is a contained value, so return original value mapped to a new value using the passed in `op` function. 175 | 176 | --- 177 | 178 | 179 | 180 | ### method `ok_or` 181 | 182 | ```python 183 | ok_or(_error: 'E') → Ok[T] 184 | ``` 185 | 186 | Return a `result.Ok` with the inner value. 187 | 188 | **NOTE**: This method is available only if the `result` package is installed. 189 | 190 | --- 191 | 192 | 193 | 194 | ### method `ok_or_else` 195 | 196 | ```python 197 | ok_or_else(_op: 'Callable[[], E]') → Ok[T] 198 | ``` 199 | 200 | Return a `result.Ok` with the inner value. 201 | 202 | **NOTE**: This method is available only if the `result` package is installed. 203 | 204 | --- 205 | 206 | 207 | 208 | ### method `or_else` 209 | 210 | ```python 211 | or_else(_op: 'object') → Some[T] 212 | ``` 213 | 214 | There is a contained value, so return `Some` with the original value 215 | 216 | --- 217 | 218 | 219 | 220 | ### method `some` 221 | 222 | ```python 223 | some() → T 224 | ``` 225 | 226 | Return the value. 227 | 228 | --- 229 | 230 | 231 | 232 | ### method `unwrap` 233 | 234 | ```python 235 | unwrap() → T 236 | ``` 237 | 238 | Return the value. 239 | 240 | --- 241 | 242 | 243 | 244 | ### method `unwrap_or` 245 | 246 | ```python 247 | unwrap_or(_default: 'U') → T 248 | ``` 249 | 250 | Return the value. 251 | 252 | --- 253 | 254 | 255 | 256 | ### method `unwrap_or_else` 257 | 258 | ```python 259 | unwrap_or_else(op: 'object') → T 260 | ``` 261 | 262 | Return the value. 263 | 264 | --- 265 | 266 | 267 | 268 | ### method `unwrap_or_raise` 269 | 270 | ```python 271 | unwrap_or_raise(e: 'object') → T 272 | ``` 273 | 274 | Return the value. 275 | 276 | 277 | --- 278 | 279 | 280 | 281 | ## class `Nothing` 282 | An object that indicates no inner value is present 283 | 284 | 285 | 286 | ### method `__init__` 287 | 288 | ```python 289 | __init__() → None 290 | ``` 291 | 292 | 293 | 294 | 295 | 296 | 297 | 298 | 299 | --- 300 | 301 | 302 | 303 | ### method `and_then` 304 | 305 | ```python 306 | and_then(_op: 'object') → Nothing 307 | ``` 308 | 309 | There is no contained value, so return `Nothing` 310 | 311 | --- 312 | 313 | 314 | 315 | ### method `expect` 316 | 317 | ```python 318 | expect(message: 'str') → NoReturn 319 | ``` 320 | 321 | Raises an `UnwrapError`. 322 | 323 | --- 324 | 325 | 326 | 327 | ### method `is_nothing` 328 | 329 | ```python 330 | is_nothing() → Literal[True] 331 | ``` 332 | 333 | 334 | 335 | 336 | 337 | --- 338 | 339 | 340 | 341 | ### method `is_some` 342 | 343 | ```python 344 | is_some() → Literal[False] 345 | ``` 346 | 347 | 348 | 349 | 350 | 351 | --- 352 | 353 | 354 | 355 | ### method `map` 356 | 357 | ```python 358 | map(_op: 'object') → Nothing 359 | ``` 360 | 361 | Return `Nothing` 362 | 363 | --- 364 | 365 | 366 | 367 | ### method `map_or` 368 | 369 | ```python 370 | map_or(default: 'U', _op: 'object') → U 371 | ``` 372 | 373 | Return the default value 374 | 375 | --- 376 | 377 | 378 | 379 | ### method `map_or_else` 380 | 381 | ```python 382 | map_or_else(default_op: 'Callable[[], U]', op: 'object') → U 383 | ``` 384 | 385 | Return the result of the `default_op` function 386 | 387 | --- 388 | 389 | 390 | 391 | ### method `ok_or` 392 | 393 | ```python 394 | ok_or(error: 'E') → Err[E] 395 | ``` 396 | 397 | There is no contained value, so return a `result.Err` with the given error value. 398 | 399 | **NOTE**: This method is available only if the `result` package is installed. 400 | 401 | --- 402 | 403 | 404 | 405 | ### method `ok_or_else` 406 | 407 | ```python 408 | ok_or_else(op: 'Callable[[], E]') → Err[E] 409 | ``` 410 | 411 | There is no contained value, so return a `result.Err` with the result of `op`. 412 | 413 | **NOTE**: This method is available only if the `result` package is installed. 414 | 415 | --- 416 | 417 | 418 | 419 | ### method `or_else` 420 | 421 | ```python 422 | or_else(op: 'Callable[[], Maybe[T]]') → Maybe[T] 423 | ``` 424 | 425 | There is no contained value, so return the result of `op` 426 | 427 | --- 428 | 429 | 430 | 431 | ### method `some` 432 | 433 | ```python 434 | some() → None 435 | ``` 436 | 437 | Return `None`. 438 | 439 | --- 440 | 441 | 442 | 443 | ### method `unwrap` 444 | 445 | ```python 446 | unwrap() → NoReturn 447 | ``` 448 | 449 | Raises an `UnwrapError`. 450 | 451 | --- 452 | 453 | 454 | 455 | ### method `unwrap_or` 456 | 457 | ```python 458 | unwrap_or(default: 'U') → U 459 | ``` 460 | 461 | Return `default`. 462 | 463 | --- 464 | 465 | 466 | 467 | ### method `unwrap_or_else` 468 | 469 | ```python 470 | unwrap_or_else(op: 'Callable[[], T]') → T 471 | ``` 472 | 473 | There is no contained value, so return a new value by calling `op`. 474 | 475 | --- 476 | 477 | 478 | 479 | ### method `unwrap_or_raise` 480 | 481 | ```python 482 | unwrap_or_raise(e: 'Type[TBE]') → NoReturn 483 | ``` 484 | 485 | There is no contained value, so raise the exception with the value. 486 | 487 | 488 | --- 489 | 490 | 491 | 492 | ## class `UnwrapError` 493 | Exception raised from ``.unwrap_<...>`` and ``.expect_<...>`` calls. 494 | 495 | The original ``Maybe`` can be accessed via the ``.maybe`` attribute, but this is not intended for regular use, as type information is lost: ``UnwrapError`` doesn't know about ``T``, since it's raised from ``Some()`` or ``Nothing()`` which only knows about either ``T`` or no-value, not both. 496 | 497 | 498 | 499 | ### method `__init__` 500 | 501 | ```python 502 | __init__(maybe: 'Maybe[object]', message: 'str') → None 503 | ``` 504 | 505 | 506 | 507 | 508 | 509 | 510 | --- 511 | 512 | #### property maybe 513 | 514 | Returns the original maybe. 515 | 516 | 517 | 518 | 519 | 520 | 521 | --- 522 | 523 | _This file was automatically generated via [lazydocs](https://github.com/ml-tooling/lazydocs)._ 524 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [tool.mypy] 6 | python_version = "3.11" 7 | files = ["src", "tests"] 8 | # Exclude files with pattern matching syntax until we drop support for Python 9 | # versions that don't support pattern matching. Trying to use with an older 10 | # Python version results in a "invalid syntax" error from mypy 11 | exclude = "tests/test_pattern_matching.py" 12 | check_untyped_defs = true 13 | disallow_incomplete_defs = true 14 | disallow_untyped_decorators = true 15 | disallow_any_generics = true 16 | disallow_subclassing_any = true 17 | disallow_untyped_calls = true 18 | disallow_untyped_defs = true 19 | ignore_missing_imports = true 20 | no_implicit_optional = true 21 | no_implicit_reexport = true 22 | pretty = true 23 | show_column_numbers = true 24 | show_error_codes = true 25 | show_error_context = true 26 | strict_equality = true 27 | strict_optional = true 28 | warn_redundant_casts = true 29 | warn_return_any = true 30 | warn_unused_configs = true 31 | warn_unused_ignores = true 32 | 33 | [tool.coverage.run] 34 | # Ignore "Couldn't parse Python file" warnings produced when attempting to parse 35 | # Python 3.10+ code using an earlier version of Python. 36 | disable_warnings = ["couldnt-parse"] 37 | 38 | [tool.pytest.ini_options] 39 | addopts = [ 40 | "--tb=short", 41 | "--cov=src", 42 | "--cov-report=term-missing", 43 | "--cov-report=xml", 44 | 45 | # By default, ignore tests that only run on Python 3.10+ 46 | "--ignore=tests/test_pattern_matching.py", 47 | ] 48 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | build 2 | flake8 3 | lazydocs 4 | mypy 5 | pytest 6 | pytest-asyncio 7 | pytest-cov 8 | pytest-mypy-plugins 9 | twine 10 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = rustedpy-maybe 3 | version = attr: maybe.__version__ 4 | description = A Rust-like option type for Python 5 | long_description = file: README.md 6 | keywords = rust, option, maybe, enum 7 | author = francium 8 | author_email = francium@francium.cc 9 | maintainer = rustedpy github org members (https://github.com/rustedpy) 10 | url = https://github.com/rustedpy/maybe 11 | license = MIT 12 | license_file = LICENSE 13 | classifiers = 14 | Development Status :: 4 - Beta 15 | License :: OSI Approved :: MIT License 16 | Programming Language :: Python :: 3 17 | Programming Language :: Python :: 3.8 18 | Programming Language :: Python :: 3.9 19 | Programming Language :: Python :: 3.10 20 | Programming Language :: Python :: 3.11 21 | Programming Language :: Python :: 3.12 22 | Programming Language :: Python :: 3 :: Only 23 | 24 | [options] 25 | include_package_data = True 26 | install_requires = 27 | typing_extensions;python_version<'3.10' 28 | package_dir = 29 | =src 30 | packages = find: 31 | python_requires = >=3.8 32 | zip_safe = True 33 | 34 | [options.packages.find] 35 | where = src 36 | 37 | [options.package_data] 38 | maybe = py.typed 39 | 40 | [options.extras_require] 41 | result = result 42 | 43 | [flake8] 44 | # flake8 does not (yet?) support pyproject.toml; see 45 | # https://github.com/PyCQA/flake8/issues/234 46 | max-line-length = 99 47 | exclude = 48 | .direnv/ 49 | .tox/ 50 | .venv/ 51 | venv/ 52 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup() 4 | -------------------------------------------------------------------------------- /src/maybe/__init__.py: -------------------------------------------------------------------------------- 1 | from .maybe import ( 2 | Nothing, 3 | Some, 4 | SomeNothing, 5 | Maybe, 6 | UnwrapError, 7 | is_some, 8 | is_nothing, 9 | ) 10 | 11 | __all__ = [ 12 | "Nothing", 13 | "Some", 14 | "SomeNothing", 15 | "Maybe", 16 | "UnwrapError", 17 | "is_some", 18 | "is_nothing", 19 | ] 20 | __version__ = "0.0.0" 21 | -------------------------------------------------------------------------------- /src/maybe/maybe.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | from typing import ( 5 | Any, 6 | Callable, 7 | Final, 8 | Generic, 9 | Literal, 10 | NoReturn, 11 | Type, 12 | TypeVar, 13 | Union, 14 | ) 15 | 16 | if sys.version_info >= (3, 10): # pragma: no cover 17 | from typing import ParamSpec, TypeAlias, TypeGuard 18 | else: # pragma: no cover 19 | from typing_extensions import ParamSpec, TypeAlias, TypeGuard 20 | 21 | 22 | try: 23 | import result 24 | 25 | _RESULT_INSTALLED = True 26 | except ImportError: # pragma: no cover 27 | _RESULT_INSTALLED = False 28 | 29 | 30 | T = TypeVar("T", covariant=True) # Success type 31 | U = TypeVar("U") 32 | E = TypeVar("E") 33 | P = ParamSpec("P") 34 | R = TypeVar("R") 35 | TBE = TypeVar("TBE", bound=BaseException) 36 | 37 | 38 | class Some(Generic[T]): 39 | """ 40 | An object that indicates some inner value is present 41 | """ 42 | 43 | __match_args__ = ("some_value",) 44 | __slots__ = ("_value",) 45 | 46 | def __init__(self, value: T) -> None: 47 | self._value = value 48 | 49 | def __repr__(self) -> str: 50 | return f"Some({self._value!r})" 51 | 52 | def __eq__(self, other: Any) -> bool: 53 | return isinstance(other, Some) and self._value == other._value 54 | 55 | def __ne__(self, other: Any) -> bool: 56 | return not (self == other) 57 | 58 | def __hash__(self) -> int: 59 | return hash((True, self._value)) 60 | 61 | def is_some(self) -> Literal[True]: 62 | return True 63 | 64 | def is_nothing(self) -> Literal[False]: 65 | return False 66 | 67 | def some(self) -> T: 68 | """ 69 | Return the value. 70 | """ 71 | return self._value 72 | 73 | @property 74 | def some_value(self) -> T: 75 | """ 76 | Return the inner value. 77 | """ 78 | return self._value 79 | 80 | def expect(self, _message: str) -> T: 81 | """ 82 | Return the value. 83 | """ 84 | return self._value 85 | 86 | def unwrap(self) -> T: 87 | """ 88 | Return the value. 89 | """ 90 | return self._value 91 | 92 | def unwrap_or(self, _default: U) -> T: # pyright: ignore[reportInvalidTypeVarUse] 93 | """ 94 | Return the value. 95 | """ 96 | return self._value 97 | 98 | def unwrap_or_else(self, op: object) -> T: 99 | """ 100 | Return the value. 101 | """ 102 | return self._value 103 | 104 | def unwrap_or_raise(self, e: object) -> T: 105 | """ 106 | Return the value. 107 | """ 108 | return self._value 109 | 110 | def map(self, op: Callable[[T], U]) -> Some[U]: 111 | """ 112 | There is a contained value, so return `Some` with original value mapped 113 | to a new value using the passed in function. 114 | """ 115 | return Some(op(self._value)) 116 | 117 | def map_or(self, _default: object, op: Callable[[T], U]) -> U: 118 | """ 119 | There is a contained value, so return the original value mapped to a 120 | new value using the passed in function. 121 | """ 122 | return op(self._value) 123 | 124 | def map_or_else(self, _default_op: object, op: Callable[[T], U]) -> U: 125 | """ 126 | There is a contained value, so return original value mapped to a new 127 | value using the passed in `op` function. 128 | """ 129 | return op(self._value) 130 | 131 | def and_then(self, op: Callable[[T], Maybe[U]]) -> Maybe[U]: 132 | """ 133 | There is a contained value, so return the maybe of `op` with the 134 | original value passed in 135 | """ 136 | return op(self._value) 137 | 138 | def or_else(self, _op: object) -> Some[T]: 139 | """ 140 | There is a contained value, so return `Some` with the original value 141 | """ 142 | return self 143 | 144 | if _RESULT_INSTALLED: 145 | 146 | def ok_or(self, _error: E) -> result.Ok[T]: # pyright: ignore[reportInvalidTypeVarUse] 147 | """ 148 | Return a `result.Ok` with the inner value. 149 | 150 | **NOTE**: This method is available only if the `result` package is 151 | installed. 152 | """ 153 | return result.Ok(self._value) 154 | 155 | def ok_or_else(self, _op: Callable[[], E]) -> result.Ok[T]: 156 | """ 157 | Return a `result.Ok` with the inner value. 158 | 159 | **NOTE**: This method is available only if the `result` package is 160 | installed. 161 | """ 162 | return result.Ok(self._value) 163 | 164 | 165 | class Nothing: 166 | """ 167 | An object that indicates no inner value is present 168 | """ 169 | 170 | __match_args__ = ("nothing_value",) 171 | __slots__ = () 172 | 173 | def __init__(self) -> None: 174 | pass 175 | 176 | def __repr__(self) -> str: 177 | return "Nothing()" 178 | 179 | def __eq__(self, other: Any) -> bool: 180 | return isinstance(other, Nothing) 181 | 182 | def __ne__(self, other: Any) -> bool: 183 | return not isinstance(other, Nothing) 184 | 185 | def __hash__(self) -> int: 186 | # A large random number is used here to avoid a hash collision with 187 | # something else since there is no real inner value for us to hash. 188 | return hash((False, 982006445019657274590041599673)) 189 | 190 | def is_some(self) -> Literal[False]: 191 | return False 192 | 193 | def is_nothing(self) -> Literal[True]: 194 | return True 195 | 196 | def some(self) -> None: 197 | """ 198 | Return `None`. 199 | """ 200 | return None 201 | 202 | def expect(self, message: str) -> NoReturn: 203 | """ 204 | Raises an `UnwrapError`. 205 | """ 206 | exc = UnwrapError( 207 | self, 208 | f"{message}", 209 | ) 210 | raise exc 211 | 212 | def unwrap(self) -> NoReturn: 213 | """ 214 | Raises an `UnwrapError`. 215 | """ 216 | exc = UnwrapError( 217 | self, 218 | "Called `Maybe.unwrap()` on a `Nothing` value", 219 | ) 220 | raise exc 221 | 222 | def unwrap_or(self, default: U) -> U: 223 | """ 224 | Return `default`. 225 | """ 226 | return default 227 | 228 | def unwrap_or_else(self, op: Callable[[], T]) -> T: 229 | """ 230 | There is no contained value, so return a new value by calling `op`. 231 | """ 232 | return op() 233 | 234 | def unwrap_or_raise(self, e: Type[TBE]) -> NoReturn: 235 | """ 236 | There is no contained value, so raise the exception with the value. 237 | """ 238 | raise e() 239 | 240 | def map(self, _op: object) -> Nothing: 241 | """ 242 | Return `Nothing` 243 | """ 244 | return self 245 | 246 | def map_or(self, default: U, _op: object) -> U: 247 | """ 248 | Return the default value 249 | """ 250 | return default 251 | 252 | def map_or_else(self, default_op: Callable[[], U], op: object) -> U: 253 | """ 254 | Return the result of the `default_op` function 255 | """ 256 | return default_op() 257 | 258 | def and_then(self, _op: object) -> Nothing: 259 | """ 260 | There is no contained value, so return `Nothing` 261 | """ 262 | return self 263 | 264 | def or_else(self, op: Callable[[], Maybe[T]]) -> Maybe[T]: 265 | """ 266 | There is no contained value, so return the result of `op` 267 | """ 268 | return op() 269 | 270 | if _RESULT_INSTALLED: 271 | 272 | def ok_or(self, error: E) -> result.Err[E]: 273 | """ 274 | There is no contained value, so return a `result.Err` with the given 275 | error value. 276 | 277 | **NOTE**: This method is available only if the `result` package is 278 | installed. 279 | """ 280 | return result.Err(error) 281 | 282 | def ok_or_else(self, op: Callable[[], E]) -> result.Err[E]: 283 | """ 284 | There is no contained value, so return a `result.Err` with the 285 | result of `op`. 286 | 287 | **NOTE**: This method is available only if the `result` package is 288 | installed. 289 | """ 290 | return result.Err(op()) 291 | 292 | 293 | # Define Maybe as a generic type alias for use in type annotations 294 | Maybe: TypeAlias = Union[Some[T], Nothing] 295 | """ 296 | A simple `Maybe` type inspired by Rust. 297 | Not all methods (https://doc.rust-lang.org/std/option/enum.Option.html) 298 | have been implemented, only the ones that make sense in the Python context. 299 | """ 300 | 301 | SomeNothing: Final = (Some, Nothing) 302 | """ 303 | A type to use in `isinstance` checks. This is purely for convenience sake, as you could 304 | also just write `isinstance(res, (Some, Nothing)) 305 | """ 306 | 307 | 308 | class UnwrapError(Exception): 309 | """ 310 | Exception raised from ``.unwrap_<...>`` and ``.expect_<...>`` calls. 311 | 312 | The original ``Maybe`` can be accessed via the ``.maybe`` attribute, but 313 | this is not intended for regular use, as type information is lost: 314 | ``UnwrapError`` doesn't know about ``T``, since it's raised from ``Some()`` 315 | or ``Nothing()`` which only knows about either ``T`` or no-value, not both. 316 | """ 317 | 318 | _maybe: Maybe[object] 319 | 320 | def __init__(self, maybe: Maybe[object], message: str) -> None: 321 | self._maybe = maybe 322 | super().__init__(message) 323 | 324 | @property 325 | def maybe(self) -> Maybe[Any]: 326 | """ 327 | Returns the original maybe. 328 | """ 329 | return self._maybe 330 | 331 | 332 | def is_some(maybe: Maybe[T]) -> TypeGuard[Some[T]]: 333 | """A typeguard to check if a maybe is a `Some`. 334 | 335 | Usage: 336 | 337 | ```plain 338 | >>> r: Maybe[int, str] = get_a_maybe() 339 | >>> if is_some(r): 340 | ... r # r is of type Some[int] 341 | ... elif is_nothing(r): 342 | ... r # r is of type Nothing[str] 343 | ``` 344 | """ 345 | return maybe.is_some() 346 | 347 | 348 | def is_nothing(maybe: Maybe[T]) -> TypeGuard[Nothing]: 349 | """A typeguard to check if a maybe is a `Nothing`. 350 | 351 | Usage: 352 | 353 | ```plain 354 | >>> r: Maybe[int, str] = get_a_maybe() 355 | >>> if is_some(r): 356 | ... r # r is of type Some[int] 357 | ... elif is_nothing(r): 358 | ... r # r is of type Nothing[str] 359 | ``` 360 | """ 361 | return maybe.is_nothing() 362 | -------------------------------------------------------------------------------- /src/maybe/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rustedpy/maybe/93926b43929b34c2dc143c738a77b2f1e4e8661f/src/maybe/py.typed -------------------------------------------------------------------------------- /tests/test_maybe.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Callable 4 | 5 | import pytest 6 | import result 7 | 8 | from maybe import Some, SomeNothing, Maybe, Nothing, UnwrapError, is_nothing, is_some 9 | 10 | 11 | def test_some_factories() -> None: 12 | instance = Some(1) 13 | assert instance._value == 1 14 | assert instance.is_some() is True 15 | 16 | 17 | def test_nothing_factory() -> None: 18 | instance = Nothing() 19 | assert instance.is_nothing() is True 20 | 21 | 22 | def test_eq() -> None: 23 | assert Some(1) == Some(1) 24 | assert Nothing() == Nothing() 25 | assert not (Nothing() != Nothing()) 26 | assert Some(1) != Nothing() 27 | assert Some(1) != Some(2) 28 | assert not (Some(1) != Some(1)) 29 | assert Some(1) != "abc" 30 | assert Some("0") != Some(0) 31 | 32 | 33 | def test_hash() -> None: 34 | assert len({Some(1), Nothing(), Some(1), Nothing()}) == 2 35 | assert len({Some(1), Some(2)}) == 2 36 | assert len({Some("a"), Nothing()}) == 2 37 | 38 | 39 | def test_repr() -> None: 40 | """ 41 | ``repr()`` returns valid code if the wrapped value's ``repr()`` does as well. 42 | """ 43 | o = Some(123) 44 | n = Nothing() 45 | 46 | assert repr(o) == "Some(123)" 47 | assert o == eval(repr(o)) 48 | 49 | assert repr(n) == "Nothing()" 50 | assert n == eval(repr(n)) 51 | 52 | 53 | def test_some_value() -> None: 54 | res = Some('haha') 55 | assert res.some_value == 'haha' 56 | 57 | 58 | def test_some() -> None: 59 | res = Some('haha') 60 | assert res.is_some() is True 61 | assert res.is_nothing() is False 62 | assert res.some_value == 'haha' 63 | 64 | 65 | def test_some_guard() -> None: 66 | assert is_some(Some(1)) 67 | 68 | 69 | def test_nothing_guard() -> None: 70 | assert is_nothing(Nothing()) 71 | 72 | 73 | def test_nothing() -> None: 74 | res = Nothing() 75 | assert res.is_some() is False 76 | assert res.is_nothing() is True 77 | 78 | 79 | def test_some_method() -> None: 80 | o = Some('yay') 81 | n = Nothing() 82 | assert o.some() == 'yay' 83 | 84 | # Unfortunately, it seems the mypy team made a very deliberate and highly contested 85 | # decision to mark using the return value from a function known to only return None 86 | # as an error, so we are forced to ignore the check here. 87 | # See https://github.com/python/mypy/issues/6549 88 | assert n.some() is None # type: ignore[func-returns-value] 89 | 90 | 91 | def test_expect() -> None: 92 | o = Some('yay') 93 | n = Nothing() 94 | assert o.expect('failure') == 'yay' 95 | with pytest.raises(UnwrapError): 96 | n.expect('failure') 97 | 98 | 99 | def test_unwrap() -> None: 100 | o = Some('yay') 101 | n = Nothing() 102 | assert o.unwrap() == 'yay' 103 | with pytest.raises(UnwrapError): 104 | n.unwrap() 105 | 106 | 107 | def test_unwrap_or() -> None: 108 | o = Some('yay') 109 | n = Nothing() 110 | assert o.unwrap_or('some_default') == 'yay' 111 | assert n.unwrap_or('another_default') == 'another_default' 112 | 113 | 114 | def test_unwrap_or_else() -> None: 115 | o = Some('yay') 116 | n = Nothing() 117 | assert o.unwrap_or_else(str.upper) == 'yay' 118 | assert n.unwrap_or_else(lambda: 'default') == 'default' 119 | 120 | 121 | def test_unwrap_or_raise() -> None: 122 | o = Some('yay') 123 | n = Nothing() 124 | assert o.unwrap_or_raise(ValueError) == 'yay' 125 | with pytest.raises(ValueError) as exc_info: 126 | n.unwrap_or_raise(ValueError) 127 | assert exc_info.value.args == () 128 | 129 | 130 | def test_map() -> None: 131 | o = Some('yay') 132 | n = Nothing() 133 | assert o.map(str.upper).some() == 'YAY' 134 | assert n.map(str.upper).is_nothing() 135 | 136 | 137 | def test_map_or() -> None: 138 | o = Some('yay') 139 | n = Nothing() 140 | assert o.map_or('hay', str.upper) == 'YAY' 141 | assert n.map_or('hay', str.upper) == 'hay' 142 | 143 | 144 | def test_map_or_else() -> None: 145 | o = Some('yay') 146 | n = Nothing() 147 | assert o.map_or_else(lambda: 'hay', str.upper) == 'YAY' 148 | assert n.map_or_else(lambda: 'hay', str.upper) == 'hay' 149 | 150 | 151 | def test_and_then() -> None: 152 | assert Some(2).and_then(sq).and_then(sq).some() == 16 153 | assert Some(2).and_then(sq).and_then(to_nothing).is_nothing() 154 | assert Some(2).and_then(to_nothing).and_then(sq).is_nothing() 155 | assert Nothing().and_then(sq).and_then(sq).is_nothing() 156 | 157 | assert Some(2).and_then(sq_lambda).and_then(sq_lambda).some() == 16 158 | assert Some(2).and_then(sq_lambda).and_then(to_nothing_lambda).is_nothing() 159 | assert Some(2).and_then(to_nothing_lambda).and_then(sq_lambda).is_nothing() 160 | assert Nothing().and_then(sq_lambda).and_then(sq_lambda).is_nothing() 161 | 162 | 163 | def test_or_else() -> None: 164 | assert Some(2).or_else(sq).or_else(sq).some() == 2 165 | assert Some(2).or_else(to_nothing).or_else(sq).some() == 2 166 | assert Nothing().or_else(lambda: sq(3)).or_else(lambda: to_nothing(2)).some() == 9 167 | assert ( 168 | Nothing() 169 | .or_else(lambda: to_nothing(2)) 170 | .or_else(lambda: to_nothing(2)) 171 | .is_nothing() 172 | ) 173 | 174 | assert Some(2).or_else(sq_lambda).or_else(sq).some() == 2 175 | assert Some(2).or_else(to_nothing_lambda).or_else(sq_lambda).some() == 2 176 | 177 | 178 | def test_isinstance_result_type() -> None: 179 | o = Some('yay') 180 | n = Nothing() 181 | assert isinstance(o, SomeNothing) 182 | assert isinstance(n, SomeNothing) 183 | assert not isinstance(1, SomeNothing) 184 | 185 | 186 | def test_error_context() -> None: 187 | n = Nothing() 188 | with pytest.raises(UnwrapError) as exc_info: 189 | n.unwrap() 190 | exc = exc_info.value 191 | assert exc.maybe is n 192 | 193 | 194 | def test_slots() -> None: 195 | """ 196 | Some and Nothing have slots, so assigning arbitrary attributes fails. 197 | """ 198 | o = Some('yay') 199 | n = Nothing() 200 | with pytest.raises(AttributeError): 201 | o.some_arbitrary_attribute = 1 # type: ignore[attr-defined] 202 | with pytest.raises(AttributeError): 203 | n.some_arbitrary_attribute = 1 # type: ignore[attr-defined] 204 | 205 | 206 | def test_some_ok_or() -> None: 207 | assert Some(1).ok_or('error') == result.Ok(1) 208 | 209 | 210 | def test_some_ok_or_else() -> None: 211 | assert Some(1).ok_or_else(lambda: 'error') == result.Ok(1) 212 | 213 | 214 | def test_nothing_ok_or() -> None: 215 | assert Nothing().ok_or('error') == result.Err('error') 216 | 217 | 218 | def test_nothing_ok_or_else() -> None: 219 | assert Nothing().ok_or_else(lambda: 'error') == result.Err('error') 220 | 221 | 222 | def sq(i: int) -> Maybe[int]: 223 | return Some(i**2) 224 | 225 | 226 | def to_nothing(_: int) -> Maybe[int]: 227 | return Nothing() 228 | 229 | 230 | # Lambda versions of the same functions, just for test/type coverage 231 | sq_lambda: Callable[[int], Maybe[int]] = lambda i: Some(i * i) 232 | to_nothing_lambda: Callable[[int], Maybe[int]] = lambda _: Nothing() 233 | -------------------------------------------------------------------------------- /tests/test_pattern_matching.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from maybe import Nothing, Some, Maybe 4 | 5 | 6 | def test_pattern_matching_on_some_type() -> None: 7 | """ 8 | Pattern matching on ``Some()`` matches the contained value. 9 | """ 10 | o: Maybe[str] = Some("yay") 11 | match o: 12 | case Some(value): 13 | reached = True 14 | 15 | assert value == "yay" 16 | assert reached 17 | 18 | 19 | def test_pattern_matching_on_err_type() -> None: 20 | """ 21 | Pattern matching on ``Err()`` matches the contained value. 22 | """ 23 | n: Maybe[int] = Nothing() 24 | match n: 25 | case Nothing(): 26 | reached = True 27 | 28 | assert reached 29 | -------------------------------------------------------------------------------- /tests/type-checking/test_maybe.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # reveal_type(res3) # N: Revealed type is "maybe.maybe.Nothing" 3 | - case: failure_lash 4 | disable_cache: false 5 | main: | 6 | from typing import Callable, List, Optional 7 | 8 | from maybe import Maybe, Some, Nothing 9 | 10 | 11 | res1: Maybe[str] = Some('hello') 12 | reveal_type(res1) # N: Revealed type is "Union[maybe.maybe.Some[builtins.str], maybe.maybe.Nothing]" 13 | if isinstance(res1, Some): 14 | some: Some[str] = res1 15 | reveal_type(some) # N: Revealed type is "maybe.maybe.Some[builtins.str]" 16 | someValue: str = res1.some() 17 | reveal_type(someValue) # N: Revealed type is "builtins.str" 18 | mapped_to_float: float = res1.map_or(1.0, lambda s: len(s) * 1.5) 19 | reveal_type(mapped_to_float) # N: Revealed type is "builtins.float" 20 | else: 21 | nothing: Nothing = res1 22 | reveal_type(nothing) # N: Revealed type is "maybe.maybe.Nothing" 23 | 24 | # Test constructor functions 25 | res2 = Some(42) 26 | reveal_type(res2) # N: Revealed type is "maybe.maybe.Some[builtins.int]" 27 | res3 = Nothing() 28 | reveal_type(res3) # N: Revealed type is "maybe.maybe.Nothing" 29 | 30 | res4 = Some(4) 31 | add1: Callable[[int], Maybe[int]] = lambda i: Some(i + 1) 32 | toint: Callable[[str], Maybe[int]] = lambda i: Some(int(i)) 33 | res5 = res4.and_then(add1) 34 | reveal_type(res5) # N: Revealed type is "Union[maybe.maybe.Some[builtins.int], maybe.maybe.Nothing]" 35 | res6 = res4.or_else(toint) 36 | reveal_type(res6) # N: Revealed type is "maybe.maybe.Some[builtins.int]" 37 | 38 | - case: covariance_pre310 39 | skip: "sys.version_info >= (3, 10)" 40 | disable_cache: false 41 | main: | 42 | from maybe import Maybe, Some, Nothing 43 | 44 | some_int: Some[int] = Some(42) 45 | some_float: Some[float] = some_int 46 | some_int = some_float # E: Incompatible types in assignment (expression has type "Some[float]", variable has type "Some[int]") [assignment] 47 | 48 | nothing: Nothing = Nothing() 49 | 50 | maybe_int: Maybe[int] = some_int or nothing 51 | maybe_float: Maybe[float] = maybe_int 52 | maybe_int = maybe_float # E: Incompatible types in assignment (expression has type "Union[Some[float], Nothing]", variable has type "Union[Some[int], Nothing]") [assignment] 53 | 54 | - case: covariance 55 | skip: "sys.version_info < (3, 10)" 56 | disable_cache: false 57 | main: | 58 | import sys 59 | from maybe import Maybe, Some, Nothing 60 | 61 | some_int: Some[int] = Some(42) 62 | some_float: Some[float] = some_int 63 | some_int = some_float # E: Incompatible types in assignment (expression has type "Some[float]", variable has type "Some[int]") [assignment] 64 | 65 | nothing: Nothing = Nothing() 66 | 67 | maybe_int: Maybe[int] = some_int or nothing 68 | maybe_float: Maybe[float] = maybe_int 69 | maybe_int = maybe_float # E: Incompatible types in assignment (expression has type "Some[float] | Nothing", variable has type "Some[int] | Nothing") [assignment] 70 | 71 | - case: map_ok 72 | disable_cache: false 73 | main: | 74 | from maybe import Maybe, Some, Nothing 75 | 76 | s = Some("42") 77 | reveal_type(s.map(int)) # N: Revealed type is "maybe.maybe.Some[builtins.int]" 78 | 79 | n = Nothing() 80 | reveal_type(n.map(int)) # N: Revealed type is "maybe.maybe.Nothing" 81 | 82 | - case: map_maybe 83 | disable_cache: false 84 | main: | 85 | from maybe import Maybe, Some 86 | 87 | greeting: Maybe[str] = Some("Hello") 88 | 89 | personalized_greeting = greeting.map(lambda g: f"{g}, John") 90 | reveal_type(personalized_greeting) # N: Revealed type is "Union[maybe.maybe.Some[builtins.str], maybe.maybe.Nothing]" 91 | 92 | some = personalized_greeting.some() 93 | reveal_type(some) # N: Revealed type is "Union[builtins.str, None]" 94 | 95 | - case: ok_or 96 | disable_cache: false 97 | main: | 98 | from maybe import Maybe, Some, Nothing 99 | from result import Ok, Err 100 | 101 | greeting: Maybe[str] = Some("Hello") 102 | 103 | ok_greeting = greeting.ok_or("error") 104 | reveal_type(ok_greeting) # N: Revealed type is "Union[result.result.Ok[builtins.str], result.result.Err[builtins.str]]" 105 | 106 | nothing: Maybe[str] = Nothing() 107 | 108 | no_greeting = nothing.ok_or("error") 109 | reveal_type(no_greeting) # N: Revealed type is "Union[result.result.Ok[builtins.str], result.result.Err[builtins.str]]" 110 | 111 | - case: ok_or_else 112 | disable_cache: false 113 | main: | 114 | from maybe import Maybe, Some, Nothing 115 | from result import Ok, Err, Result 116 | 117 | greeting: Maybe[str] = Some("Hello") 118 | 119 | ok_greeting = greeting.ok_or_else(lambda: "error") 120 | reveal_type(ok_greeting) # N: Revealed type is "Union[result.result.Ok[builtins.str], result.result.Err[builtins.str]]" 121 | greeting.ok_or_else("error") # E: Argument 1 to "ok_or_else" of "Some" has incompatible type "str"; expected "Callable[[], Never]" [arg-type] # E: Argument 1 to "ok_or_else" of "Nothing" has incompatible type "str"; expected "Callable[[], Never]" [arg-type] 122 | 123 | nothing: Maybe[str] = Nothing() 124 | 125 | no_greeting: Result[str, ValueError] = nothing.ok_or_else(lambda: ValueError("error")) 126 | reveal_type(no_greeting) # N: Revealed type is "Union[result.result.Ok[builtins.str], result.result.Err[builtins.ValueError]]" 127 | nothing.ok_or_else("error") # E: Argument 1 to "ok_or_else" of "Some" has incompatible type "str"; expected "Callable[[], Never]" [arg-type] # E: Argument 1 to "ok_or_else" of "Nothing" has incompatible type "str"; expected "Callable[[], Never]" [arg-type] 128 | 129 | - case: typeguard 130 | disable_cache: false 131 | main: | 132 | from maybe import Maybe, Some, Nothing, is_some, is_nothing 133 | 134 | maybe = Some(1) 135 | nothing = Nothing() 136 | if is_some(maybe): 137 | reveal_type(maybe) # N: Revealed type is "maybe.maybe.Some[builtins.int]" 138 | elif is_nothing(nothing): 139 | reveal_type(nothing) # N: Revealed type is "maybe.maybe.Nothing" 140 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | ; Version 4 rewrite fixed https://github.com/tox-dev/tox/issues/1297, which was 3 | ; causing `usedevelop = true` to be ignored. 4 | min_version = 4.0 5 | envlist = py312,py311,py310,py39,py38 6 | 7 | [testenv] 8 | ; Required for test coverage to work correctly 9 | usedevelop = true 10 | deps = -rrequirements-dev.txt 11 | commands = pytest {posargs} 12 | 13 | [testenv:py310] 14 | deps = -rrequirements-dev.txt 15 | commands = 16 | pytest {posargs} 17 | ; Reset coverage options since we don't need to report coverage 18 | ; for testing pattern matching, which erroneously shows misses for 19 | ; code covered by the preceding command. 20 | pytest {posargs} --cov-reset tests/test_pattern_matching.py 21 | --------------------------------------------------------------------------------