├── .github ├── FUNDING.yml └── workflows │ └── testing.yaml ├── .gitignore ├── LICENSE ├── README.md ├── docs ├── README-ko.md └── railroad-oriented-ko.ipynb ├── pyproject.toml ├── src └── fieldenum │ ├── __init__.py │ ├── _fieldenum.py │ ├── _flag.py │ ├── _utils.py │ ├── enums.py │ └── exceptions.py ├── tests ├── test_doc.py ├── test_enums.py ├── test_fieldenum.py ├── test_flag.py └── test_type_hints.py └── uv.lock /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: "ilotoki0804" # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 12 | polar: # Replace with a single Polar username 13 | buy_me_a_coffee: # Replace with a single Buy Me a Coffee username 14 | thanks_dev: # Replace with a single thanks.dev username 15 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 16 | -------------------------------------------------------------------------------- /.github/workflows/testing.yaml: -------------------------------------------------------------------------------- 1 | name: testing 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - name: Set up Python 3.12 15 | uses: actions/setup-python@v5 16 | with: 17 | python-version: 3.12 18 | - name: Install dependencies 19 | run: | 20 | python -m pip install pytest pytest-cov 21 | python -m pip install -e . 22 | - name: Test with pytest 23 | run: | 24 | python -m pytest --cov --cov-report lcov 25 | - name: Coveralls 26 | uses: coverallsapp/github-action@master 27 | with: 28 | github-token: ${{ secrets.GITHUB_TOKEN }} 29 | path-to-lcov: coverage.lcov 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | #################### 2 | # Special Excludes # 3 | #################### 4 | 5 | # Exclude all files that starts with "_ignore" 6 | _ignore* 7 | 8 | # venv for this repository 9 | .venv/ 10 | 11 | # webtoon folder 12 | webtoon/ 13 | 14 | #################### 15 | # Regular Excludes # 16 | #################### 17 | 18 | # VSCode workspace folder 19 | .vscode/ 20 | 21 | # Byte-compiled / optimized / DLL files 22 | __pycache__/ 23 | *.py[cod] 24 | *$py.class 25 | 26 | # C extensions 27 | *.so 28 | 29 | # vim 30 | *.swp 31 | 32 | # ipython notebooks 33 | .ipynb 34 | 35 | # Distribution / packaging 36 | .Python 37 | build/ 38 | develop-eggs/ 39 | dist/ 40 | downloads/ 41 | eggs/ 42 | .eggs/ 43 | lib/ 44 | lib64/ 45 | parts/ 46 | sdist/ 47 | var/ 48 | wheels/ 49 | *.egg-info/ 50 | .installed.cfg 51 | *.egg 52 | 53 | # PyInstaller 54 | # Usually these files are written by a python script from a template 55 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 56 | *.manifest 57 | *.spec 58 | 59 | # Installer logs 60 | pip-log.txt 61 | pip-delete-this-directory.txt 62 | 63 | # Unit test / coverage reports 64 | htmlcov/ 65 | .tox/ 66 | .coverage 67 | .coverage.* 68 | .cache 69 | nosetests.xml 70 | coverage.xml 71 | *.cover 72 | .hypothesis/ 73 | .pytest_cache/ 74 | 75 | # Translations 76 | *.mo 77 | *.pot 78 | 79 | # Django stuff: 80 | *.log 81 | local_settings.py 82 | db.sqlite3 83 | 84 | # Flask stuff: 85 | instance/ 86 | .webassets-cache 87 | 88 | # Scrapy stuff: 89 | .scrapy 90 | 91 | # Sphinx documentation 92 | docs/_build/ 93 | docs/html 94 | docs/latex 95 | 96 | # PyBuilder 97 | target/ 98 | 99 | # Jupyter Notebook 100 | .ipynb_checkpoints 101 | 102 | # pyenv 103 | .python-version 104 | 105 | # celery beat schedule file 106 | celerybeat-schedule 107 | 108 | # SageMath parsed files 109 | *.sage.py 110 | 111 | # Environments 112 | .env 113 | .venv 114 | env/ 115 | venv/ 116 | ENV/ 117 | env.bak/ 118 | venv.bak/ 119 | 120 | # Spyder project settings 121 | .spyderproject 122 | .spyproject 123 | 124 | # Rope project settings 125 | .ropeproject 126 | 127 | # mkdocs documentation 128 | /site 129 | 130 | # mypy 131 | .mypy_cache/ 132 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 ilotoki0804 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # fieldenum 2 | 3 | [![PyPI - Downloads](https://img.shields.io/pypi/dm/fieldenum)](https://pypi.org/project/fieldenum/) 4 | [![Hits](https://hits.seeyoufarm.com/api/count/incr/badge.svg?url=https%3A%2F%2Fgithub.com%2Filotoki0804%2Ffieldenum&count_bg=%2379C83D&title_bg=%23555555&icon=&icon_color=%23E7E7E7&title=hits&edge_flat=false)](https://github.com/ilotoki0804/fieldenum) 5 | [![Coverage Status](https://coveralls.io/repos/github/ilotoki0804/fieldenum/badge.svg?branch=master)](https://coveralls.io/github/ilotoki0804/fieldenum?branch=master) 6 | [![Sponsoring](https://img.shields.io/badge/Sponsoring-Toss-blue?logo=GitHub%20Sponsors&logoColor=white)](https://toss.me/ilotoki) 7 | 8 | Rust-like fielded Enums (ADT) in Python 9 | 10 | **[한국어로 보기](docs/README-ko.md)** 11 | 12 | ## Examples 13 | 14 | ```python 15 | from fieldenum import fieldenum, Unit, Variant 16 | 17 | @fieldenum 18 | class Message: 19 | Quit = Unit 20 | Move = Variant(x=int, y=int) 21 | Write = Variant(str) 22 | ChangeColor = Variant(int, int, int) 23 | 24 | # Corresponding code in Rust: 25 | # enum Message { 26 | # Quit, 27 | # Move { x: i32, y: i32 }, 28 | # Write(String), 29 | # ChangeColor(i32, i32, i32), 30 | # } 31 | 32 | 33 | # usage 34 | message = Message.Quit 35 | message = Message.Move(x=1, y=2) 36 | message = Message.Write("hello, world!") 37 | message = Message.ChangeColor(256, 256, 0) 38 | ``` 39 | 40 | ## Credits 41 | 42 | This project is heavily influenced by [Rust's `Enum`](https://doc.rust-lang.org/reference/items/enumerations.html), and also borrows some of its design from [rust_enum](https://github.com/girvel/rust_enum). 43 | 44 | ## Releases 45 | 46 | * 0.1.0: initial release 47 | -------------------------------------------------------------------------------- /docs/README-ko.md: -------------------------------------------------------------------------------- 1 | # fieldenum 2 | 3 | 4 | [![PyPI - Downloads](https://img.shields.io/pypi/dm/fieldenum)](https://pypi.org/project/fieldenum/) 5 | [![Hits](https://hits.seeyoufarm.com/api/count/incr/badge.svg?url=https%3A%2F%2Fgithub.com%2Filotoki0804%2Ffieldenum&count_bg=%2379C83D&title_bg=%23555555&icon=&icon_color=%23E7E7E7&title=hits&edge_flat=false)](https://github.com/ilotoki0804/fieldenum) 6 | [![Coverage Status](https://coveralls.io/repos/github/ilotoki0804/fieldenum/badge.svg?branch=master)](https://coveralls.io/github/ilotoki0804/fieldenum?branch=master) 7 | [![Sponsoring](https://img.shields.io/badge/Sponsoring-Toss-blue?logo=GitHub%20Sponsors&logoColor=white)](https://toss.me/ilotoki) 8 | 9 | 10 | 파이썬에서의 러스트 스타일의 필드형 enum (ADT) 11 | 12 | **[English Docs](/README.md)** 13 | 14 | ## Introduction 15 | 16 | 러스트의 여러 킬러 기능 중에서도 단연 돋보이는 것은 enum입니다. 17 | 함수형 프로그래밍의 개념을 차용한 이 enum은 매우 강력한데, 18 | 그중에서도 돋보이는 점은 바로 필드를 가질 수 있다는 점입니다. 19 | 20 | 파이썬에도 이미 `enum`이라는 기본 모듈이 있으나, 이는 필드를 사용할 수가 없습니다. 21 | 반대로 `dataclass`도 기본적으로 지원되나, 이는 enum처럼 선택지의 개념이 존재하지 않습니다. 22 | 23 | fieldenum은 러스트와 거의 비슷하면서도 파이썬의 문법과 잘 어울리는 필드가 있는 enum을 사용할 수 있도록 합니다. 24 | 또한 `fieldenum.enums` 모듈에서는 타입 힌트를 완전하게 제공하는 다양한 fieldenum들을 제공하며, 이를 통해 Railroad Oriented Programming이나 `Option`과 같은 여러 함수형 프로그래밍의 개념을 활용할 수 있습니다. 25 | 26 | fieldenum은 파이썬에서의 함수형 프로그래밍에 대한 실험이 *아닌* 실용성을 지니는 것을 목적으로 합니다. 27 | 실제로 fieldenum의 여러 기능들은 dataclass와 같이 *실제로* 유용합니다. 28 | 29 | ## Installation 30 | 31 | 다음의 명령어를 통해 `fieldenum`을 설치할 수 있습니다. 32 | 33 | ```python 34 | pip install fieldenum 35 | ``` 36 | 37 | 메인 파트는 파이썬 3.10 이상에서 호환됩니다. 다만, 하위 모듈인 `fieldenum.enums`는 파이썬 3.12 이상에서만 사용 가능합니다. 38 | 39 | ## 사용 방법 40 | 41 | ### `@fieldenum` 42 | 43 | 클래스에 `Variant`혹은 `Unit`을 값으로 가지는 변수들을 추가하고 클래스를 `@fieldenum`으로 감싸주면 fieldenum이 됩니다. 44 | 예를 들어 다음과 같이 코드를 짤 수 있습니다. 45 | 46 | ```python 47 | from fieldenum import Variant, Unit, fieldenum 48 | 49 | @fieldenum # fieldenum으로 감싸면 만들어집니다. 깜박하고 안 쓰면 안 됩니다! 50 | class Message: 51 | Quit = Unit # 유닛 배리언트는 다음과 같이 정의합니다. 52 | Write = Variant(str) # 튜플 배리언트는 다음과 같이 정의합니다. 53 | ``` 54 | 55 | ### 배리언트 정의하기 56 | 57 | 모든 fieldenum은 배리언트를 가지는데, 이 베리언트들은 enum이 가질 수 있는 상태들의 모음입니다. 58 | 59 | fieldenum이 만들어지면 원래의 enum 클래스는 더 이상 인스턴스화될 수 없고, 60 | 그 대신 각각의 배리언트들이 인스턴스화될 수 있습니다. 61 | 62 | 또한 enum은 상속될 수 없기 때문에 모든 enum 클래스의 서브클래스는 배리언트밖에 없고, 63 | enum 클래스의 인스턴스에는 배리언트들의 인스턴스만 존재합니다. 64 | 65 | ### 유닛 배리언트 66 | 67 | 유닛 배리언트는 별도의 필드를 갖지 않는 배리언트로, 괄호를 통해 값을 초가화할 필요가 없습니다. 68 | 69 | ```python 70 | from fieldenum import Variant, Unit, fieldenum, unreachable 71 | 72 | @fieldenum 73 | class Message: 74 | # 유닛 배리언트는 다음과 같이 정의합니다. 75 | Quit = Unit 76 | Stay = Unit 77 | 78 | message: Message = Message.Quit 79 | 80 | # message가 Message.Quit인지 확인합니다. 81 | if message is Message.Quit: 82 | print("Quit!") 83 | 84 | # match statement를 사용할 수도 있습니다. 85 | match message: 86 | case Message.Quit: 87 | print("Quit!") 88 | 89 | case Message.Stay: 90 | print("Stay!") 91 | ``` 92 | 93 | ### 튜플형 배리언트 94 | 95 | 튜플형 배리언트는 튜플형의 익명의 값을 가지는 배리언트입니다. 96 | 97 | 튜플형 배리언트는 다음과 같이 정의합니다. 98 | 99 | ```python 100 | from fieldenum import Variant, Unit, fieldenum, unreachable 101 | 102 | @fieldenum 103 | class Message[T]: 104 | Quit = Variant(int) # Variant(...)와 같이 적고 안에는 타입을 적습니다. 105 | Stay = Variant(T) # 제너릭도 사용 가능합니다. 다만 타입 힌트로서의 의미 그 이상은 없습니다. 106 | Var3 = Variant(int, str, dict[int, str]) # 여러 값을 이어서 적으면 각각이 파라미터가 됩니다. 107 | 108 | 109 | Message.Quit(123) # OK 110 | Message[str].Stay("hello") # OK 111 | Message.Stay("hello") # OK 112 | Message.Var3(123, "hello", {1: "world"}) # OK 113 | ``` 114 | 115 | 튜플의 값에는 타입이 적히는데, 이는 런타임에 확인되지는 않는 어노테이션에 가깝습니다. 116 | 117 | ### 이름 있는 배리언트 118 | 119 | 이름 있는 배리언트는 순서가 없는 여러 키워드로 이루어진 배리언트입니다. 120 | 121 | ```python 122 | from fieldenum import Variant, Unit, fieldenum, unreachable 123 | 124 | @fieldenum 125 | class Cord: 126 | D1 = Variant(x=float) 127 | D2 = Variant(x=float, y=float) 128 | D3 = Variant(x=float, y=float, z=float) 129 | D4 = Variant(timestamp=float, x=float, y=float, z=float) 130 | 131 | 132 | Cord.D1(x=123.456) 133 | Cord.D3(x=1343.5, y=25.2, z=465.312) 134 | ``` 135 | 136 | 이때 위치 인자를 사용해 초기화하거나 위치 인자와 키워드 인자를 섞어서 사용할 수 있습니다. 137 | 138 | ```python 139 | Cord.D1(123.456) # 가능 140 | Cord.D2(123.456, y=789.0) # 가능 141 | Cord.D3(1.2, 2.3, 3.4) # 가능 142 | ``` 143 | 144 | 값은 속성을 통해서도 접근할 수 있습니다. 145 | 146 | ```python 147 | cord = Cord.D3(x=1343.5, y=25.2, z=465.312) 148 | 149 | assert cord.x == 1343.5 150 | assert cord.y == 25.2 151 | assert cord.z == 465.312 152 | ``` 153 | 154 | 물론 match문을 사용하는 방식도 있으며, 일반적으로는 속성으로 접근하는 방식보다 더 선호됩니다. 155 | 156 | ```python 157 | match cord: 158 | case Cord.D1(x=x): 159 | print(f"x cord is ({x})") 160 | 161 | case Cord.D2(x=x, y=y): 162 | print(f"x-y cord is ({x}, {y})") 163 | 164 | case Cord.D3(x=x, y=y, z=_): 165 | print(f"x-y cord is ({x}, {y})") 166 | 167 | case Cord.D3(timestamp=time, x=x, y=y, z=_): 168 | print(f"x-y cord is ({x}, {y}) at {time}") 169 | ``` 170 | 171 | 또한 `.kw_only()`를 이용하면 위치 인자 사용을 금지하고 키워드 인자만 사용하도록 설정할 수 있습니다. 172 | 173 | ```python 174 | from fieldenum import Variant, Unit, fieldenum, unreachable 175 | 176 | @fieldenum 177 | class Cord: 178 | D1 = Variant(x=float).kw_only() 179 | D2 = Variant(x=float, y=float).kw_only() 180 | D3 = Variant(x=float, y=float, z=float).kw_only() 181 | D4 = Variant(timestamp=float, x=float, y=float, z=float).kw_only() 182 | 183 | Cord.D1(x=123.456) # 가능 184 | Cord.D3(x=1343.5, y=25.2, z=465.312) # 가능 185 | 186 | Cord.D1(123.456) # XXX: 불가능 187 | Cord.D2(123.456, y=789.0) # XXX: 불가능 188 | Cord.D3(1.2, 2.3, 3.4) # XXX: 불가능 189 | ``` 190 | 191 | ### 클래스형 이름 있는 배리언트 192 | 193 | 배리언트는 `@variant`(`Variant`와 달리 소문자로 시작합니다)로 감싼 클래스의 형태로 제작할 수 있습니다. 194 | 195 | ```python 196 | from fieldenum import Variant, Unit, fieldenum, unreachable, variant 197 | 198 | @fieldenum 199 | class Product: 200 | @variant 201 | class Liquid: 202 | product: str 203 | amount: float 204 | price_per_unit: float 205 | unit: str = "liter" 206 | currency: str = "USD" 207 | 208 | @variant 209 | class Quantifiable: 210 | product: str 211 | count: int 212 | price: float 213 | currency: str = "USD" 214 | 215 | gasoline = Product.Liquid("gasoline", amount=5, price_per_unit=200, unit="barrel") 216 | mouse = Product.Quantifiable("mouse", count=23, price=8) 217 | ``` 218 | 219 | 이때 타입 힌트 없이 값만 작성한 값이나 메서드 등은 필드에서 빠지며 무시됩니다. 220 | 즉, `@variant`를 통해 이름 있는 배리언트를 만든다면 결과적으로 Variant를 사용하는 것과 크게 다름이 없으며, 221 | 현재로서는 보기 조금 편하게 하는 것 이외에는 특별한 기능은 없습니다. 222 | 223 | ### 함수형 배리언트 224 | 225 | 위에서 설명한 모든 배리언트들은 실제 함수의 표현력에는 미치지 못합니다. 226 | 위치 인자나 키워드 인자 사용을 인자별로 각각 조절하지 못할 뿐더러 타입 힌트는 항상 실행되고 기본값을 지정하는 문법은 조금 복잡합니다. 227 | 228 | 함수형 배리언트는 이러한 문제를 깔끔히 해결합니다. 229 | 230 | ```python 231 | from fieldenum import Variant, Unit, factory, fieldenum, unreachable, variant 232 | 233 | @fieldenum 234 | class Product: 235 | @variant 236 | def Liquid( 237 | product: str, 238 | amount: float, 239 | /, 240 | price_per_unit: float, 241 | *, 242 | unit: str = "liter", 243 | currency: str = "USD", 244 | extras: dict = factory(dict) 245 | ): 246 | pass 247 | ``` 248 | 249 | 위의 `Product.Liquid` 함수는 정확히 똑같은 형태로 값을 초기화합니다. 250 | 즉, 251 | 252 | * `product`와 `amount`는 위치 인자로만 받습니다. 253 | * `price_per_unit`은 위치 인자로도, 키워드 인자로도 넘길 수 있습니다. 254 | * `unit`, `currency`는 키워드 인자로만 받으며, 인자가 없을 경우 설정된 기본값으로 자동으로 설정됩니다. 255 | * `extras`는 키워드 인자로 받습니다. `factory(dict)`에 대해서는 조금 이따가 설명드릴게요. 256 | 257 | 함수형 배리언트는 익숙한 형태이고, 다른 형태들에서는 실현할 수 없는 형태를 만들 수 있다는 장점이 있습니다. 258 | 259 | 또한 함수형 배리언트는 body를 가질 수 있습니다. 260 | 기본적으로 함수의 body는 실행되지 않지만, 만약 첫 번째 인자가 `self`라면 함수의 body가 값과 함께 실행됩니다. 261 | 이때 함수의 파라미터로 주어지는 값과 `self`로 전해지는 배리언트는 서로 같으니 262 | 263 | ### `__post_init__` 264 | 265 | fieldenum은 `__post_init__`을 가질 수 있습니다. 이 메서드를 통해 클래스를 준비하거나 몇 가지 체크를 할 수 있습니다. 266 | 267 | ```python 268 | from fieldenum import Variant, Unit, fieldenum, unreachable 269 | 270 | @fieldenum 271 | class SpaceTime: 272 | Dist = Variant(dist=float, velocity=float) 273 | Cord = Variant(x=float, y=float, z=float, velocity=float) 274 | 275 | def __post_init__(self): 276 | if self.velocity > 299_792_458: 277 | raise ValueError("The speed can't exceed the speed of light!") 278 | 279 | st = SpaceTime.Dist(3000, 402) # OK 280 | st = SpaceTime.Cord(20, 35, 51, 42363) # OK 281 | st = SpaceTime.Cord(2535, 350_000_000) # The speed can't exceed the speed of light! 282 | 283 | ``` 284 | 285 | 286 | 함수형 배리언트가 `self`를 가지고 있다면 **__post_init__은 실행되지 않고 함수 바디가 실행됩니다**. 287 | 물론 만약 원한다면 `self.__post_init__()`을 직접 불러서 실행시킬 수도 있습니다. 288 | 289 | ### 기본값과 기본값 팩토리 290 | 291 | fieldenum의 292 | 293 | ### match문을 이용한 enum의 사용 294 | 295 | `enum`은 파이썬 3.10에서 추가된 match문과 같이 사용하면 매우 어울립니다. 296 | 다음과 같은 enum이 있다고 해봅시다. 297 | 298 | ```python 299 | from fieldenum import Variant, Unit, fieldenum, unreachable 300 | 301 | @fieldenum 302 | class Message: 303 | Quit = Unit 304 | Move = Variant(x=int, y=int) 305 | Write = Variant(str) 306 | ChangeColor = Variant(int, int, int) 307 | ``` 308 | 309 | 이때 이 enum을 처리하는 함수는 다음과 같이 작성할 수 있습니다. 310 | 311 | ```python 312 | class MyClass: 313 | def process_message(self, message: Message): 314 | match message: 315 | case Message.Quit: 316 | sys.exit(0) 317 | 318 | case Message.Move(x=x, y=y): 319 | self.x += x 320 | self.y += y 321 | 322 | case Message.Write(value): 323 | self.f.write(value) 324 | 325 | case Message.ChangeColor(red, green, blue): 326 | self.color = rgb_to_hsv(red, green, blue) 327 | ``` 328 | 329 | match문에 대한 자세한 설명은 [공식 match문 튜토리얼](https://docs.python.org/3/tutorial/controlflow.html#match-statements)을 참고하세요. 330 | 331 | ### 원본 클래스의 메서드 사용 332 | 333 | enum을 만들면 그 배리언트들은 원본 클래스의 메서드들을 사용할 수 있습니다. 334 | 예를 들어 다음은 `Option`의 구현을 모사해 나타낸 예시입니다. 335 | 336 | ```python 337 | from fieldenum import fieldenum, Variant 338 | 339 | @fieldenum 340 | class Option[T]: 341 | Nothing = Unit 342 | Some = Variant(T) 343 | 344 | def unwrap(self) -> T: 345 | match self: 346 | case Option.Nothing: 347 | print("Unwrap failed.") 348 | 349 | case Option.Some(value): 350 | return value 351 | ``` 352 | 353 | `Option`에 구현되어 있는 메서드는 옵션의 배리언트들인 `Nothing`이나 `Some`에서 사용될 수 있습니다. 354 | 355 | ```python 356 | Option.Nothing.unwrap() # TypeError를 raise합니다. 357 | print(Option.Some(123).unwrap()) # 123을 출력합니다. 358 | ``` 359 | 360 | ### `isinstance()` 361 | 362 | 모든 배리언트는 원본 enum 클래스의 인스턴스입니다. 따라서 `isinstance(message, Message)`와 같이 `isinstance()`를 통해 해당 enum인지를 쉽게 확인할 수 있습니다. 363 | 364 | ```python 365 | from fieldenum import fieldenum, Variant 366 | 367 | @fieldenum 368 | class Message: 369 | Quit = Unit 370 | Move = Variant(x=int, y=int) 371 | Write = Variant(str) 372 | ChangeColor = Variant(int, int, int) 373 | 374 | assert isinstance(Message.Write("hello!"), Message) 375 | ``` 376 | 377 | ## Examples 378 | 379 | ### `Option` 380 | 381 | `Option` 타입은 값이 있거나 없을 수 있는 아주 흔한 상황을 나타냅니다. 382 | 383 | 파이썬 개발자들은 이런 상황을 위해 `Optional[T]`이나 `T | None`을 사용합니다. 384 | 예를 들어 `Optional[str]`나 `str | None`이라고 적죠. 385 | 386 | `fieldenum.enums` 패키지에서 제공되는 `Option`은 `Optional`과 정말 비슷합니다. `Option[str]`은 `Optional[str]`과 같이 387 | 값이 없거나 `str`이죠. 388 | 389 | 하지만 여러 면에서 `Option`은 독특한 장점을 가집니다. 390 | 391 | 다음의 예시를 살펴보세요. 392 | 393 | ```python 394 | from fieldenum.enums import Option, Some 395 | 396 | optional: str | None = input("Type anything!") or None 397 | option = Option.new(input("Type anything!") or None) 398 | 399 | # Union을 사용한 경우 다음과 같은 코드는 타입 체커 오류를 일으키고, 런타임 오류의 가능성이 있습니다. 400 | print(optional.upper()) # 어쩔 때는 오류가 나고 어쩔 때는 아닙니다. 401 | 402 | # 이렇게 사용하는 것은 아예 불가능하고, 이는 런타임에서도 명백합니다. 403 | print(option.upper()) # 항상 오류가 발생합니다. 404 | 405 | # 그 대신, 사용자는 다음과 같이 명시적으로 값을 변환시켜야 합니다. 406 | match option: 407 | case Some(value): 408 | print(value.upper()) 409 | 410 | case Option.Nothing: 411 | print("Nothing to show.") 412 | 413 | print(option.map(str.upper).unwrap("Nothing to show.")) # 위에 있던 코드와 완전히 같은 코드입니다. 414 | ``` 415 | 416 | `Option`의 장점 중 하나는 `Union`이나 `Optional`과 달리 '실제 클래스'라는 점입니다. 417 | 따라서 실제 메소드들을 구현할 수 있습니다. 418 | 419 | 예를 들어 위에서 보여드린 `.unwrap()` 메소드도 있고 그 외에도 `.map()` `.new()` 등의 함수도 존재합니다. 420 | 또한 `bool()`같은 속성도 안정적으로 구현할 수 있습니다. 421 | 예를 들어 `int | None` 타입의 경우 값이 `None`일 때도 거짓으로 처리되지만, `0`일 때도 거짓이어서 참인지 거짓인지를 통해 `None`인지 `int`인지 구별하기 애매합니다. 422 | 423 | 하지만 `Option`의 경우에는 안정적으로 `Nothing`일 때는 거짓, `Some`일 때는 참으로 처리할 수 있습니다. 424 | 예를 들어 다음의 코드는 항상 `0`을 출력합니다. 425 | 426 | ```python 427 | from fieldenum.enums import Option, Some 428 | 429 | int_option = Option.Some(0) 430 | if int_option: # evaluated as `True` 431 | print(int_option) 432 | else: 433 | print("There's no value!") 434 | ``` 435 | 436 | `.new()`는 `Optional`을 `Option`으로 바꿔줍니다. 더 쉽게 말하면, `Option.new(None)`은 `Option.Nothing`을 반환하고, 다머지 경우에서는 `Option.new(value)`는 `Option.Some(value)`를 반환합니다. 437 | 438 | `.map(func)`는 `Option.Nothing`에서는 별 영향이 없고, `Option.Some(value)`에서는 `func(value)` 값을 `Option.new`안에 넣습니다. 439 | 예를 들어 `Option.Nothing.map(str)`의 결과는 그대로 `Option.Nothing`이지만, `Option.Some(123).map(str)`의 결과는 `Option.Some('123')`입니다. 440 | 441 | 이러한 기능들은 [PEP 505](https://peps.python.org/pep-0505/)의 부분적인 대안이 될 수 있습니다. 442 | 443 | ### `Message` enum의 예시 444 | 445 | ```python 446 | from fieldenum import fieldenum, Variant, Unit 447 | 448 | # define 449 | @fieldenum 450 | class Message: 451 | Quit = Unit 452 | Move = Variant(x=int, y=int) 453 | Write = Variant(str) 454 | ChangeColor = Variant(int, int, int) 455 | 456 | 457 | # usage 458 | message = Message.Quit 459 | message = Message.Move(x=1, y=2) 460 | message = Message.Write("hello, world!") 461 | message = Message.ChangeColor(256, 256, 0) 462 | ``` 463 | 464 | ### 실제 사례: `ConcatOption` 465 | 466 | fieldenum는 이질적인 성격을 가진 설정들을 모아놓는 경우에 사용하기에 좋았습니다. 467 | 제가 fieldenum을 만들게 된 직접적인 계기이기도 합니다. 468 | 469 | 아래의 예시는 이미지가 여럿 들어 있는 디렉토리의 이미지들을 특정 기준을 통해 470 | 이미지들을 세로로 결합시키는 기능을 가진 패키지에서 사용될 수 있는 471 | fieldenum의 예시입니다. 472 | 473 | 각각의 요구사항에 따라 필요한 정보와 타입이 다르기에 키워드 인자 등으로 해결하기 매우 곤란합니다. 474 | fieldenum을 사용하면 문제를 우아하게 해결할 수 있습니다. 475 | 476 | ```python 477 | from fieldenum import fieldenum, Variant, Unit 478 | 479 | @fieldenum 480 | class ConcatOption: 481 | """이미지를 결합하는 기준을 설정합니다.""" 482 | All = Unit # 모든 이미지를 결합합니다. 483 | Count = Variant(int) # 이미지를 설정된 개수만큼 결합합니다. 484 | Height = Variant(int) # 이미지의 세로 픽셀 수가 설정한 수 이상이 되도록 결합합니다. 485 | Ratio = Variant(float) # 이미지의 세로 픽셀 대 가로 픽셀 수 비 이상이 되도록 결합합니다. 486 | 487 | def concatenate(directory: Path, option: ConcatOption): 488 | ... 489 | 490 | # 사용 예시들 491 | concatenate(Path("images/"), ConcatOption.All) 492 | concatenate(Path("images/"), ConcatOption.Count(5)) 493 | concatenate(Path("images/"), ConcatOption.Height(3000)) 494 | concatenate(Path("images/"), ConcatOption.Ratio(11.5)) 495 | ``` 496 | 497 | ### 연결 리스트 예시 498 | 499 | 다음은 [Rust By Example](https://doc.rust-lang.org/rust-by-example/custom_types/enum/testcase_linked_list.html)에서 찾을 수 있는 연결 리스트 구현 예시입니다. 500 | 501 | ```rust 502 | // 러스트에 대해 잘 모르신다면 이 원본 러스트 구현은 넘겨뛰고 503 | // 아래에 있는 fieldenum 구현을 확인해 보세요! 504 | 505 | use crate::List::*; 506 | 507 | enum List { 508 | Cons(u32, Box), 509 | Nil, 510 | } 511 | 512 | impl List { 513 | fn new() -> List { 514 | Nil 515 | } 516 | 517 | fn prepend(self, elem: u32) -> List { 518 | Cons(elem, Box::new(self)) 519 | } 520 | 521 | fn len(&self) -> u32 { 522 | match *self { 523 | Cons(_, ref tail) => 1 + tail.len(), 524 | Nil => 0 525 | } 526 | } 527 | 528 | fn stringify(&self) -> String { 529 | match *self { 530 | Cons(head, ref tail) => { 531 | format!("{}, {}", head, tail.stringify()) 532 | }, 533 | Nil => { 534 | format!("Nil") 535 | }, 536 | } 537 | } 538 | } 539 | 540 | fn main() { 541 | let mut list = List::new(); 542 | 543 | list = list.prepend(1); 544 | list = list.prepend(2); 545 | list = list.prepend(3); 546 | 547 | println!("linked list has length: {}", list.len()); 548 | println!("{}", list.stringify()); 549 | } 550 | ``` 551 | 552 | 위의 러스트 코드는 아래와 같은 파이썬 코드로 변환할 수 있습니다. 553 | 554 | ```python 555 | from __future__ import annotations 556 | 557 | from fieldenum import Unit, Variant, fieldenum, unreachable 558 | 559 | 560 | @fieldenum 561 | class List: 562 | Cons = Variant(int, "List") 563 | Nil = Unit 564 | 565 | @classmethod 566 | def new(cls) -> List: 567 | return List.Nil 568 | 569 | def prepend(self, elem: int) -> List: 570 | return List.Cons(elem, self) 571 | 572 | def __len__(self) -> int: 573 | match self: 574 | case List.Cons(_, tail): 575 | return 1 + len(tail) 576 | 577 | case List.Nil: 578 | return 0 579 | 580 | case other: 581 | unreachable(other) 582 | 583 | def __str__(self) -> str: 584 | match self: 585 | case List.Cons(head, tail): 586 | return f"{head}, {tail}" 587 | 588 | case List.Nil: 589 | return "Nil" 590 | 591 | case other: 592 | unreachable(other) 593 | 594 | 595 | if __name__ == "__main__": 596 | linked_list = List.new() 597 | linked_list = linked_list.prepend(1) 598 | linked_list = linked_list.prepend(2) 599 | linked_list = linked_list.prepend(3) 600 | print("length:", len(linked_list)) # length: 3 601 | print(linked_list) # 3, 2, 1, Nil 602 | ``` 603 | 604 | ## Railroad Oriented Programming 605 | 606 | Railroad Oriented Programming은 파이썬의 '예외' 시스템을 대체하는 특이한 방법의 프로그래밍입니다. 607 | [BoundResult를 통한 ROP](railroad-oriented-ko.ipynb) 문서를 확인해 보세요. 608 | 609 | ## fieldenum 튜토리얼 610 | 611 | > 이 파트의 대부분은 [<러스트 프로그래밍 언어>의 '열거형 정의하기' 쳅터](https://doc.rust-kr.org/ch06-01-defining-an-enum.html) 내용을 fieldenum의 경우에 맞게 변경한 것입니다. 612 | 613 | fieldenum(이하 enum과 혼용)은 어떤 값이 여러 개의 가능한 614 | 값의 집합 중 하나라는 것을 나타내는 방법을 제공합니다. 예를 들면 `Rectangle`이 615 | `Circle`과 `Triangle`을 포함하는 다양한 모양들의 집합 중 하나라고 표현하고 616 | 싶을 수도 있습니다. 이렇게 하기 위해서 enum은 가능한 것들을 나타내게 해줍니다. 617 | 618 | IP 주소를 다루는 프로그램을 만들어 보면서, 619 | 어떤 상황에서 enum이 유용하고 적절한지 알아보겠습니다. 620 | 현재 사용되는 IP 주소 표준은 IPv4, IPv6 두 종류입니다(앞으로 v4, v6로 표기하겠습니다). 621 | 우리가 만들 프로그램에서 다룰 IP 종류는 이 두 가지가 전부이므로, 622 | 이처럼 가능한 모든 배리언트 들을 죽 *늘어놓을* 수 있는데, 이 때문에 623 | `enum`이라는 이름이 붙은 것입니다. 624 | 625 | IP 주소는 반드시 v4나 v6 중 하나만 될 수 있는데, 626 | 이러한 특성은 enum 자료 구조에 적합합니다. 627 | 왜냐하면, enum의 값은 여러 배리언트 중 하나만 될 수 있기 때문입니다. 628 | v4, v6는 근본적으로 IP 주소이기 때문에, 이 둘은 코드에서 629 | 모든 종류의 IP 주소에 적용되는 상황을 다룰 때 동일한 타입으로 처리되는 것이 630 | 좋습니다. 631 | 632 | `IpAddrKind`라는 enum을 정의하면서 포함할 수 있는 IP 주소인 `V4`와 `V6`를 633 | 나열함으로써 이 개념을 코드에 표현할 수 있습니다. 634 | 이것들을 enum의 *배리언트*라고 합니다: 635 | 636 | ```python 637 | from fieldenum import Unit, Variant, fieldenum, unreachable 638 | 639 | @fieldenum 640 | class IpAddrKind: 641 | V4 = Unit 642 | V6 = Unit 643 | ``` 644 | 645 | ### enum 값 646 | 647 | 아래처럼 `IpAddrKind`의 두 개의 배리언트에 대한 변수를 만들 수 있습니다: 648 | 649 | ```python 650 | four = IpAddrKind.V4 651 | six = IpAddrKind.V6 652 | ``` 653 | 654 | 이제 `IpAddrKind` 타입을 인수로 받는 함수를 정의해 봅시다: 655 | 656 | ```python 657 | def route(ip_kind: IpAddrKind): 658 | pass 659 | ``` 660 | 661 | 그리고, 배리언트 중 하나를 사용해서 함수를 호출할 수 있습니다: 662 | 663 | ```python 664 | route(IpAddrKind.V4) 665 | route(IpAddrKind.V6) 666 | ``` 667 | 668 | enum을 사용하면 더 많은 이점이 있습니다. IP 주소 타입에 대해 669 | 더 생각해 보면, 지금으로서는 실제 IP 주소 *데이터*를 저장할 670 | 방법이 없고 어떤 *종류*인지만 알 수 있습니다. 이 문제를 dataclass를 사용하여 해결하고 671 | 싶을 수 있겠습니다: 672 | 673 | ```python 674 | from dataclasses import dataclass 675 | 676 | from fieldenum import Unit, Variant, fieldenum, unreachable 677 | 678 | @fieldenum 679 | class IpAddrKind: 680 | V4 = Unit 681 | V6 = Unit 682 | 683 | @dataclass 684 | class IpAddr: 685 | kind: IpAddrKind 686 | address: str 687 | 688 | home = IpAddr(kind=IpAddrKind.V4, address="127.0.0.1") 689 | loopback = IpAddr(kind=IpAddrKind.V6, address="::1") 690 | ``` 691 | 692 | `dataclass`를 사용해서 IP 주소의 데이터와 693 | `IpAddrKind` 배리언트 저장하기 694 | 695 | 여기서는 `IpAddrKind` 타입인 `kind` 키와 696 | `str` 타입인 `address` 키를 갖는 `IpAddr`를 정의했습니다. 697 | 그리고 이 dataclass를 가지는 두 변수를 생성했습니다. 첫 번째 `home`은 698 | `kind`의 값으로 `IpAddrKind.V4`를, 연관된 주소 데이터로 699 | `127.0.0.1`을 갖습니다. 두 번째 `loopback`은 `IpAddrKind`의 다른 배리언트인 700 | `V6`를 값으로 갖고, 연관된 주소로 `::1`을 갖습니다. `kind`와 `address`의 701 | 값을 함께 사용하기 위해 dataclass를 사용했습니다. 그렇게 함으로써 배리언트가 702 | 연관된 값을 갖게 되었습니다. 703 | 704 | 각 배리언트에 필드를 추가하는 방식을 사용해서 enum을 dataclass의 일부로 705 | 사용하는 방식보다 더 간결하게 동일한 개념을 표현할 수 있습니다. 706 | `IpAddr` enum의 새로운 정의에서 두 개의 `V4`와 `V6` 배리언트는 연관된 707 | `str` 타입의 값을 갖게 됩니다: 708 | 709 | ```python 710 | from fieldenum import Variant, Unit, fieldenum, unreachable 711 | 712 | @fieldenum 713 | class IpAddr: 714 | V4 = Variant(str) 715 | V6 = Variant(str) 716 | 717 | home = IpAddr.V4("127.0.0.1") 718 | loopback = IpAddr.V6("::1") 719 | ``` 720 | 721 | enum의 각 배리언트에 직접 데이터를 붙임으로써, dataclass를 사용할 필요가 722 | 없어졌습니다. 또한 여기서 enum의 동작에 대한 다른 세부 사항을 살펴보기가 723 | 좀 더 쉬워졌습니다: 각 enum 배리언트의 이름이 해당 enum 인스턴스의 724 | 생성자 함수처럼 된다는 것이죠. 즉, `IpAddr.V4()`는 `str` 인수를 725 | 입력받아서 `IpAddr` 타입의 인스턴스 결과를 만드는 함수입니다. 726 | enum을 정의한 결과로써 이러한 생성자 함수가 자동적으로 727 | 정의됩니다. 728 | 729 | dataclass 대신 enum을 사용하면 또 다른 장점이 있습니다. 730 | 각 배리언트는 다른 타입과 다른 양의 연관된 데이터를 가질 수 있습니다. 731 | V4 IP 주소는 항상 0 ~ 255 사이의 숫자 4개로 된 구성 요소를 갖게 될 것입니다. 732 | `V4` 주소에 4개의 `int` 값을 저장하길 원하지만, `V6` 주소는 하나의 `str` 733 | 값으로 표현되길 원한다면, dataclass로는 이렇게 할 수 없습니다. 734 | fieldenum은 이런 경우를 쉽게 처리합니다: 735 | 736 | ```python 737 | from fieldenum import Variant, Unit, fieldenum, unreachable 738 | 739 | @fieldenum 740 | class IpAddrKind: 741 | V4 = Variant(int, int, int, int) 742 | V6 = Variant(str) 743 | 744 | home = IpAddrKind.V4(127, 0, 0, 1) 745 | loopback = IpAddrKind.V6("::1") 746 | ``` 747 | 748 | enum 배리언트에는 어떤 종류의 데이터라도 넣을 수 있습니다. 749 | 문자열, 숫자 타입, dataclass 등은 물론, 다른 enum마저도 포함할 수 있죠! 750 | 751 | enum의 다른 예제를 살펴봅시다. 이 예제에서는 각 배리언트에 752 | 다양한 종류의 타입들이 포함되어 있습니다: 753 | 754 | ```python 755 | from fieldenum import Variant, Unit, fieldenum, unreachable 756 | 757 | @fieldenum 758 | class Message: 759 | Quit = Unit 760 | Move = Variant(x=int, y=int) 761 | Write = Variant(str) 762 | ChangeColor = Variant(int, int, int) 763 | ``` 764 | 765 | `Message` enum은 각 배리언트가 다른 타입과 766 | 다른 양의 값을 저장합니다. 767 | 768 | 이 enum에는 다른 데이터 타입을 갖는 네 개의 배리언트가 있습니다: 769 | 770 | * `Quit`은 연관된 데이터가 전혀 없습니다. 771 | * `Move`은 dataclass처럼 이름이 있는 필드를 갖습니다. 772 | * `Write`은 하나의 `str`을 가집니다. 773 | * `ChangeColor`는 세 개의 `int`를 가집니다. 774 | 775 | fieldenum에 추가적인 메소드를 정의할 수 있습니다. 여기 `Message` fieldenum에 776 | 정의한 `call`이라는 메서드가 있습니다: 777 | 778 | ```python 779 | from fieldenum import Variant, Unit, fieldenum, unreachable 780 | 781 | @fieldenum 782 | class Message: 783 | Quit = Unit 784 | Move = Variant(x=int, y=int) 785 | Write = Variant(str) 786 | ChangeColor = Variant(int, int, int) 787 | 788 | def process(self): 789 | print(f"Processing `{self}`...") 790 | 791 | m = Message.Write("hello") 792 | m.process() # Processing `Message.Write("hello")`... 793 | ``` 794 | 795 | 메서드 본문에서는 `self`를 사용하여 호출한 fieldenum의 값을 가져올 것입니다. 796 | 이 예제에서 생성한 변수 `m`은 `Message.Write("hello")` 값을 갖게 되고, 797 | 이 값은 `m.process()`이 실행될 때 798 | `process` 메서드 안에서 `self`가 될 것입니다. 799 | 800 | ## 안티 패턴 801 | 802 | ### 배리언트 자체를 타입으로 사용하는 것 803 | 804 | 하나의 배리언트의 값을 내보내고 싶을 때 다음과 같이 배리언트를 타입으로 처리하고 싶을 수 있습니다. 805 | 806 | ```python 807 | from fieldenum import fieldenum, Variant, Unit 808 | from fieldenum.enums import Option 809 | 810 | def hello() -> Option.Some: # XXX 811 | return Option.Some("hello") 812 | 813 | def print_hello(option: Option.Some): # XXX 814 | print(option.unwrap()) 815 | 816 | value = hello() 817 | print_hello(value) 818 | ``` 819 | 820 | 그 대신 함수는 `Option`타입으로 처리하는 것이 더 적절하지는 않을지 고려해 보세요. 821 | 822 | ```python 823 | from fieldenum import fieldenum, Variant, Unit, unreachable 824 | from fieldenum.enums import Option 825 | 826 | def hello() -> Option: # GOOD 827 | return Option.Some("hello") 828 | 829 | def print_hello(option: Option): # GOOD 830 | print(option.unwrap()) 831 | 832 | value = hello() 833 | print_hello(value) 834 | ``` 835 | 836 | 이렇게 하면 추후에 내부 구현을 변경할 때 외부 API를 변경할 필요가 없어 안정성을 올릴 수 있습니다. 837 | 838 | 비슷하게 여러 배리언트를 Union으로 묶어 (예: `Option.Nothing | Option.Some`) 사용하는 것도 나쁜 디자인할 확률이 높습니다. 839 | 840 | 내부적으로만 사용할 때는 정당화될 수 있으나, 이 경우에도 외부로 배리언트 타입을 노출하지는 않도록 하는 것이 좋습니다. 841 | 842 | ### 필드의 타입으로 Union을 사용하는 것 843 | 844 | 다음과 같이 배리언트의 필드에 Union을 사용하는 것은 금지되지는 않지만 말리고 싶습니다. 845 | 그 대신 두 개의 다른 배리언트로 나누는 것을 고려해 보세요. 846 | 847 | ```python 848 | from fieldenum import fieldenum, Variant, Unit, unreachable 849 | 850 | @fieldenum 851 | class InvalidIoResult: 852 | Success = Variant(content=str) 853 | Error = Variant(str | int) # XXX 854 | 855 | # Do instead: 856 | @fieldenum 857 | class ValidIoResult: 858 | Success = Variant(content=str) 859 | ErrorCode = Variant(int) 860 | ErrorMessage = Variant(str) 861 | ``` 862 | 863 | ## 디자인 864 | 865 | ### 상속 금지 866 | 867 | 러스트의 enum이 그렇듯 fieldenum 또한 상속이 가능하지 않습니다. 이는 런타임에서도 저지됩니다. 868 | 869 | 이는 메서드를 그대로 사용할 수 있다는 상속의 가장 큰 이유가 fieldenum에게는 무의미하고, 870 | 상속의 특성이 fieldenum에서 해롭게 작용하기 때문입니다. 871 | 872 | 예를 들어 봅시다. 만약 모종의 사유로 `Option` 배리언트에 `Maybe`를 추가하고 싶다고 해 봅시다. 873 | 874 | ```python 875 | from fieldenum import fieldenum, Variant, Unit 876 | 877 | @fieldenum 878 | class Option[T]: 879 | """실제 Option 구현의 단순화 버전""" 880 | 881 | Nothing = Unit 882 | Some = Variant(T) 883 | 884 | def unwrap(self) -> T: 885 | """실제 `unwrap` 구현을 단순화한 버전""" 886 | match self: 887 | case Option.Nothing: 888 | raise UnwrapFailedError("Unwrap failed.") 889 | 890 | case Option.Some(value): 891 | return value 892 | 893 | case other: 894 | unreachable(other) 895 | 896 | ... 897 | 898 | @fieldenum 899 | class MaybeOption[T](Option[T]): # XXX 900 | Maybe = Unit 901 | ``` 902 | 903 | 이렇게 하면 문제가 생깁니다. 바로 `Option`에서 사용되었던 기존의 모든 메서드가 망가진다는 점입니다. 904 | 905 | 예를 들어 `Maybe` 배리언트에서 `unwrap`을 사용하면 `Unreachable` 오류가 나게 됩니다. 906 | 907 | ```python 908 | MaybeOption.Maybe.unwrap() # Unreachable 909 | ``` 910 | 911 | `Unreachable` 오류는 코드에 버그가 있을 때 생기는 오류인데, 이 경우에는 버그가 아니니 `Maybe`가 처리되도록 912 | 메서드를 직접 변경해야 합니다. 913 | 914 | ```python 915 | @fieldenum 916 | class MaybeOption[T](Option[T]): # XXX 917 | Maybe = Unit 918 | 919 | def unwrap(self) -> T: 920 | """실제 `unwrap` 구현을 단순화한 버전""" 921 | match self: 922 | case Option.Nothing: 923 | raise UnwrapFailedError("Unwrap failed.") 924 | 925 | case Option.Some(value): 926 | return value 927 | 928 | case MaybeOption.Maybe: # 메서드 변경 929 | return None 930 | 931 | case other: 932 | unreachable(other) 933 | ``` 934 | 935 | 그러나 이 방식의 문제는 모든 메서드에 대해 이러한 작업을 수행해야 한다는 점이고, 936 | 그 말은 상속을 써야 하는 근본적인 이유가 없어진다는 의미입니다. 937 | 938 | 따라서 그 대신 다음과 같은 완전히 다른 enum을 작성하는 것이 더 적절합니다. 939 | 940 | ```python 941 | @fieldenum 942 | class MaybeOption[T]: 943 | Nothing = Unit 944 | Some = Variant(T) 945 | Maybe = Unit 946 | 947 | def unwrap(self) -> T: 948 | match self: 949 | case Option.Nothing: 950 | raise UnwrapFailedError("Unwrap failed.") 951 | 952 | case Option.Some(value): 953 | return value 954 | 955 | case MaybeOption.Maybe: 956 | return None 957 | 958 | case other: 959 | unreachable(other) 960 | ``` 961 | 962 | 물론 새로운 배리언트를 추가하는 것인 아닌 새로운 메서드를 추가하기 위해 963 | 상속을 고려해 볼 수도 있습니다. 964 | 965 | 하지만 구현상의 이유로 그 메서드는 사용할 수 없기 때문에 사실상 무의미합니다. 966 | 967 | ```python 968 | from fieldenum import fieldenum, Variant, Unit 969 | from fieldenum.enums import Option 970 | 971 | @fieldenum 972 | class DebuggableOption[T](Option[T]): 973 | def debug(self): 974 | match self: 975 | case DebuggableOption.Nothing: 976 | print("Nothing here...") 977 | 978 | case DebuggableOption.Some(value): 979 | print(f"here is {value}!") 980 | 981 | # 마치 작동하는 것처럼 보입니다. 982 | opt = DebuggableOption.Some(123) 983 | # AttributeError가 raise됩니다. 실제로는 debug라는 메서드는 존재하지 않기 때문입니다. 984 | opt.debug() 985 | # 왜냐하면 `opt`은 `DebuggableOption`의 인스턴스가 아니기 때문입니다! 986 | assert not isinstance(opt, DebuggableOption) 987 | # 그 대신 `opt`는 `Option`의 인스턴스입니다(정확히는 `Option`의 서브클래스(Option의 배리언트)의 인스턴스입니다). 988 | assert isinstance(opt, Option) 989 | ``` 990 | 991 | 구현을 변경하면 서브클래싱이 가능하게 할 수도 있습니다. 992 | 그러나 이는 fieldenum에 대한 근본적인 가정을 흐트러뜨립니다. 993 | 994 | 예를 들어 매우 전형적인 `append_option`이라는 함수를 정의해 봅시다. 995 | 996 | ```python 997 | from collections.abc import MutableSequence 998 | 999 | from fieldenum import fieldenum, Variant, Unit 1000 | from fieldenum.enums import Option, Some 1001 | 1002 | def append_option(sequence: MutableSequence, option: Option): 1003 | # 이 단언문을 통해 타입 힌트를 위반한 코드가 걸러집니다. 1004 | assert isinstance(option, Option) 1005 | 1006 | # 설명을 위한 예시입니다. 실제로는 1007 | # myoption.map(mylist.append)를 사용하면 됩니다! 1008 | match option: 1009 | # option은 Option.Nothing | Option.Some으로 볼 수 있습니다. 1010 | case Option.Some(value): 1011 | sequence.append(value) 1012 | 1013 | case Option.Nothing: 1014 | pass 1015 | 1016 | # 이 코드를 통해 프로그램에 '명백한 오류'가 있을 시 1017 | # 빠르게 잡아낼 수 있습니다. 1018 | case other: 1019 | unreachable(other) 1020 | 1021 | 1022 | mylist = [] 1023 | append_option(mylist, Some(1)) # 1이 append됩니다. 1024 | assert mylist == [1] 1025 | append_option(mylist, Some(2)) # 2가 append됩니다. 1026 | assert mylist == [1, 2] 1027 | append_option(mylist, Option.Nothing) # 아무것도 append되지 않습니다. 1028 | assert mylist == [1, 2] 1029 | ``` 1030 | 1031 | 만약 서브클래싱을 허용한다면 위의 코드는 완전히 무너지게 됩니다. 1032 | 1033 | ```python 1034 | append_option(mylist, DebuggableOption.Some(1)) # Unreachable 1035 | ``` 1036 | 1037 | 위의 코드를 실행하면 `DebuggableOption.Some(1)`는 `Option`의 서브클래스이지만, 1038 | 동시에 `Option.Some`도, `Option.Nothing`도 아니기에 `Unreachable` 오류를 발생시키게 됩니다. 1039 | 1040 | 이는 타입 체커에게조차도 완전히 유효한 코드이기 때문에 잡아내기 쉽지 않으며, 1041 | fieldenum을 사용하는 근본적인 목적을 흐리기 때문에 금지됩니다. 1042 | 1043 | #### 영향 1044 | 1045 | enum은 모두 서브클래싱이 불가능하기에 다음과 같이 `cls`나 `type(self)`를 사용하는 대신 1046 | 그냥 `Option`과 같이 이름을 직접 사용해도 좋습니다. 1047 | 1048 | ```python 1049 | @fieldenum 1050 | class Option[T]: 1051 | Nothing = Unit 1052 | Some = Variant(T) 1053 | 1054 | @classmethod 1055 | def new(cls, value: T | None) -> Self: 1056 | """실제 `Option.new()`의 구현의 단순화 버전""" 1057 | match value: 1058 | case None: 1059 | return Option.Nothing # cls.Nothing이 아닌 Option.Nothing을 사용했습니다. 1060 | 1061 | case value: 1062 | return Option.Some(value) # 여기도 cls.Some(value)이 아닌 Option.Some(value)입니다. 1063 | 1064 | def unwrap(self) -> T: 1065 | """실제 `unwrap` 구현을 단순화한 버전""" 1066 | match self: 1067 | case Option.Nothing: # type(self) 대신 Option을 그대로 사용해도 됩니다. 1068 | raise UnwrapFailedError("Unwrap failed.") 1069 | 1070 | case Option.Some(value): 1071 | return value 1072 | 1073 | case other: 1074 | unreachable(other) 1075 | 1076 | ... 1077 | ``` 1078 | 1079 | ### 왜 타입을 타입 파라미터 대신 호출 인자로 받나요? 1080 | 1081 | fieldenum의 배리언트는 타입을 타입 파라미터가 아닌 호출 인자로 받습니다. 1082 | 1083 | ```python 1084 | from fieldenum import fieldenum, Variant, Unit 1085 | 1086 | @fieldenum 1087 | class InvalidMessage: # XXX 1088 | Quit: Unit # XXX 1089 | Move = Variant[x=int, y=int] # XXX 1090 | Write: Variant[str] # XXX 1091 | ChangeColor: Variant[int, int, int] # XXX 1092 | 1093 | 1094 | @fieldenum 1095 | class ValidMessage: # GOOD 1096 | Quit = Unit 1097 | Move = Variant(x=int, y=int) 1098 | Write = Variant(str) 1099 | ChangeColor = Variant(int, int, int) 1100 | ``` 1101 | 1102 | 해당 결정에는 두 가지 이유가 있습니다. 1103 | 1104 | * 러스트 코드의 모양새를 최대한 따라가고자 했습니다. 타입 파라미터는 러스트의 모양새와는 다릅니다. 1105 | * 튜플 배리언트는 어느 정도 구현이 가능하지만, 이름 있는 필드에 대해서는 아예 표현이 불가능합니다. 예를 들어 `Variant[x=int, y=int]`는 `SyntaxError`가 나는 컴파일 불가능한 틀린 문법입니다. 1106 | 1107 | ### 왜 러스트의 named field와 비슷하게 생긴 딕셔너리 대신 keyword arguments를 사용하나요? 1108 | 1109 | 이름 있는 배리언트는 고정적입니다. 이러한 고정적인 값에는 딕셔너리보다 keyword arguments가 더 유용하고 어울립니다. 1110 | 1111 | 또한 딕셔너리를 사용하지 않음으로써 match문의 가독성이 높아집니다. 1112 | 1113 | ### 왜 `__init__`을 추가할 수 없나요? 1114 | 1115 | `__init__`은 기본적으로 객체가 생성됨을 전제로 합니다. enum 클래스의 서브클래스는 *enum 클래스 자기 자신을 포함해서* 절대 배리언트가 아니면 안 되기에 `__init__`은 기본적으로 사용이 금지되어 있습니다. 1116 | 1117 | 이는 만약 사용자가 `__init__`을 추가로 명시해 사용하더라도 마찬가지입니다. 1118 | 1119 | ### 대략적인 실제 구현 모사 1120 | 1121 | 다음과 같은 enum이 있다고 해 봅시다. 1122 | 1123 | ```python 1124 | from fieldenum import fieldenum, Variant 1125 | 1126 | @fieldenum 1127 | class Message: 1128 | Quit = Unit 1129 | Move = Variant(x=int, y=int) 1130 | Write = Variant(str) 1131 | ChangeColor = Variant(int, int, int) 1132 | 1133 | def say_loud(self): 1134 | if self is not Message.Quit: 1135 | print(self) 1136 | ``` 1137 | 1138 | 위의 fieldenum은 아래의 코드와 유사합니다: 1139 | 1140 | ```python 1141 | class Message: 1142 | def say_loud(self): 1143 | if self is not Message.Quit: 1144 | print(self) 1145 | 1146 | QuitMessage = Message() 1147 | class MoveMessage(Message): 1148 | def __init__(self, x: int, y: int): 1149 | self.x = x 1150 | self.y = y 1151 | class WriteMessage(Message): 1152 | def __init__(self, _0: str, /): 1153 | self._0 = _0 1154 | class ChangeColorMessage(Message): 1155 | def __init__(self, _0: int, _1: int, _2: int, /): 1156 | self._0 = _0 1157 | self._1 = _1 1158 | self._2 = _2 1159 | 1160 | Message.Quit = QuitMessage 1161 | Message.Move = MoveMessage 1162 | Message.Write = WriteMessage 1163 | Message.ChangeColor = ChangeColorMessage 1164 | ``` 1165 | 1166 | ### Unit 배리언트 vs fieldless 배리언트 1167 | 1168 | 필드가 없는 값을 다룰 때는 두 가지 배리언트를 사용 가능합니다. 1169 | 첫 번째는 유닛 배리언트로, `()`를 통해 인스턴스화할 필요가 없이 바로 사용 가능한 배리언트입니다. 1170 | 두 번째는 fieldless 배리언트로, `()`를 통해 인스턴스화가 필요하지만, 그 안에는 어떠한 인자도 받지 않습니다. 1171 | 1172 | ```python 1173 | from fieldenum import fieldenum, Variant, Unit 1174 | 1175 | @fieldenum 1176 | class NoFieldVariants: 1177 | UnitVariant = Unit 1178 | FieldlessVariant = Variant() 1179 | 1180 | unit = NoFieldVariants.UnitVariant # 괄호를 필요로 하지 않습니다. 1181 | fieldless = NoFieldVariants.FieldlessVariant() # 괄호를 필요로 합니다. 1182 | 1183 | # 두 배리언트 모두 isinstance로 확인할 수 있습니다. 1184 | assert isinstance(unit, NoFieldVariants) 1185 | assert isinstance(fieldless, NoFieldVariants) 1186 | 1187 | # 두 배리언트 모두 싱글톤이기에 `is` 연산자로 동일성을 확인할 수 있습니다. 1188 | assert unit is NoFieldVariants.UnitVariant 1189 | assert fieldless is NoFieldVariants.FieldlessVariant() 1190 | ``` 1191 | 1192 | fieldless 배리언트의 경우에도 싱글톤이라는 점을 기억해 주세요. 1193 | 1194 | 일반적으로는 유닛 배리언트를 사용하는 것이 권장되지만, 만약 fieldless 배리언트가 더 어울리는 경우가 있다면 1195 | 사용해도 좋습니다. 1196 | 1197 | ### `unreachable`의 사용법 1198 | 1199 | `unreachable`은 코드가 논리적으로 도달할 수 없지만 타입 체커를 위해서나 하위 호환성이 없는 미래의 변화 등에 제대로 된 오류를 내보내기 위한 목적으로 사용됩니다. 1200 | 1201 | 이 함수는 작성한 코드에 분명한 버그가 있을 때 나타나도록 디자인되어 있습니다. 1202 | 사용자가 버그가 아닌 코드에서 `Unreachable` 오류를 만나는 일이 없도록 주의해 주세요. 1203 | 1204 | 다음의 경우를 확인해 봅시다. 1205 | 1206 | ```python 1207 | from fieldenum import Unit, Variant, fieldenum, unreachable 1208 | 1209 | @fieldenum 1210 | class Option[T]: 1211 | """실제 Option 구현의 단순화된 버전""" 1212 | 1213 | Nothing = Unit 1214 | Some = Variant(T) 1215 | 1216 | def unwrap(self: Option[T]) -> T: 1217 | match self: 1218 | case Option.Nothing: 1219 | raise ValueError("Unwrap failed!") 1220 | 1221 | case Option.Some(value): 1222 | return value 1223 | 1224 | case other: 1225 | unreachable(other) 1226 | ``` 1227 | 1228 | 이 코드에서는 `unreachable()`을 통해 코드를 방어합니다. 1229 | 1230 | 여기에는 세 가지 목적이 있습니다. 1231 | 1232 | * 이는 타입 체커가 발생할 수 없는 결과를 가정하는 것을 방지합니다. `unreachable`이 없으면 타입 체커는 `unwrap` 함수가 매치되지 않고 통과할 가능성이 있다고 생각해 반환 타입을 `T | None`으로 잘못 인식합니다. 1233 | * 이는 `self`에 `Option` 이외의 타입이 왔을 때 생길 수 있는 오류를 방지합니다. 사용자가 `self`에 `Option` 외의 타입을 전달하면 조용히 `None`이 반환되는 것이 아니라 오류를 내보냅니다. 1234 | * 이는 미래의 하위 호환성 없는 변화가 일어났을 때 생길 수 있는 오류를 방지합니다. 1235 | 1236 | 이중 마지막 번째를 한번 더 살피겠습니다. 1237 | 만약 `Nothing`으로는 부족해서 '뭔가 Nothing같지만 확실하지 않은' 값을 표현하기 위해 `Maybe` 배리언트를 추가한다면 어떻게 될까요? 1238 | 1239 | ```python 1240 | from fieldenum import Unit, Variant, fieldenum, unreachable 1241 | 1242 | @fieldenum 1243 | class Option[T]: 1244 | Nothing = Unit 1245 | Some = Variant(T) 1246 | Maybe = Unit 1247 | 1248 | def unwrap(self: Option[T]) -> T: 1249 | match self: 1250 | case Option.Nothing: 1251 | raise ValueError("Unwrap failed!") 1252 | 1253 | case Option.Some(value): 1254 | return value 1255 | 1256 | case other: 1257 | unreachable(other) 1258 | ``` 1259 | 1260 | 이렇게 되면 기존의 코드들의 하위 호환성이 깨지게 되는데, 이때 `unreachable`을 사용한 `.unwrap()`의 구현은 오류를 통해 현재 상태가 잘못되어 보인다고 명확하게 알립니다. 1261 | 1262 | 이러한 `unreachable`의 사용은 없어도 99.9% 확률로 큰 문제가 없습니다(혹은 *없어야 합니다*). 따라서 빼먹더라도 재앙적인 일이 발생하지는 않으니 간단한 코드에서는 생략해도 됩니다. 1263 | 1264 | 하지만 여러 사람이 사용하는 라이브러리 등에서는 `unreachable`을 통해 잘못된 타입 추론을 막고 혹시 모를 미래에 생길 문제를 방지하는 것이 모두에게 좋습니다. 1265 | 1266 | #### `unreachable`을 사용하면 안 되는 경우 1267 | 1268 | 앞서 설명한 경우가 `unreachable`이 유용한 거의 모든 경우입니다. 그 외의 경우에는 `unreachable`을 사용해서는 안 됩니다. 1269 | 1270 | 예를 들어 타입 힌트를 위반한 사용 정도는 `unreachable`이 사용되어서는 안 됩니다. 1271 | 1272 | ```python 1273 | def get_message(message: Option[str]): 1274 | match message: 1275 | case Some(value): 1276 | print("Received:", value) 1277 | 1278 | case Option.Nothing: 1279 | print("Nothing received.") 1280 | 1281 | case other: 1282 | unreachable(other) # XXX: 타입 체커를 어겨서 이곳에 도달할 수 있습니다. 1283 | 1284 | get_message(123) # will raise Unreachable (XXX) 1285 | ``` 1286 | 1287 | 그 대신 아래와 같이 짜야 합니다: 1288 | 1289 | ```python 1290 | def get_message(message: Option[str]): 1291 | match message: 1292 | case Some(value): 1293 | print("Received:", value) 1294 | 1295 | case Option.Nothing: 1296 | print("Nothing received.") 1297 | 1298 | case other: 1299 | raise TypeError(f"Expect `Option` but received {other}.") # GOOD 1300 | 1301 | get_message(123) # will raise TypeError (GOOD) 1302 | ``` 1303 | 1304 | ### `Option.Some` vs `Some` 1305 | 1306 | `enums` 모듈에는 `Option` fieldenum도 제공하며 동시에 `Option`의 배리언트인 `Some`도 1307 | 1308 | ## Credits 1309 | 1310 | 이 프로젝트는 [러스트의 `Enum`](https://doc.rust-lang.org/reference/items/enumerations.html)에서 크게 영향을 받았으며, [rust_enum](https://github.com/girvel/rust_enum)에서 일부 디자인을 차용하였습니다. 1311 | 1312 | 또한 튜토리얼 중 일부는 [<러스트 프로그래밍 언어>의 '열거형 정의하기' 쳅터](https://doc.rust-kr.org/ch06-01-defining-an-enum.html)에서 발췌했습니다. 1313 | 1314 | ## Releases 1315 | 1316 | * 0.3.0: `as_is` 함수 제거, `map`이 해당 fieldenum과 동작하는 방식 변경, 클래스와 함수를 이용한 배리언트 선언 추가, uv 사용, `BoundResult.exit()` 추가, Flag 타입 추가, UnwrapFailedError를 unwrap 실패 시 오류로 사용, 문서 및 기타 기능 개선, `flatmap` 추가 1317 | * 0.2.0: `as_is` 함수들을 따로 분리, 각종 버그 수정, UnwrapFailedError 및 NotAllowedError 제거, 타입 힌트 개선, `kw_only()` 메서드 추가, 이름 있는 배리언트에서 인자로 사용 허용, `with_default()` 메서드 `default()`로 이름 변경, `default_factory()` 메서드 추가, `runtime_check` 인자 제거, `__post_init__()` 메서드 호출 추가, `BoundResult`에 서브클래스 검증 추가, `@variant` 데코레이터 추가 1318 | * 0.1.0: 첫 릴리즈 1319 | -------------------------------------------------------------------------------- /docs/railroad-oriented-ko.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "## Railroad oriented programming with BoundResult - 한국어" 8 | ] 9 | }, 10 | { 11 | "cell_type": "markdown", 12 | "metadata": {}, 13 | "source": [ 14 | "파이썬은 예외를 통해 작업의 성패를 관리하지만,\n", 15 | "함수형 프로그래밍 언어들은 railroad oriented programming(이하 ROP)이라는 재미있는 개념을 통해 Exception을 대체합니다.\n", 16 | "\n", 17 | "fieldenum은 파이썬에 잘 어울리는 ROP를 위한 `BoundResult`라는 enum을 `fieldenum.enums` 모듈에서 제공하고 있습니다.\n", 18 | "\n", 19 | "\n", 20 | "> `fieldenums.enums` 모듈은 현재는 파이썬 3.12 이상에서만 사용 가능합니다. 향후에는 지원이 확대될 수 있습니다.\n", 21 | "\n", 22 | "이 문서에서는 `BoundResult`를 통한 파이썬에서의 ROP를 구현하는 방식을 설명합니다." 23 | ] 24 | }, 25 | { 26 | "cell_type": "markdown", 27 | "metadata": {}, 28 | "source": [ 29 | "## 기초" 30 | ] 31 | }, 32 | { 33 | "cell_type": "markdown", 34 | "metadata": {}, 35 | "source": [ 36 | "다음과 같은 함수가 있다고 이야기해 봅시다." 37 | ] 38 | }, 39 | { 40 | "cell_type": "code", 41 | "execution_count": 2, 42 | "metadata": {}, 43 | "outputs": [], 44 | "source": [ 45 | "from fieldenum import Unit, Variant, fieldenum, unreachable\n", 46 | "from fieldenum.enums import BoundResult, Message, Option\n", 47 | "\n", 48 | "\n", 49 | "def calculate(value: str) -> float:\n", 50 | " int_value = int(value)\n", 51 | " return 100 / int_value" 52 | ] 53 | }, 54 | { 55 | "cell_type": "markdown", 56 | "metadata": {}, 57 | "source": [ 58 | "이 함수는 문자열로 값을 받아서 정수로 변환한 다음에 100에서 변환된 값을 나눈 뒤 값을 내보내는 함수입니다.\n", 59 | "다음과 같이 사용할 수 있습니다.\n" 60 | ] 61 | }, 62 | { 63 | "cell_type": "code", 64 | "execution_count": 2, 65 | "metadata": {}, 66 | "outputs": [], 67 | "source": [ 68 | "assert calculate(\"5\") == 20.0\n", 69 | "assert calculate(\"10\") == 10.0\n", 70 | "assert calculate(\"100\") == 1.0\n", 71 | "assert calculate(\"500\") == 0.2" 72 | ] 73 | }, 74 | { 75 | "cell_type": "markdown", 76 | "metadata": {}, 77 | "source": [ 78 | "이 함수는 두 가지 상황에서 오류를 내보낼 가능성이 있습니다.\n", 79 | "첫 번째는 `value`를 `int`로 변환하는 데에 실패하면 `ValueError`가 발생할 가능성이 있고,\n", 80 | "두 번쩨는 `int_value`가 `0`이 되면 `ZeroDivisionError`가 발생할 가능성이 있습니다." 81 | ] 82 | }, 83 | { 84 | "cell_type": "code", 85 | "execution_count": 3, 86 | "metadata": {}, 87 | "outputs": [ 88 | { 89 | "ename": "ValueError", 90 | "evalue": "invalid literal for int() with base 10: 'not_an_integer'", 91 | "output_type": "error", 92 | "traceback": [ 93 | "\u001b[1;31m---------------------------------------------------------------------------\u001b[0m", 94 | "\u001b[1;31mValueError\u001b[0m Traceback (most recent call last)", 95 | "Cell \u001b[1;32mIn[3], line 1\u001b[0m\n\u001b[1;32m----> 1\u001b[0m \u001b[43mcalculate\u001b[49m\u001b[43m(\u001b[49m\u001b[38;5;124;43m\"\u001b[39;49m\u001b[38;5;124;43mnot_an_integer\u001b[39;49m\u001b[38;5;124;43m\"\u001b[39;49m\u001b[43m)\u001b[49m \u001b[38;5;66;03m# ValueError\u001b[39;00m\n", 96 | "Cell \u001b[1;32mIn[1], line 6\u001b[0m, in \u001b[0;36mcalculate\u001b[1;34m(value)\u001b[0m\n\u001b[0;32m 5\u001b[0m \u001b[38;5;28;01mdef\u001b[39;00m \u001b[38;5;21mcalculate\u001b[39m(value: \u001b[38;5;28mstr\u001b[39m) \u001b[38;5;241m-\u001b[39m\u001b[38;5;241m>\u001b[39m \u001b[38;5;28mfloat\u001b[39m:\n\u001b[1;32m----> 6\u001b[0m int_value \u001b[38;5;241m=\u001b[39m \u001b[38;5;28;43mint\u001b[39;49m\u001b[43m(\u001b[49m\u001b[43mvalue\u001b[49m\u001b[43m)\u001b[49m\n\u001b[0;32m 7\u001b[0m \u001b[38;5;28;01mreturn\u001b[39;00m \u001b[38;5;241m100\u001b[39m \u001b[38;5;241m/\u001b[39m int_value\n", 97 | "\u001b[1;31mValueError\u001b[0m: invalid literal for int() with base 10: 'not_an_integer'" 98 | ] 99 | } 100 | ], 101 | "source": [ 102 | "calculate(\"not_an_integer\") # ValueError" 103 | ] 104 | }, 105 | { 106 | "cell_type": "code", 107 | "execution_count": 4, 108 | "metadata": {}, 109 | "outputs": [ 110 | { 111 | "ename": "ZeroDivisionError", 112 | "evalue": "division by zero", 113 | "output_type": "error", 114 | "traceback": [ 115 | "\u001b[1;31m---------------------------------------------------------------------------\u001b[0m", 116 | "\u001b[1;31mZeroDivisionError\u001b[0m Traceback (most recent call last)", 117 | "Cell \u001b[1;32mIn[4], line 1\u001b[0m\n\u001b[1;32m----> 1\u001b[0m \u001b[43mcalculate\u001b[49m\u001b[43m(\u001b[49m\u001b[38;5;124;43m\"\u001b[39;49m\u001b[38;5;124;43m0\u001b[39;49m\u001b[38;5;124;43m\"\u001b[39;49m\u001b[43m)\u001b[49m \u001b[38;5;66;03m# ZeroDivisionError\u001b[39;00m\n", 118 | "Cell \u001b[1;32mIn[1], line 7\u001b[0m, in \u001b[0;36mcalculate\u001b[1;34m(value)\u001b[0m\n\u001b[0;32m 5\u001b[0m \u001b[38;5;28;01mdef\u001b[39;00m \u001b[38;5;21mcalculate\u001b[39m(value: \u001b[38;5;28mstr\u001b[39m) \u001b[38;5;241m-\u001b[39m\u001b[38;5;241m>\u001b[39m \u001b[38;5;28mfloat\u001b[39m:\n\u001b[0;32m 6\u001b[0m int_value \u001b[38;5;241m=\u001b[39m \u001b[38;5;28mint\u001b[39m(value)\n\u001b[1;32m----> 7\u001b[0m \u001b[38;5;28;01mreturn\u001b[39;00m \u001b[38;5;241;43m100\u001b[39;49m\u001b[43m \u001b[49m\u001b[38;5;241;43m/\u001b[39;49m\u001b[43m \u001b[49m\u001b[43mint_value\u001b[49m\n", 119 | "\u001b[1;31mZeroDivisionError\u001b[0m: division by zero" 120 | ] 121 | } 122 | ], 123 | "source": [ 124 | "calculate(\"0\") # ZeroDivisionError" 125 | ] 126 | }, 127 | { 128 | "cell_type": "markdown", 129 | "metadata": {}, 130 | "source": [ 131 | "예외를 이용해 잘못된 값을 처리하는 것은 장점도 있지만 단점도 명확합니다.\n", 132 | "우선 사용자는 해당 함수가 어떤 오류를 내보낼지에 대해 알 수가 없습니다.\n", 133 | "또한 개발자도 사용자가 함수의 오류를 적절하게 처리했는지를 알 수 있는 방법이나 처리하도록 강제할 수 있는 방법이 없습니다." 134 | ] 135 | }, 136 | { 137 | "cell_type": "markdown", 138 | "metadata": {}, 139 | "source": [ 140 | "ROP는 다른 접근을 취합니다.\n", 141 | "예외라는 또다른 제어 흐름을 만드는 대신, 성공과 실패라는 두 가지 상태를 가질 수 있는 하나의 타입을 리턴하는 방식을 사용합니다.\n", 142 | "사용자가 값을 사용하려면, 해당 타입을 직접 처리해 오류를 해결해야 합니다." 143 | ] 144 | }, 145 | { 146 | "cell_type": "markdown", 147 | "metadata": {}, 148 | "source": [ 149 | "한 번 `BoundResult`를 이용해서 구현해 보죠.\n", 150 | "`BoundResult`는 `Success`와 `Failed` 두 상태를 지닙니다.\n", 151 | "(두 번째 인자에 들어가는 값에 대해서는 조금 이따가 설명해 드리겠습니다.)" 152 | ] 153 | }, 154 | { 155 | "cell_type": "code", 156 | "execution_count": 5, 157 | "metadata": {}, 158 | "outputs": [ 159 | { 160 | "name": "stdout", 161 | "output_type": "stream", 162 | "text": [ 163 | "BoundResult.Success('operation success!', )\n", 164 | "BoundResult.Failed(ValueError('operation failed...'), )\n" 165 | ] 166 | } 167 | ], 168 | "source": [ 169 | "print(BoundResult.Success(\"operation success!\", Exception)) # 성공을 나타냄\n", 170 | "print(BoundResult.Failed(ValueError(\"operation failed...\"), Exception)) # 실패를 나타냄" 171 | ] 172 | }, 173 | { 174 | "cell_type": "markdown", 175 | "metadata": {}, 176 | "source": [ 177 | "파이썬 함수를 원래 예외를 raise해 성패를 알리지만,\n", 178 | "ROP를 사용하는 함수는 그 대신 `Success`와 `Failed`를 통해 값을 보냄으로써 성패를 알립니다." 179 | ] 180 | }, 181 | { 182 | "cell_type": "code", 183 | "execution_count": 3, 184 | "metadata": {}, 185 | "outputs": [], 186 | "source": [ 187 | "def ordinary_python_function(value) -> str:\n", 188 | " if isinstance(value, str):\n", 189 | " return value + \"world!\" # valid operation returns.\n", 190 | " else:\n", 191 | " raise TypeError(\"Type of value is invalid\") # invalid operation raises.\n", 192 | "\n", 193 | "def rop_function(value) -> BoundResult[str, Exception]:\n", 194 | " if isinstance(value, str):\n", 195 | " return BoundResult.Success(value + \"world!\", Exception) # valid operation returns Success.\n", 196 | " else:\n", 197 | " return BoundResult.Failed(TypeError(\"Type of value is invalid\"), Exception) # invalid operation returns Failed." 198 | ] 199 | }, 200 | { 201 | "cell_type": "markdown", 202 | "metadata": {}, 203 | "source": [ 204 | "이렇게 하면 사용자는 이제 오류가 일어나지 않을 거라고 넘겨짚을 수 없습니다. 반드시 어떠한 방식으로든 명시적으로 예외를 처리해야 합니다." 205 | ] 206 | }, 207 | { 208 | "cell_type": "code", 209 | "execution_count": null, 210 | "metadata": {}, 211 | "outputs": [], 212 | "source": [ 213 | "result1: str = ordinary_python_function(1233)\n", 214 | "print(result1) # 작동하지 않더라도 아무튼 사용할 수도 있습니다" 215 | ] 216 | }, 217 | { 218 | "cell_type": "code", 219 | "execution_count": 6, 220 | "metadata": {}, 221 | "outputs": [ 222 | { 223 | "name": "stdout", 224 | "output_type": "stream", 225 | "text": [ 226 | "failed...\n" 227 | ] 228 | } 229 | ], 230 | "source": [ 231 | "result2: BoundResult[str, Exception] = rop_function(123)\n", 232 | "\n", 233 | "match result2: # 명시적으로 값을 처리하지 않으면 사용할 수 없습니다.\n", 234 | " case BoundResult.Success(value, _):\n", 235 | " print(value)\n", 236 | "\n", 237 | " case BoundResult.Failed(err, _):\n", 238 | " print(\"failed...\")" 239 | ] 240 | }, 241 | { 242 | "cell_type": "markdown", 243 | "metadata": {}, 244 | "source": [ 245 | "이제 아까의 ROP 함수를 다시 봅시다." 246 | ] 247 | }, 248 | { 249 | "cell_type": "code", 250 | "execution_count": null, 251 | "metadata": {}, 252 | "outputs": [], 253 | "source": [ 254 | "def rop_function(value) -> BoundResult[str, Exception]:\n", 255 | " if isinstance(value, str):\n", 256 | " return BoundResult.Success(value + \"world!\", Exception) # valid operation returns Success.\n", 257 | " else:\n", 258 | " return BoundResult.Failed(TypeError(\"Type of value is invalid\"), Exception) # invalid operation returns Failed." 259 | ] 260 | }, 261 | { 262 | "cell_type": "markdown", 263 | "metadata": {}, 264 | "source": [ 265 | "솔직히 우리가 기대하는 간결한 파이썬의 모습과는 거리가 멉니다.\n", 266 | "특히나 저런 방식으로 짜면 다른 정상적인 예외 처리를 사용하는 함수들과 잘 맞지도 않습니다.\n", 267 | "그러면 모든 함수를 저렇게 못생기고 일반적인 모습에서 벗어나는 방식으로 짜야 할까요?\n", 268 | "\n", 269 | "물론 아닙니다. `BoundResult.wrap` 데코레이터를 통해 값을 내보내는 일반적인 함수를 `BoundResult`를 내보내는 함수로 변환할 수 있습니다." 270 | ] 271 | }, 272 | { 273 | "cell_type": "code", 274 | "execution_count": 8, 275 | "metadata": {}, 276 | "outputs": [], 277 | "source": [ 278 | "@BoundResult.wrap(ArithmeticError)\n", 279 | "def calculate(value: str) -> float:\n", 280 | " int_value = int(value)\n", 281 | " return 100 / int_value" 282 | ] 283 | }, 284 | { 285 | "cell_type": "markdown", 286 | "metadata": {}, 287 | "source": [ 288 | "이제 `calculate`는 `@BoundResult.wrap(Exception)`로 감싸졌습니다.\n", 289 | "이제 한 번 다시 `calculate`를 사용해 봅시다." 290 | ] 291 | }, 292 | { 293 | "cell_type": "code", 294 | "execution_count": 9, 295 | "metadata": {}, 296 | "outputs": [ 297 | { 298 | "data": { 299 | "text/plain": [ 300 | "BoundResult.Success(20.0, )" 301 | ] 302 | }, 303 | "execution_count": 9, 304 | "metadata": {}, 305 | "output_type": "execute_result" 306 | } 307 | ], 308 | "source": [ 309 | "calculate(\"5\")" 310 | ] 311 | }, 312 | { 313 | "cell_type": "markdown", 314 | "metadata": {}, 315 | "source": [ 316 | "함수값 `20.0`이 그대로 리턴되는 대신 `BoundResult.Success`라는 값으로 감싸진 것을 확인할 수 있습니다.\n", 317 | "이는 이 함수가 예외를 일으키지 않고 정상적으로 값을 반환했다는 의미입니다.\n", 318 | "\n", 319 | "에제 한 번 함수가 실패하도록 해 볼까요? 다음과 같은 코드는 원래 예외를 일으켜야 합니다." 320 | ] 321 | }, 322 | { 323 | "cell_type": "code", 324 | "execution_count": 10, 325 | "metadata": {}, 326 | "outputs": [ 327 | { 328 | "data": { 329 | "text/plain": [ 330 | "BoundResult.Failed(ZeroDivisionError('division by zero'), )" 331 | ] 332 | }, 333 | "execution_count": 10, 334 | "metadata": {}, 335 | "output_type": "execute_result" 336 | } 337 | ], 338 | "source": [ 339 | "calculate(\"0\")" 340 | ] 341 | }, 342 | { 343 | "cell_type": "markdown", 344 | "metadata": {}, 345 | "source": [ 346 | "예외가 일어나는 대신 `BoundResult.Failed`라는 배리언트가 리턴되었네요.\n", 347 | "이는 `BoundResult.wrap`으로 감싸진 함수가 예외를 일으킬 경우 예외를 잡아 `BoundResult.Failed`의 값으로 만들어 리턴합니다." 348 | ] 349 | }, 350 | { 351 | "cell_type": "markdown", 352 | "metadata": {}, 353 | "source": [ 354 | "좋네요. 그렇지만 특정 오류는 리턴으로 처리되는 대신 그냥 예외로 던져지는 것이 나을 수도 있습니다. 예를 들어 `KeyboardInterrupt`나 `SystemExit`같은 오류는 굳이 잡기보단 원래 자기가 하던 일을 할 수 있도록 오류가 전파되는 것이 더 좋을 수 있습니다.\n", 355 | "\n", 356 | "따라서 사용자는 bound를 명시적으로 설정해야 합니다. 이는 예외가 전파되지 않을 기준을 설정합니다. 예를 들어 우리의 `calculate` 함수는 `ArithmeticError`를 바운드로 설정했는데, `ZeroDivisionError`는 `ArithmeticError`의 서브클래스이므로 예외가 전파되는 대신 `BoundResult.Failed`가 리턴됩니다.\n", 357 | "하지만 `int` 함수는 변환에 실패했을 경우 `ValueError`를 내보내고, 이는 `ArithmeticError`의 서브클래스가 아니기 때문에 이는 오류로 전파됩니다." 358 | ] 359 | }, 360 | { 361 | "cell_type": "code", 362 | "execution_count": 11, 363 | "metadata": {}, 364 | "outputs": [ 365 | { 366 | "ename": "ValueError", 367 | "evalue": "invalid literal for int() with base 10: 'not_an_integer'", 368 | "output_type": "error", 369 | "traceback": [ 370 | "\u001b[1;31m---------------------------------------------------------------------------\u001b[0m", 371 | "\u001b[1;31mValueError\u001b[0m Traceback (most recent call last)", 372 | "Cell \u001b[1;32mIn[11], line 1\u001b[0m\n\u001b[1;32m----> 1\u001b[0m \u001b[43mcalculate\u001b[49m\u001b[43m(\u001b[49m\u001b[38;5;124;43m\"\u001b[39;49m\u001b[38;5;124;43mnot_an_integer\u001b[39;49m\u001b[38;5;124;43m\"\u001b[39;49m\u001b[43m)\u001b[49m\n", 373 | "File \u001b[1;32m~\\fieldenum\\src\\fieldenum\\enums.py:186\u001b[0m, in \u001b[0;36mBoundResult.wrap..decorator..inner\u001b[1;34m(*args, **kwargs)\u001b[0m\n\u001b[0;32m 184\u001b[0m \u001b[38;5;28;01mdef\u001b[39;00m \u001b[38;5;21minner\u001b[39m(\u001b[38;5;241m*\u001b[39margs, \u001b[38;5;241m*\u001b[39m\u001b[38;5;241m*\u001b[39mkwargs):\n\u001b[0;32m 185\u001b[0m \u001b[38;5;28;01mtry\u001b[39;00m:\n\u001b[1;32m--> 186\u001b[0m \u001b[38;5;28;01mreturn\u001b[39;00m BoundResult\u001b[38;5;241m.\u001b[39mSuccess(\u001b[43mfunc\u001b[49m\u001b[43m(\u001b[49m\u001b[38;5;241;43m*\u001b[39;49m\u001b[43margs\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[38;5;241;43m*\u001b[39;49m\u001b[38;5;241;43m*\u001b[39;49m\u001b[43mkwargs\u001b[49m\u001b[43m)\u001b[49m, Bound)\n\u001b[0;32m 187\u001b[0m \u001b[38;5;28;01mexcept\u001b[39;00m Bound \u001b[38;5;28;01mas\u001b[39;00m exc:\n\u001b[0;32m 188\u001b[0m \u001b[38;5;28;01mreturn\u001b[39;00m BoundResult\u001b[38;5;241m.\u001b[39mFailed(exc, Bound)\n", 374 | "Cell \u001b[1;32mIn[8], line 3\u001b[0m, in \u001b[0;36mcalculate\u001b[1;34m(value)\u001b[0m\n\u001b[0;32m 1\u001b[0m \u001b[38;5;129m@BoundResult\u001b[39m\u001b[38;5;241m.\u001b[39mwrap(\u001b[38;5;167;01mArithmeticError\u001b[39;00m)\n\u001b[0;32m 2\u001b[0m \u001b[38;5;28;01mdef\u001b[39;00m \u001b[38;5;21mcalculate\u001b[39m(value: \u001b[38;5;28mstr\u001b[39m) \u001b[38;5;241m-\u001b[39m\u001b[38;5;241m>\u001b[39m \u001b[38;5;28mfloat\u001b[39m:\n\u001b[1;32m----> 3\u001b[0m int_value \u001b[38;5;241m=\u001b[39m \u001b[38;5;28;43mint\u001b[39;49m\u001b[43m(\u001b[49m\u001b[43mvalue\u001b[49m\u001b[43m)\u001b[49m\n\u001b[0;32m 4\u001b[0m \u001b[38;5;28;01mreturn\u001b[39;00m \u001b[38;5;241m100\u001b[39m \u001b[38;5;241m/\u001b[39m int_value\n", 375 | "\u001b[1;31mValueError\u001b[0m: invalid literal for int() with base 10: 'not_an_integer'" 376 | ] 377 | } 378 | ], 379 | "source": [ 380 | "calculate(\"not_an_integer\")" 381 | ] 382 | }, 383 | { 384 | "cell_type": "markdown", 385 | "metadata": {}, 386 | "source": [ 387 | "마지막으로 `BoundResult.Success`와 `BoundResult.Failed`는 모두 `fieldenum.enums`에서 직접적으로 접근이 가능합니다.\n", 388 | "따라서 다음과 같이 임포트해서 사용할 수도 있습니다." 389 | ] 390 | }, 391 | { 392 | "cell_type": "code", 393 | "execution_count": 12, 394 | "metadata": {}, 395 | "outputs": [], 396 | "source": [ 397 | "from fieldenum.enums import BoundResult, Success, Failed # noqa: I001\n", 398 | "\n", 399 | "assert Success(1000, Exception) == BoundResult.Success(1000, Exception)\n", 400 | "\n", 401 | "error = ValueError()\n", 402 | "assert Failed(error, Exception) == BoundResult.Failed(error, Exception)" 403 | ] 404 | }, 405 | { 406 | "cell_type": "markdown", 407 | "metadata": {}, 408 | "source": [ 409 | "## BoundResult의 메서드 사용하기" 410 | ] 411 | }, 412 | { 413 | "cell_type": "markdown", 414 | "metadata": {}, 415 | "source": [ 416 | "`BoundResult`는 더욱 간단한 방식으로 ROP를 실현하기 위한 다양한 메서드를 지원합니다." 417 | ] 418 | }, 419 | { 420 | "cell_type": "code", 421 | "execution_count": 13, 422 | "metadata": {}, 423 | "outputs": [], 424 | "source": [ 425 | "# 이 장의 모든 코드들은 이 코드들이 먼저 실행된다고 가정하겠습니다.\n", 426 | "\n", 427 | "from fieldenum.enums import BoundResult, Success, Failed, Some # noqa: I001\n", 428 | "\n", 429 | "@BoundResult.wrap(ArithmeticError)\n", 430 | "def calculate(value: str) -> float:\n", 431 | " int_value = int(value)\n", 432 | " return 100 / int_value" 433 | ] 434 | }, 435 | { 436 | "cell_type": "markdown", 437 | "metadata": {}, 438 | "source": [ 439 | "### `BoundResult.unwrap([default])`" 440 | ] 441 | }, 442 | { 443 | "cell_type": "markdown", 444 | "metadata": {}, 445 | "source": [ 446 | "기본적으로는 오류를 처리하기 위해서는 match문을 사용해야 합니다." 447 | ] 448 | }, 449 | { 450 | "cell_type": "code", 451 | "execution_count": 14, 452 | "metadata": {}, 453 | "outputs": [ 454 | { 455 | "name": "stdout", 456 | "output_type": "stream", 457 | "text": [ 458 | "20.0\n" 459 | ] 460 | } 461 | ], 462 | "source": [ 463 | "match calculate(\"5\"):\n", 464 | " case Success(ok, _):\n", 465 | " print(ok)\n", 466 | "\n", 467 | " case Failed(err, _):\n", 468 | " raise err" 469 | ] 470 | }, 471 | { 472 | "cell_type": "code", 473 | "execution_count": 15, 474 | "metadata": {}, 475 | "outputs": [ 476 | { 477 | "ename": "ZeroDivisionError", 478 | "evalue": "division by zero", 479 | "output_type": "error", 480 | "traceback": [ 481 | "\u001b[1;31m---------------------------------------------------------------------------\u001b[0m", 482 | "\u001b[1;31mZeroDivisionError\u001b[0m Traceback (most recent call last)", 483 | "Cell \u001b[1;32mIn[15], line 6\u001b[0m\n\u001b[0;32m 3\u001b[0m \u001b[38;5;28mprint\u001b[39m(ok)\n\u001b[0;32m 5\u001b[0m \u001b[38;5;28;01mcase\u001b[39;00m\u001b[38;5;250m \u001b[39mFailed(err, \u001b[38;5;28;01m_\u001b[39;00m):\n\u001b[1;32m----> 6\u001b[0m \u001b[38;5;28;01mraise\u001b[39;00m err\n", 484 | "File \u001b[1;32m~\\fieldenum\\src\\fieldenum\\enums.py:186\u001b[0m, in \u001b[0;36mBoundResult.wrap..decorator..inner\u001b[1;34m(*args, **kwargs)\u001b[0m\n\u001b[0;32m 184\u001b[0m \u001b[38;5;28;01mdef\u001b[39;00m \u001b[38;5;21minner\u001b[39m(\u001b[38;5;241m*\u001b[39margs, \u001b[38;5;241m*\u001b[39m\u001b[38;5;241m*\u001b[39mkwargs):\n\u001b[0;32m 185\u001b[0m \u001b[38;5;28;01mtry\u001b[39;00m:\n\u001b[1;32m--> 186\u001b[0m \u001b[38;5;28;01mreturn\u001b[39;00m BoundResult\u001b[38;5;241m.\u001b[39mSuccess(\u001b[43mfunc\u001b[49m\u001b[43m(\u001b[49m\u001b[38;5;241;43m*\u001b[39;49m\u001b[43margs\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[38;5;241;43m*\u001b[39;49m\u001b[38;5;241;43m*\u001b[39;49m\u001b[43mkwargs\u001b[49m\u001b[43m)\u001b[49m, Bound)\n\u001b[0;32m 187\u001b[0m \u001b[38;5;28;01mexcept\u001b[39;00m Bound \u001b[38;5;28;01mas\u001b[39;00m exc:\n\u001b[0;32m 188\u001b[0m \u001b[38;5;28;01mreturn\u001b[39;00m BoundResult\u001b[38;5;241m.\u001b[39mFailed(exc, Bound)\n", 485 | "Cell \u001b[1;32mIn[13], line 7\u001b[0m, in \u001b[0;36mcalculate\u001b[1;34m(value)\u001b[0m\n\u001b[0;32m 4\u001b[0m \u001b[38;5;129m@BoundResult\u001b[39m\u001b[38;5;241m.\u001b[39mwrap(\u001b[38;5;167;01mArithmeticError\u001b[39;00m)\n\u001b[0;32m 5\u001b[0m \u001b[38;5;28;01mdef\u001b[39;00m \u001b[38;5;21mcalculate\u001b[39m(value: \u001b[38;5;28mstr\u001b[39m) \u001b[38;5;241m-\u001b[39m\u001b[38;5;241m>\u001b[39m \u001b[38;5;28mfloat\u001b[39m:\n\u001b[0;32m 6\u001b[0m int_value \u001b[38;5;241m=\u001b[39m \u001b[38;5;28mint\u001b[39m(value)\n\u001b[1;32m----> 7\u001b[0m \u001b[38;5;28;01mreturn\u001b[39;00m \u001b[38;5;241;43m100\u001b[39;49m\u001b[43m \u001b[49m\u001b[38;5;241;43m/\u001b[39;49m\u001b[43m \u001b[49m\u001b[43mint_value\u001b[49m\n", 486 | "\u001b[1;31mZeroDivisionError\u001b[0m: division by zero" 487 | ] 488 | } 489 | ], 490 | "source": [ 491 | "match calculate(\"0\"):\n", 492 | " case Success(ok, _):\n", 493 | " print(ok)\n", 494 | "\n", 495 | " case Failed(err, _):\n", 496 | " raise err" 497 | ] 498 | }, 499 | { 500 | "cell_type": "markdown", 501 | "metadata": {}, 502 | "source": [ 503 | "하지만 모든 오류마다 이러한 코드를 작성하기에는 힘이 들 겁니다. 따라서 `.unwrap()`을 통해 한 줄로 해당 코드와 동일한 작업을 하는 코드를 생성할 수 있습니다." 504 | ] 505 | }, 506 | { 507 | "cell_type": "code", 508 | "execution_count": 16, 509 | "metadata": {}, 510 | "outputs": [ 511 | { 512 | "data": { 513 | "text/plain": [ 514 | "20.0" 515 | ] 516 | }, 517 | "execution_count": 16, 518 | "metadata": {}, 519 | "output_type": "execute_result" 520 | } 521 | ], 522 | "source": [ 523 | "calculate(\"5\").unwrap()" 524 | ] 525 | }, 526 | { 527 | "cell_type": "code", 528 | "execution_count": 17, 529 | "metadata": {}, 530 | "outputs": [ 531 | { 532 | "ename": "ZeroDivisionError", 533 | "evalue": "division by zero", 534 | "output_type": "error", 535 | "traceback": [ 536 | "\u001b[1;31m---------------------------------------------------------------------------\u001b[0m", 537 | "\u001b[1;31mZeroDivisionError\u001b[0m Traceback (most recent call last)", 538 | "Cell \u001b[1;32mIn[17], line 1\u001b[0m\n\u001b[1;32m----> 1\u001b[0m \u001b[43mcalculate\u001b[49m\u001b[43m(\u001b[49m\u001b[38;5;124;43m\"\u001b[39;49m\u001b[38;5;124;43m0\u001b[39;49m\u001b[38;5;124;43m\"\u001b[39;49m\u001b[43m)\u001b[49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43munwrap\u001b[49m\u001b[43m(\u001b[49m\u001b[43m)\u001b[49m\n", 539 | "File \u001b[1;32m~\\fieldenum\\src\\fieldenum\\enums.py:97\u001b[0m, in \u001b[0;36mBoundResult.unwrap\u001b[1;34m(self, default)\u001b[0m\n\u001b[0;32m 94\u001b[0m \u001b[38;5;28;01mreturn\u001b[39;00m ok\n\u001b[0;32m 96\u001b[0m \u001b[38;5;28;01mcase\u001b[39;00m\u001b[38;5;250m \u001b[39mBoundResult\u001b[38;5;241m.\u001b[39mFailed(err, \u001b[38;5;28;01m_\u001b[39;00m) \u001b[38;5;28;01mif\u001b[39;00m default \u001b[38;5;129;01mis\u001b[39;00m _MISSING:\n\u001b[1;32m---> 97\u001b[0m \u001b[38;5;28;01mraise\u001b[39;00m err\n\u001b[0;32m 99\u001b[0m \u001b[38;5;28;01mcase\u001b[39;00m\u001b[38;5;250m \u001b[39mBoundResult\u001b[38;5;241m.\u001b[39mFailed(err, \u001b[38;5;28;01m_\u001b[39;00m):\n\u001b[0;32m 100\u001b[0m \u001b[38;5;28;01mreturn\u001b[39;00m default\n", 540 | "File \u001b[1;32m~\\fieldenum\\src\\fieldenum\\enums.py:186\u001b[0m, in \u001b[0;36mBoundResult.wrap..decorator..inner\u001b[1;34m(*args, **kwargs)\u001b[0m\n\u001b[0;32m 184\u001b[0m \u001b[38;5;28;01mdef\u001b[39;00m \u001b[38;5;21minner\u001b[39m(\u001b[38;5;241m*\u001b[39margs, \u001b[38;5;241m*\u001b[39m\u001b[38;5;241m*\u001b[39mkwargs):\n\u001b[0;32m 185\u001b[0m \u001b[38;5;28;01mtry\u001b[39;00m:\n\u001b[1;32m--> 186\u001b[0m \u001b[38;5;28;01mreturn\u001b[39;00m BoundResult\u001b[38;5;241m.\u001b[39mSuccess(\u001b[43mfunc\u001b[49m\u001b[43m(\u001b[49m\u001b[38;5;241;43m*\u001b[39;49m\u001b[43margs\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[38;5;241;43m*\u001b[39;49m\u001b[38;5;241;43m*\u001b[39;49m\u001b[43mkwargs\u001b[49m\u001b[43m)\u001b[49m, Bound)\n\u001b[0;32m 187\u001b[0m \u001b[38;5;28;01mexcept\u001b[39;00m Bound \u001b[38;5;28;01mas\u001b[39;00m exc:\n\u001b[0;32m 188\u001b[0m \u001b[38;5;28;01mreturn\u001b[39;00m BoundResult\u001b[38;5;241m.\u001b[39mFailed(exc, Bound)\n", 541 | "Cell \u001b[1;32mIn[13], line 7\u001b[0m, in \u001b[0;36mcalculate\u001b[1;34m(value)\u001b[0m\n\u001b[0;32m 4\u001b[0m \u001b[38;5;129m@BoundResult\u001b[39m\u001b[38;5;241m.\u001b[39mwrap(\u001b[38;5;167;01mArithmeticError\u001b[39;00m)\n\u001b[0;32m 5\u001b[0m \u001b[38;5;28;01mdef\u001b[39;00m \u001b[38;5;21mcalculate\u001b[39m(value: \u001b[38;5;28mstr\u001b[39m) \u001b[38;5;241m-\u001b[39m\u001b[38;5;241m>\u001b[39m \u001b[38;5;28mfloat\u001b[39m:\n\u001b[0;32m 6\u001b[0m int_value \u001b[38;5;241m=\u001b[39m \u001b[38;5;28mint\u001b[39m(value)\n\u001b[1;32m----> 7\u001b[0m \u001b[38;5;28;01mreturn\u001b[39;00m \u001b[38;5;241;43m100\u001b[39;49m\u001b[43m \u001b[49m\u001b[38;5;241;43m/\u001b[39;49m\u001b[43m \u001b[49m\u001b[43mint_value\u001b[49m\n", 542 | "\u001b[1;31mZeroDivisionError\u001b[0m: division by zero" 543 | ] 544 | } 545 | ], 546 | "source": [ 547 | "calculate(\"0\").unwrap()" 548 | ] 549 | }, 550 | { 551 | "cell_type": "markdown", 552 | "metadata": {}, 553 | "source": [ 554 | "러스트의 경우에는 `.unwrap_or(default)`라는 메서드를 통해 값이 실패일 때 기본값을 제공할 수 있습니다.\n", 555 | "\n", 556 | "이와 비슷하게 `.unwrap(default)`와 같이 `.unwrap` 메서드에 기본값을 제공하면 `BoundResult.Failed`일 경우 `default`를 리턴합니다." 557 | ] 558 | }, 559 | { 560 | "cell_type": "code", 561 | "execution_count": 18, 562 | "metadata": {}, 563 | "outputs": [ 564 | { 565 | "data": { 566 | "text/plain": [ 567 | "20.0" 568 | ] 569 | }, 570 | "execution_count": 18, 571 | "metadata": {}, 572 | "output_type": "execute_result" 573 | } 574 | ], 575 | "source": [ 576 | "calculate(\"5\").unwrap(0.0) # 기본값이 사용되지 않고 함수의 반환값이 unwrap됩니다." 577 | ] 578 | }, 579 | { 580 | "cell_type": "code", 581 | "execution_count": 19, 582 | "metadata": {}, 583 | "outputs": [ 584 | { 585 | "data": { 586 | "text/plain": [ 587 | "0" 588 | ] 589 | }, 590 | "execution_count": 19, 591 | "metadata": {}, 592 | "output_type": "execute_result" 593 | } 594 | ], 595 | "source": [ 596 | "calculate(\"0\").unwrap(0.0) # 결과가 실패였기 때문에 기본값이 사용됩니다." 597 | ] 598 | }, 599 | { 600 | "cell_type": "markdown", 601 | "metadata": {}, 602 | "source": [ 603 | "### `bool()`" 604 | ] 605 | }, 606 | { 607 | "cell_type": "markdown", 608 | "metadata": {}, 609 | "source": [ 610 | "`BoundResult`는 성공일 때는 `True`로 간주되고, 실패일 때는 `False`로 간주됩니다. 이 방식을 통해서도 간단하고 확실하게 오류인지 검증할 수 있습니다." 611 | ] 612 | }, 613 | { 614 | "cell_type": "code", 615 | "execution_count": 20, 616 | "metadata": {}, 617 | "outputs": [ 618 | { 619 | "name": "stdout", 620 | "output_type": "stream", 621 | "text": [ 622 | "success!\n" 623 | ] 624 | } 625 | ], 626 | "source": [ 627 | "if calculate(\"5\"):\n", 628 | " print(\"success!\")\n", 629 | "else:\n", 630 | " print(\"failed...\")" 631 | ] 632 | }, 633 | { 634 | "cell_type": "code", 635 | "execution_count": 21, 636 | "metadata": {}, 637 | "outputs": [ 638 | { 639 | "name": "stdout", 640 | "output_type": "stream", 641 | "text": [ 642 | "failed...\n" 643 | ] 644 | } 645 | ], 646 | "source": [ 647 | "if calculate(\"0\"):\n", 648 | " print(\"success!\")\n", 649 | "else:\n", 650 | " print(\"failed...\")" 651 | ] 652 | }, 653 | { 654 | "cell_type": "markdown", 655 | "metadata": {}, 656 | "source": [ 657 | "### `BoundResult.map(func)`" 658 | ] 659 | }, 660 | { 661 | "cell_type": "markdown", 662 | "metadata": {}, 663 | "source": [ 664 | "`.map(func)` 메서드는 `BoundResult.Success(value)`일 경우에는 `func(value)`를 실행하고 그 값을 다시 `BoundResult`에 넘깁니다.\n", 665 | "하지만 값이 `BoundResult.Failed`일 경우에는 아무것도 실행하지 않고 그대로를 넘깁니다." 666 | ] 667 | }, 668 | { 669 | "cell_type": "code", 670 | "execution_count": 22, 671 | "metadata": {}, 672 | "outputs": [ 673 | { 674 | "data": { 675 | "text/plain": [ 676 | "BoundResult.Success(2.0, )" 677 | ] 678 | }, 679 | "execution_count": 22, 680 | "metadata": {}, 681 | "output_type": "execute_result" 682 | } 683 | ], 684 | "source": [ 685 | "def revert(x: float) -> float:\n", 686 | " return 1 / x\n", 687 | "\n", 688 | "calculate(\"200\").map(revert) # 결과값인 0.5가 역수가 되어 2.0이 됩니다." 689 | ] 690 | }, 691 | { 692 | "cell_type": "code", 693 | "execution_count": 23, 694 | "metadata": {}, 695 | "outputs": [ 696 | { 697 | "data": { 698 | "text/plain": [ 699 | "BoundResult.Failed(ZeroDivisionError('division by zero'), )" 700 | ] 701 | }, 702 | "execution_count": 23, 703 | "metadata": {}, 704 | "output_type": "execute_result" 705 | } 706 | ], 707 | "source": [ 708 | "Success(0, ArithmeticError).map(revert) # map의 함수가 실패했을 때는 `BoundResult.Failed`가 리턴됩니다." 709 | ] 710 | }, 711 | { 712 | "cell_type": "code", 713 | "execution_count": 24, 714 | "metadata": {}, 715 | "outputs": [ 716 | { 717 | "data": { 718 | "text/plain": [ 719 | "BoundResult.Failed(ZeroDivisionError('division by zero'), )" 720 | ] 721 | }, 722 | "execution_count": 24, 723 | "metadata": {}, 724 | "output_type": "execute_result" 725 | } 726 | ], 727 | "source": [ 728 | "calculate(\"0\").map(revert) # `revert`가 수행되지 않고 기존의 Failed 배리언트가 다시 건네집니다." 729 | ] 730 | }, 731 | { 732 | "cell_type": "markdown", 733 | "metadata": {}, 734 | "source": [ 735 | "이때 함수가 `BoundResult`를 반환한다면 해당 값을 기준으로 성패를 결정합니다. 만약 `BoundResult`가 반환되었을 때조차도 무조건 성공으로 처리하고 싶다면 `map_as_is`를 사용하세요." 736 | ] 737 | }, 738 | { 739 | "cell_type": "code", 740 | "execution_count": 25, 741 | "metadata": {}, 742 | "outputs": [ 743 | { 744 | "data": { 745 | "text/plain": [ 746 | "BoundResult.Failed(ZeroDivisionError('division by zero'), )" 747 | ] 748 | }, 749 | "execution_count": 25, 750 | "metadata": {}, 751 | "output_type": "execute_result" 752 | } 753 | ], 754 | "source": [ 755 | "@BoundResult.wrap(ArithmeticError)\n", 756 | "def revert(x: float) -> float:\n", 757 | " return 1 / x\n", 758 | "\n", 759 | "Success(0, ArithmeticError).map(revert) # revert가 예외 대신 BoundResult를 리턴하지만 잘 처리합니다." 760 | ] 761 | }, 762 | { 763 | "cell_type": "code", 764 | "execution_count": 26, 765 | "metadata": {}, 766 | "outputs": [ 767 | { 768 | "data": { 769 | "text/plain": [ 770 | "BoundResult.Success(BoundResult.Failed(ZeroDivisionError('division by zero'), ), )" 771 | ] 772 | }, 773 | "execution_count": 26, 774 | "metadata": {}, 775 | "output_type": "execute_result" 776 | } 777 | ], 778 | "source": [ 779 | "# as_is를 True로 두면 revert 함수가 `BoundResult`를 리턴하면 성공한 것으로 처리하고 그 값을 그대로 값으로 삼습니다.\n", 780 | "# 일반적으로는 사용할 일이 없습니다.\n", 781 | "Success(0, ArithmeticError).map_as_is(revert)" 782 | ] 783 | }, 784 | { 785 | "cell_type": "markdown", 786 | "metadata": {}, 787 | "source": [ 788 | "### `BoundResult.as_option()`" 789 | ] 790 | }, 791 | { 792 | "cell_type": "markdown", 793 | "metadata": {}, 794 | "source": [ 795 | "아까 설명한 `enums` 모듈에는 사실 `Option`이라는 다른 enum도 포함되어 있습니다.\n", 796 | "\n", 797 | "어떨 때는 `BoundResult`가 `Option`으로 변환되는 것이 필요할 수 있습니다.\n", 798 | "\n", 799 | "그럴 때에 위해, `.as_option()` 메서드는 `BoundResult.Success`일 때는 `Option.Some`을, `BoundResult.Failed`일 때는 `Option.Nothing`을 내보냅니다." 800 | ] 801 | }, 802 | { 803 | "cell_type": "code", 804 | "execution_count": 27, 805 | "metadata": {}, 806 | "outputs": [ 807 | { 808 | "data": { 809 | "text/plain": [ 810 | "Option.Some(20.0)" 811 | ] 812 | }, 813 | "execution_count": 27, 814 | "metadata": {}, 815 | "output_type": "execute_result" 816 | } 817 | ], 818 | "source": [ 819 | "calculate(\"5\").as_option()" 820 | ] 821 | }, 822 | { 823 | "cell_type": "code", 824 | "execution_count": 28, 825 | "metadata": {}, 826 | "outputs": [ 827 | { 828 | "data": { 829 | "text/plain": [ 830 | "Option.Nothing" 831 | ] 832 | }, 833 | "execution_count": 28, 834 | "metadata": {}, 835 | "output_type": "execute_result" 836 | } 837 | ], 838 | "source": [ 839 | "calculate(\"0\").as_option()" 840 | ] 841 | }, 842 | { 843 | "cell_type": "markdown", 844 | "metadata": {}, 845 | "source": [ 846 | "### `BoundResult.rebound(Bound)`" 847 | ] 848 | }, 849 | { 850 | "cell_type": "markdown", 851 | "metadata": {}, 852 | "source": [ 853 | "바운드를 수정할 때는 `.rebound(Bound)`를 사용할 수 있습니다." 854 | ] 855 | }, 856 | { 857 | "cell_type": "code", 858 | "execution_count": 29, 859 | "metadata": {}, 860 | "outputs": [ 861 | { 862 | "data": { 863 | "text/plain": [ 864 | "BoundResult.Success(20.0, )" 865 | ] 866 | }, 867 | "execution_count": 29, 868 | "metadata": {}, 869 | "output_type": "execute_result" 870 | } 871 | ], 872 | "source": [ 873 | "calculate(\"5\").rebound(Exception)" 874 | ] 875 | }, 876 | { 877 | "cell_type": "markdown", 878 | "metadata": {}, 879 | "source": [ 880 | "이때 새로운 `BoundResult` 객체가 만들어지고, 기존의 값은 변경되지 않는다는 사실에 주의하세요." 881 | ] 882 | }, 883 | { 884 | "cell_type": "code", 885 | "execution_count": 30, 886 | "metadata": {}, 887 | "outputs": [ 888 | { 889 | "name": "stdout", 890 | "output_type": "stream", 891 | "text": [ 892 | "BoundResult.Success(20.0, )\n", 893 | "BoundResult.Success(20.0, )\n", 894 | "BoundResult.Success(20.0, )\n" 895 | ] 896 | } 897 | ], 898 | "source": [ 899 | "result = calculate(\"5\")\n", 900 | "print(result)\n", 901 | "print(result.rebound(Exception)) # rebound가 새로운 객체를 반환합니다.\n", 902 | "print(result) # 기존에 `result`에 있던 객체는 변환되지 않습니다." 903 | ] 904 | }, 905 | { 906 | "cell_type": "markdown", 907 | "metadata": {}, 908 | "source": [ 909 | "## 결과 연결하기" 910 | ] 911 | }, 912 | { 913 | "cell_type": "markdown", 914 | "metadata": {}, 915 | "source": [ 916 | "`BoundResult`는 결과를 서로 연결하기 위한 별도의 연산자를 제공하지는 않습니다. 하지만 다양한 기본 연산자들과 함수들을 통해 결과를 취합할 수 있습니다." 917 | ] 918 | }, 919 | { 920 | "cell_type": "markdown", 921 | "metadata": {}, 922 | "source": [ 923 | "다음과 같은 두 함수가 있다고 해봅시다.\n", 924 | "`will_fail`는 이름처럼 항상 실패하고, `will_success`는 항상 성공합니다.\n", 925 | "단, 이 값들은 모두 `BoundResult.Failed`와 `BoundResult.Success`로 리턴됩니다." 926 | ] 927 | }, 928 | { 929 | "cell_type": "code", 930 | "execution_count": 31, 931 | "metadata": {}, 932 | "outputs": [], 933 | "source": [ 934 | "@BoundResult.wrap(Exception)\n", 935 | "def will_fail(value: int) -> float:\n", 936 | " raise ValueError(value)\n", 937 | "\n", 938 | "@BoundResult.wrap(Exception)\n", 939 | "def will_success(value: int) -> int:\n", 940 | " return value" 941 | ] 942 | }, 943 | { 944 | "cell_type": "markdown", 945 | "metadata": {}, 946 | "source": [ 947 | "우선 두 개의 연산 중 하나라도 실패한 게 있는지 확인하고 싶다면 `and`를 통해 결과를 이어 확인할 수 있습니다." 948 | ] 949 | }, 950 | { 951 | "cell_type": "code", 952 | "execution_count": 32, 953 | "metadata": {}, 954 | "outputs": [ 955 | { 956 | "data": { 957 | "text/plain": [ 958 | "BoundResult.Failed(ValueError(1), )" 959 | ] 960 | }, 961 | "execution_count": 32, 962 | "metadata": {}, 963 | "output_type": "execute_result" 964 | } 965 | ], 966 | "source": [ 967 | "will_fail(1) and will_success(2) # 한 결과가 Failed이므로 해당 값을 리턴합니다." 968 | ] 969 | }, 970 | { 971 | "cell_type": "code", 972 | "execution_count": 33, 973 | "metadata": {}, 974 | "outputs": [ 975 | { 976 | "data": { 977 | "text/plain": [ 978 | "BoundResult.Success(2, )" 979 | ] 980 | }, 981 | "execution_count": 33, 982 | "metadata": {}, 983 | "output_type": "execute_result" 984 | } 985 | ], 986 | "source": [ 987 | "will_success(1) and will_success(2) # 모든 결과가 Success이므로 성공을 리턴합니다." 988 | ] 989 | }, 990 | { 991 | "cell_type": "code", 992 | "execution_count": 34, 993 | "metadata": {}, 994 | "outputs": [ 995 | { 996 | "data": { 997 | "text/plain": [ 998 | "BoundResult.Success(4, )" 999 | ] 1000 | }, 1001 | "execution_count": 34, 1002 | "metadata": {}, 1003 | "output_type": "execute_result" 1004 | } 1005 | ], 1006 | "source": [ 1007 | "will_success(1) and will_success(2) and will_success(3) and will_success(4) # 임의의 길이에서도 작동합니다." 1008 | ] 1009 | }, 1010 | { 1011 | "cell_type": "code", 1012 | "execution_count": 35, 1013 | "metadata": {}, 1014 | "outputs": [ 1015 | { 1016 | "data": { 1017 | "text/plain": [ 1018 | "BoundResult.Failed(ValueError(3), )" 1019 | ] 1020 | }, 1021 | "execution_count": 35, 1022 | "metadata": {}, 1023 | "output_type": "execute_result" 1024 | } 1025 | ], 1026 | "source": [ 1027 | "will_success(1) and will_success(2) and will_fail(3) and will_success(4)" 1028 | ] 1029 | }, 1030 | { 1031 | "cell_type": "markdown", 1032 | "metadata": {}, 1033 | "source": [ 1034 | "반대로 전체 중 하나라도 성공한 게 있는지 확인하려면 `or`을 사용할 수 있습니다. 마찬가지로 임의의 길이로 사용할 수 있습니다." 1035 | ] 1036 | }, 1037 | { 1038 | "cell_type": "code", 1039 | "execution_count": 36, 1040 | "metadata": {}, 1041 | "outputs": [ 1042 | { 1043 | "data": { 1044 | "text/plain": [ 1045 | "BoundResult.Success(2, )" 1046 | ] 1047 | }, 1048 | "execution_count": 36, 1049 | "metadata": {}, 1050 | "output_type": "execute_result" 1051 | } 1052 | ], 1053 | "source": [ 1054 | "will_fail(1) or will_success(2)" 1055 | ] 1056 | }, 1057 | { 1058 | "cell_type": "code", 1059 | "execution_count": 37, 1060 | "metadata": {}, 1061 | "outputs": [ 1062 | { 1063 | "data": { 1064 | "text/plain": [ 1065 | "BoundResult.Failed(ValueError(4), )" 1066 | ] 1067 | }, 1068 | "execution_count": 37, 1069 | "metadata": {}, 1070 | "output_type": "execute_result" 1071 | } 1072 | ], 1073 | "source": [ 1074 | "will_fail(1) or will_fail(2) or will_fail(3) or will_fail(4)" 1075 | ] 1076 | }, 1077 | { 1078 | "cell_type": "code", 1079 | "execution_count": 38, 1080 | "metadata": {}, 1081 | "outputs": [ 1082 | { 1083 | "data": { 1084 | "text/plain": [ 1085 | "BoundResult.Success(3, )" 1086 | ] 1087 | }, 1088 | "execution_count": 38, 1089 | "metadata": {}, 1090 | "output_type": "execute_result" 1091 | } 1092 | ], 1093 | "source": [ 1094 | "will_fail(1) or will_fail(2) or will_success(3) or will_fail(4)" 1095 | ] 1096 | }, 1097 | { 1098 | "cell_type": "markdown", 1099 | "metadata": {}, 1100 | "source": [ 1101 | "만약 결과의 개수가 임의적이거나 너무 많아 고정된 길이로 연결하기 어렵다면 `key=bool`로 설정한 `max`나 `min`을 이용할 수도 있습니다." 1102 | ] 1103 | }, 1104 | { 1105 | "cell_type": "code", 1106 | "execution_count": 39, 1107 | "metadata": {}, 1108 | "outputs": [ 1109 | { 1110 | "data": { 1111 | "text/plain": [ 1112 | "BoundResult.Failed(ValueError(0), )" 1113 | ] 1114 | }, 1115 | "execution_count": 39, 1116 | "metadata": {}, 1117 | "output_type": "execute_result" 1118 | } 1119 | ], 1120 | "source": [ 1121 | "max((will_fail(i) for i in range(100)), key=bool) # max에서 모든 함수가 실패했으므로 가장 처음에 실패한 값을 내보냅니다." 1122 | ] 1123 | }, 1124 | { 1125 | "cell_type": "code", 1126 | "execution_count": 40, 1127 | "metadata": {}, 1128 | "outputs": [ 1129 | { 1130 | "data": { 1131 | "text/plain": [ 1132 | "BoundResult.Success(100, )" 1133 | ] 1134 | }, 1135 | "execution_count": 40, 1136 | "metadata": {}, 1137 | "output_type": "execute_result" 1138 | } 1139 | ], 1140 | "source": [ 1141 | "max(*(will_fail(i) for i in range(100)), will_success(100), key=bool) # max에서 한 함수가 성공했으므로 해당 값을 내보냅니다." 1142 | ] 1143 | }, 1144 | { 1145 | "cell_type": "code", 1146 | "execution_count": 41, 1147 | "metadata": {}, 1148 | "outputs": [ 1149 | { 1150 | "data": { 1151 | "text/plain": [ 1152 | "BoundResult.Failed(ValueError(100), )" 1153 | ] 1154 | }, 1155 | "execution_count": 41, 1156 | "metadata": {}, 1157 | "output_type": "execute_result" 1158 | } 1159 | ], 1160 | "source": [ 1161 | "min(*(will_success(i) for i in range(100)), will_fail(100), key=bool) # min에서 한 함수가 실패했으므로 해당 값을 내보냅니다." 1162 | ] 1163 | }, 1164 | { 1165 | "cell_type": "markdown", 1166 | "metadata": {}, 1167 | "source": [ 1168 | "만약 결과가 필요하지 않고 성패 여부만 궁금하다면 `all`과 `any`를 사용할 수 있습니다.\n", 1169 | "\n", 1170 | "* `all`: 모든 함수가 성공했다면 `True`, 하나라도 실패했다면 `False`를 리턴합니다.\n", 1171 | "* `any`: 한 함수라도 성공했다면 `True`, 모든 함수가 실패했다면 `False`를 리턴합니다.\n", 1172 | "\n", 1173 | "사전적인 `all`과 `any`의 뜻과 일치하므로 그리 어렵지 않게 받아들이실 수 있을 것입니다.\n", 1174 | "또한 이 함수들은 lazy하므로 `min`, `max`보다 살짝 더 효율적입니다." 1175 | ] 1176 | }, 1177 | { 1178 | "cell_type": "code", 1179 | "execution_count": 42, 1180 | "metadata": {}, 1181 | "outputs": [ 1182 | { 1183 | "data": { 1184 | "text/plain": [ 1185 | "True" 1186 | ] 1187 | }, 1188 | "execution_count": 42, 1189 | "metadata": {}, 1190 | "output_type": "execute_result" 1191 | } 1192 | ], 1193 | "source": [ 1194 | "all(will_success(i) for i in range(100)) # 모두 성공했으므로 True를 리턴합니다." 1195 | ] 1196 | }, 1197 | { 1198 | "cell_type": "code", 1199 | "execution_count": 43, 1200 | "metadata": {}, 1201 | "outputs": [ 1202 | { 1203 | "data": { 1204 | "text/plain": [ 1205 | "False" 1206 | ] 1207 | }, 1208 | "execution_count": 43, 1209 | "metadata": {}, 1210 | "output_type": "execute_result" 1211 | } 1212 | ], 1213 | "source": [ 1214 | "# 하나가 실패했으므로 False를 리턴합니다. 이 과정에서 뒤쪽을 일일이 확인하지 않고 `will_fail`을 만나자마자 바로 리턴합니다.\n", 1215 | "all((will_fail(-1), *(will_success(i) for i in range(100))))" 1216 | ] 1217 | }, 1218 | { 1219 | "cell_type": "code", 1220 | "execution_count": 44, 1221 | "metadata": {}, 1222 | "outputs": [ 1223 | { 1224 | "data": { 1225 | "text/plain": [ 1226 | "False" 1227 | ] 1228 | }, 1229 | "execution_count": 44, 1230 | "metadata": {}, 1231 | "output_type": "execute_result" 1232 | } 1233 | ], 1234 | "source": [ 1235 | "any(will_fail(i) for i in range(100)) # 모두 실패했으므로 False를 리턴합니다." 1236 | ] 1237 | }, 1238 | { 1239 | "cell_type": "code", 1240 | "execution_count": 45, 1241 | "metadata": {}, 1242 | "outputs": [ 1243 | { 1244 | "data": { 1245 | "text/plain": [ 1246 | "True" 1247 | ] 1248 | }, 1249 | "execution_count": 45, 1250 | "metadata": {}, 1251 | "output_type": "execute_result" 1252 | } 1253 | ], 1254 | "source": [ 1255 | "# 하나가 성공했으므로 True를 리턴합니다. 이 과정에서 뒤쪽을 일일이 확인하지 않고 `will_success`을 만나자마자 바로 리턴합니다.\n", 1256 | "any((will_success(-1), *(will_fail(i) for i in range(100))))" 1257 | ] 1258 | } 1259 | ], 1260 | "metadata": { 1261 | "kernelspec": { 1262 | "display_name": ".venv", 1263 | "language": "python", 1264 | "name": "python3" 1265 | }, 1266 | "language_info": { 1267 | "codemirror_mode": { 1268 | "name": "ipython", 1269 | "version": 3 1270 | }, 1271 | "file_extension": ".py", 1272 | "mimetype": "text/x-python", 1273 | "name": "python", 1274 | "nbconvert_exporter": "python", 1275 | "pygments_lexer": "ipython3", 1276 | "version": "3.12.2" 1277 | } 1278 | }, 1279 | "nbformat": 4, 1280 | "nbformat_minor": 2 1281 | } 1282 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "fieldenum" 3 | version = "0.2.0" 4 | description = "Rust-like fielded Enums in Python" 5 | authors = [{ name = "ilotoki0804", email = "ilotoki0804@gmail.com" }] 6 | readme = "README.md" 7 | requires-python = ">=3.12" 8 | keywords = [ 9 | "enum", 10 | "dataclass", 11 | "rust", 12 | "functional", 13 | ] 14 | classifiers = [ 15 | "License :: OSI Approved :: MIT License", 16 | "Operating System :: OS Independent", 17 | "Programming Language :: Python :: 3.10", 18 | "Programming Language :: Python :: 3.11", 19 | "Programming Language :: Python :: 3.12", 20 | "Natural Language :: Korean", 21 | "Intended Audience :: Developers", 22 | "Programming Language :: Rust", 23 | "Typing :: Typed", 24 | ] 25 | dependencies = [] 26 | 27 | [project.urls] 28 | Repository = "https://github.com/ilotoki0804/fieldenum" 29 | Funding = "https://buymeacoffee.com/ilotoki0804" 30 | 31 | [tool.uv] 32 | dev-dependencies = [ 33 | "ipykernel>=6.29.5", 34 | "pytest>=8.3.2", 35 | "pytest-cov>=5.0.0", 36 | "notebook>=7.2.0", 37 | ] 38 | 39 | [tool.ruff] 40 | line-length = 120 41 | exclude = [".venv", "tests"] 42 | 43 | [tool.ruff.lint] 44 | ignore = ["F821"] 45 | 46 | [tool.coverage.report] 47 | exclude_also = [ 48 | "unreachable", 49 | "case other:", 50 | "assert False", 51 | "if (typing\\.)?TYPE_CHECKING:", 52 | "@(typing\\.)?deprecated", 53 | "@overload", 54 | 'if __name__ == "__main__":' 55 | ] 56 | 57 | [build-system] 58 | requires = ["hatchling"] 59 | build-backend = "hatchling.build" 60 | -------------------------------------------------------------------------------- /src/fieldenum/__init__.py: -------------------------------------------------------------------------------- 1 | from ._flag import Flag 2 | from ._fieldenum import Unit, Variant, fieldenum, variant, factory 3 | from .exceptions import unreachable 4 | 5 | __all__ = ["Unit", "Variant", "Flag", "factory", "fieldenum", "unreachable", "variant"] 6 | __version__ = "0.2.0" 7 | -------------------------------------------------------------------------------- /src/fieldenum/_fieldenum.py: -------------------------------------------------------------------------------- 1 | """fieldenum core implementation.""" 2 | 3 | from __future__ import annotations 4 | 5 | import copyreg 6 | import inspect 7 | import types 8 | import typing 9 | from contextlib import suppress 10 | 11 | from ._utils import OneTimeSetter, ParamlessSingletonMeta, unpickle 12 | from .exceptions import unreachable 13 | 14 | T = typing.TypeVar("T") 15 | 16 | 17 | class Variant: # MARK: Variant 18 | __slots__ = ( 19 | "name", 20 | "field", 21 | "attached", 22 | "_slots_names", 23 | "_base", 24 | "_generics", 25 | "_actual", 26 | "_defaults_and_factories", 27 | "_kw_only", 28 | ) 29 | 30 | def __set_name__(self, owner, name) -> None: 31 | if self.attached: 32 | raise TypeError(f"This variants already attached to {self._base.__name__!r}.") 33 | self.name = name 34 | 35 | def __get__(self, obj, objtype=None) -> typing.Self: 36 | if self.attached: 37 | # This is needed in order to make match statements work. 38 | return self._actual # type: ignore 39 | 40 | return self 41 | 42 | def kw_only(self) -> typing.Self: 43 | self._kw_only = True 44 | return self 45 | 46 | # fieldless variant 47 | @typing.overload 48 | def __init__(self) -> None: ... 49 | 50 | # tuple variant 51 | @typing.overload 52 | def __init__(self, *tuple_field) -> None: ... 53 | 54 | # named variant 55 | @typing.overload 56 | def __init__(self, **named_field) -> None: ... 57 | 58 | def __init__(self, *tuple_field, **named_field) -> None: 59 | self.attached = False 60 | self._kw_only = False 61 | self._defaults_and_factories = {} 62 | if tuple_field and named_field: 63 | raise TypeError("Cannot mix tuple fields and named fields. Use named fields.") 64 | self.field = (tuple_field, named_field) 65 | if named_field: 66 | self._slots_names = tuple(named_field) 67 | else: 68 | self._slots_names = tuple(f"_{i}" for i in range(len(tuple_field))) 69 | 70 | if typing.TYPE_CHECKING: 71 | def dump(self): ... 72 | 73 | def default(self, **defaults_and_factories) -> typing.Self: 74 | _, named_field = self.field 75 | if not named_field: 76 | raise TypeError("Only named variants can have defaults.") 77 | 78 | self._defaults_and_factories.update(defaults_and_factories) 79 | return self 80 | 81 | def attach( 82 | self, 83 | cls, 84 | /, 85 | *, 86 | eq: bool, 87 | build_hash: bool, 88 | build_repr: bool, 89 | frozen: bool, 90 | ) -> None | typing.Self: 91 | if self.attached: 92 | raise TypeError(f"This variants already attached to {self._base.__name__!r}.") 93 | 94 | self._base = cls 95 | tuple_field, named_field = self.field 96 | if not self._kw_only: 97 | named_field_keys = tuple(named_field) 98 | item = self 99 | 100 | self._actual: ConstructedVariant 101 | 102 | # fmt: off 103 | class ConstructedVariant(cls): 104 | if frozen and not typing.TYPE_CHECKING: 105 | __slots__ = tuple(f"__original_{name}" for name in item._slots_names) 106 | for name in item._slots_names: 107 | # to prevent potential security risk 108 | if name.isidentifier(): 109 | exec(f"{name} = OneTimeSetter()") 110 | else: 111 | unreachable(name) 112 | OneTimeSetter() # Show IDEs that OneTimeSetter is used. Not executed at runtime. 113 | else: 114 | __slots__ = item._slots_names 115 | 116 | if tuple_field: 117 | class TupleConstructedVariant(ConstructedVariant): 118 | __name__ = item.name 119 | __qualname__ = f"{cls.__qualname__}.{item.name}" 120 | __fields__ = tuple(range(len(tuple_field))) 121 | __slots__ = () 122 | __match_args__ = item._slots_names 123 | 124 | if build_hash: 125 | __slots__ += ("__hash",) 126 | 127 | if frozen: 128 | def __hash__(self) -> int: 129 | with suppress(AttributeError): 130 | return self.__hash 131 | 132 | self.__hash = hash(self.dump()) 133 | return self.__hash 134 | else: 135 | __hash__ = None # type: ignore 136 | 137 | if eq: 138 | def __eq__(self, other: typing.Self): 139 | return type(self) is type(other) and self.dump() == other.dump() 140 | 141 | if build_repr: 142 | def __repr__(self) -> str: 143 | values_repr = ", ".join(repr(getattr(self, f"_{name}" if isinstance(name, int) else name)) for name in self.__fields__) 144 | return f"{item._base.__name__}.{self.__name__}({values_repr})" 145 | 146 | @staticmethod 147 | def _pickle(variant): 148 | assert isinstance(variant, ConstructedVariant) 149 | return unpickle, (cls, self.name, tuple(getattr(variant, f"_{i}") for i in variant.__fields__), {}) 150 | 151 | def dump(self) -> tuple: 152 | return tuple(getattr(self, f"_{name}") for name in self.__fields__) 153 | 154 | def __init__(self, *args) -> None: 155 | if len(tuple_field) != len(args): 156 | raise TypeError(f"Expect {len(tuple_field)} field(s), but received {len(args)} argument(s).") 157 | 158 | for name, field, value in zip(item._slots_names, tuple_field, args, strict=True): 159 | setattr(self, name, value) 160 | 161 | post_init = getattr(self, "__post_init__", lambda: None) 162 | post_init() 163 | 164 | self._actual = TupleConstructedVariant 165 | 166 | elif named_field: 167 | class NamedConstructedVariant(ConstructedVariant): 168 | __name__ = item.name 169 | __qualname__ = f"{cls.__qualname__}.{item.name}" 170 | __fields__ = item._slots_names 171 | __slots__ = () 172 | if not item._kw_only: 173 | __match_args__ = item._slots_names 174 | 175 | if build_hash: 176 | __slots__ += ("__hash",) 177 | 178 | if frozen: 179 | def __hash__(self) -> int: 180 | with suppress(AttributeError): 181 | return self.__hash 182 | 183 | self.__hash = hash(tuple(self.dump().items())) 184 | return self.__hash 185 | else: 186 | __hash__ = None # type: ignore 187 | 188 | if eq: 189 | def __eq__(self, other: typing.Self): 190 | return type(self) is type(other) and self.dump() == other.dump() 191 | 192 | @staticmethod 193 | def _pickle(variant): 194 | assert isinstance(variant, ConstructedVariant) 195 | return unpickle, (cls, self.name, (), {name: getattr(variant, name) for name in variant.__fields__}) 196 | 197 | def dump(self): 198 | return {name: getattr(self, name) for name in self.__fields__} 199 | 200 | def __repr__(self) -> str: 201 | values_repr = ', '.join(f'{name}={getattr(self, f"_{name}" if isinstance(name, int) else name)!r}' for name in self.__fields__) 202 | return f"{item._base.__name__}.{self.__name__}({values_repr})" 203 | 204 | def __init__(self, *args, **kwargs) -> None: 205 | if args: 206 | if item._kw_only: 207 | raise TypeError(f"Variant '{type(self).__qualname__}' is keyword only.") 208 | 209 | if len(args) > len(named_field_keys): 210 | raise TypeError(f"{self.__name__} takes {len(named_field_keys)} positional argument(s) but {len(args)} were/was given") 211 | 212 | # a valid use case of zip without strict=True 213 | for arg, field_name in zip(args, named_field_keys): 214 | if field_name in kwargs: 215 | raise TypeError(f"Inconsistent input for field '{field_name}': received both positional and keyword values") 216 | kwargs[field_name] = arg 217 | 218 | if item._defaults_and_factories: 219 | for name, default_or_factory in item._defaults_and_factories.items(): 220 | if name not in kwargs: 221 | kwargs[name] = factory._produce_from(default_or_factory) 222 | 223 | if missed_keys := kwargs.keys() ^ named_field.keys(): 224 | raise TypeError(f"Key mismatch: {missed_keys}") 225 | 226 | for name in named_field: 227 | value = kwargs[name] 228 | # field = named_field[name] 229 | setattr(self, name, value) 230 | 231 | post_init = getattr(self, "__post_init__", lambda: None) 232 | post_init() 233 | 234 | self._actual = NamedConstructedVariant 235 | 236 | else: 237 | class FieldlessConstructedVariant(ConstructedVariant, metaclass=ParamlessSingletonMeta): 238 | __name__ = item.name 239 | __qualname__ = f"{cls.__qualname__}.{item.name}" 240 | __fields__ = () 241 | __slots__ = () 242 | 243 | if build_hash and not frozen: 244 | __hash__ = None # type: ignore 245 | else: 246 | def __hash__(self): 247 | return hash(type(self)) 248 | 249 | @staticmethod 250 | def _pickle(variant): 251 | assert isinstance(variant, ConstructedVariant) 252 | return unpickle, (cls, self.name, (), {}) 253 | 254 | def dump(self): 255 | return () 256 | 257 | def __repr__(self) -> str: 258 | values_repr = "" 259 | return f"{item._base.__name__}.{self.__name__}({values_repr})" 260 | 261 | def __init__(self) -> None: 262 | post_init = getattr(self, "__post_init__", lambda: None) 263 | post_init() 264 | 265 | self._actual = FieldlessConstructedVariant 266 | # fmt: on 267 | 268 | copyreg.pickle(self._actual, self._actual._pickle) 269 | self.attached = True 270 | 271 | def __call__(self, *args, **kwargs): 272 | return self._actual(*args, **kwargs) 273 | 274 | 275 | POSITIONALS = (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD) 276 | 277 | 278 | class _FunctionVariant(Variant): # MARK: FunctionVariant 279 | __slots__ = ("_func", "_signature", "_match_args", "_self_included") 280 | name: str 281 | 282 | def __init__(self, func: types.FunctionType) -> None: 283 | assert type(func) is types.FunctionType, "Type other than function is not allowed." 284 | self.attached = False 285 | self._func = func 286 | signature = inspect.signature(func) 287 | parameters_raw = signature.parameters 288 | self._signature = signature 289 | 290 | parameters_iter = iter(parameters_raw) 291 | self._self_included = next(parameters_iter) == "self" 292 | 293 | parameter_names = tuple(parameters_iter) if self._self_included else tuple(parameters_raw) 294 | self._slots_names = parameter_names 295 | self.field = ((), parameter_names) 296 | self._match_args = tuple( 297 | name for name in parameter_names 298 | if parameters_raw[name].kind in POSITIONALS 299 | ) 300 | 301 | def kw_only(self) -> typing.NoReturn: 302 | raise TypeError( 303 | "`.kw_only()` method cannot be used in function variant. " 304 | "Use function keyword-only specifier(asterisk) instead." 305 | ) 306 | 307 | def default(self, **_) -> typing.NoReturn: 308 | raise TypeError( 309 | "`.default()` method cannot be used in function variant. " 310 | "Use function defaults instead." 311 | ) 312 | 313 | def attach( 314 | self, 315 | cls, 316 | /, 317 | *, 318 | eq: bool, 319 | build_hash: bool, 320 | build_repr: bool, 321 | frozen: bool, 322 | ) -> None | typing.Self: 323 | if self.attached: 324 | raise TypeError(f"This variants already attached to {self._base.__name__!r}.") 325 | 326 | self._base = cls 327 | item = self 328 | 329 | # fmt: off 330 | class ConstructedVariant(cls): 331 | if frozen and not typing.TYPE_CHECKING: 332 | __slots__ = tuple(f"__original_{name}" for name in item._slots_names) 333 | for name in item._slots_names: 334 | # to prevent potential security risk 335 | if name.isidentifier(): 336 | exec(f"{name} = OneTimeSetter()") 337 | else: 338 | unreachable(name) 339 | OneTimeSetter() # Show IDEs that OneTimeSetter is used. Not executed at runtime. 340 | else: 341 | __slots__ = item._slots_names 342 | 343 | __name__ = item.name 344 | __qualname__ = f"{cls.__qualname__}.{item.name}" 345 | __fields__ = item._slots_names 346 | __match_args__ = item._match_args 347 | 348 | if build_hash: 349 | __slots__ += ("__hash",) 350 | 351 | if frozen: 352 | def __hash__(self) -> int: 353 | with suppress(AttributeError): 354 | return self.__hash 355 | 356 | self.__hash = hash(tuple(self.dump().items())) 357 | return self.__hash 358 | else: 359 | __hash__ = None # type: ignore 360 | 361 | if eq: 362 | def __eq__(self, other: typing.Self): 363 | return type(self) is type(other) and self.dump() == other.dump() 364 | 365 | def _get_positions(self) -> tuple[dict[str, typing.Any], dict[str, typing.Any]]: 366 | match_args = self.__match_args__ 367 | args_dict = {} 368 | kwargs = {} 369 | for name in self.__fields__: 370 | if name in match_args: 371 | args_dict[name] = getattr(self, name) 372 | else: 373 | kwargs[name] = getattr(self, name) 374 | return args_dict, kwargs 375 | 376 | @staticmethod 377 | def _pickle(variant): 378 | assert isinstance(variant, ConstructedVariant) 379 | args_dict, kwargs = variant._get_positions() 380 | return unpickle, (cls, self.name, tuple(args_dict.values()), kwargs) 381 | 382 | def dump(self): 383 | return {name: getattr(self, name) for name in self.__fields__} 384 | 385 | if eq: 386 | def __eq__(self, other: typing.Self): 387 | return type(self) is type(other) and self.dump() == other.dump() 388 | 389 | if build_repr: 390 | def __repr__(self) -> str: 391 | args_dict, kwargs = self._get_positions() 392 | args_repr = ", ".join(repr(value) for value in args_dict.values()) 393 | kwargs_repr = ", ".join( 394 | f'{name}={value!r}' 395 | for name, value in kwargs.items() 396 | ) 397 | 398 | if args_repr and kwargs_repr: 399 | values_repr = f"{args_repr}, {kwargs_repr}" 400 | else: 401 | values_repr = f"{args_repr}{kwargs_repr}" 402 | 403 | return f"{item._base.__name__}.{self.__name__}({values_repr})" 404 | 405 | def __init__(self, *args, **kwargs) -> None: 406 | bound = ( 407 | item._signature.bind(None, *args, **kwargs) 408 | if item._self_included 409 | else item._signature.bind(*args, **kwargs) 410 | ) 411 | 412 | # code from Signature.apply_defaults() 413 | arguments = bound.arguments 414 | new_arguments = {} 415 | for name, param in item._signature.parameters.items(): 416 | try: 417 | value = arguments[name] 418 | except KeyError: 419 | assert param.default is not inspect._empty, "Argument is not properly bound." 420 | value = param.default 421 | new_arguments[name] = factory._produce_from(value) 422 | bound.arguments = new_arguments # type: ignore # why not OrderedDict? I don't know 423 | 424 | for name, value in new_arguments.items(): 425 | if item._self_included and name == "self": 426 | continue 427 | setattr(self, name, value) 428 | 429 | if item._self_included: 430 | bound.arguments["self"] = self 431 | result = item._func(*bound.args, **bound.kwargs) 432 | if result is not None: 433 | raise TypeError("Initializer should return None.") 434 | 435 | post_init = getattr(self, "__post_init__", lambda: None) 436 | post_init() 437 | # fmt: on 438 | 439 | self._actual = ConstructedVariant 440 | copyreg.pickle(self._actual, self._actual._pickle) 441 | self.attached = True 442 | 443 | 444 | @typing.overload 445 | def variant(cls: type, /) -> Variant: ... 446 | 447 | @typing.overload 448 | def variant(*, kw_only: bool = False) -> typing.Callable[[type], Variant]: ... 449 | 450 | @typing.overload 451 | def variant(func: types.FunctionType, /) -> Variant: ... 452 | 453 | def variant(cls_or_func=None, /, *, kw_only: bool = False) -> typing.Any: # MARK: variant 454 | if cls_or_func is None: 455 | return lambda cls_or_func: variant(cls_or_func, kw_only=kw_only) # type: ignore 456 | 457 | if isinstance(cls_or_func, types.FunctionType): 458 | constructed = _FunctionVariant(cls_or_func) 459 | 460 | else: 461 | fields = cls_or_func.__annotations__ 462 | defaults = { 463 | field_name: getattr(cls_or_func, field_name) 464 | for field_name in fields if hasattr(cls_or_func, field_name) 465 | } 466 | 467 | constructed = Variant(**fields).default(**defaults) 468 | if kw_only: 469 | constructed = constructed.kw_only() 470 | 471 | return constructed 472 | 473 | 474 | class factory(typing.Generic[T]): # MARK: factory 475 | def __init__(self, func: typing.Callable[[], T]): 476 | self.__factory = func 477 | 478 | @classmethod 479 | def _produce_from(cls, value: factory[T] | T) -> T: 480 | return value.produce() if isinstance(value, factory) else value # type: ignore 481 | 482 | def produce(self) -> T: 483 | return self.__factory() 484 | 485 | 486 | class UnitDescriptor: # MARK: Unit 487 | __slots__ = ("name",) 488 | __fields__ = None 489 | 490 | def __init__(self, name: str | None = None): 491 | self.name = name 492 | 493 | def __set_name__(self, owner, name): 494 | setattr(owner, name, UnitDescriptor(name)) 495 | 496 | @typing.overload 497 | def __get__(self, obj, objtype: type[T] = ...) -> T: ... # type: ignore 498 | 499 | @typing.overload 500 | def __get__(self, obj, objtype: None = ...) -> typing.Self: ... 501 | 502 | def __get__(self, obj, objtype: type[T] | None = None) -> T | typing.Self: 503 | return self 504 | 505 | def attach( 506 | self, 507 | cls, 508 | /, 509 | *, 510 | eq: bool, # not needed since nothing to check equality 511 | build_hash: bool, 512 | build_repr: bool, 513 | frozen: bool, 514 | ) -> None: 515 | if self.name is None: 516 | raise TypeError("`self.name` is not set.") 517 | 518 | class UnitConstructedVariant(cls, metaclass=ParamlessSingletonMeta): 519 | __name__ = self.name 520 | __slots__ = () 521 | __fields__ = None # `None` means it does not require calling for initialize. 522 | 523 | if build_hash and not frozen: 524 | __hash__ = None # type: ignore # Explicitly disable hash 525 | else: 526 | def __hash__(self): 527 | return hash(type(self)) 528 | 529 | def dump(self): 530 | return None 531 | 532 | @staticmethod 533 | def _pickle(variant): 534 | assert isinstance(variant, UnitConstructedVariant) 535 | return unpickle, (cls, self.name, None, None) 536 | 537 | def __init__(self): 538 | pass 539 | 540 | if build_repr: 541 | def __repr__(self): 542 | return f"{cls.__name__}.{self.__name__}" 543 | 544 | copyreg.pickle(UnitConstructedVariant, UnitConstructedVariant._pickle) 545 | 546 | # This will replace Unit to specialized instance. 547 | setattr(cls, self.name, UnitConstructedVariant()) 548 | 549 | 550 | Unit = UnitDescriptor() 551 | 552 | 553 | def fieldenum( 554 | cls=None, 555 | /, 556 | *, 557 | eq: bool = True, 558 | frozen: bool = True, 559 | ): 560 | if cls is None: 561 | return lambda cls: fieldenum( 562 | cls, 563 | eq=eq, 564 | frozen=frozen, 565 | ) 566 | 567 | # Preventing subclassing fieldenums at runtime. 568 | # This also prevent double decoration. 569 | is_final = False 570 | for base in cls.mro()[1:]: 571 | with suppress(Exception): 572 | if base.__final__: 573 | is_final = True 574 | break 575 | if is_final: 576 | raise TypeError( 577 | "One of the base classes of fieldenum class is marked as final, " 578 | "which means it does not want to be subclassed and it may be fieldenum class, " 579 | "which should not be subclassed." 580 | ) 581 | 582 | class_attributes = vars(cls) 583 | has_own_hash = "__hash__" in class_attributes 584 | build_hash = eq and not has_own_hash 585 | build_repr = cls.__repr__ is object.__repr__ 586 | 587 | attrs = [] 588 | for name, attr in class_attributes.items(): 589 | if isinstance(attr, Variant | UnitDescriptor): 590 | attr.attach( 591 | cls, 592 | eq=eq, 593 | build_hash=build_hash, 594 | build_repr=build_repr, 595 | frozen=frozen, 596 | ) 597 | attrs.append(name) 598 | 599 | cls.__variants__ = attrs 600 | cls.__init__ = _init_not_allowed 601 | 602 | return typing.final(cls) 603 | 604 | 605 | def _init_not_allowed(*args, **kwargs) -> typing.NoReturn: 606 | raise TypeError("A base fieldenum cannot be initialized.") 607 | -------------------------------------------------------------------------------- /src/fieldenum/_flag.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from contextlib import suppress 4 | import typing 5 | from collections.abc import Iterable, MutableMapping, MutableSet 6 | 7 | T = typing.TypeVar("T") 8 | U = typing.TypeVar("U") 9 | V = typing.TypeVar("V") 10 | _MISSING = object() 11 | 12 | 13 | class Flag(typing.Generic[T], MutableSet[T]): 14 | def __init__(self, *flags: T) -> None: 15 | flags_dict: dict[type[T], T] = {type(flag): flag for flag in flags} 16 | self._flags = flags_dict 17 | self.variants = _VariantAdepter(flags_dict, __class__) 18 | 19 | @classmethod 20 | def _from_iterable(cls, it) -> typing.Self: 21 | return cls(*it) 22 | 23 | def __contains__(self, other: T) -> bool: 24 | flag = self._flags.get(type(other), _MISSING) 25 | return flag is not _MISSING and (flag is other or flag == other) 26 | 27 | def __len__(self) -> int: 28 | return len(self._flags) 29 | 30 | def __iter__(self) -> typing.Iterator[T]: 31 | return iter(self._flags.values()) 32 | 33 | def __repr__(self) -> str: 34 | return f"{type(self).__name__}({", ".join(repr(value) for value in self._flags.values())})" 35 | 36 | def add(self, flag: T) -> None: 37 | self._flags[type(flag)] = flag 38 | 39 | def discard(self, flag: T) -> None: 40 | try: 41 | value = self._flags.pop(type(flag)) 42 | except KeyError: 43 | return 44 | else: 45 | if flag != value: 46 | self.add(value) 47 | 48 | def remove(self, flag: T) -> None: 49 | value = self._flags.pop(type(flag)) 50 | if value != flag: 51 | self.add(value) 52 | raise KeyError(value) 53 | 54 | def clear(self) -> None: 55 | self._flags.clear() 56 | 57 | 58 | class _VariantAdepter(typing.Generic[T], MutableSet[type[T]], MutableMapping[type[T], T]): 59 | def __init__(self, flag: dict[type[T], T], variant_constructor: type[Flag], /): 60 | self._flags = flag 61 | self._constructor = variant_constructor 62 | 63 | # Set dunders 64 | 65 | def __contains__(self, other: type[T]) -> bool: 66 | return self._get_type_if_unit(other) in self._flags 67 | 68 | def __len__(self) -> int: 69 | return len(self._flags) 70 | 71 | def __and__(self, other) -> Flag[T]: 72 | if not isinstance(other, Iterable): 73 | return NotImplemented 74 | items = [] 75 | for type in other: 76 | with suppress(KeyError): 77 | items.append(self[type]) 78 | return self._constructor(*items) 79 | 80 | def __iand__(self, other) -> typing.Self: 81 | if not isinstance(other, Iterable): 82 | return NotImplemented 83 | other = {self._get_type_if_unit(item) for item in other} 84 | for type in list(self._flags): # No concurrent safety 85 | if type not in other: 86 | del self._flags[type] 87 | return self 88 | 89 | def __sub__(self, other) -> Flag[T]: 90 | if not isinstance(other, Iterable): 91 | return NotImplemented 92 | other = {self._get_type_if_unit(item) for item in other} 93 | return self._constructor(*( 94 | flag for type, flag in self._flags.items() 95 | if type not in other 96 | )) 97 | 98 | def __isub__(self, it) -> typing.Self: 99 | if it is self: 100 | self.clear() 101 | else: 102 | for value in it: 103 | self.discard(value) 104 | return self 105 | 106 | def __rand__(self, other: typing.Never): 107 | return NotImplemented 108 | 109 | def __rsub__(self, other: typing.Never): 110 | return NotImplemented 111 | 112 | def __or__(self, other: typing.Never): 113 | return NotImplemented 114 | 115 | def __ror__(self, other: typing.Never): 116 | return NotImplemented 117 | 118 | def __xor__(self, other: typing.Never): 119 | return NotImplemented 120 | 121 | def __rxor__(self, other: typing.Never): 122 | return NotImplemented 123 | 124 | def __ior__(self, it: typing.Never): 125 | return NotImplemented 126 | 127 | def __ixor__(self, it: typing.Never): 128 | return NotImplemented 129 | 130 | def isdisjoint(self, other: typing.Never) -> typing.Never: 131 | """This won't work. Use `flag.isdisjoint()` instead.""" 132 | raise TypeError("Cannot use this method with variant adapter. Use `flag.isdisjoint()` instead.") 133 | 134 | def add(self, object: typing.Never) -> typing.Never: 135 | """This won't work. Use `flag.add()` instead.""" 136 | raise TypeError("Cannot add to variant adapter. Use `flag.add()` instead.") 137 | 138 | def clear(self) -> None: 139 | self._flags.clear() 140 | 141 | def discard(self, flag_type: type[T]) -> None: 142 | self._flags.pop(self._get_type_if_unit(flag_type), None) 143 | 144 | def remove(self, flag_type: type[T]) -> None: 145 | self._flags.pop(self._get_type_if_unit(flag_type)) 146 | 147 | # Dictionary APIs 148 | 149 | def __iter__(self) -> typing.Iterator[type[T]]: 150 | raise TypeError("Cannot iterate over variant adapter.") 151 | 152 | def items(self) -> typing.Never: 153 | raise TypeError("Cannot get items from variant adapter.") 154 | 155 | def __delitem__(self, key) -> None: 156 | """delete item from flag. 157 | 158 | This method exist only for parity with mappings. 159 | Use `adapter.remove()` instead. 160 | """ 161 | del self._flags[self._get_type_if_unit(key)] 162 | 163 | def __getitem__(self, key: type[T]) -> T: 164 | return self._flags[self._get_type_if_unit(key)] 165 | 166 | def __setitem__(self, key: typing.Never, value: typing.Never) -> typing.Never: 167 | """This won't work. Use `flag.add()` instead.""" 168 | raise TypeError("Cannot set item with variant adapter. Use `flag.add()` instead.") 169 | 170 | def __eq__(self, other: typing.Never) -> typing.Never: 171 | """This won't work. Use `flag == other` instead.""" 172 | raise TypeError("Cannot check equality with variant adapter. Use `flag == other` instead.") 173 | 174 | def popitem(self) -> typing.Never: 175 | raise TypeError("Cannot pop item from variant adapter.") 176 | 177 | @typing.overload 178 | def pop(self, flag_type: type[T]) -> T: ... 179 | 180 | @typing.overload 181 | def pop(self, flag_type: type[T], default: T) -> T: ... 182 | 183 | @typing.overload 184 | def pop(self, flag_type: type[T], default: U) -> T | U: ... 185 | 186 | def pop(self, flag_type, default=_MISSING): 187 | if default is _MISSING: 188 | return self._flags.pop(self._get_type_if_unit(flag_type)) 189 | else: 190 | return self._flags.pop(self._get_type_if_unit(flag_type), default) 191 | 192 | @typing.overload 193 | def get(self, flag_type: type[T]) -> T | None: ... 194 | 195 | @typing.overload 196 | def get(self, flag_type: type[T], default: T) -> T: ... 197 | 198 | @typing.overload 199 | def get(self, flag_type: type[T], default: U) -> T | U: ... 200 | 201 | def get(self, flag_type, default=_MISSING): 202 | if default is _MISSING: 203 | return self._flags.get(self._get_type_if_unit(flag_type)) 204 | else: 205 | return self._flags.get(self._get_type_if_unit(flag_type), default) 206 | 207 | def update(self, other: typing.Never, /, **kwds: typing.Never) -> typing.Never: 208 | """This won't work. Use `flag |= to_update` instead.""" 209 | raise TypeError("Cannot update item with variant adapter. Use `flag |= to_update` instead.") 210 | 211 | @staticmethod 212 | def _get_type_if_unit(variant: U) -> U: 213 | """Get type if unit, return as is otherwise. 214 | 215 | This function is used for code treating unit variant as a type. 216 | """ 217 | # You could pass either a class or an instance of the variant. 218 | if isinstance(variant, type): 219 | return variant 220 | try: 221 | # If variant.__fields__ is None, the variant is treated as Unit variant. 222 | is_unit = variant.__fields__ is None # type: ignore 223 | except AttributeError: 224 | # Not a variant. Your code should not include those on Flag. 225 | # It's not tested as well. 226 | return variant 227 | else: 228 | return type(variant) if is_unit else variant 229 | -------------------------------------------------------------------------------- /src/fieldenum/_utils.py: -------------------------------------------------------------------------------- 1 | """Internal utilities of fieldenum. 2 | 3 | This functions and classes are not meant to be used by users, 4 | which means they can be modified, deleted, or added without notice. 5 | """ 6 | 7 | from __future__ import annotations 8 | 9 | 10 | def unpickle(cls, name: str, args, kwargs): 11 | Variant = getattr(cls, name) 12 | if args is None and kwargs is None: 13 | return Variant 14 | else: 15 | return Variant(*args, **kwargs) 16 | 17 | 18 | class OneTimeSetter: 19 | def __set_name__(self, owner, name): 20 | self.name = name 21 | self.private_name = f"__original_{name}" 22 | 23 | def __get__(self, obj, objtype=None): 24 | return getattr(obj, self.private_name) 25 | 26 | def __set__(self, obj, value): 27 | if hasattr(obj, self.private_name): 28 | raise TypeError(f"Cannot mutate attribute `{self.name}` since it's frozen.") 29 | setattr(obj, self.private_name, value) 30 | 31 | 32 | class ParamlessSingletonMeta(type): 33 | """Singleton implementation for class that does not have any parameter.""" 34 | _instance = None 35 | 36 | def __call__(cls): 37 | if cls._instance is None: 38 | cls._instance = super().__call__() 39 | return cls._instance 40 | -------------------------------------------------------------------------------- /src/fieldenum/enums.py: -------------------------------------------------------------------------------- 1 | """Collection of useful fieldenums. 2 | 3 | WARNING: This submodule can only be imported on Python 3.12 or later. 4 | """ 5 | 6 | from __future__ import annotations 7 | 8 | import functools 9 | import sys 10 | from types import UnionType 11 | from typing import ( 12 | TYPE_CHECKING, 13 | Any, 14 | Callable, 15 | Generic, 16 | Mapping, 17 | NoReturn, 18 | Protocol, 19 | Self, 20 | Sequence, 21 | SupportsIndex, 22 | TypeVar, 23 | final, 24 | overload, 25 | ) 26 | 27 | from . import Unit, Variant, fieldenum, unreachable 28 | from .exceptions import IncompatibleBoundError, UnwrapFailedError 29 | 30 | __all__ = ["Option", "BoundResult", "Message", "Some", "Success", "Failed", "Result", "Ok", "Err"] 31 | 32 | _MISSING = object() 33 | type _ExceptionTypes = type[BaseException] | tuple[type[BaseException], ... ] | UnionType 34 | type _Types = type | tuple[type, ... ] | UnionType 35 | 36 | 37 | class _SupportsGetitem[Item, Value](Protocol): 38 | def __getitem__(self, item: Item, /) -> Value: ... 39 | 40 | 41 | T = TypeVar("T", covariant=True) # variance inference did not work well and i don't know why 42 | 43 | @final # A redundant decorator for type checkers. 44 | @fieldenum 45 | class Option(Generic[T]): 46 | if TYPE_CHECKING: 47 | Nothing = Unit 48 | class Some[T](Option[T]): # type: ignore 49 | __match_args__ = ("_0",) 50 | __fields__ = (0,) 51 | 52 | @property 53 | def _0(self) -> T: ... 54 | 55 | def __init__(self, value: T, /): ... 56 | 57 | def dump(self) -> tuple[T]: ... 58 | 59 | else: 60 | Nothing = Unit 61 | Some = Variant(T) 62 | 63 | def __bool__(self): 64 | return self is not Option.Nothing 65 | 66 | @classmethod 67 | @overload 68 | def new(cls, value: None, /) -> Self: ... 69 | 70 | @classmethod 71 | @overload 72 | def new[U](cls, value: U | None, /) -> Option[U]: ... 73 | 74 | @classmethod 75 | def new(cls, value, /): 76 | match value: 77 | case None: 78 | return Option.Nothing 79 | 80 | case value: 81 | return Option.Some(value) 82 | 83 | @overload 84 | def unwrap(self) -> T: ... 85 | 86 | @overload 87 | def unwrap[U](self, default: U) -> T | U: ... 88 | 89 | def unwrap(self, default=_MISSING): 90 | match self: 91 | case Option.Nothing if default is _MISSING: 92 | raise UnwrapFailedError("Unwrap failed.") 93 | 94 | case Option.Nothing: 95 | return default 96 | 97 | case Option.Some(value): 98 | return value 99 | 100 | case other: 101 | unreachable(other) 102 | 103 | def expect(self, message_or_exception: str | BaseException, /) -> T: 104 | match self, message_or_exception: 105 | case Option.Nothing, BaseException() as exception: 106 | raise exception 107 | 108 | case Option.Nothing, message: 109 | raise UnwrapFailedError(message) 110 | 111 | case Option.Some(value), _: 112 | return value 113 | 114 | case other: 115 | unreachable(other) 116 | 117 | # where default type is equal to result type 118 | 119 | @overload 120 | def get[Key, Value]( 121 | self: Option[Mapping[Key, Value]], 122 | key: Key, 123 | /, 124 | *, 125 | default: Value, 126 | suppress: _ExceptionTypes = ..., 127 | ignore: _Types = ..., 128 | ) -> Option[Value]: ... 129 | 130 | @overload 131 | def get[Value]( 132 | self: Option[Sequence[Value]], 133 | key: SupportsIndex, 134 | /, 135 | *, 136 | default: Value, 137 | suppress: _ExceptionTypes = ..., 138 | ignore: _Types = ..., 139 | ) -> Option[Value]: ... 140 | 141 | @overload 142 | def get[Item, Value]( 143 | self: Option[_SupportsGetitem[Item, Value]], 144 | key: Item, 145 | /, 146 | *, 147 | default: Value, 148 | suppress: _ExceptionTypes = ..., 149 | ignore: _Types = ..., 150 | ) -> Option[Value]: ... 151 | 152 | # with default 153 | 154 | @overload 155 | def get[Key, Value, Default]( 156 | self: Option[Mapping[Key, Value]], 157 | key: Key, 158 | /, 159 | *, 160 | default: Default, 161 | suppress: _ExceptionTypes = ..., 162 | ignore: _Types = ..., 163 | ) -> Option[Value | Default]: ... 164 | 165 | @overload 166 | def get[Value, Default]( 167 | self: Option[Sequence[Value]], 168 | key: SupportsIndex, 169 | /, 170 | *, 171 | default: Default, 172 | suppress: _ExceptionTypes = ..., 173 | ignore: _Types = ..., 174 | ) -> Option[Value | Default]: ... 175 | 176 | @overload 177 | def get[Item, Value, Default]( 178 | self: Option[_SupportsGetitem[Item, Value]], 179 | key: Item, 180 | /, 181 | *, 182 | default: Default, 183 | suppress: _ExceptionTypes = ..., 184 | ignore: _Types = ..., 185 | ) -> Option[Value | Default]: ... 186 | 187 | # no default 188 | 189 | @overload 190 | def get[Key, Value]( 191 | self: Option[Mapping[Key, Value]], 192 | key: Key, 193 | /, 194 | *, 195 | suppress: _ExceptionTypes = ..., 196 | ignore: _Types = ..., 197 | ) -> Option[Value]: ... 198 | 199 | @overload 200 | def get[Value]( 201 | self: Option[Sequence[Value]], 202 | key: SupportsIndex, 203 | /, 204 | *, 205 | suppress: _ExceptionTypes = ..., 206 | ignore: _Types = ..., 207 | ) -> Option[Value]: ... 208 | 209 | @overload 210 | def get[Item, Value]( 211 | self: Option[_SupportsGetitem[Item, Value]], 212 | key: Item, 213 | /, 214 | *, 215 | suppress: _ExceptionTypes = ..., 216 | ignore: _Types = ..., 217 | ) -> Option[Value]: ... 218 | 219 | # fallback 220 | 221 | @overload 222 | def get[Default]( 223 | self, 224 | key, 225 | /, 226 | *, 227 | default: Default, 228 | suppress: _ExceptionTypes = ..., 229 | ignore: _Types = ..., 230 | ) -> Option[Any | Default]: ... 231 | 232 | @overload 233 | def get( 234 | self, 235 | key, 236 | /, 237 | *, 238 | suppress: _ExceptionTypes = ..., 239 | ignore: _Types = ..., 240 | ) -> Option: ... 241 | 242 | def get(self, key, /, *, default=_MISSING, suppress=(TypeError, IndexError, KeyError), ignore=(str, bytes, bytearray)): 243 | match self: 244 | case Option.Nothing: 245 | return self 246 | 247 | case Option.Some(to_subscript): 248 | if ignore and isinstance(to_subscript, ignore): 249 | return Option.Nothing.setdefault(default) 250 | try: 251 | return Option.Some(to_subscript[key]) # type: ignore 252 | except BaseException as e: 253 | if not isinstance(e, suppress): 254 | raise 255 | return Option.Nothing.setdefault(default) 256 | 257 | def setdefault[U](self, value: U, /) -> Option[T | U]: 258 | if value is _MISSING: 259 | return self 260 | elif self is Option.Nothing: 261 | return Option.Some(value) 262 | else: 263 | return self 264 | 265 | def map[U](self, func: Callable[[T], U], /, *, suppress: _ExceptionTypes = ()) -> Option[U]: 266 | match self: 267 | case Option.Nothing: 268 | return Option.Nothing 269 | 270 | case Option.Some(value): 271 | try: 272 | return Option.Some(func(value)) 273 | except BaseException as e: 274 | if isinstance(e, suppress): 275 | return Option.Nothing 276 | else: 277 | raise 278 | 279 | case other: 280 | unreachable(other) 281 | 282 | def flatmap[NewOption: Option](self, func: Callable[[T], NewOption], /, *, suppress: _ExceptionTypes = ()) -> NewOption: 283 | match self: 284 | case Option.Nothing: 285 | return Option.Nothing # type: ignore 286 | 287 | case Option.Some(value): 288 | try: 289 | result = func(value) 290 | except BaseException as e: 291 | if isinstance(e, suppress): 292 | return Option.Nothing # type: ignore 293 | else: 294 | raise 295 | 296 | if isinstance(result, Option): 297 | return result 298 | else: 299 | raise TypeError( 300 | f"Expect Option but received {type(result).__name__!r}" 301 | ) 302 | 303 | case other: 304 | unreachable(other) 305 | 306 | @classmethod 307 | def wrap[**Params, Return](cls, func: Callable[Params, Return | None], /) -> Callable[Params, Option[Return]]: 308 | @functools.wraps(func) 309 | def decorator(*args: Params.args, **kwargs: Params.kwargs): 310 | return Option.new(func(*args, **kwargs)) 311 | return decorator 312 | 313 | 314 | @final # A redundant decorator for type checkers. 315 | @fieldenum 316 | class Result[R, E: BaseException]: 317 | if TYPE_CHECKING: 318 | class Ok[R, E: BaseException](Result[R, E]): # type: ignore 319 | __match_args__ = ("value",) 320 | __fields__ = ("value",) 321 | 322 | @property 323 | def value(self) -> R: ... 324 | 325 | def __init__(self, value: R): ... 326 | 327 | def dump(self) -> tuple[R]: ... 328 | 329 | class Err[R, E: BaseException](Result[R, E]): # type: ignore 330 | __match_args__ = ("error",) 331 | __fields__ = ("error",) 332 | 333 | @property 334 | def error(self) -> E: ... 335 | 336 | def __init__(self, error: BaseException): ... 337 | 338 | def dump(self) -> tuple[E]: ... 339 | 340 | else: 341 | Ok = Variant(value=R) 342 | Err = Variant(error=E) 343 | 344 | def __bool__(self) -> bool: 345 | return isinstance(self, Result.Ok) 346 | 347 | @overload 348 | def unwrap(self, default: R) -> R: ... 349 | 350 | @overload 351 | def unwrap[T](self, default: T) -> R | T: ... 352 | 353 | @overload 354 | def unwrap(self) -> R: ... 355 | 356 | def unwrap(self, default=_MISSING): 357 | match self: 358 | case Result.Ok(value): 359 | return value 360 | 361 | case Result.Err(error) if default is _MISSING: 362 | raise error 363 | 364 | case Result.Err(error): 365 | return default 366 | 367 | case other: 368 | unreachable(other) 369 | 370 | def as_option(self) -> Option[R]: 371 | match self: 372 | case Result.Ok(value): 373 | return Option.Some(value) 374 | 375 | case Result.Err(_): 376 | return Option.Nothing 377 | 378 | case other: 379 | unreachable(other) 380 | 381 | def exit(self, error_code: str | int | None = 1) -> NoReturn: 382 | sys.exit(0 if self else error_code) 383 | 384 | def map[NewReturn](self, func: Callable[[R], NewReturn], /, bound: _ExceptionTypes) -> Result[NewReturn, E]: 385 | match self: 386 | case Result.Ok(ok): 387 | try: 388 | return Result.Ok(func(ok)) 389 | except BaseException as error: 390 | if isinstance(error, bound): 391 | return Result.Err(error) 392 | else: 393 | raise 394 | 395 | case Result.Err() as err: 396 | return err # type: ignore 397 | 398 | case other: 399 | unreachable(other) 400 | 401 | def flatmap[NewResult: Result](self, func: Callable[[R], NewResult], /, bound: _ExceptionTypes) -> NewResult: 402 | match self: 403 | case Result.Ok(value): 404 | try: 405 | result = func(value) 406 | except BaseException as exc: 407 | if isinstance(exc, bound): 408 | return Result.Err(exc) # type: ignore 409 | else: 410 | raise 411 | 412 | if isinstance(result, Result): 413 | return result 414 | else: 415 | raise TypeError( 416 | f"Expect Result but received {type(result).__name__!r}" 417 | ) 418 | 419 | case Result.Err() as err: 420 | return err # type: ignore 421 | 422 | case other: 423 | unreachable(other) 424 | 425 | @overload 426 | @classmethod 427 | def wrap[**Params, Return, Bound: BaseException]( 428 | cls, bound: type[Bound], / 429 | ) -> Callable[[Callable[Params, Return]], Callable[Params, Result[Return, Bound]]]: ... 430 | 431 | @overload 432 | @classmethod 433 | def wrap[**Params, Return, Bound: BaseException]( 434 | cls, bound: type[Bound], func: Callable[Params, Return], / 435 | ) -> Callable[Params, Result[Return, Bound]]: ... 436 | 437 | @classmethod 438 | def wrap(cls, bound, func=None) -> Any: 439 | if func is None: 440 | return lambda func: cls.wrap(bound, func) 441 | 442 | @functools.wraps(func) 443 | def inner(*args, **kwargs): 444 | try: 445 | return Result.Ok(func(*args, **kwargs)) 446 | except BaseException as error: 447 | if isinstance(error, bound): 448 | return Result.Err(error) 449 | else: 450 | raise 451 | return inner 452 | 453 | 454 | @final # A redundant decorator for type checkers. 455 | @fieldenum 456 | class BoundResult[R, E: BaseException]: 457 | if TYPE_CHECKING: 458 | class Success[R, E: BaseException](BoundResult[R, E]): # type: ignore 459 | __match_args__ = ("value", "bound") 460 | __fields__ = ("value", "bound") 461 | 462 | @property 463 | def value(self) -> R: ... 464 | 465 | @property 466 | def bound(self) -> type[E]: ... 467 | 468 | def __init__(self, value: R, bound: type[E]): ... 469 | 470 | def dump(self) -> tuple[R, type[E]]: ... 471 | 472 | class Failed[R, E: BaseException](BoundResult[R, E]): # type: ignore 473 | __match_args__ = ("error", "bound") 474 | __fields__ = ("error", "bound") 475 | 476 | @property 477 | def error(self) -> E: ... 478 | 479 | @property 480 | def bound(self) -> type[E]: ... 481 | 482 | def __init__(self, error: BaseException, bound: type[E]): ... 483 | 484 | def dump(self) -> tuple[E, type[E]]: ... 485 | 486 | @property 487 | def bound(self) -> type[E]: ... 488 | 489 | else: 490 | Success = Variant(value=R, bound=type[E]) 491 | Failed = Variant(error=E, bound=type[E]) 492 | 493 | def __bool__(self) -> bool: 494 | return isinstance(self, BoundResult.Success) 495 | 496 | if __debug__: 497 | def __post_init__(self): 498 | if not issubclass(self.bound, BaseException): 499 | raise IncompatibleBoundError(f"{self.bound} is not an exception.") 500 | 501 | if isinstance(self, Failed) and not isinstance(self.error, self.bound): 502 | raise IncompatibleBoundError( 503 | f"Bound {self.bound.__qualname__!r} is not compatible with existing error: {type(self.error).__qualname__}." 504 | ) 505 | 506 | @overload 507 | def unwrap(self) -> R: ... 508 | 509 | @overload 510 | def unwrap(self, default: R) -> R: ... 511 | 512 | @overload 513 | def unwrap[T](self, default: T) -> R | T: ... 514 | 515 | def unwrap(self, default=_MISSING): 516 | match self: 517 | case BoundResult.Success(value, _): 518 | return value 519 | 520 | case BoundResult.Failed(error, _) if default is _MISSING: 521 | raise error 522 | 523 | case BoundResult.Failed(error, _): 524 | return default 525 | 526 | case other: 527 | unreachable(other) 528 | 529 | def as_option(self) -> Option[R]: 530 | match self: 531 | case BoundResult.Success(value, _): 532 | return Option.Some(value) 533 | 534 | case BoundResult.Failed(_, _): 535 | return Option.Nothing 536 | 537 | case other: 538 | unreachable(other) 539 | 540 | def exit(self, error_code: str | int | None = 1) -> NoReturn: 541 | sys.exit(0 if self else error_code) 542 | 543 | def rebound[NewBound: BaseException](self, bound: type[NewBound], /) -> BoundResult[R, NewBound]: 544 | match self: 545 | case BoundResult.Success(value, _): 546 | return BoundResult.Success(value, bound) 547 | 548 | case BoundResult.Failed(error, _): 549 | return BoundResult.Failed(error, bound) 550 | 551 | case other: 552 | unreachable(other) 553 | 554 | def map[NewReturn](self, func: Callable[[R], NewReturn], /) -> BoundResult[NewReturn, E]: 555 | match self: 556 | case BoundResult.Success(ok, bound): 557 | try: 558 | return BoundResult.Success(func(ok), bound) 559 | except BaseException as error: 560 | if isinstance(error, bound): 561 | return BoundResult.Failed(error, bound) 562 | else: 563 | raise 564 | 565 | case BoundResult.Failed(error, bound) as failed: 566 | if TYPE_CHECKING: 567 | return BoundResult.Failed[NewReturn, E](error, bound) 568 | else: 569 | return failed 570 | 571 | case other: 572 | unreachable(other) 573 | 574 | @overload 575 | @classmethod 576 | def wrap[**Params, Return, Bound: BaseException]( 577 | cls, bound: type[Bound], / 578 | ) -> Callable[[Callable[Params, Return]], Callable[Params, BoundResult[Return, Bound]]]: ... 579 | 580 | @overload 581 | @classmethod 582 | def wrap[**Params, Return, Bound: BaseException]( 583 | cls, bound: type[Bound], func: Callable[Params, Return], / 584 | ) -> Callable[Params, BoundResult[Return, Bound]]: ... 585 | 586 | @classmethod 587 | def wrap(cls, bound, func=None) -> Any: 588 | if func is None: 589 | return lambda func: cls.wrap(bound, func) 590 | 591 | @functools.wraps(func) 592 | def inner(*args, **kwargs): 593 | try: 594 | return BoundResult.Success(func(*args, **kwargs), bound) 595 | except BaseException as error: 596 | if isinstance(error, bound): 597 | return BoundResult.Failed(error, bound) 598 | else: 599 | raise 600 | return inner 601 | 602 | 603 | @final # A redundant decorator for type checkers. 604 | @fieldenum 605 | class Message: 606 | """Test fieldenum to play with.""" 607 | if TYPE_CHECKING: 608 | Quit = Unit 609 | 610 | class Move(Message): # type: ignore 611 | __match_args__ = ("x", "y") 612 | __fields__ = ("x", "y") 613 | 614 | @property 615 | def x(self) -> int: ... 616 | 617 | @property 618 | def y(self) -> int: ... 619 | 620 | def __init__(self, x: int, y: int): ... 621 | 622 | def dump(self) -> dict[str, int]: ... 623 | 624 | class Write(Message): # type: ignore 625 | __match_args__ = ("_0",) 626 | __fields__ = (0,) 627 | 628 | @property 629 | def _0(self) -> str: ... 630 | 631 | def __init__(self, message: str, /): ... 632 | 633 | def dump(self) -> tuple[int]: ... 634 | 635 | class ChangeColor(Message): # type: ignore 636 | __match_args__ = ("_0", "_1", "_2") 637 | __fields__ = (0, 1, 2) 638 | 639 | @property 640 | def _0(self) -> int: ... 641 | 642 | @property 643 | def _1(self) -> int: ... 644 | 645 | @property 646 | def _2(self) -> int: ... 647 | 648 | def __init__(self, red: int, green: int, blue: int, /): ... 649 | 650 | def dump(self) -> tuple[int, int, int]: ... 651 | 652 | class Pause(Message): # type: ignore 653 | __match_args__ = () 654 | __fields__ = () 655 | 656 | @property 657 | def _0(self) -> int: ... 658 | 659 | @property 660 | def _1(self) -> int: ... 661 | 662 | @property 663 | def _2(self) -> int: ... 664 | 665 | def __init__(self): ... 666 | 667 | def dump(self) -> tuple[()]: ... 668 | 669 | else: 670 | Quit = Unit 671 | Move = Variant(x=int, y=int) 672 | Write = Variant(str) 673 | ChangeColor = Variant(int, int, int) 674 | Pause = Variant() 675 | 676 | 677 | Some = Option.Some 678 | Ok = Result.Ok 679 | Err = Result.Err 680 | Success = BoundResult.Success 681 | Failed = BoundResult.Failed 682 | -------------------------------------------------------------------------------- /src/fieldenum/exceptions.py: -------------------------------------------------------------------------------- 1 | """Exceptions used on this package.""" 2 | 3 | from typing import NoReturn as _NoReturn 4 | 5 | _MISSING = object() 6 | 7 | 8 | class FieldEnumError(Exception): 9 | pass 10 | 11 | 12 | class UnreachableError(Exception): 13 | pass 14 | 15 | 16 | def unreachable(value=_MISSING) -> _NoReturn: 17 | if value is _MISSING: 18 | raise UnreachableError( 19 | "This code is meant to be unreachable, but somehow the code reached here. " 20 | "Address developers to fix the issue." 21 | ) 22 | else: 23 | raise UnreachableError( 24 | f"Unexpected type {type(value).__name__!r} of {value!r}" 25 | ) 26 | 27 | 28 | class UnwrapFailedError(FieldEnumError): 29 | pass 30 | 31 | 32 | class IncompatibleBoundError(FieldEnumError): 33 | pass 34 | -------------------------------------------------------------------------------- /tests/test_doc.py: -------------------------------------------------------------------------------- 1 | # type: ignore 2 | from __future__ import annotations 3 | 4 | import pytest 5 | 6 | 7 | def test_decorator(): 8 | from fieldenum import Unit, Variant, fieldenum, unreachable 9 | 10 | @fieldenum 11 | class IpAddrKind: 12 | V4 = Unit 13 | V6 = Unit 14 | 15 | _ = IpAddrKind.V4 16 | _ = IpAddrKind.V6 17 | 18 | def route(ip_kind: IpAddrKind): 19 | pass 20 | 21 | route(IpAddrKind.V4) 22 | route(IpAddrKind.V6) 23 | 24 | 25 | def test_unit_variant(): 26 | from fieldenum import Unit, Variant, fieldenum, unreachable 27 | 28 | @fieldenum 29 | class Message: 30 | Quit = Unit 31 | Stay = Unit 32 | Fieldless = Variant() 33 | 34 | assert Message.Quit is not Message.Stay 35 | assert Message.Fieldless() is Message.Fieldless() 36 | message: Message = Message.Quit 37 | assert message is Message.Quit 38 | 39 | match message: 40 | case Message.Stay: 41 | assert False 42 | 43 | case Message.Fieldless(): 44 | assert False 45 | 46 | case Message.Quit: 47 | assert True 48 | 49 | case other: 50 | unreachable(other) 51 | 52 | 53 | def test_dataclasses(): 54 | from dataclasses import dataclass 55 | 56 | from fieldenum import Unit, Variant, fieldenum, unreachable 57 | 58 | @fieldenum 59 | class IpAddrKind: 60 | V4 = Unit 61 | V6 = Unit 62 | 63 | @dataclass 64 | class IpAddr: 65 | kind: IpAddrKind 66 | address: str 67 | 68 | _ = IpAddr(kind=IpAddrKind.V4, address="127.0.0.1") 69 | _ = IpAddr(kind=IpAddrKind.V6, address="::1") 70 | 71 | 72 | def test_fields(): 73 | from fieldenum import Unit, Variant, fieldenum, unreachable 74 | 75 | @fieldenum 76 | class IpAddrKind: 77 | V4 = Variant(str) 78 | V6 = Variant(str) 79 | 80 | _ = IpAddrKind.V4("127.0.0.1") 81 | _ = IpAddrKind.V6("::1") 82 | 83 | @fieldenum 84 | class IpAddrKind: 85 | V4 = Variant(int, int, int, int) 86 | V6 = Variant(str) 87 | 88 | _ = IpAddrKind.V4(127, 0, 0, 1) 89 | _ = IpAddrKind.V6("::1") 90 | 91 | 92 | def test_tuple_variant(): 93 | from fieldenum import Variant, Unit, fieldenum, unreachable 94 | 95 | @fieldenum 96 | class Message[T]: 97 | Quit = Variant(int) # Variant(...)와 같이 적고 안에는 타입을 적습니다. 98 | Stay = Variant(T) # 제너릭도 사용 가능합니다. 99 | Var3 = Variant(int, str, dict[int, str]) # 여러 값을 이어서 적으면 각각이 파라미터가 됩니다. 100 | 101 | Message.Quit(123) # OK 102 | Message[str].Stay("hello") # OK 103 | Message.Stay("hello") # OK 104 | Message.Var3(123, "hello", {1: "world"}) # OK 105 | 106 | 107 | def test_named_variant(): 108 | from fieldenum import Variant, Unit, fieldenum, unreachable 109 | 110 | @fieldenum 111 | class Cord: 112 | D1 = Variant(x=float) 113 | D2 = Variant(x=float, y=float) 114 | D3 = Variant(x=float, y=float, z=float) 115 | D4 = Variant(timestamp=float, x=float, y=float, z=float) 116 | 117 | Cord.D1(x=123.456) 118 | Cord.D3(x=1343.5, y=25.2, z=465.312) 119 | 120 | Cord.D1(123.456) # 가능 121 | Cord.D2(123.456, y=789.0) # 가능 122 | Cord.D3(1.2, 2.3, 3.4) # 가능 123 | 124 | cord = Cord.D3(x=1343.5, y=25.2, z=465.312) 125 | 126 | assert cord.x == 1343.5 127 | assert cord.y == 25.2 128 | assert cord.z == 465.312 129 | 130 | match cord: 131 | case Cord.D1(x=x): 132 | assert False 133 | 134 | case Cord.D2(x=x, y=y): 135 | assert False 136 | 137 | case Cord.D3(x=x, y=y, z=_): 138 | assert True 139 | 140 | case Cord.D3(timestamp=time, x=x, y=y, z=_): 141 | assert False 142 | 143 | 144 | def test_kw_only(): 145 | from fieldenum import Variant, Unit, fieldenum, unreachable 146 | 147 | @fieldenum 148 | class Cord: 149 | D1 = Variant(x=float).kw_only() 150 | D2 = Variant(x=float, y=float).kw_only() 151 | D3 = Variant(x=float, y=float, z=float).kw_only() 152 | D4 = Variant(timestamp=float, x=float, y=float, z=float).kw_only() 153 | 154 | Cord.D3(x=1343.5, y=25.2, z=465.312) 155 | with pytest.raises(TypeError): 156 | Cord.D1(123.456) # XXX: 불가능 157 | with pytest.raises(TypeError): 158 | Cord.D2(123.456, y=789.0) # XXX: 불가능 159 | with pytest.raises(TypeError): 160 | Cord.D3(1.2, 2.3, 3.4) # XXX: 불가능 161 | 162 | 163 | def test_list(): 164 | from fieldenum import Unit, Variant, fieldenum, unreachable 165 | 166 | @fieldenum 167 | class List: 168 | Cons = Variant(int, "List") 169 | Nil = Unit 170 | 171 | @classmethod 172 | def new(cls) -> List: 173 | return List.Nil 174 | 175 | def prepend(self, elem: int) -> List: 176 | return List.Cons(elem, self) 177 | 178 | def __len__(self) -> int: 179 | match self: 180 | case List.Cons(_, tail): 181 | return 1 + len(tail) 182 | 183 | case List.Nil: 184 | return 0 185 | 186 | case other: 187 | unreachable(other) 188 | 189 | def __str__(self) -> str: 190 | match self: 191 | case List.Cons(head, tail): 192 | return f"{head}, {tail}" 193 | 194 | case List.Nil: 195 | return "Nil" 196 | 197 | case other: 198 | unreachable(other) 199 | 200 | linked_list = List.new() 201 | linked_list = linked_list.prepend(1) 202 | linked_list = linked_list.prepend(2) 203 | linked_list = linked_list.prepend(3) 204 | assert len(linked_list) == 3 205 | assert str(linked_list) == "3, 2, 1, Nil" 206 | 207 | 208 | def test_unit(): 209 | from fieldenum import Unit, Variant, fieldenum 210 | 211 | @fieldenum 212 | class NoFieldVariants: 213 | UnitVariant = Unit 214 | FieldlessVariant = Variant() 215 | 216 | unit = NoFieldVariants.UnitVariant # 괄호를 필요로 하지 않습니다. 217 | fieldless = NoFieldVariants.FieldlessVariant() # 괄호를 필요로 합니다. 218 | 219 | # 두 배리언트 모두 isinstance로 확인할 수 있습니다. 220 | assert isinstance(unit, NoFieldVariants) 221 | assert isinstance(fieldless, NoFieldVariants) 222 | 223 | # 두 배리언트 모두 싱글톤이기에 `is` 연산자로 동일성을 확인할 수 있습니다. 224 | assert unit is NoFieldVariants.UnitVariant 225 | assert fieldless is NoFieldVariants.FieldlessVariant() 226 | 227 | 228 | def test_class_named(): 229 | from fieldenum import Variant, Unit, fieldenum, unreachable, variant 230 | 231 | @fieldenum 232 | class Product: 233 | @variant 234 | class Liquid: 235 | product: str 236 | amount: float 237 | price_per_unit: float 238 | unit: str = "L" 239 | currency: str = "USD" 240 | 241 | @variant 242 | class Quantifiable: 243 | product: str 244 | count: int 245 | price: float 246 | currency: str = "USD" 247 | 248 | gasoline = Product.Liquid("gasoline", amount=5, price_per_unit=200, unit="barrel") 249 | mouse = Product.Quantifiable("mouse", count=23, price=8) 250 | 251 | assert gasoline.dump() == dict(product="gasoline", amount=5, price_per_unit=200, unit="barrel", currency="USD") 252 | assert mouse.dump() == dict(product="mouse", count=23, price=8, currency="USD") 253 | 254 | 255 | def test_post_init(): 256 | from fieldenum import Variant, Unit, fieldenum, unreachable, variant 257 | 258 | @fieldenum 259 | class SpaceTime: 260 | Dist = Variant(dist=float, velocity=float) 261 | Cord = Variant(x=float, y=float, z=float, velocity=float) 262 | 263 | def __post_init__(self): 264 | if self.velocity > 299_792_458: 265 | raise ValueError("The speed can't exceed the speed of light!") 266 | 267 | st = SpaceTime.Dist(3000, 402) # OK 268 | assert st.dist == 3000 269 | st = SpaceTime.Cord(20, 35, 51, 42363) # OK 270 | assert st.x == 20 271 | with pytest.raises(ValueError, match="speed of light"): 272 | st = SpaceTime.Dist(2535, 350_000_000) # The speed can't exceed the speed of light! 273 | -------------------------------------------------------------------------------- /tests/test_enums.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Callable 2 | from typing import Any, assert_type 3 | import pytest 4 | from fieldenum import * 5 | from fieldenum.enums import * 6 | from fieldenum.exceptions import IncompatibleBoundError, UnwrapFailedError 7 | 8 | 9 | def test_result_maps(): 10 | result = Result.Ok("hello") 11 | err = result.map(int, ValueError) 12 | assert type(err.error) == ValueError # type: ignore 13 | upper = result.map(str.upper, Exception) 14 | assert upper.unwrap() == "HELLO" 15 | with pytest.raises(ValueError): 16 | result.map(int, ArithmeticError) 17 | 18 | def returns_result[T, R, E: BaseException](func: Callable[[T], R], bound: type[E]) -> Callable[[T], Result[R, E]]: 19 | def inner(value: T): 20 | try: 21 | return Ok(func(value)) 22 | except bound as exc: 23 | return Err(exc) 24 | return inner 25 | 26 | # flatmap with returning result 27 | result = Result.Ok("hello") 28 | err = result.flatmap(returns_result(int, ValueError), ValueError) 29 | assert type(err.error) == ValueError # type: ignore 30 | upper = result.flatmap(returns_result(str.upper, ValueError), Exception) 31 | assert upper.unwrap() == "HELLO" 32 | err = Exception() 33 | assert Result.Err(err).flatmap(returns_result(int, ValueError), ValueError).error is err # type: ignore 34 | with pytest.raises(ValueError): 35 | result.flatmap(returns_result(int, ArithmeticError), ArithmeticError) # type: ignore 36 | 37 | # flatmap with raising 38 | err = result.flatmap(returns_result(int, ArithmeticError), ValueError) 39 | assert type(err.error) == ValueError # type: ignore 40 | upper = result.flatmap(returns_result(str.upper, ArithmeticError), Exception) 41 | assert upper.unwrap() == "HELLO" 42 | assert Result.Err(err).flatmap(returns_result(int, ArithmeticError), ValueError).error is err # type: ignore 43 | with pytest.raises(ValueError): 44 | result.flatmap(returns_result(int, ArithmeticError), ArithmeticError) # type: ignore 45 | with pytest.raises(TypeError): 46 | result.flatmap(str.upper, ArithmeticError) # type: ignore 47 | 48 | 49 | def test_option_flatmap(): 50 | opt = Option.new("123").map(int).flatmap(Option.new) 51 | assert_type(opt, Option[int]) 52 | assert opt == Option.Some(123) 53 | 54 | opt = Option.new("가나다").map(int, suppress=ValueError).flatmap(Option.new) 55 | assert_type(opt, Option[int]) 56 | assert opt == Option.Nothing 57 | 58 | list_opt = Option.Some([str(i) for i in range(20)]) 59 | opt = Option.new(3).flatmap(list_opt.get) 60 | assert_type(opt, Option[str]) 61 | assert opt == Option.Some("3") 62 | 63 | # check suppress 64 | def raising(opt): 65 | raise ValueError 66 | with pytest.raises(ValueError): 67 | Option.new(3).flatmap(raising) 68 | assert Option.new(3).flatmap(raising, suppress=ValueError) is Option.Nothing 69 | 70 | with pytest.raises(TypeError): 71 | Option.new(3).flatmap(str) # type: ignore 72 | 73 | def test_option_get(): 74 | list_opt = Option.new(list(range(100))) 75 | dict_opt = Option.new({f"no{i}": i for i in range(100)}) 76 | other_opt = Option.new(234) 77 | 78 | # Basic get 79 | assert list_opt.get(3) == Option.Some(3) 80 | assert list_opt.get(300) is Option.Nothing 81 | assert dict_opt.get("no4") == Option.Some(4) 82 | assert dict_opt.get("not_key") is Option.Nothing 83 | assert other_opt.get(123) is Option.Nothing 84 | assert_type(list_opt.get(3), Option[int]) 85 | assert_type(list_opt.get(300), Option[int]) 86 | assert_type(dict_opt.get("no4"), Option[int]) 87 | assert_type(dict_opt.get("not_key"), Option[int]) 88 | assert_type(other_opt.get(123), Option) 89 | 90 | # get with a default 91 | assert list_opt.get(3, default=23) == Option.Some(3) 92 | assert list_opt.get(3, default="hello") == Option.Some(3) 93 | assert list_opt.get(300, default=3) == Option.Some(3) 94 | assert list_opt.get(300, default="hello") == Option.Some("hello") 95 | assert dict_opt.get("no4", default=35) == Option.Some(4) 96 | assert dict_opt.get("no4", default="hello") == Option.Some(4) 97 | assert dict_opt.get("not_key") is Option.Nothing 98 | assert dict_opt.get("not_key", default=123) == Option.Some(123) 99 | assert dict_opt.get("not_key", default="e3") == Option.Some("e3") 100 | assert other_opt.get(123, default="hello") == Option.Some("hello") 101 | assert_type(list_opt.get(3, default=23), Option[int]) 102 | assert_type(list_opt.get(3, default="hello"), Option[int | str]) 103 | assert_type(list_opt.get(300, default=3), Option[int]) 104 | assert_type(list_opt.get(300, default="hello"), Option[int | str]) 105 | assert_type(dict_opt.get("no4", default=35), Option[int]) 106 | assert_type(dict_opt.get("no4", default="hello"), Option[int | str]) 107 | assert_type(dict_opt.get("not_key"), Option[int]) 108 | assert_type(dict_opt.get("not_key", default=123), Option[int]) 109 | assert_type(dict_opt.get("not_key", default="e3"), Option[int | str]) 110 | assert_type(other_opt.get(123, default="hello"), Option[Any | str]) 111 | 112 | # get with nothing 113 | assert Option.Nothing.get("hello") is Option.Nothing 114 | # with random methods 115 | assert Option.Nothing.get("hello", suppress=(), default="others") is Option.Nothing 116 | 117 | # get with exc 118 | assert list_opt.get(3, suppress=()) == Option.Some(3) 119 | assert list_opt.get(300, suppress=IndexError) is Option.Nothing 120 | assert list_opt.get(300, suppress=IndexError | KeyError | TypeError) is Option.Nothing 121 | assert list_opt.get(300, suppress=(IndexError, TypeError)) is Option.Nothing 122 | assert dict_opt.get("no4", suppress=()) == Option.Some(4) 123 | assert dict_opt.get("not_key", suppress=KeyError) is Option.Nothing 124 | assert other_opt.get(123, suppress=TypeError) is Option.Nothing 125 | with pytest.raises(IndexError): 126 | assert list_opt.get(300, suppress=()) is Option.Nothing 127 | with pytest.raises(IndexError): 128 | assert list_opt.get(300, suppress=KeyError | TypeError) is Option.Nothing 129 | with pytest.raises(KeyError): 130 | assert dict_opt.get("not_key", suppress=TypeError | IndexError) is Option.Nothing 131 | with pytest.raises(TypeError): 132 | assert other_opt.get(123, suppress=(KeyError, IndexError)) is Option.Nothing 133 | with pytest.raises(TypeError): 134 | assert other_opt.get(123, suppress=KeyError | IndexError) is Option.Nothing 135 | 136 | # compound gets 137 | complex_dict_opt = Option.new({f"no{i}": i for i in range(100)} | {"hello": {"world": {"spam": "ham"}}}) 138 | assert complex_dict_opt.get("hello").get("world") == Option.Some({"spam": "ham"}) 139 | assert complex_dict_opt.get("hello").get("world").get("spam") == Option.Some("ham") 140 | assert complex_dict_opt.get("hello").get("world").get("spam").get("hello") is Option.Nothing 141 | 142 | # ignore classes 143 | str_opt = Option.new("hello, world!") 144 | assert str_opt.get(7) is Option.Nothing 145 | assert str_opt.get(7, ignore=()) == Option.Some("w") 146 | assert str_opt.get(7, ignore=(dict, list)) == Option.Some("w") 147 | assert list_opt.get(7, ignore=(dict, list)) is Option.Nothing 148 | assert list_opt.get(7, ignore=dict | list) is Option.Nothing 149 | 150 | def test_option(): 151 | assert Option[int].Some(123) == Option.Some(123) # Can be changed in future. 152 | 153 | # test new 154 | assert Option.new(123) == Option.Some(123) 155 | assert Option.new(Option.Some(123)) == Option.Some(Option.Some(123)) 156 | assert Option.new(None) is Option.Nothing 157 | assert Option.new(Option.Nothing) == Option.Some(Option.Nothing) 158 | 159 | # test unwrap 160 | option = Option.Some(123) 161 | assert option._0 == 123 162 | assert option.unwrap() == 123 163 | assert option.unwrap(456) == 123 164 | option = Option.Nothing 165 | assert option is Option.Nothing 166 | with pytest.raises(UnwrapFailedError): 167 | option.unwrap() 168 | assert option.unwrap("default") == "default" 169 | 170 | # test expect 171 | option = Option.Some("hello") 172 | assert option.expect("message") == "hello" 173 | assert option.expect(ValueError("message")) == "hello" 174 | option = Option.Nothing 175 | with pytest.raises(UnwrapFailedError, match="message"): 176 | option.expect("message") 177 | with pytest.raises(ValueError, match="message"): 178 | option.expect(ValueError("message")) 179 | 180 | # test __bool__ 181 | assert Option.Some(False) 182 | assert not Option.Nothing 183 | 184 | # test map 185 | option = Option.Some("123") 186 | assert option.map(int) == Option.Some(123) 187 | option = Option.Nothing 188 | assert option.map(int) is Option.Nothing 189 | assert Some(123).map(lambda _: None) == Option.Some(None) 190 | assert Some(123).map(lambda _: Option.Nothing) == Option.Some(Option.Nothing) 191 | assert Some(123).map(lambda _: Option.Some(567)) == Option.Some(Option.Some(567)) 192 | 193 | # test map suppressing 194 | opt = Some("not an integer").map(int, suppress=ValueError) 195 | assert_type(opt, Option[int]) 196 | assert opt is Option.Nothing 197 | 198 | opt = Some("not an integer").map(int, suppress=ValueError | TypeError) 199 | assert_type(opt, Option[int]) 200 | assert opt is Option.Nothing 201 | 202 | opt = Some("123").map(int, suppress=ValueError) 203 | assert_type(opt, Option[int]) 204 | assert opt == Option.Some(123) 205 | 206 | with pytest.raises(ValueError): 207 | opt = Some("not an integer").map(int) 208 | with pytest.raises(ValueError): 209 | opt = Some("not an integer").map(int, suppress=()) 210 | 211 | # test wrap 212 | @Option.wrap 213 | def func[T](returns: T) -> T: 214 | return returns 215 | 216 | assert func(1233) == Some(1233) 217 | assert func(None) is Option.Nothing 218 | 219 | assert Option.Nothing.setdefault("hello") == Option.Some("hello") 220 | assert Option.Some("world").setdefault("hello") == Option.Some("world") 221 | assert_type(Option[str].Nothing.setdefault("hello"), Option[str]) 222 | assert_type(Option[int].Nothing.setdefault("hello"), Option[str | int]) 223 | assert_type(Option.new("world").setdefault("hello"), Option[str]) 224 | assert_type(Option.Some(123).setdefault("hello"), Option[str | int]) 225 | 226 | 227 | def test_bound_result(): 228 | _ = BoundResult[int, Exception].map( 229 | BoundResult.Success(123, Exception), str 230 | ) # type should be BoundResult[str, Exception] 231 | 232 | # general features 233 | assert BoundResult.Success(2342, Exception) == BoundResult.Success(2342, Exception) 234 | assert BoundResult.Success(1234, Exception).unwrap() == 1234 235 | assert BoundResult.Success(1234, Exception).unwrap(34556) == 1234 236 | with pytest.raises(ValueError, match="error"): 237 | BoundResult.Failed(ValueError("error"), ValueError).unwrap() 238 | assert BoundResult.Failed(Exception("err"), Exception).unwrap(34556) == 34556 239 | assert BoundResult.Success(False, Exception) 240 | assert not BoundResult.Failed(Exception("Some exception."), Exception) 241 | 242 | error = ValueError(123) 243 | def raising(error): 244 | raise error 245 | with pytest.raises(ValueError): 246 | BoundResult.Success(2342, ArithmeticError).map( 247 | lambda _: raising(error) 248 | ) 249 | error = ValueError(1234) 250 | 251 | 252 | with pytest.raises(SystemExit) as exc: 253 | BoundResult.Success("success", Exception).exit() 254 | assert exc.value.code == 0 255 | 256 | with pytest.raises(SystemExit) as exc: 257 | BoundResult.Failed(error, Exception).exit() 258 | assert exc.value.code == 1 259 | 260 | with pytest.raises(SystemExit) as exc: 261 | BoundResult.Failed(error, Exception).exit("failed miserably...") 262 | assert exc.value.code == "failed miserably..." 263 | 264 | with pytest.raises(SystemExit) as exc: 265 | BoundResult.Failed(error, Exception).exit(None) 266 | assert exc.value.code == None 267 | 268 | # test __post_init__ of BoundResult 269 | with pytest.raises(IncompatibleBoundError, match="not an exception"): 270 | BoundResult.Success(None, int) # type: ignore 271 | with pytest.raises(IncompatibleBoundError, match="not an exception"): 272 | BoundResult.Failed(ValueError("hello"), int) # type: ignore 273 | with pytest.raises(IncompatibleBoundError, match="not compatible"): 274 | BoundResult.Failed(123, ValueError) # type: ignore 275 | with pytest.raises(IncompatibleBoundError, match="not compatible"): 276 | BoundResult.Failed(Exception(345), ValueError) 277 | 278 | 279 | @pytest.mark.parametrize( 280 | "second_param", 281 | [True, False], 282 | ) 283 | def test_bound_result_wrap(second_param): 284 | def func[T](raises: BaseException | None = None, returns: T = None) -> T: 285 | if raises: 286 | raise raises 287 | return returns 288 | 289 | if second_param: 290 | exception_bound_func = BoundResult.wrap(Exception, func) 291 | valueerror_bound_func = BoundResult.wrap(ValueError, func) 292 | else: 293 | exception_bound_func = BoundResult.wrap(Exception)(func) 294 | valueerror_bound_func = BoundResult.wrap(ValueError)(func) 295 | 296 | assert exception_bound_func(None, "hello") == BoundResult.Success("hello", Exception) 297 | match exception_bound_func(ValueError(), "hello"): 298 | case BoundResult.Failed(err, _): 299 | assert isinstance(err, ValueError) 300 | case other: 301 | assert False, other 302 | with pytest.raises(Exception, match="message"): 303 | valueerror_bound_func(Exception("message")) 304 | with pytest.raises(BaseException, match="message"): 305 | exception_bound_func(BaseException("message")) 306 | assert exception_bound_func(None, "hello") 307 | assert not exception_bound_func(ValueError(), "hello") 308 | 309 | assert exception_bound_func(None, "hello").as_option() == Option.Some("hello") 310 | assert exception_bound_func(Exception()).as_option() is Option.Nothing 311 | 312 | assert exception_bound_func(None, "hello").rebound(ValueError).map(lambda s: s + ", world!") == BoundResult.Success( 313 | "hello, world!", ValueError 314 | ) 315 | match exception_bound_func(None, "hello").map(lambda s: 1 / 0): 316 | case BoundResult.Failed(err, _): 317 | assert isinstance(err, ZeroDivisionError) 318 | case other: 319 | assert False, other 320 | with pytest.raises(ZeroDivisionError): 321 | exception_bound_func(None, "hello").rebound(ValueError).map(lambda s: 1 / 0) 322 | 323 | with pytest.raises(TypeError): 324 | BoundResult.wrap(lambda: None, Exception, "unexpected_param") # type: ignore 325 | with pytest.raises(TypeError): 326 | BoundResult.wrap(lambda: None, Exception, "unexpected_param", "unexpected_param2") # type: ignore 327 | 328 | assert valueerror_bound_func(None, 1234).bound is ValueError 329 | assert valueerror_bound_func(ValueError(123), 1234).bound is ValueError 330 | assert valueerror_bound_func(ValueError(123), 1234).rebound(Exception).bound is Exception 331 | 332 | assert Success("hello", ValueError).map(lambda x: exception_bound_func(None, x)).unwrap() == Success("hello", Exception) 333 | error = ValueError("hello") 334 | assert BoundResult.Failed(error, ValueError).map(lambda x: exception_bound_func(None, x)) == BoundResult.Failed( 335 | error, ValueError 336 | ) 337 | 338 | 339 | def test_result(): 340 | from fieldenum.enums import Result 341 | 342 | # general features 343 | assert Result.Ok(2342) == Result.Ok(2342) 344 | assert Result.Ok(1234).unwrap() == 1234 345 | assert Result.Ok(1234).unwrap(34556) == 1234 346 | with pytest.raises(ValueError, match="error"): 347 | Result.Err(ValueError("error")).unwrap() 348 | assert Result.Err(Exception("err")).unwrap(34556) == 34556 349 | assert Result.Ok(False) 350 | assert not Result.Err(Exception("Some exception.")) 351 | 352 | error = ValueError(123) 353 | def raising(error): 354 | raise error 355 | with pytest.raises(ValueError): 356 | Result.Ok(2342).map( 357 | lambda _: raising(error), ArithmeticError 358 | ) 359 | error = ValueError(1234) 360 | 361 | 362 | with pytest.raises(SystemExit) as exc: 363 | Result.Ok("success").exit() 364 | assert exc.value.code == 0 365 | 366 | with pytest.raises(SystemExit) as exc: 367 | Result.Err(error).exit() 368 | assert exc.value.code == 1 369 | 370 | with pytest.raises(SystemExit) as exc: 371 | Result.Err(error).exit("failed miserably...") 372 | assert exc.value.code == "failed miserably..." 373 | 374 | with pytest.raises(SystemExit) as exc: 375 | Result.Err(error).exit(None) 376 | assert exc.value.code == None 377 | 378 | 379 | @pytest.mark.parametrize( 380 | "second_param", 381 | [True, False], 382 | ) 383 | def test_result_wrap(second_param): 384 | from fieldenum.enums import Result 385 | 386 | def func[T](raises: BaseException | None = None, returns: T = None) -> T: 387 | if raises: 388 | raise raises 389 | return returns 390 | 391 | if second_param: 392 | exception_bound_func = Result.wrap(Exception, func) 393 | valueerror_bound_func = Result.wrap(ValueError, func) 394 | else: 395 | exception_bound_func = Result.wrap(Exception)(func) 396 | valueerror_bound_func = Result.wrap(ValueError)(func) 397 | 398 | assert exception_bound_func(None, "hello") == Result.Ok("hello") 399 | match exception_bound_func(ValueError(), "hello"): 400 | case Result.Err(err): 401 | assert isinstance(err, ValueError) 402 | case other: 403 | assert False, other 404 | with pytest.raises(Exception, match="message"): 405 | valueerror_bound_func(Exception("message")) 406 | with pytest.raises(BaseException, match="message"): 407 | exception_bound_func(BaseException("message")) 408 | assert exception_bound_func(None, "hello") 409 | assert not exception_bound_func(ValueError(), "hello") 410 | 411 | assert exception_bound_func(None, "hello").as_option() == Option.Some("hello") 412 | assert exception_bound_func(Exception()).as_option() is Option.Nothing 413 | 414 | assert exception_bound_func(None, "hello").map(lambda s: s + ", world!", ValueError) == Result.Ok("hello, world!") 415 | match exception_bound_func(None, "hello").map(lambda s: 1 / 0, Exception): 416 | case Result.Err(err): 417 | assert isinstance(err, ZeroDivisionError) 418 | case other: 419 | assert False, other 420 | with pytest.raises(ZeroDivisionError): 421 | exception_bound_func(None, "hello").map(lambda s: 1 / 0, ValueError) 422 | 423 | with pytest.raises(TypeError): 424 | Result.wrap(lambda: None, Exception, "unexpected_param") # type: ignore 425 | with pytest.raises(TypeError): 426 | Result.wrap(lambda: None, Exception, "unexpected_param", "unexpected_param2") # type: ignore 427 | 428 | assert Result.Ok("hello").map(lambda x: exception_bound_func(None, x), ValueError).unwrap() == Result.Ok("hello") 429 | error = ValueError("hello") 430 | assert Result.Err(error).map(lambda x: exception_bound_func(None, x), ValueError) == Result.Err(error) 431 | 432 | if __name__ == "__main__": 433 | test_bound_result_wrap(False) 434 | test_bound_result_wrap(True) 435 | -------------------------------------------------------------------------------- /tests/test_fieldenum.py: -------------------------------------------------------------------------------- 1 | # type: ignore 2 | 3 | import pickle 4 | from typing import Any, Self 5 | 6 | import pytest 7 | from fieldenum import Unit, Variant, factory, fieldenum, unreachable, variant 8 | from fieldenum.exceptions import UnreachableError 9 | 10 | 11 | class ExceptionForTest(Exception): 12 | def __init__(self, value): 13 | self.value = value 14 | 15 | 16 | @fieldenum 17 | class Message: 18 | Quit = Unit 19 | Move = Variant(x=int, y=int).kw_only().default(x=234569834) 20 | ArgMove = Variant(x=int, y=int).default(x=234569834) 21 | FactoryTest = Variant(x=int, y=list, z=dict).default(y=factory(list), z=factory(lambda: {"hello": "world"})) 22 | Write = Variant(str) 23 | ChangeColor = Variant(int, int, int) 24 | Pause = Variant() 25 | 26 | @variant 27 | class ClassVariant: 28 | x: int 29 | y: "Message" 30 | z: str = "hello" 31 | 32 | @variant(kw_only=True) 33 | class KwOnlyClassVariant: 34 | x: int 35 | y: "Message" 36 | z: str = "hello" 37 | 38 | @variant 39 | def FunctionVariantWithBody(self, a: int, b, /, c: str, d, *, f: float = 3.4, e: dict | None = None, raise_it: bool = False): 40 | if raise_it: 41 | raise ExceptionForTest((self, a, b, c, d, f, e)) 42 | 43 | @variant 44 | def FunctionVariant(a: int, b, /, c: str, d, *, f: float = factory(float), e: dict | None = factory(lambda: {"hello": "good"})): 45 | raise ExceptionForTest((a, b, c, d, f, e)) 46 | 47 | @variant 48 | def NotNoneReturnFunctionVariant(self, a: int, b, /, c: str, d, *, f: float = factory(float), e: dict | None = factory(lambda: {"hello": "good"})): 49 | return 123 50 | 51 | @variant 52 | def ArgsOnlyFuncVariant(a, b, /, c, d): 53 | pass 54 | 55 | @variant 56 | def KwargsOnlyFuncVariant(*, a, b, c, d=None): 57 | pass 58 | 59 | @variant 60 | def ParamlessFuncVariantWithBody(self): 61 | pass 62 | 63 | 64 | def test_unreachable(): 65 | with pytest.raises(UnreachableError, match="Unexpected type 'str' of 'hello'"): 66 | unreachable("hello") 67 | with pytest.raises(UnreachableError, match="This code is meant to be unreachable, but somehow the code reached here. Address developers to fix the issue."): 68 | unreachable() 69 | 70 | def test_class_variant(): 71 | variant = Message.ClassVariant(123, Message.Quit) 72 | assert variant.dump() == dict(x=123, y=Message.Quit, z="hello") 73 | 74 | variant = Message.ClassVariant(123, y=Message.ArgMove(y=34)) 75 | assert variant.dump() == dict(x=123, y=Message.ArgMove(x=234569834, y=34), z="hello") 76 | 77 | variant = Message.KwOnlyClassVariant(x=123, y=Message.ArgMove(y=34)) 78 | assert variant.dump() == dict(x=123, y=Message.ArgMove(x=234569834, y=34), z="hello") 79 | 80 | with pytest.raises(TypeError): 81 | Message.KwOnlyClassVariant(123, y=Message.ArgMove(y=34)) 82 | 83 | with pytest.raises(TypeError): 84 | Message.KwOnlyClassVariant(123, Message.ArgMove(y=34)) 85 | 86 | 87 | def test_default_factory(): 88 | message = Message.FactoryTest(x=234, y=[12, 3], z={}) 89 | assert message.dump() == dict(x=234, y=[12, 3], z={}) 90 | 91 | message = Message.FactoryTest(x=456) 92 | assert message.dump() == dict(x=456, y=[], z={"hello": "world"}) 93 | 94 | 95 | def test_none_kw_only(): 96 | assert Message.ArgMove(1, 2) == Message.ArgMove(1, y=2) == Message.ArgMove(x=1, y=2) 97 | with pytest.raises(TypeError): 98 | Message.ArgMove(1, 2, 3) 99 | with pytest.raises(TypeError): 100 | Message.ArgMove(1, 2, x=1) 101 | with pytest.raises(TypeError): 102 | Message.ArgMove(1, 2, x=1) 103 | with pytest.raises(TypeError): 104 | Message.ArgMove(1, x=1) 105 | assert Message.ArgMove(234569834, 234) == Message.ArgMove(y=234) 106 | 107 | 108 | def test_misc(): 109 | with pytest.raises(TypeError): 110 | Message() 111 | 112 | with pytest.raises(TypeError): 113 | @fieldenum 114 | class DerivedMessage(Message): 115 | New = Unit 116 | 117 | with pytest.raises(TypeError): 118 | @fieldenum 119 | class Mixed: 120 | New = Variant(str, x=int) 121 | 122 | with pytest.raises(TypeError, match="self.name"): 123 | Unit.attach(Message, eq=True, build_hash=False, build_repr=False, frozen=False) 124 | 125 | with pytest.raises(TypeError): 126 | message = Message.Move(x=123, y=567) 127 | message.x = 224 128 | 129 | assert Message.Move(y=325) == Message.Move(x=234569834, y=325) 130 | 131 | with pytest.raises(TypeError): 132 | @fieldenum 133 | class TupleDefault: 134 | Move = Variant(int, int).default(a=123) 135 | 136 | with pytest.raises(TypeError): 137 | @fieldenum 138 | class FieldlessDefault: 139 | Move = Variant().default(a=123) 140 | 141 | 142 | def test_mutable_fieldenum(): 143 | @fieldenum(frozen=False) 144 | class Message: 145 | Quit = Unit 146 | Move = Variant(x=int, y=int).kw_only() 147 | Write = Variant(str) 148 | ChangeColor = Variant(int, int, int) 149 | Pause = Variant() 150 | 151 | @variant 152 | def FunctionVariantWithBody(self, a: int, b, /, c: str, d, *, f: float = 3.4, e: dict | None = None, raise_it: bool = False): 153 | if raise_it: 154 | raise ExceptionForTest((self, a, b, c, d, f, e)) 155 | 156 | with pytest.raises(TypeError, match="unhashable type:"): 157 | {Message.Quit} 158 | 159 | with pytest.raises(TypeError, match="unhashable type:"): 160 | {Message.Move(x=123, y=345)} 161 | 162 | with pytest.raises(TypeError, match="unhashable type:"): 163 | {Message.Write("hello")} 164 | 165 | with pytest.raises(TypeError, match="unhashable type:"): 166 | {Message.ChangeColor(123, 456, 789)} 167 | 168 | with pytest.raises(TypeError, match="unhashable type:"): 169 | {Message.Pause()} 170 | 171 | with pytest.raises(TypeError, match="unhashable type:"): 172 | {Message.FunctionVariantWithBody(1, 2, "hello", d=123, f=1.3, e={"hello": "world"})} 173 | 174 | message = Message.Move(x=123, y=345) 175 | message.x = 654 176 | assert message == Message.Move(x=654, y=345) 177 | assert message.dump() == {"x": 654, "y": 345} 178 | 179 | message = Message.Write("hello") 180 | message._0 = "world" 181 | assert message == Message.Write("world") 182 | assert message.dump() == ("world",) 183 | 184 | message = Message.ChangeColor(123, 456, 789) 185 | message._1 = 2345 186 | assert message == Message.ChangeColor(123, 2345, 789) 187 | assert message.dump() == (123, 2345, 789) 188 | 189 | assert Message.Quit.dump() is None 190 | assert Message.Pause().dump() == () 191 | 192 | message = Message.FunctionVariantWithBody(1, 2, "hello", d=123, f=1.3, e={"hello": "world"}) 193 | message.a = 235 194 | message.e = [235, 346] 195 | assert message.dump() == dict(a=235, b=2, c="hello", d=123, f=1.3, e=[235, 346], raise_it=False) 196 | 197 | 198 | def test_relationship(): 199 | # Unit variants are the instances. Use fieldless variant if you want issubclass work. 200 | # Theoretically it's possible to make it to be both subclass and instance of original class, 201 | # It breaks instance and class methods. 202 | assert isinstance(Message.Quit, Message) 203 | 204 | assert issubclass(Message.Move, Message) 205 | assert isinstance(Message.Write("hello"), Message.Write) 206 | assert isinstance(Message.Write("hello"), Message) 207 | assert isinstance(Message.Move(x=123, y="hello"), Message.Move) 208 | assert isinstance(Message.Move(x=123, y="hello"), Message) 209 | assert isinstance(Message.ChangeColor(123, 456, 789), Message.ChangeColor) 210 | assert isinstance(Message.ChangeColor(123, 456, 789), Message) 211 | assert isinstance(Message.Pause(), Message.Pause) 212 | assert isinstance(Message.Pause(), Message) 213 | 214 | 215 | def test_instancing(): 216 | type MyTypeAlias = int | str | bytes 217 | 218 | @fieldenum 219 | class Message[T]: 220 | Quit = Unit 221 | Move = Variant(x=int, y=int).kw_only() 222 | Write = Variant(str) 223 | ChangeColor = Variant(int | str, T, Any) 224 | Pause = Variant() 225 | UseTypeAlias = Variant(MyTypeAlias) 226 | UseSelf = Variant(Self) 227 | 228 | Message.Move(x=123, y=567) 229 | Message.Write("hello, world!") 230 | Message.ChangeColor("hello", (), 123) 231 | Message.ChangeColor(1234, [], b"bytes") 232 | Message.Pause() 233 | 234 | with pytest.raises(TypeError, match=r"Expect 3 field\(s\), but received 4 argument\(s\)\."): 235 | Message.ChangeColor(1, 2, 3, 4) 236 | 237 | with pytest.raises(TypeError, match=r"Expect 3 field\(s\), but received 4 argument\(s\)\."): 238 | Message.ChangeColor(1, 2, 3, 4) 239 | 240 | with pytest.raises(TypeError, match="x"): 241 | Message.Move(y=4) 242 | 243 | with pytest.raises(TypeError, match="hello"): 244 | Message.Move(hello=4) 245 | 246 | 247 | def test_eq_and_hash(): 248 | assert Message.ChangeColor(1, ("hello",), [1, 2, 3]) == Message.ChangeColor(1, ("hello",), [1, 2, 3]) 249 | assert Message.FunctionVariant(1, 2, "hello", d=123, f=1.3, e={"hello": "world"}) == Message.FunctionVariant( 250 | 1, 2, "hello", d=123, f=1.3, e={"hello": "world"}) 251 | my_set = { 252 | Message.ChangeColor(1, ("hello",), (1, 2, 3)), 253 | Message.Quit, 254 | Message.Move(x=234, y=(2, "hello")), 255 | Message.Pause(), 256 | Message.FunctionVariant(1, 2, "hello", d=123, f=1.3, e="world") 257 | } 258 | assert Message.ChangeColor(1, ("hello",), (1, 2, 3)) in my_set 259 | assert Message.Quit in my_set 260 | assert Message.Move(x=234, y=(2, "hello")) in my_set 261 | assert Message.Pause() in my_set 262 | assert Message.FunctionVariant(1, 2, "hello", d=123, f=1.3, e="world") in my_set 263 | 264 | my_set.add(Message.ChangeColor(1, ("hello",), (1, 2, 3))) 265 | my_set.add(Message.Quit) 266 | my_set.add(Message.Move(x=234, y=(2, "hello"))) 267 | my_set.add(Message.Pause()) 268 | my_set.add(Message.FunctionVariant(1, 2, "hello", d=123, f=1.3, e="world")) 269 | assert len(my_set) == 5 270 | 271 | 272 | @pytest.mark.parametrize( 273 | "message", 274 | [ 275 | Message.Move(x=123, y=456), 276 | Message.ChangeColor(1, 2, 3), 277 | Message.Pause(), 278 | Message.Write("hello"), 279 | Message.Quit, 280 | Message.FunctionVariant(1, 2, "hello", d=123, f=1.3, e={"hello": "world"}), 281 | ], 282 | ) 283 | def test_pickling(message): 284 | dump = pickle.dumps(message) 285 | load = pickle.loads(dump) 286 | assert message == load 287 | 288 | 289 | def test_complex_matching(): 290 | match Message.Move(x=123, y=456): 291 | case Message.Write("hello"): 292 | assert False 293 | 294 | case Message.Move(x=_, y=123): 295 | assert False 296 | 297 | case Message.Move(x=_, y=1 | 456): 298 | assert True 299 | 300 | case other: 301 | assert False, other 302 | 303 | 304 | @pytest.mark.parametrize( 305 | "message,expect", 306 | [ 307 | (Message.Move(x=123, y=456), ("move", 456)), 308 | (Message.ChangeColor(1, 2, 3), ("color", 1, 2, 3)), 309 | (Message.Pause(), "fieldless"), 310 | (Message.Write("hello"), ("write", "hello")), 311 | (Message.Quit, "quit"), 312 | (Message.FunctionVariant(1, 2, "hello", d=123, f=1.3, e={"hello": "world"}), "function_variant"), 313 | ], 314 | ) 315 | def test_simple_matching(message, expect): 316 | match message: 317 | case Message.ChangeColor(x, y, z): 318 | assert expect == ("color", x, y, z) 319 | 320 | case Message.Quit: 321 | assert expect == "quit" 322 | 323 | case Message.Pause(): 324 | assert expect == "fieldless" 325 | 326 | case Message.Write(msg): 327 | assert expect == ("write", msg) 328 | 329 | case Message.Move(x=123, y=y): 330 | assert expect == ("move", y) 331 | 332 | case Message.FunctionVariant(1, 2, "hello", d=123, f=1.3, e={"hello": "world"}): 333 | assert expect == "function_variant" 334 | 335 | # don't do these 336 | match message: 337 | case Message.ChangeColor(x, y): 338 | assert expect[0] == "color" 339 | 340 | case Message.Write(): 341 | assert expect[0] == "write" 342 | 343 | case Message.Move(): 344 | assert expect[0] == "move" 345 | 346 | 347 | def test_repr(): 348 | assert repr(Message.Quit) == "Message.Quit" 349 | assert repr(Message.Move(x=123, y=234)) == "Message.Move(x=123, y=234)" 350 | assert repr(Message.Write("hello!")) == "Message.Write('hello!')" 351 | assert repr(Message.ChangeColor(123, 456, 789)) == "Message.ChangeColor(123, 456, 789)" 352 | assert repr(Message.Pause()) == "Message.Pause()" 353 | assert repr(Message.FunctionVariant(1, 2, "hello", d=123, f=1.3, e={"hello": "world"})) == "Message.FunctionVariant(1, 2, 'hello', 123, f=1.3, e={'hello': 'world'})" 354 | assert repr(Message.ArgsOnlyFuncVariant(1,2,3,d=4)) == "Message.ArgsOnlyFuncVariant(1, 2, 3, 4)" 355 | assert repr(Message.KwargsOnlyFuncVariant(a=1,b=2,c=3)) == "Message.KwargsOnlyFuncVariant(a=1, b=2, c=3, d=None)" 356 | assert repr(Message.ParamlessFuncVariantWithBody()) == "Message.ParamlessFuncVariantWithBody()" 357 | 358 | assert repr(Message.Quit) == "Message.Quit" 359 | assert repr(Message.Move) == "" 360 | assert repr(Message.Write) == "" 361 | assert repr(Message.ChangeColor) == "" 362 | assert repr(Message.Pause) == "" 363 | assert repr(Message.FunctionVariant) == "" 364 | 365 | 366 | def test_multiple_assignment(): 367 | variant = Variant(x=int) 368 | @fieldenum 369 | class OneVariant: 370 | my = variant 371 | assert variant.attached 372 | with pytest.raises(TypeError, match="This variants already attached to"): 373 | @fieldenum 374 | class AnotherVariant: 375 | my = variant 376 | with pytest.raises(TypeError, match="This variants already attached to"): 377 | variant.attach(object, eq=True, build_hash=True, build_repr=True, frozen=True) 378 | 379 | 380 | def test_function_variant(): 381 | message = Message.FunctionVariantWithBody(1, 2, "hello", d=123, f=1.3, e={"hello": "world"}) 382 | assert message.dump() == dict(a=1, b=2, c="hello", d=123, f=1.3, e={"hello": "world"}, raise_it=False) 383 | try: 384 | Message.FunctionVariantWithBody(1, 2, "hello", d=123, f=1.3, e={"hello": "world"}, raise_it=True) 385 | except ExceptionForTest as exc: 386 | message, *others = exc.value 387 | assert isinstance(message, Message) 388 | assert isinstance(message, Message.FunctionVariantWithBody) 389 | assert others == [1, 2, "hello", 123, 1.3, {"hello": "world"}] 390 | 391 | message = Message.FunctionVariant(1, 2, "hello", d=123, f=1.3, e={"hello": "world"}) 392 | assert message.dump() == dict(a=1, b=2, c="hello", d=123, f=1.3, e={"hello": "world"}) 393 | 394 | message = Message.FunctionVariant(1, 2, "hello", d=123) 395 | assert message.dump() == dict(a=1, b=2, c="hello", d=123, f=0.0, e={"hello": "good"}) 396 | 397 | with pytest.raises(TypeError, match="Initializer should return None."): 398 | Message.NotNoneReturnFunctionVariant(1, 2, "hello", d=123, f=1.3, e={"hello": "world"}) 399 | 400 | 401 | def test_method_abuse_on_function_variant(): 402 | @variant 403 | def MyVariant(self, hello, world): 404 | raise ValueError 405 | 406 | with pytest.raises(TypeError, match="method cannot be used in function variant"): 407 | @fieldenum 408 | class NeverGonnaBeUsed: 409 | V = MyVariant.kw_only() 410 | 411 | with pytest.raises(TypeError, match="method cannot be used in function variant"): 412 | @fieldenum 413 | class NeverGonnaBeUsed: 414 | V = MyVariant.default(hello=123) 415 | -------------------------------------------------------------------------------- /tests/test_flag.py: -------------------------------------------------------------------------------- 1 | from email.errors import MessageError 2 | from typing import TYPE_CHECKING 3 | import pytest 4 | from fieldenum import Flag 5 | from fieldenum.enums import Message 6 | 7 | def test_flag(): 8 | flag = Flag[Message]() 9 | flag.add(Message.Move(1, 2)) 10 | flag.add(Message.Quit) 11 | flag.add(Message.ChangeColor(1, 2, 3)) 12 | 13 | assert Message.Move(1, 2) in flag 14 | assert Message.Move(3, 5) not in flag 15 | assert Message.Quit in flag 16 | assert set(flag) == { 17 | Message.Move(1, 2), 18 | Message.Quit, 19 | Message.ChangeColor(1, 2, 3), 20 | } 21 | 22 | flag.add(Message.Move(4, 5)) 23 | assert Message.Move(4, 5) in flag 24 | 25 | assert len(flag) == 3 26 | assert repr(flag) == "Flag(Message.Move(x=4, y=5), Message.Quit, Message.ChangeColor(1, 2, 3))" 27 | 28 | assert Message.Pause() not in flag 29 | flag.add(Message.Pause()) 30 | assert Message.Pause() in flag 31 | 32 | assert len(flag) == 4 33 | flag.discard(Message.Move(3, 4)) 34 | assert len(flag) == 4 35 | assert Message.Move(4, 5) in flag 36 | flag.discard(Message.Move(4, 5)) 37 | assert len(flag) == 3 38 | 39 | flag.add(Message.Move(1, 2)) 40 | assert len(flag) == 4 41 | with pytest.raises(KeyError): 42 | flag.remove(Message.Move(4, 2)) 43 | flag.remove(Message.Move(1, 2)) 44 | assert len(flag) == 3 45 | 46 | flag.clear() 47 | assert not flag 48 | 49 | 50 | def test_mixins(): 51 | flag = Flag[Message]() 52 | flag.add(Message.Move(1, 2)) 53 | flag.add(Message.Quit) 54 | flag.add(Message.ChangeColor(1, 2, 3)) 55 | 56 | flag2 = Flag[Message]() 57 | flag2.add(Message.Move(1, 2)) 58 | flag2.add(Message.Quit) 59 | flag2.add(Message.ChangeColor(1, 2, 3)) 60 | 61 | assert flag == flag2 62 | assert flag.isdisjoint(Flag(Message.Write("hello"))) 63 | assert not flag.isdisjoint(flag2) 64 | assert flag > Flag(Message.Quit) 65 | 66 | flag -= {Message.Move(1, 2)} 67 | assert flag == Flag(Message.Quit, Message.ChangeColor(1, 2, 3)) 68 | 69 | new_flag = flag | Flag( 70 | Message.Move(4, 5), 71 | Message.Write("hello") 72 | ) 73 | assert new_flag == Flag( 74 | Message.Move(4, 5), 75 | Message.Write("hello"), 76 | Message.Quit, 77 | Message.ChangeColor(1, 2, 3), 78 | ) 79 | 80 | 81 | def test_adapter(): 82 | flag = Flag[Message]() 83 | flag.add(Message.Move(1, 2)) 84 | flag.add(Message.Quit) 85 | flag.add(Message.ChangeColor(1, 2, 3)) 86 | 87 | adapter = flag.variants 88 | assert Message.Move in adapter 89 | assert Message.Quit in adapter # type: ignore 90 | assert Message.ChangeColor in adapter 91 | 92 | assert len(adapter) == 3 93 | with pytest.raises(TypeError, match="[Cc]annot"): 94 | iter(adapter) 95 | 96 | with pytest.raises(TypeError): 97 | adapter & 2 # type: ignore 98 | with pytest.raises(TypeError): 99 | () & adapter # type: ignore 100 | with pytest.raises(TypeError): 101 | adapter - 2 # type: ignore 102 | with pytest.raises(TypeError): 103 | () - adapter # type: ignore 104 | with pytest.raises(TypeError): 105 | adapter | () # type: ignore 106 | with pytest.raises(TypeError): 107 | () | adapter # type: ignore 108 | with pytest.raises(TypeError): 109 | adapter ^ () # type: ignore 110 | with pytest.raises(TypeError): 111 | () ^ adapter # type: ignore 112 | with pytest.raises(TypeError): 113 | adapter == () # type: ignore 114 | if not TYPE_CHECKING: 115 | with pytest.raises(TypeError): 116 | adapter |= () # type: ignore 117 | with pytest.raises(TypeError): 118 | adapter ^= () # type: ignore 119 | with pytest.raises(TypeError): 120 | adapter &= 2 # type: ignore 121 | 122 | assert adapter & {Message.Move, Message.Quit, Message.Pause} == Flag( 123 | Message.Move(1, 2), 124 | Message.Quit, 125 | ) 126 | adapter &= {Message.Move, Message.Quit, Message.Pause} 127 | assert flag == Flag( 128 | Message.Move(1, 2), 129 | Message.Quit, 130 | ) 131 | assert len(adapter) == 2 132 | flag.variants &= {Message.Move, Message.Pause} 133 | assert len(adapter) == 1 134 | flag.add(Message.Quit) 135 | 136 | assert set(adapter - {Message.Move}) == {Message.Quit} 137 | flag.add(Message.Move(3, 4)) 138 | flag.add(Message.Pause()) 139 | flag.add(Message.ChangeColor(1, 2, 3)) 140 | assert set(adapter - {Message.Quit}) == { 141 | Message.Move(3, 4), 142 | Message.Pause(), 143 | Message.ChangeColor(1, 2, 3), 144 | } 145 | flag.add(Message.Quit) 146 | adapter -= {Message.Pause, Message.ChangeColor} 147 | assert set(flag) == { 148 | Message.Move(3, 4), 149 | Message.Quit 150 | } 151 | flag.discard(Message.Pause) 152 | assert set(flag) == { 153 | Message.Move(3, 4), 154 | Message.Quit 155 | } 156 | # This will introduce type checker error but I can't do much :( 157 | adapter.discard(Message.Quit) # type: ignore 158 | assert set(flag) == { 159 | Message.Move(3, 4), 160 | } 161 | adapter.discard(Message.Move) 162 | assert not flag 163 | with pytest.raises(TypeError): 164 | adapter.isdisjoint((Message.Quit,)) # type: ignore 165 | with pytest.raises(TypeError): 166 | adapter.add(Message.Quit) # type: ignore 167 | with pytest.raises(TypeError): 168 | adapter.add(Message.Move(1, 2)) # type: ignore 169 | 170 | with pytest.raises(KeyError): 171 | adapter.remove(Message.Quit) # type: ignore 172 | with pytest.raises(KeyError): 173 | adapter.remove(Message.Move) 174 | flag |= (Message.Quit, Message.Move(1, 2)) # type: ignore 175 | assert len(flag) == 2 176 | adapter.remove(Message.Quit) # type: ignore 177 | adapter.remove(Message.Move) 178 | assert not flag 179 | 180 | flag |= (Message.Quit, Message.Move(1, 2)) # type: ignore 181 | del adapter[Message.Quit] 182 | del adapter[Message.Move] 183 | assert not flag 184 | 185 | flag |= (Message.Quit, Message.Move(1, 2)) # type: ignore 186 | with pytest.raises(TypeError): 187 | adapter[Message.Move] = Message.Move(4, 5) # type: ignore 188 | assert Message.Move(1, 2) in flag 189 | 190 | assert adapter[Message.Quit] == Message.Quit # type: ignore 191 | assert adapter[Message.Move] == Message.Move(1, 2) 192 | 193 | with pytest.raises(TypeError, match="[Cc]annot"): 194 | adapter.popitem() 195 | 196 | flag |= (Message.Quit, Message.Move(1, 2)) # type: ignore 197 | 198 | assert adapter.get(Message.Quit) == Message.Quit # type: ignore 199 | assert adapter.get(Message.Move, 123) == Message.Move(1, 2) 200 | assert adapter.get(Message.Pause, 123) == 123 201 | assert adapter.get(Message.ChangeColor) is None 202 | 203 | with pytest.raises(KeyError): 204 | adapter.pop(Message.ChangeColor) # type: ignore 205 | assert adapter.pop(Message.ChangeColor, 2) == 2 206 | assert adapter.pop(Message.Quit) == Message.Quit # type: ignore 207 | assert adapter.pop(Message.Move, 5) == Message.Move(1, 2) # type: ignore 208 | 209 | with pytest.raises(TypeError): 210 | adapter.update(Message.Quit) # type: ignore 211 | with pytest.raises(TypeError): 212 | adapter.update(Message.ChangeColor(1, 2, 3)) # type: ignore 213 | 214 | flag |= (Message.Quit, Message.Move(1, 2)) # type: ignore 215 | assert flag 216 | adapter.clear() 217 | assert not flag 218 | 219 | flag |= (Message.Quit, Message.Move(1, 2)) # type: ignore 220 | adapter -= adapter 221 | assert not flag 222 | 223 | flag |= (Message.Quit, Message.Move(1, 2)) # type: ignore 224 | with pytest.raises(TypeError, match="[Cc]annot"): 225 | adapter.items() 226 | -------------------------------------------------------------------------------- /tests/test_type_hints.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from fieldenum import fieldenum, Variant, Unit 3 | from fieldenum.enums import Option, BoundResult, Message, Some, Success, Failed 4 | from typing import TYPE_CHECKING, assert_type 5 | 6 | 7 | def test_option(): 8 | option = Option.new("hello") 9 | assert_type(option, Option[str]) 10 | 11 | # option = Option.Some("hello") 12 | # assert_type(option, Option[str]) 13 | 14 | option = Option.new(None) 15 | assert_type(option, Option) 16 | 17 | option = Option[str].new(None) 18 | assert_type(option, Option[str]) 19 | 20 | option = Option.new(Option.new("hello")) 21 | assert_type(option, Option[Option[str]]) 22 | 23 | option = Option.new(Option.Nothing) 24 | assert_type(option, Option[Option]) 25 | 26 | option = Option[str].Nothing 27 | assert_type(option, Option[str]) 28 | 29 | option = Option.Some("hello") 30 | assert_type(option.unwrap(), str) 31 | 32 | if TYPE_CHECKING: 33 | option3: Option[str] = Option.Nothing 34 | assert_type(option3.unwrap(), str) 35 | 36 | option = Option.Some("hello") 37 | assert_type(option.unwrap("hi"), str) 38 | 39 | option = Option.Some("hello") 40 | assert_type(option.unwrap(123), str | int) 41 | 42 | if TYPE_CHECKING: 43 | option5: Option[str] = Option.Nothing 44 | assert_type(option5.expect("Cannot be None."), str) 45 | 46 | option4: Option[str] = Option.Nothing 47 | assert_type(option4.expect(TypeError("Cannot be None.")), str) 48 | 49 | option = Option.Some("hello") 50 | assert_type(option.map(lambda x: x + ", world!"), Option[str]) 51 | 52 | option = Option.Some("hello") 53 | assert_type(option.map(lambda x: x + ", world!"), Option[str]) 54 | 55 | option6: Option[str] = Option.Nothing 56 | assert_type(option6.map(lambda x: x + ", world!"), Option[str]) 57 | 58 | option = Option.Some("hello") 59 | assert_type(option6.map(lambda _: 1), Option[int]) 60 | 61 | option = Option.Some("hello") 62 | assert_type(option6.map(lambda _: Option.new(1)), Option[Option[int]]) 63 | 64 | option = Option.Some("hello") 65 | assert_type(option6.map(lambda _: Option[int].Nothing), Option[Option[int]]) 66 | 67 | @Option.wrap 68 | def wrapped[T](a: T) -> T | None: 69 | return a 70 | 71 | assert_type(wrapped(1), Option[int]) 72 | # assert_type(wrapped(None), Option) # not working and not needing to work 73 | 74 | @Option.wrap 75 | def wrapped2[T](a: T | None) -> Option[T]: 76 | return Option.new(a) 77 | 78 | assert_type(wrapped2(1), Option[Option[int]]) 79 | 80 | 81 | def test_fieldenum(): 82 | with pytest.raises(TypeError): 83 | @fieldenum 84 | class FieldEnum: 85 | variant0 = Variant() 86 | variant1 = Variant(int) 87 | variant3 = Variant(named=str) 88 | # should raise type checker error 89 | variant3 = Variant(int, named=str) # type: ignore 90 | --------------------------------------------------------------------------------