├── darkcore ├── py.typed ├── reader.py ├── __init__.py ├── maybe_t.py ├── state.py ├── traverse.py ├── either_t.py ├── maybe.py ├── result_t.py ├── either.py ├── validation_t.py ├── core.py ├── result.py ├── reader_t.py ├── writer_t.py ├── state_t.py ├── writer.py ├── validation.py └── rwst.py ├── tests ├── __init__.py ├── test_match_writer.py ├── test_match_either.py ├── test_match_result.py ├── test_match_maybe.py ├── test_either.py ├── test_result.py ├── test_laws_property_based.py ├── test_integration.py ├── test_traverse.py ├── test_maybe.py ├── test_either_t.py ├── test_validation.py ├── test_validation_t.py ├── test_rwst.py ├── test_reader.py ├── test_writer.py ├── test_state.py ├── test_state_t.py ├── test_maybe_t.py ├── test_result_t.py ├── test_writer_t.py ├── test_reader_t.py └── property │ └── test_laws_property_based.py ├── .github └── workflows │ ├── ci.yml │ └── publish-on-tag.yml ├── LICENSE ├── pyproject.toml ├── .gitignore ├── README.md └── poetry.lock /darkcore/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_match_writer.py: -------------------------------------------------------------------------------- 1 | from darkcore.writer import Writer 2 | 3 | def test_match_writer(): 4 | w = Writer(3, ["a"], empty=list, combine=lambda a, b: a + b) 5 | match w: 6 | case Writer(v, log=ls): 7 | assert v == 3 and ls == ["a"] 8 | case _: 9 | assert False, "unreachable" 10 | -------------------------------------------------------------------------------- /tests/test_match_either.py: -------------------------------------------------------------------------------- 1 | from darkcore.either import Right, Left 2 | 3 | def test_match_either(): 4 | def handle(x): 5 | match x: 6 | case Right(v): 7 | return ("right", v) 8 | case Left(e): 9 | return ("left", e) 10 | assert handle(Right(7)) == ("right", 7) 11 | assert handle(Left("e")) == ("left", "e") 12 | -------------------------------------------------------------------------------- /tests/test_match_result.py: -------------------------------------------------------------------------------- 1 | from darkcore.result import Ok, Err 2 | 3 | def test_match_result_ok_err(): 4 | def handle(r): 5 | match r: 6 | case Ok(v) if v > 10: 7 | return ("ok-big", v) 8 | case Ok(v): 9 | return ("ok", v) 10 | case Err(e): 11 | return ("err", e) 12 | assert handle(Ok(5)) == ("ok", 5) 13 | assert handle(Ok(42)) == ("ok-big", 42) 14 | assert handle(Err("x")) == ("err", "x") 15 | -------------------------------------------------------------------------------- /tests/test_match_maybe.py: -------------------------------------------------------------------------------- 1 | from darkcore.maybe import Maybe 2 | 3 | def test_match_maybe_value_none(): 4 | def handle(m): 5 | match m: 6 | case Maybe(value=None): 7 | return "nothing" 8 | case Maybe(value=v) if v % 2: 9 | return ("odd", v) 10 | case Maybe(value=v): 11 | return ("even", v) 12 | assert handle(Maybe(None)) == "nothing" 13 | assert handle(Maybe(3)) == ("odd", 3) 14 | assert handle(Maybe(4)) == ("even", 4) 15 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: ["main"] 5 | tags: ["v*"] 6 | pull_request: 7 | branches: ["main"] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | python-version: ["3.10", "3.11", "3.12"] 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: actions/setup-python@v5 18 | with: 19 | python-version: "${{ matrix.python-version }}" 20 | - run: pip install poetry 21 | - run: poetry install --with dev 22 | - run: poetry run pytest -q 23 | - run: poetry run mypy --strict darkcore 24 | -------------------------------------------------------------------------------- /darkcore/reader.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import Callable, Generic, TypeVar 3 | from .core import MonadOpsMixin 4 | 5 | A = TypeVar("A") 6 | B = TypeVar("B") 7 | C = TypeVar("C") 8 | 9 | 10 | class Reader(MonadOpsMixin[B], Generic[A, B]): 11 | def __init__(self, run: Callable[[A], B]) -> None: 12 | self.run = run 13 | 14 | @classmethod 15 | def pure(cls, value: B) -> "Reader[A, B]": 16 | return Reader(lambda _: value) 17 | 18 | def fmap(self, f: Callable[[B], C]) -> "Reader[A, C]": 19 | return Reader(lambda r: f(self.run(r))) 20 | 21 | map = fmap 22 | 23 | def ap(self: "Reader[A, Callable[[B], C]]", fa: "Reader[A, B]") -> "Reader[A, C]": 24 | return Reader(lambda r: self.run(r)(fa.run(r))) 25 | 26 | def bind(self, f: Callable[[B], "Reader[A, C]"]) -> "Reader[A, C]": 27 | return Reader(lambda r: f(self.run(r)).run(r)) 28 | -------------------------------------------------------------------------------- /tests/test_either.py: -------------------------------------------------------------------------------- 1 | from darkcore.either import Left, Right 2 | 3 | def test_right_map_and_bind(): 4 | r = Right(10) 5 | r2 = r | (lambda x: x + 5) 6 | assert isinstance(r2, Right) 7 | assert r2.value == 15 8 | 9 | r3 = r >> (lambda x: Right(x * 2)) 10 | assert r3 == Right(20) 11 | 12 | def test_left_propagates(): 13 | l = Left("error") 14 | assert (l | (lambda x: x + 1)) == l 15 | assert l >> (lambda x: Right(x * 2)) == l 16 | 17 | def test_monad_laws(): 18 | f = lambda x: Right(x + 1) 19 | g = lambda x: Right(x * 2) 20 | x = 5 21 | # 左単位元 22 | assert Right.pure(x).bind(f) == f(x) 23 | # 右単位元 24 | m = Right(x) 25 | assert m.bind(Right.pure) == m 26 | # 結合律 27 | assert m.bind(f).bind(g) == m.bind(lambda y: f(y).bind(g)) 28 | 29 | 30 | def test_either_ap_operator(): 31 | rf = Right(lambda x: x + 1) 32 | rx = Right(2) 33 | assert rf @ rx == Right(3) 34 | assert Left("e") @ rx == Left("e") 35 | -------------------------------------------------------------------------------- /tests/test_result.py: -------------------------------------------------------------------------------- 1 | from darkcore.result import Ok, Err 2 | 3 | def test_ok_map_and_bind(): 4 | r = Ok(3) 5 | r2 = r.map(lambda x: x + 1) 6 | assert r2 == Ok(4) 7 | 8 | r3 = r2.bind(lambda x: Ok(x * 2)) 9 | assert r3 == Ok(8) 10 | 11 | def test_err_propagates(): 12 | e = Err("fail") 13 | assert e.map(lambda x: x + 1) == e 14 | assert e.bind(lambda x: Ok(x * 2)) == e 15 | 16 | def test_result_laws(): 17 | f = lambda x: Ok(x + 1) 18 | g = lambda x: Ok(x * 2) 19 | x = 7 20 | # 左単位元 21 | assert Ok.pure(x).bind(f) == f(x) 22 | # 右単位元 23 | m = Ok(x) 24 | assert m.bind(Ok.pure) == m 25 | # 結合律 26 | assert m.bind(f).bind(g) == m.bind(lambda y: f(y).bind(g)) 27 | 28 | 29 | def test_result_map_operator(): 30 | assert (Ok(3) | (lambda x: x + 1)) == Ok(4) 31 | 32 | 33 | def test_result_ap_operator(): 34 | rf = Ok(lambda x: x + 1) 35 | rx = Ok(2) 36 | assert rf @ rx == Ok(3) 37 | assert Err("e") @ rx == Err("e") 38 | -------------------------------------------------------------------------------- /tests/test_laws_property_based.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from hypothesis import given, strategies as st 4 | 5 | from darkcore.maybe import Maybe 6 | from darkcore.result import Ok 7 | from darkcore.validation import Success 8 | 9 | 10 | @given(st.integers()) 11 | def test_functor_identity_maybe(x: int) -> None: 12 | m = Maybe(x) 13 | assert (m | (lambda v: v)) == m 14 | 15 | 16 | @given(st.integers()) 17 | def test_monad_associativity_result(x: int) -> None: 18 | r = Ok(x) 19 | f = lambda a: Ok(a + 1) 20 | g = lambda a: Ok(a * 2) 21 | assert ((r >> f) >> g) == (r >> (lambda a: f(a) >> g)) 22 | 23 | 24 | @given(st.integers(), st.integers(), st.integers()) 25 | def test_applicative_composition_validation(x: int, y: int, z: int) -> None: 26 | u = Success(lambda b: b + x) 27 | v = Success(lambda a: a * y) 28 | w = Success(z) 29 | compose = lambda f: lambda g: lambda a: f(g(a)) 30 | left = Success(compose) @ u @ v @ w 31 | right = u @ (v @ w) 32 | assert left == right 33 | -------------------------------------------------------------------------------- /.github/workflows/publish-on-tag.yml: -------------------------------------------------------------------------------- 1 | name: publish-on-tag 2 | on: 3 | push: 4 | tags: ["v*"] 5 | 6 | jobs: 7 | pypi: 8 | permissions: 9 | contents: write 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | 14 | - uses: actions/setup-python@v5 15 | with: 16 | python-version: "3.12" 17 | 18 | - run: pipx install poetry 19 | 20 | - run: poetry install 21 | 22 | - run: poetry run mypy --strict darkcore 23 | 24 | - run: poetry run pytest -v --cov=darkcore 25 | 26 | # PyPI に公開(要: repo secrets に PYPI_TOKEN) 27 | - name: Publish to PyPI 28 | env: 29 | POETRY_PYPI_TOKEN_PYPI: ${{ secrets.PYPI_KEY }} 30 | run: poetry publish --build 31 | 32 | # GitHub Release を作成(タグ名=リリース名、ノート自動生成、dist/* を添付) 33 | - name: Create GitHub Release (auto notes) 34 | uses: softprops/action-gh-release@v2 35 | with: 36 | tag_name: ${{ github.ref_name }} 37 | name: ${{ github.ref_name }} 38 | generate_release_notes: true 39 | files: | 40 | dist/* 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 minamorl 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /darkcore/__init__.py: -------------------------------------------------------------------------------- 1 | from .maybe import Maybe 2 | from .result import Ok, Err 3 | from .either import Left, Right 4 | from .reader import Reader 5 | from .writer import Writer 6 | from .state import State 7 | from .validation import Success, Failure, Validation, from_result, to_result 8 | from .traverse import ( 9 | sequence_maybe, 10 | sequence_result, 11 | traverse_maybe, 12 | traverse_result, 13 | liftA2, 14 | left_then, 15 | then_right, 16 | ) 17 | from .rwst import RWST 18 | from .maybe_t import MaybeT 19 | from .reader_t import ReaderT 20 | from .state_t import StateT 21 | from .writer_t import WriterT 22 | from .either_t import EitherT 23 | from .result_t import ResultT 24 | from .validation_t import ValidationT 25 | 26 | __all__ = [ 27 | "Maybe", "Ok", "Err", "Left", "Right", 28 | "Reader", "Writer", "State", "Validation", "Success", "Failure", 29 | "sequence_maybe", "sequence_result", "traverse_maybe", "traverse_result", 30 | "liftA2", "left_then", "then_right", "RWST", 31 | "MaybeT", "ReaderT", "StateT", "WriterT", "EitherT", "ResultT", "ValidationT", 32 | "from_result", "to_result", 33 | ] 34 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "darkcore" 3 | version = "0.5.1" 4 | description = "Practical functional programming primitives for Python: Monads, Transformers, and DSL operators for safe business logic" 5 | authors = ["minamorl "] 6 | license = "MIT" 7 | readme = "README.md" 8 | repository = "https://github.com/minamorl/darkcore" 9 | homepage = "https://github.com/minamorl/darkcore" 10 | keywords = ["monad", "functional", "dsl", "business logic"] 11 | packages = [{ include = "darkcore" }] 12 | include = ["darkcore/py.typed"] 13 | 14 | [tool.poetry.dependencies] 15 | python = ">=3.10,<3.13" 16 | 17 | 18 | [tool.poetry.group.dev.dependencies] 19 | mypy = "^1.15.0" 20 | pytest = "^8.3.5" 21 | pytest-cov = "^6.1.1" 22 | hypothesis = "^6.112" 23 | 24 | [build-system] 25 | requires = ["poetry-core"] 26 | build-backend = "poetry.core.masonry.api" 27 | 28 | [tool.mypy] 29 | strict = true 30 | ignore_missing_imports = true 31 | disallow_untyped_defs = true 32 | disallow_incomplete_defs = true 33 | disable_error_code = ["misc"] 34 | files = ["darkcore"] 35 | 36 | [tool.pytest.ini_options] 37 | addopts = "-v --cov=darkcore" 38 | testpaths = ["tests"] 39 | 40 | -------------------------------------------------------------------------------- /tests/test_integration.py: -------------------------------------------------------------------------------- 1 | from darkcore.reader import Reader 2 | from darkcore.writer import Writer 3 | from darkcore.state import State 4 | from darkcore.result import Ok, Err 5 | 6 | def test_reader_writer_state_result_integration(): 7 | # Step1: read user from env 8 | get_user = Reader(lambda env: env.get("user")) 9 | 10 | # Step2: wrap into Result (fail if missing) 11 | def to_result(user): 12 | if user is None: 13 | return Err("no user") 14 | return Ok(user) 15 | 16 | # Step3: log the user using Writer 17 | def log_user(user): 18 | return Writer(user, [f"got user={user}"]) 19 | 20 | # Step4: update state counter with State 21 | def update_state(user): 22 | return State(lambda s: (f"{user}@{s}", s+1)) 23 | 24 | env = {"user": "alice"} 25 | 26 | # run integration 27 | user = get_user.run(env) 28 | result = to_result(user) >> (lambda u: Ok(log_user(u))) 29 | assert isinstance(result, Ok) 30 | 31 | writer: Writer = result.value 32 | assert writer.log == ["got user=alice"] 33 | 34 | # state part 35 | state_prog = update_state(writer.value) 36 | out, s2 = state_prog.run(42) 37 | assert out == "alice@42" 38 | assert s2 == 43 39 | -------------------------------------------------------------------------------- /tests/test_traverse.py: -------------------------------------------------------------------------------- 1 | from darkcore.maybe import Maybe 2 | from darkcore.result import Ok, Err, Result 3 | from darkcore.traverse import ( 4 | sequence_maybe, 5 | sequence_result, 6 | traverse_maybe, 7 | traverse_result, 8 | liftA2, 9 | left_then, 10 | then_right, 11 | ) 12 | 13 | 14 | def test_traverse_identity_maybe(): 15 | xs = [1, 2, 3] 16 | assert traverse_maybe(xs, Maybe) == Maybe(xs) 17 | 18 | 19 | def test_sequence_equivalence_maybe(): 20 | xs = [Maybe(1), Maybe(2)] 21 | assert sequence_maybe(xs) == traverse_maybe(xs, lambda x: x) 22 | 23 | 24 | def test_sequence_result_early_stop(): 25 | xs: list[Result[int]] = [Ok(1), Err("e"), Ok(2)] 26 | assert sequence_result(xs) == Err("e") 27 | 28 | 29 | def test_traverse_result_identity(): 30 | xs = [1, 2] 31 | assert traverse_result(xs, Ok) == Ok(xs) 32 | 33 | 34 | def test_liftA2_and_sequence(): 35 | fa = Ok(2) 36 | fb = Ok(3) 37 | add = lambda a, b: a + b 38 | assert liftA2(add, fa, fb) == Ok(5) 39 | assert left_then(fa, fb) == Ok(2) 40 | assert then_right(fa, fb) == Ok(3) 41 | 42 | 43 | def test_sequence_maybe_boundary_cases(): 44 | assert sequence_maybe([]) == Maybe.pure([]) 45 | assert sequence_maybe([Maybe(1), Maybe(2)]) == Maybe([1, 2]) 46 | assert sequence_maybe([Maybe(1), Maybe(None)]) == Maybe(None) 47 | -------------------------------------------------------------------------------- /darkcore/maybe_t.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import Callable, Generic, TypeVar, Any 3 | from .core import Monad as MonadLike 4 | from .maybe import Maybe 5 | 6 | A = TypeVar("A") 7 | B = TypeVar("B") 8 | 9 | class MaybeT(Generic[A]): 10 | """ 11 | Wraps: m (Maybe a) 12 | """ 13 | def __init__(self, run: MonadLike[Any]) -> None: 14 | self.run: MonadLike[Any] = run 15 | 16 | @classmethod 17 | def lift(cls, monad: MonadLike[A]) -> "MaybeT[A]": 18 | return MaybeT(monad.bind(lambda x: monad.pure(Maybe(x)))) # type: ignore[arg-type] 19 | 20 | def fmap(self, f: Callable[[A], B]) -> "MaybeT[B]": 21 | return MaybeT(self.run.bind(lambda maybe: self.run.pure(maybe.fmap(f)))) 22 | 23 | map = fmap 24 | 25 | def ap(self: "MaybeT[Callable[[A], B]]", fa: "MaybeT[A]") -> "MaybeT[B]": 26 | return MaybeT(self.run.bind(lambda mf: fa.run.bind(lambda mx: self.run.pure(mf.ap(mx))))) 27 | 28 | def bind(self, f: Callable[[A], "MaybeT[B]"]) -> "MaybeT[B]": 29 | def step(maybe: Maybe[A]) -> MonadLike[Any]: 30 | if maybe.is_nothing(): 31 | return self.run.pure(Maybe(None)) 32 | else: 33 | return f(maybe.get_or_else(None)).run 34 | return MaybeT(self.run.bind(step)) 35 | 36 | def __eq__(self, other: object) -> bool: 37 | return isinstance(other, MaybeT) and self.run == other.run 38 | 39 | def __repr__(self) -> str: 40 | return f"MaybeT({self.run!r})" 41 | -------------------------------------------------------------------------------- /darkcore/state.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import Callable, Generic, TypeVar, Tuple 3 | from .core import MonadOpsMixin 4 | 5 | S = TypeVar("S") # State 6 | A = TypeVar("A") 7 | B = TypeVar("B") 8 | 9 | class State(MonadOpsMixin[A], Generic[S, A]): 10 | def __init__(self, run: Callable[[S], Tuple[A, S]]) -> None: 11 | self.run = run 12 | 13 | @classmethod 14 | def pure(cls, value: A) -> State[S, A]: 15 | return State(lambda s: (value, s)) 16 | 17 | def fmap(self, f: Callable[[A], B]) -> State[S, B]: 18 | def new_run(s: S) -> Tuple[B, S]: 19 | (a, s1) = self.run(s) 20 | return (f(a), s1) 21 | return State(new_run) 22 | 23 | map = fmap 24 | 25 | def ap(self: "State[S, Callable[[A], B]]", fa: "State[S, A]") -> "State[S, B]": 26 | def new_run(s: S) -> Tuple[B, S]: 27 | (f, s1) = self.run(s) 28 | (x, s2) = fa.run(s1) 29 | return (f(x), s2) 30 | return State(new_run) 31 | 32 | def bind(self, f: Callable[[A], State[S, B]]) -> State[S, B]: 33 | def new_run(s: S) -> Tuple[B, S]: 34 | (a, s1) = self.run(s) 35 | return f(a).run(s1) 36 | return State(new_run) 37 | 38 | @staticmethod 39 | def get() -> State[S, S]: 40 | return State(lambda s: (s, s)) 41 | 42 | @staticmethod 43 | def put(new_state: S) -> State[S, None]: 44 | return State(lambda _: (None, new_state)) 45 | 46 | def __repr__(self) -> str: 47 | return f"State({self.run})" 48 | -------------------------------------------------------------------------------- /darkcore/traverse.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import Callable, List, Sequence, TypeVar, Any, cast 3 | from .maybe import Maybe 4 | from .result import Result, Ok, Err 5 | from .core import SupportsFmapBindAp 6 | 7 | A = TypeVar("A") 8 | B = TypeVar("B") 9 | T = TypeVar("T") 10 | F = TypeVar("F", bound=SupportsFmapBindAp[Any]) 11 | 12 | def sequence_maybe(xs: Sequence[Maybe[A]]) -> Maybe[List[A]]: 13 | acc: Maybe[List[A]] = Maybe.pure(cast(List[A], [])) 14 | for m in xs: 15 | acc = liftA2(lambda lst, v: lst + [v], acc, m) 16 | return acc 17 | 18 | def traverse_maybe(xs: Sequence[T], f: Callable[[T], Maybe[A]]) -> Maybe[List[A]]: 19 | return sequence_maybe([f(x) for x in xs]) 20 | 21 | def sequence_result(xs: Sequence[Result[A]]) -> Result[List[A]]: 22 | acc: List[A] = [] 23 | for r in xs: 24 | if isinstance(r, Err): 25 | return cast(Result[List[A]], r) 26 | acc.append(cast(Ok[A], r).value) 27 | return Ok(acc) 28 | 29 | def traverse_result(xs: Sequence[T], f: Callable[[T], Result[A]]) -> Result[List[A]]: 30 | return sequence_result([f(x) for x in xs]) 31 | 32 | def liftA2( 33 | f: Callable[[A, B], T], fa: SupportsFmapBindAp[A], fb: SupportsFmapBindAp[B] 34 | ) -> Any: 35 | return fa.fmap(lambda a: (lambda b: f(a, b))).ap(fb) 36 | 37 | def left_then(fa: SupportsFmapBindAp[A], fb: SupportsFmapBindAp[B]) -> Any: 38 | return liftA2(lambda a, _b: a, fa, fb) 39 | 40 | def then_right(fa: SupportsFmapBindAp[A], fb: SupportsFmapBindAp[B]) -> Any: 41 | return liftA2(lambda _a, b: b, fa, fb) 42 | -------------------------------------------------------------------------------- /tests/test_maybe.py: -------------------------------------------------------------------------------- 1 | from darkcore.maybe import Maybe 2 | 3 | def test_maybe_bind_success(): 4 | m = Maybe(3) 5 | result = ( 6 | m.bind(lambda x: Maybe(x + 1)) 7 | .bind(lambda x: Maybe(x * 2)) 8 | ) 9 | assert result._value == 8 10 | 11 | def test_maybe_bind_none(): 12 | m = Maybe(None) 13 | result = ( 14 | m.bind(lambda x: Maybe(x + 1)) 15 | .bind(lambda x: Maybe(x * 2)) 16 | ) 17 | assert result._value is None 18 | 19 | def test_maybe_ap_success(): 20 | mf = Maybe(lambda x: x + 2) 21 | mx = Maybe(3) 22 | result = mf.ap(mx) 23 | assert result.get_or_else(0) == 5 24 | 25 | def test_maybe_ap_none_function(): 26 | mf = Maybe(None) 27 | mx = Maybe(3) 28 | result = mf.ap(mx) 29 | assert result.is_nothing() 30 | 31 | def test_maybe_ap_none_value(): 32 | mf = Maybe(lambda x: x + 2) 33 | mx = Maybe(None) 34 | result = mf.ap(mx) 35 | assert result.is_nothing() 36 | 37 | 38 | def test_maybe_ap_operator(): 39 | mf = Maybe(lambda x: x * 2) 40 | mx = Maybe(4) 41 | assert (mf @ mx).get_or_else(0) == 8 42 | 43 | 44 | def test_maybe_map_operator(): 45 | m = Maybe(3) | (lambda x: x + 1) 46 | assert m.get_or_else(0) == 4 47 | 48 | def test_monad_left_identity(): 49 | f = lambda x: Maybe(x + 1) 50 | x = 5 51 | assert Maybe.pure(x).bind(f) == f(x) 52 | 53 | def test_monad_right_identity(): 54 | m = Maybe(5) 55 | assert m.bind(Maybe.pure) == m 56 | 57 | def test_monad_associativity(): 58 | m = Maybe(5) 59 | f = lambda x: Maybe(x + 1) 60 | g = lambda x: Maybe(x * 2) 61 | assert m.bind(f).bind(g) == m.bind(lambda x: f(x).bind(g)) 62 | -------------------------------------------------------------------------------- /darkcore/either_t.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import Callable, Generic, TypeVar, Any 3 | from .core import Monad as MonadLike 4 | from .either import Either, Left, Right 5 | 6 | A = TypeVar("A") 7 | B = TypeVar("B") 8 | 9 | class EitherT(Generic[A]): 10 | """Monad transformer for Either. 11 | 12 | Wraps: m (Either a) 13 | """ 14 | 15 | def __init__(self, run: MonadLike[Any]) -> None: 16 | self.run: MonadLike[Any] = run 17 | 18 | @classmethod 19 | def lift(cls, monad: MonadLike[A]) -> "EitherT[A]": 20 | """Lift a monad into EitherT.""" 21 | return EitherT(monad.bind(lambda x: monad.pure(Right(x)))) # type: ignore[arg-type] 22 | 23 | def map(self, f: Callable[[A], B]) -> "EitherT[B]": 24 | return EitherT(self.run.bind(lambda e: self.run.pure(e.fmap(f)))) 25 | 26 | def ap(self: "EitherT[Callable[[A], B]]", fa: "EitherT[A]") -> "EitherT[B]": 27 | return EitherT(self.run.bind(lambda mf: fa.run.bind(lambda mx: self.run.pure(mf.ap(mx))))) 28 | 29 | def bind(self, f: Callable[[A], "EitherT[B]"]) -> "EitherT[B]": 30 | def step(either: Either[A]) -> MonadLike[Any]: 31 | if isinstance(either, Left): 32 | return self.run.pure(either) 33 | if isinstance(either, Right): 34 | return f(either.value).run 35 | # This branch is theoretically unreachable but keeps mypy satisfied 36 | raise TypeError("Unexpected Either subtype") 37 | return EitherT(self.run.bind(step)) 38 | 39 | def __eq__(self, other: object) -> bool: 40 | return isinstance(other, EitherT) and self.run == other.run 41 | 42 | def __repr__(self) -> str: 43 | return f"EitherT({self.run!r})" 44 | -------------------------------------------------------------------------------- /tests/test_either_t.py: -------------------------------------------------------------------------------- 1 | from darkcore.either import Left, Right 2 | from darkcore.either_t import EitherT 3 | 4 | class DummyMonad: 5 | """Minimal monad for testing (only carries a value).""" 6 | def __init__(self, value): 7 | self.value = value 8 | 9 | @staticmethod 10 | def pure(x): 11 | return DummyMonad(x) 12 | 13 | def fmap(self, f): 14 | return DummyMonad(f(self.value)) 15 | 16 | def bind(self, f): 17 | return f(self.value) 18 | 19 | def __eq__(self, other): 20 | return isinstance(other, DummyMonad) and self.value == other.value 21 | 22 | def __repr__(self): 23 | return f"DummyMonad({self.value!r})" 24 | 25 | 26 | def test_lift(): 27 | m = DummyMonad(10) 28 | et = EitherT.lift(m) 29 | assert isinstance(et, EitherT) 30 | assert et.run == DummyMonad(Right(10)) 31 | 32 | 33 | def test_map(): 34 | m = DummyMonad(Right(3)) 35 | et = EitherT(m) 36 | et2 = et.map(lambda x: x + 1) 37 | assert et2.run == DummyMonad(Right(4)) 38 | 39 | 40 | def test_bind_right(): 41 | m = DummyMonad(Right(2)) 42 | et = EitherT(m) 43 | 44 | def f(x): 45 | return EitherT(DummyMonad(Right(x * 10))) 46 | 47 | result = et.bind(f) 48 | assert result.run == DummyMonad(Right(20)) 49 | 50 | 51 | def test_bind_left(): 52 | m = DummyMonad(Left("fail")) 53 | et = EitherT(m) 54 | 55 | def f(x): 56 | return EitherT(DummyMonad(Right(x * 10))) 57 | 58 | result = et.bind(f) 59 | assert result.run == DummyMonad(Left("fail")) 60 | 61 | 62 | def test_ap(): 63 | mf = DummyMonad(Right(lambda x: x + 5)) 64 | mx = DummyMonad(Right(7)) 65 | etf = EitherT(mf) 66 | etx = EitherT(mx) 67 | result = etf.ap(etx) 68 | assert result.run == DummyMonad(Right(12)) 69 | -------------------------------------------------------------------------------- /darkcore/maybe.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import Callable, Generic, Optional, TypeVar 3 | from .core import MonadOpsMixin 4 | 5 | A = TypeVar("A") 6 | B = TypeVar("B") 7 | 8 | class Maybe(MonadOpsMixin[A], Generic[A]): 9 | __slots__ = ("_value",) 10 | __match_args__ = ("value",) 11 | 12 | def __init__(self, value: Optional[A]) -> None: 13 | self._value = value 14 | 15 | @classmethod 16 | def pure(cls, value: A) -> "Maybe[A]": 17 | return cls(value) 18 | 19 | def fmap(self, f: Callable[[A], B]) -> "Maybe[B]": 20 | if self._value is None: 21 | return Maybe(None) 22 | return Maybe(f(self._value)) 23 | 24 | map = fmap # alias 25 | 26 | def ap(self: "Maybe[Callable[[A], B]]", fa: "Maybe[A]") -> "Maybe[B]": 27 | if self._value is None or fa._value is None: 28 | return Maybe(None) 29 | return Maybe(self._value(fa._value)) 30 | 31 | def bind(self, f: Callable[[A], "Maybe[B]"]) -> "Maybe[B]": 32 | if self._value is None: 33 | return Maybe(None) 34 | return f(self._value) 35 | 36 | def is_nothing(self) -> bool: 37 | return self._value is None 38 | 39 | def is_just(self) -> bool: 40 | return self._value is not None 41 | 42 | def get_or_else(self, default: Optional[A]) -> A: 43 | if self._value is None: 44 | return default # type: ignore[return-value] 45 | return self._value 46 | 47 | def __eq__(self, other: object) -> bool: 48 | return isinstance(other, Maybe) and self._value == other._value 49 | 50 | def __repr__(self) -> str: 51 | return "Nothing" if self._value is None else f"Just({self._value!r})" 52 | 53 | @property 54 | def value(self) -> Optional[A]: 55 | return self._value 56 | -------------------------------------------------------------------------------- /tests/test_validation.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from darkcore.validation import Success, Failure, from_result, to_result 3 | from darkcore.result import Ok, Err 4 | 5 | 6 | def test_fmap_identity(): 7 | assert Success(1).fmap(lambda x: x) == Success(1) 8 | assert Failure(["e"]).fmap(lambda x: x) == Failure(["e"]) 9 | 10 | 11 | def test_fmap_composition(): 12 | f = lambda x: x + 1 13 | g = lambda x: x * 2 14 | v = Success(3) 15 | assert v.fmap(f).fmap(g) == v.fmap(lambda x: g(f(x))) 16 | 17 | 18 | def test_applicative_identity(): 19 | v = Success(10) 20 | assert Success(lambda x: x).ap(v) == v 21 | 22 | 23 | def test_applicative_homomorphism(): 24 | f = lambda x: x + 5 25 | x = 3 26 | assert Success(f).ap(Success(x)) == Success(f(x)) 27 | 28 | 29 | def test_applicative_interchange(): 30 | f = Success(lambda x: x + 1) 31 | y = 2 32 | assert f.ap(Success(y)) == Success(lambda g: g(y)).ap(f) 33 | 34 | 35 | def test_applicative_composition(): 36 | u = Success(lambda y: y * 2) 37 | v = Success(lambda x: x + 3) 38 | w = Success(1) 39 | compose = lambda f: lambda g: lambda x: f(g(x)) 40 | left = Success(compose).ap(u).ap(v).ap(w) 41 | right = u.ap(v.ap(w)) 42 | assert left == right 43 | 44 | 45 | def test_failure_accumulates(): 46 | f1 = Failure(["e1"]) 47 | f2 = Failure(["e2"]) 48 | assert f1.ap(Success(1)) == f1 49 | assert Success(lambda x: x).ap(f1) == f1 50 | assert f1.ap(f2) == Failure(["e1", "e2"]) 51 | 52 | 53 | def test_from_to_result_roundtrip(): 54 | ok = Ok(1) 55 | err = Err("x") 56 | assert from_result(ok) == Success(1) 57 | assert from_result(err) == Failure(["x"]) 58 | assert to_result(Success(2)) == Ok(2) 59 | assert to_result(Failure(["a", "b"])) == Err("a, b") 60 | 61 | 62 | def test_non_commutative_errors(): 63 | e1 = ("tag1", "a") 64 | e2 = ("tag2", "b") 65 | f1 = Failure([e1]) 66 | f2 = Failure([e2]) 67 | assert f1.ap(f2) == Failure([e1, e2]) 68 | -------------------------------------------------------------------------------- /tests/test_validation_t.py: -------------------------------------------------------------------------------- 1 | from darkcore.validation import Success, Failure 2 | from darkcore.validation_t import ValidationT 3 | from darkcore.result import Ok 4 | 5 | 6 | def test_functor_identity(): 7 | vt = ValidationT(Ok(Success(1))) 8 | assert vt.fmap(lambda x: x).run == Ok(Success(1)) 9 | 10 | 11 | def test_functor_composition(): 12 | vt = ValidationT(Ok(Success(2))) 13 | f = lambda x: x + 1 14 | g = lambda x: x * 2 15 | left = vt.fmap(f).fmap(g).run 16 | right = vt.fmap(lambda x: g(f(x))).run 17 | assert left == right 18 | 19 | 20 | def test_applicative_identity(): 21 | v = ValidationT(Ok(Success(3))) 22 | identity = ValidationT(Ok(Success(lambda x: x))) 23 | assert identity.ap(v).run == Ok(Success(3)) 24 | 25 | 26 | def test_applicative_homomorphism(): 27 | f = lambda x: x + 5 28 | x = 3 29 | left = ValidationT(Ok(Success(f))).ap(ValidationT(Ok(Success(x)))).run 30 | right = Ok(Success(f(x))) 31 | assert left == right 32 | 33 | 34 | def test_applicative_interchange(): 35 | u = ValidationT(Ok(Success(lambda x: x + 1))) 36 | y = 2 37 | left = u.ap(ValidationT(Ok(Success(y)))).run 38 | right = ValidationT(Ok(Success(lambda g: g(y)))).ap(u).run 39 | assert left == right 40 | 41 | 42 | def test_applicative_composition(): 43 | u = ValidationT(Ok(Success(lambda y: y * 2))) 44 | v = ValidationT(Ok(Success(lambda x: x + 3))) 45 | w = ValidationT(Ok(Success(1))) 46 | compose = lambda f: lambda g: lambda x: f(g(x)) 47 | left = ValidationT(Ok(Success(compose))).ap(u).ap(v).ap(w).run 48 | right = u.ap(v.ap(w)).run 49 | assert left == right 50 | 51 | 52 | def test_failure_accumulates(): 53 | f1 = ValidationT(Ok(Failure(["e1"]))) 54 | f2 = ValidationT(Ok(Failure(["e2"]))) 55 | res = f1.ap(f2).run 56 | assert res == Ok(Failure(["e1", "e2"])) 57 | 58 | 59 | def test_lift_and_bind_failure(): 60 | vt = ValidationT.lift(Ok(1)) 61 | assert vt.run == Ok(Success(1)) 62 | fail = ValidationT(Ok(Failure(["e"]))) 63 | res = fail.bind(lambda x: ValidationT(Ok(Success(x + 1)))).run 64 | assert res == Ok(Failure(["e"])) 65 | -------------------------------------------------------------------------------- /darkcore/result_t.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import Callable, Generic, TypeVar, Any, cast 3 | from .core import Monad as MonadLike 4 | from .result import Result, Ok, Err 5 | 6 | A = TypeVar("A") 7 | B = TypeVar("B") 8 | 9 | class ResultT(Generic[A]): 10 | """Monad transformer for :class:`~darkcore.result.Result`. 11 | 12 | Wraps ``m (Result a)``. 13 | """ 14 | 15 | def __init__(self, run: MonadLike[Result[A]]) -> None: 16 | self.run = run 17 | 18 | @classmethod 19 | def lift(cls, monad: MonadLike[A]) -> "ResultT[A]": 20 | def step(x: A) -> MonadLike[Result[A]]: 21 | return cast(MonadLike[Result[A]], cast(Any, monad).pure(Ok(x))) 22 | return ResultT(cast(MonadLike[Result[A]], monad.bind(step))) 23 | 24 | def fmap(self, f: Callable[[A], B]) -> "ResultT[B]": 25 | def step(res: Result[A]) -> MonadLike[Result[B]]: 26 | if isinstance(res, Err): 27 | return cast(MonadLike[Result[B]], self.run.pure(res)) 28 | ok = cast(Ok[A], res) 29 | return cast( 30 | MonadLike[Result[B]], 31 | cast(Any, self.run).pure(cast(Result[B], Ok(f(ok.value)))) 32 | ) 33 | 34 | return ResultT(self.run.bind(step)) 35 | 36 | map = fmap 37 | 38 | def ap(self: "ResultT[Callable[[A], B]]", fa: "ResultT[A]") -> "ResultT[B]": 39 | return ResultT( 40 | self.run.bind( 41 | lambda mf: fa.run.bind( 42 | lambda mx: cast(MonadLike[Result[B]], self.run.pure(mf.ap(mx))) 43 | ) 44 | ) 45 | ) 46 | 47 | def bind(self, f: Callable[[A], "ResultT[B]"]) -> "ResultT[B]": 48 | def step(res: Result[A]) -> MonadLike[Result[B]]: 49 | if isinstance(res, Err): 50 | return cast(MonadLike[Result[B]], self.run.pure(res)) 51 | ok = cast(Ok[A], res) 52 | return f(ok.value).run 53 | return ResultT(self.run.bind(step)) 54 | 55 | def __eq__(self, other: object) -> bool: 56 | return isinstance(other, ResultT) and self.run == other.run 57 | 58 | def __repr__(self) -> str: 59 | return f"ResultT({self.run!r})" 60 | -------------------------------------------------------------------------------- /darkcore/either.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import Callable, Generic, TypeVar, Any, cast 3 | from .core import Monad, MonadOpsMixin 4 | 5 | A = TypeVar("A") 6 | B = TypeVar("B") 7 | 8 | 9 | class Either(MonadOpsMixin[A], Monad[A], Generic[A]): 10 | # fmap は具象側で実装 11 | def fmap(self, f: Callable[[A], B]) -> "Either[B]": 12 | raise NotImplementedError 13 | 14 | def map(self, f: Callable[[A], B]) -> "Either[B]": 15 | return self.fmap(f) 16 | 17 | def __eq__(self, other: object) -> bool: 18 | return isinstance(other, Either) and self.__dict__ == other.__dict__ 19 | 20 | 21 | class Left(Either[A]): 22 | __match_args__ = ("error",) 23 | 24 | def __init__(self, value: A) -> None: 25 | self.value = value 26 | 27 | @property 28 | def error(self) -> A: 29 | return self.value 30 | 31 | @classmethod 32 | def pure(cls, value: A) -> "Either[A]": 33 | # Left.pure は Right に持ち上げるのが通例 34 | return Right(value) 35 | 36 | def fmap(self, f: Callable[[A], B]) -> "Either[B]": 37 | return cast(Either[B], self) 38 | 39 | def bind(self, f: Callable[[A], Monad[B]]) -> Monad[B]: 40 | return cast(Monad[B], self) 41 | 42 | def ap(self, fa: "Either[A]") -> "Either[B]": 43 | return cast(Either[B], self) 44 | 45 | def __repr__(self) -> str: 46 | return f"Left({self.value!r})" 47 | 48 | 49 | class Right(Either[A]): 50 | __match_args__ = ("value",) 51 | 52 | def __init__(self, value: A) -> None: 53 | self.value = value 54 | 55 | @classmethod 56 | def pure(cls, value: A) -> "Either[A]": 57 | return Right(value) 58 | 59 | def fmap(self, f: Callable[[A], B]) -> "Either[B]": 60 | return Right(f(self.value)) 61 | 62 | # 基底 Monad と同じシグネチャ 63 | def bind(self, f: Callable[[A], Monad[B]]) -> Monad[B]: 64 | return f(self.value) 65 | 66 | def ap(self: "Right[Callable[[A], B]]", fa: "Either[A]") -> "Either[B]": 67 | if isinstance(fa, Right): 68 | func = self.value # Callable[[A], B] 69 | return Right(func(fa.value)) 70 | return cast(Either[B], fa) # Left はそのまま伝播 71 | 72 | def __repr__(self) -> str: 73 | return f"Right({self.value!r})" 74 | -------------------------------------------------------------------------------- /darkcore/validation_t.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import Callable, Generic, TypeVar, Any, cast 3 | from .core import Monad as MonadLike 4 | from .validation import Validation, Success, Failure 5 | 6 | E = TypeVar("E") 7 | A = TypeVar("A") 8 | B = TypeVar("B") 9 | 10 | class ValidationT(Generic[E, A]): 11 | """Monad transformer for :class:`~darkcore.validation.Validation`. 12 | 13 | Wraps ``m (Validation e a)``. 14 | """ 15 | 16 | def __init__(self, run: MonadLike[Validation[E, A]]) -> None: 17 | self.run = run 18 | 19 | @classmethod 20 | def lift(cls, monad: MonadLike[A]) -> "ValidationT[E, A]": 21 | def step(x: A) -> MonadLike[Validation[E, A]]: 22 | return cast(MonadLike[Validation[E, A]], cast(Any, monad).pure(Success(x))) 23 | return ValidationT(cast(MonadLike[Validation[E, A]], monad.bind(step))) 24 | 25 | def fmap(self, f: Callable[[A], B]) -> "ValidationT[E, B]": 26 | def step(val: Validation[E, A]) -> MonadLike[Validation[E, B]]: 27 | return cast( 28 | MonadLike[Validation[E, B]], 29 | cast(Any, self.run).pure(val.fmap(f)), 30 | ) 31 | return ValidationT(self.run.bind(step)) 32 | 33 | map = fmap 34 | 35 | def ap(self: "ValidationT[E, Callable[[A], B]]", fa: "ValidationT[E, A]") -> "ValidationT[E, B]": 36 | return ValidationT( 37 | self.run.bind( 38 | lambda mf: fa.run.bind( 39 | lambda mx: cast( 40 | MonadLike[Validation[E, B]], 41 | cast(Any, self.run).pure(cast(Any, mf).ap(mx)), 42 | ) 43 | ) 44 | ) 45 | ) 46 | 47 | def bind(self, f: Callable[[A], "ValidationT[E, B]"]) -> "ValidationT[E, B]": 48 | def step(val: Validation[E, A]) -> MonadLike[Validation[E, B]]: 49 | if isinstance(val, Failure): 50 | return cast(MonadLike[Validation[E, B]], cast(Any, self.run).pure(val)) 51 | succ = cast(Success[E, A], val) 52 | return f(succ.value).run 53 | return ValidationT(self.run.bind(step)) 54 | 55 | def __eq__(self, other: object) -> bool: # pragma: no cover - structural 56 | return isinstance(other, ValidationT) and self.run == other.run 57 | 58 | def __repr__(self) -> str: # pragma: no cover - debug 59 | return f"ValidationT({self.run!r})" 60 | -------------------------------------------------------------------------------- /darkcore/core.py: -------------------------------------------------------------------------------- 1 | # filepath: darkcore/core.py 2 | from __future__ import annotations 3 | from typing import Any, Callable, Generic, Protocol, TypeVar 4 | 5 | A = TypeVar("A") 6 | B = TypeVar("B") 7 | 8 | 9 | class Applicative(Protocol, Generic[A]): 10 | """ 11 | 構造的サブタイピングで表現した最小限の Applicative プロトコル。 12 | 具体型(Maybe, Result, Either など)は、このプロトコルが要求する 13 | メソッド群(pure, ap)を実装していれば「Applicative 的」に振る舞える。 14 | """ 15 | 16 | @classmethod 17 | def pure(cls, value: A) -> Applicative[A]: 18 | """値を Applicative コンテキストに持ち上げる""" 19 | ... 20 | 21 | def ap(self, fa: Applicative[Any]) -> Applicative[Any]: 22 | """ 23 | self が f: (A -> B) を含む Applicative, 24 | fa が A を含む Applicative のとき、 25 | f を適用して B を含む Applicative を返す。 26 | """ 27 | ... 28 | 29 | 30 | # プロトコル定義 31 | class Monad(Protocol, Generic[A]): 32 | """ 33 | 構造的サブタイピングで表現した最小限の Monad プロトコル。 34 | ・pure: a -> m a 35 | ・bind: m a -> (a -> m b) -> m b 36 | ・fmap: m a -> (a -> b) -> m b 37 | HKTs が無い Python では正確な型制約ができないため Any を許容。 38 | """ 39 | 40 | @classmethod 41 | def pure(cls, value: A) -> Monad[A]: 42 | """値を Monad コンテキストに持ち上げる""" 43 | ... 44 | 45 | def bind(self, f: Callable[[A], Monad[Any]]) -> Monad[Any]: 46 | """文脈付き値に f: a -> m b を適用して m b を返す""" 47 | ... 48 | 49 | def fmap(self, f: Callable[[A], B]) -> Monad[B]: 50 | """文脈付き値に純粋関数を適用して m b を返す""" 51 | ... 52 | 53 | 54 | class SupportsFmapBindAp(Protocol, Generic[A]): 55 | """Protocol for types supporting ``fmap``, ``bind`` and ``ap``.""" 56 | 57 | def fmap(self, f: Callable[[A], B]) -> Any: # pragma: no cover - structural 58 | ... 59 | 60 | def bind(self, f: Callable[[A], Any]) -> Any: # pragma: no cover - structural 61 | ... 62 | 63 | def ap(self, fa: Any) -> Any: # pragma: no cover - structural 64 | ... 65 | 66 | 67 | class MonadOpsMixin(Generic[A]): 68 | """演算子 DSL を提供するミックスイン。 69 | 70 | ``|`` は ``fmap``、``>>`` は ``bind``、``@`` は ``ap`` に対応する。 71 | ``fmap``/``bind``/``ap`` を実装する型はこのミックスインを継承するだけで 72 | これらの演算子を利用できる。 73 | """ 74 | 75 | def __or__(self: SupportsFmapBindAp[A], f: Callable[[A], B]) -> Any: 76 | return self.fmap(f) 77 | 78 | def __rshift__(self: SupportsFmapBindAp[A], f: Callable[[A], Any]) -> Any: 79 | return self.bind(f) 80 | 81 | def __matmul__(self: SupportsFmapBindAp[A], fa: Any) -> Any: 82 | return self.ap(fa) 83 | -------------------------------------------------------------------------------- /darkcore/result.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import Callable, Generic, TypeVar, Any, cast 3 | from .core import Monad, MonadOpsMixin 4 | 5 | A = TypeVar("A") 6 | B = TypeVar("B") 7 | 8 | 9 | class Result(MonadOpsMixin[A], Monad[A], Generic[A]): 10 | """ 11 | Result は構造的に Monad を満たす成功/失敗の直和型。 12 | fmap は具象側で実装し、ここではシグネチャだけ宣言する。 13 | """ 14 | def fmap(self, f: Callable[[A], B]) -> "Result[B]": # 宣言のみ 15 | raise NotImplementedError 16 | 17 | def map(self, f: Callable[[A], B]) -> "Result[B]": 18 | return self.fmap(f) 19 | 20 | def ap(self, fa: "Result[Any]") -> "Result[Any]": # pragma: no cover - interface 21 | raise NotImplementedError 22 | 23 | def __eq__(self, other: object) -> bool: 24 | return isinstance(other, Result) and self.__dict__ == other.__dict__ 25 | 26 | class Ok(Result[A]): 27 | __match_args__ = ("value",) 28 | def __init__(self, value: A) -> None: 29 | self.value = value 30 | 31 | @classmethod 32 | def pure(cls, value: A) -> "Result[A]": 33 | return Ok(value) 34 | 35 | def fmap(self, f: Callable[[A], B]) -> "Result[B]": 36 | return Ok(f(self.value)) 37 | 38 | # 基底 Monad と同じシグネチャにする(LSP 違反を避ける) 39 | def bind(self, f: Callable[[A], Monad[B]]) -> Monad[B]: 40 | return f(self.value) 41 | 42 | # self は「関数を包んだ Ok」であることを要求 43 | def ap(self: "Ok[Callable[[A], B]]", fa: "Result[A]") -> "Result[B]": 44 | if isinstance(fa, Ok): 45 | func = self.value # Callable[[A], B] 46 | return Ok(func(fa.value)) 47 | return cast(Result[B], fa) # Err はそのまま伝播 48 | 49 | def __repr__(self) -> str: 50 | return f"Ok({self.value!r})" 51 | 52 | 53 | class Err(Result[A]): 54 | __match_args__ = ("error",) 55 | def __init__(self, error: str) -> None: 56 | self.error = error 57 | 58 | @classmethod 59 | def pure(cls, value: A) -> "Result[A]": 60 | # pure は成功側へ 61 | return Ok(value) 62 | 63 | def fmap(self, f: Callable[[A], B]) -> "Result[B]": 64 | # 失敗はそのまま(型的には B へキャストが必要) 65 | return cast(Result[B], self) 66 | 67 | def bind(self, f: Callable[[A], Monad[B]]) -> Monad[B]: 68 | # 失敗はそのまま(型的には B へキャストが必要) 69 | return cast(Monad[B], self) 70 | 71 | def ap(self, fa: "Result[A]") -> "Result[B]": 72 | # 失敗はそのまま(B へキャスト) 73 | return cast(Result[B], self) 74 | 75 | def __repr__(self) -> str: 76 | return f"Err({self.error!r})" 77 | -------------------------------------------------------------------------------- /darkcore/reader_t.py: -------------------------------------------------------------------------------- 1 | """ReaderT monad transformer. 2 | 3 | Equality is extensional. Compare outputs of `run` on same inputs. 4 | """ 5 | from __future__ import annotations 6 | from typing import Callable, Generic, TypeVar 7 | from .core import Monad as MonadLike # Protocol として使う 8 | 9 | R = TypeVar("R") 10 | A = TypeVar("A") 11 | B = TypeVar("B") 12 | 13 | class ReaderT(Generic[R, A]): 14 | def __init__(self, run: Callable[[R], MonadLike[A]]) -> None: 15 | self.run = run 16 | 17 | @classmethod 18 | def lift(cls, monad: MonadLike[A]) -> "ReaderT[R, A]": 19 | return ReaderT(lambda _: monad) 20 | 21 | @classmethod 22 | def pure(cls, value: A) -> "ReaderT[R, A]": 23 | raise NotImplementedError("ReaderT.pure not implemented (needs monad context)") 24 | 25 | @classmethod 26 | def pure_with( 27 | cls, pure: Callable[[A], MonadLike[A]], value: A 28 | ) -> "ReaderT[R, A]": 29 | """Construct a ``ReaderT`` using a provided ``pure`` for the base monad. 30 | 31 | Needed because Python lacks higher-kinded types. 32 | """ 33 | return ReaderT(lambda _r: pure(value)) 34 | 35 | def fmap(self, f: Callable[[A], B]) -> "ReaderT[R, B]": 36 | return ReaderT(lambda env: self.run(env).fmap(f)) 37 | 38 | map = fmap 39 | 40 | def ap(self: "ReaderT[R, Callable[[A], B]]", fa: "ReaderT[R, A]") -> "ReaderT[R, B]": 41 | return ReaderT( 42 | lambda env: self.run(env).bind(lambda func: fa.run(env).fmap(func)) 43 | ) 44 | 45 | def bind(self, f: Callable[[A], "ReaderT[R, B]"]) -> "ReaderT[R, B]": 46 | def new_run(env: R) -> MonadLike[B]: 47 | inner = self.run(env) 48 | return inner.bind(lambda x: f(x).run(env)) 49 | return ReaderT(new_run) 50 | 51 | def __rshift__(self, f: Callable[[A], "ReaderT[R, B]"]) -> "ReaderT[R, B]": 52 | return self.bind(f) 53 | 54 | def __call__(self, env: R) -> MonadLike[A]: 55 | return self.run(env) 56 | 57 | def __repr__(self) -> str: 58 | return f"ReaderT({self.run!r})" 59 | 60 | def __eq__(self, other: object) -> bool: 61 | """Structural equality for ``ReaderT`` is undefined. 62 | 63 | ``ReaderT`` wraps a function ``R -> m a``; comparing these function 64 | objects directly would yield identity-based results rather than 65 | extensional equality. Tests should compare outputs of ``run`` with the 66 | same environment instead. 67 | """ 68 | return NotImplemented 69 | -------------------------------------------------------------------------------- /tests/test_rwst.py: -------------------------------------------------------------------------------- 1 | from darkcore.rwst import RWST 2 | from darkcore.result import Ok 3 | 4 | 5 | def run(rwst, r=0, s=0): 6 | return rwst(r, s) 7 | 8 | 9 | def combine_list(a, b): 10 | return a + b 11 | 12 | 13 | def test_rwst_extensional_equality_basic(): 14 | combine = lambda a, b: a + b 15 | empty = list 16 | r1 = RWST(lambda r, s: Ok(((s + r, s), ["a"])), combine=combine, empty=empty) 17 | r2 = RWST(lambda r, s: Ok(((s + r, s), ["a"])), combine=combine, empty=empty) 18 | for env in [0, 1, 2]: 19 | for st in [0, 3]: 20 | assert r1.run(env, st) == r2.run(env, st) 21 | 22 | 23 | def test_rwst_applicative_identity_extensional(): 24 | combine = lambda a, b: a + b 25 | empty = list 26 | pure = Ok.pure 27 | fa = RWST.pure_with(pure, 5, combine=combine, empty=empty) 28 | identity = RWST.pure_with(pure, lambda x: x, combine=combine, empty=empty).ap(fa) 29 | for env in [0, 1]: 30 | for st in [2, 3]: 31 | assert identity.run(env, st) == fa.run(env, st) 32 | 33 | 34 | def test_functor_applicative_monad_laws(): 35 | empty = list 36 | pure = Ok.pure 37 | fa = RWST.pure_with(pure, 1, combine=combine_list, empty=empty) 38 | fb = RWST.pure_with(pure, 2, combine=combine_list, empty=empty) 39 | # Functor identity 40 | assert run(fa.fmap(lambda x: x)) == run(fa) 41 | # Applicative identity 42 | assert run(RWST.pure_with(pure, lambda x: x, combine=combine_list, empty=empty).ap(fa)) == run(fa) 43 | # Monad associativity 44 | f = lambda x: RWST.pure_with(pure, x + 1, combine=combine_list, empty=empty) 45 | g = lambda x: RWST.pure_with(pure, x * 2, combine=combine_list, empty=empty) 46 | left = run(fa.bind(f).bind(g)) 47 | right = run(fa.bind(lambda x: f(x).bind(g))) 48 | assert left == right 49 | 50 | 51 | def test_tell_put_ask(): 52 | pure = Ok.pure 53 | empty = list 54 | action = RWST.ask(pure, combine=combine_list, empty=empty).bind( 55 | lambda env: RWST.tell([env], pure, combine=combine_list, empty=empty) 56 | ) 57 | assert run(action, r=5, s=10) == Ok(((None, 10), [5])) 58 | 59 | put_action = RWST.put(42, pure, combine=combine_list, empty=empty) 60 | assert run(put_action, r=0, s=0) == Ok(((None, 42), [])) 61 | 62 | 63 | def test_log_associativity_str(): 64 | pure = Ok.pure 65 | empty = lambda: "" 66 | combine = lambda a, b: a + b 67 | a = RWST.tell("a", pure, combine=combine, empty=empty) 68 | b = RWST.tell("b", pure, combine=combine, empty=empty) 69 | c = RWST.tell("c", pure, combine=combine, empty=empty) 70 | res1 = run(a.bind(lambda _: b).bind(lambda _: c), r=0, s=0) 71 | res2 = run(a.bind(lambda _: b.bind(lambda _: c)), r=0, s=0) 72 | assert res1 == res2 == Ok(((None, 0), "abc")) 73 | -------------------------------------------------------------------------------- /tests/test_reader.py: -------------------------------------------------------------------------------- 1 | from darkcore.reader import Reader 2 | import pytest 3 | 4 | 5 | def test_reader_basic(): 6 | r = Reader(lambda env: env + 1) 7 | assert r.run(10) == 11 8 | 9 | 10 | def test_reader_map_operator(): 11 | r = Reader(lambda env: env) | (lambda x: x + 1) 12 | assert r.run(2) == 3 13 | 14 | 15 | def test_reader_bind(): 16 | r = Reader(lambda env: env * 2) 17 | s = r >> (lambda x: Reader(lambda env: x + env)) 18 | # env=3 のとき: r=6, その後 f(6) = Reader(lambda env: 6+env)=9 19 | assert s.run(3) == 9 20 | 21 | 22 | def test_reader_ap_operator(): 23 | rf = Reader(lambda env: lambda x: x + env) 24 | rx = Reader(lambda env: env * 2) 25 | r = rf @ rx 26 | assert r.run(3) == 9 27 | 28 | 29 | # Functor laws 30 | @pytest.mark.parametrize("env", [0, 1, 5]) 31 | def test_reader_functor_identity(env): 32 | r = Reader(lambda e: e + 1) 33 | assert (r | (lambda x: x)).run(env) == r.run(env) 34 | 35 | 36 | @pytest.mark.parametrize("env", [0, 2]) 37 | def test_reader_functor_composition(env): 38 | r = Reader(lambda e: e * 2) 39 | f = lambda x: x + 3 40 | g = lambda x: x * 4 41 | lhs = r | (lambda x: f(g(x))) 42 | rhs = (r | g) | f 43 | assert lhs.run(env) == rhs.run(env) 44 | 45 | 46 | # Applicative laws 47 | @pytest.mark.parametrize("env", [1, 3]) 48 | def test_reader_applicative_identity(env): 49 | v = Reader(lambda e: e + 5) 50 | assert (Reader.pure(lambda x: x) @ v).run(env) == v.run(env) 51 | 52 | 53 | @pytest.mark.parametrize("env", [0]) 54 | def test_reader_applicative_homomorphism(env): 55 | f = lambda x: x + 1 56 | x = 3 57 | left = Reader.pure(f) @ Reader.pure(x) 58 | right = Reader.pure(f(x)) 59 | assert left.run(env) == right.run(env) 60 | 61 | 62 | @pytest.mark.parametrize("env", [2]) 63 | def test_reader_applicative_interchange(env): 64 | u = Reader.pure(lambda x: x * 2) 65 | y = 7 66 | left = u @ Reader.pure(y) 67 | right = Reader.pure(lambda f: f(y)) @ u 68 | assert left.run(env) == right.run(env) 69 | 70 | 71 | # Monad laws 72 | @pytest.mark.parametrize("env", [0, 4]) 73 | def test_reader_monad_left_identity(env): 74 | f = lambda x: Reader(lambda r: x + r) 75 | x = 5 76 | assert Reader.pure(x).bind(f).run(env) == f(x).run(env) 77 | 78 | 79 | @pytest.mark.parametrize("env", [1, 2]) 80 | def test_reader_monad_right_identity(env): 81 | m = Reader(lambda r: r * 2) 82 | assert m.bind(Reader.pure).run(env) == m.run(env) 83 | 84 | 85 | @pytest.mark.parametrize("env", [3]) 86 | def test_reader_monad_associativity(env): 87 | m = Reader(lambda r: r + 1) 88 | f = lambda x: Reader(lambda r: x * r) 89 | g = lambda y: Reader(lambda r: y - r) 90 | left = m.bind(f).bind(g) 91 | right = m.bind(lambda x: f(x).bind(g)) 92 | assert left.run(env) == right.run(env) 93 | -------------------------------------------------------------------------------- /tests/test_writer.py: -------------------------------------------------------------------------------- 1 | # tests for Writer 2 | import pytest 3 | from darkcore.writer import Writer 4 | 5 | 6 | def writer_list(value, log=None): 7 | return Writer(value, log if log is not None else []) 8 | 9 | 10 | def writer_str(value, log=None): 11 | return Writer(value, log if log is not None else "", combine=str.__add__, empty=str) 12 | 13 | 14 | def test_writer_requires_explicit_monoid(): 15 | with pytest.raises(TypeError): 16 | Writer(1, "") 17 | 18 | # Functor laws 19 | @pytest.mark.parametrize("factory,log", [ 20 | (writer_list, ["log"]), 21 | (writer_str, "log"), 22 | ]) 23 | def test_writer_functor_identity(factory, log): 24 | w = factory(3, log) 25 | assert (w | (lambda x: x)) == w 26 | 27 | 28 | @pytest.mark.parametrize("factory,log", [ 29 | (writer_list, ["log"]), 30 | (writer_str, "log"), 31 | ]) 32 | def test_writer_functor_composition(factory, log): 33 | w = factory(2, log) 34 | f = lambda x: x + 3 35 | g = lambda x: x * 4 36 | assert (w | (lambda x: f(g(x)))) == ((w | g) | f) 37 | 38 | # Applicative laws 39 | @pytest.mark.parametrize("factory,log", [ 40 | (writer_list, ["v"]), 41 | (writer_str, "v"), 42 | ]) 43 | def test_writer_applicative_identity(factory, log): 44 | v = factory(5, log) 45 | pure_id = factory(lambda x: x) 46 | assert (pure_id @ v) == v 47 | 48 | 49 | @pytest.mark.parametrize("factory", [writer_list, writer_str]) 50 | def test_writer_applicative_homomorphism(factory): 51 | f = lambda x: x + 1 52 | x = 3 53 | left = factory(f) @ factory(x) 54 | right = factory(f(x)) 55 | assert left == right 56 | 57 | 58 | @pytest.mark.parametrize("factory", [writer_list, writer_str]) 59 | def test_writer_applicative_interchange(factory): 60 | u = factory(lambda x: x * 2) 61 | y = 7 62 | left = u @ factory(y) 63 | right = factory(lambda f: f(y)) @ u 64 | assert left == right 65 | 66 | 67 | @pytest.mark.parametrize("factory", [writer_list, writer_str]) 68 | def test_writer_applicative_composition(factory): 69 | compose = lambda f: lambda g: lambda x: f(g(x)) 70 | u = factory(lambda x: x + 1) 71 | v = factory(lambda x: x * 2) 72 | w = factory(3) 73 | left = factory(compose) @ u @ v @ w 74 | right = u @ (v @ w) 75 | assert left == right 76 | 77 | # Monad laws 78 | @pytest.mark.parametrize("factory", [writer_list, writer_str]) 79 | def test_writer_monad_left_identity(factory): 80 | f = lambda x: factory(x + 1) 81 | x = 5 82 | assert factory(x).bind(f) == f(x) 83 | 84 | 85 | @pytest.mark.parametrize("factory,log", [ 86 | (writer_list, ["m"]), 87 | (writer_str, "m"), 88 | ]) 89 | def test_writer_monad_right_identity(factory, log): 90 | m = factory(4, log) 91 | assert m.bind(factory) == m 92 | 93 | 94 | @pytest.mark.parametrize("factory", [writer_list, writer_str]) 95 | def test_writer_monad_associativity(factory): 96 | m = factory(1) 97 | f = lambda x: factory(x + 1) 98 | g = lambda y: factory(y * 2) 99 | assert m.bind(f).bind(g) == m.bind(lambda x: f(x).bind(g)) 100 | -------------------------------------------------------------------------------- /darkcore/writer_t.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import Generic, TypeVar, Callable, Any, cast 3 | from .core import Monad 4 | 5 | A = TypeVar("A") 6 | B = TypeVar("B") 7 | W = TypeVar("W") # ログ型(モノイド) 8 | 9 | 10 | class WriterT(Generic[A, W]): 11 | """Writer モナドトランスフォーマー。``m (a, w)`` を包む。""" 12 | 13 | def __init__(self, run: Monad[tuple[A, W]], combine: Callable[[W, W], W] | None = None) -> None: 14 | self.run = run 15 | self.combine: Callable[[W, W], W] = combine or cast(Callable[[W, W], W], lambda a, b: a + b) 16 | 17 | @classmethod 18 | def lift( 19 | cls, monad: Monad[A], empty_log: W, *, combine: Callable[[W, W], W] | None = None 20 | ) -> "WriterT[A, W]": 21 | """m a -> WriterT m a""" 22 | combine_fn: Callable[[W, W], W] = combine or cast(Callable[[W, W], W], lambda a, b: a + b) 23 | 24 | def step(a: A) -> Monad[tuple[A, W]]: 25 | # 型変数不一致を回避するため Any/cast を用いる。 26 | return cast(Monad[tuple[A, W]], cast(Any, monad).pure((a, empty_log))) 27 | 28 | return WriterT(monad.bind(step), combine=combine_fn) 29 | 30 | @classmethod 31 | def pure_with( 32 | cls, 33 | pure: Callable[[tuple[A, W]], Monad[tuple[A, W]]], 34 | value: A, 35 | *, 36 | empty: Callable[[], W], 37 | combine: Callable[[W, W], W] | None = None, 38 | ) -> "WriterT[A, W]": 39 | """Construct ``WriterT`` with a supplied ``pure`` and ``empty`` (no HKTs).""" 40 | return WriterT(pure((value, empty())), combine=combine) 41 | 42 | def fmap(self, f: Callable[[A], B]) -> "WriterT[B, W]": 43 | return WriterT(self.run.fmap(lambda pair: (f(pair[0]), pair[1])), combine=self.combine) 44 | 45 | map = fmap 46 | 47 | def ap(self: "WriterT[Callable[[A], B], W]", fa: "WriterT[A, W]") -> "WriterT[B, W]": 48 | def step(pair_f: tuple[Callable[[A], B], W]) -> Monad[tuple[B, W]]: 49 | return fa.run.bind( 50 | lambda pair_a: cast( 51 | Monad[tuple[B, W]], 52 | cast(Any, self.run).pure( 53 | (pair_f[0](pair_a[0]), self.combine(pair_f[1], pair_a[1])) 54 | ), 55 | ) 56 | ) 57 | 58 | return WriterT(self.run.bind(step), combine=self.combine) 59 | 60 | def bind(self, f: Callable[[A], "WriterT[B, W]"]) -> "WriterT[B, W]": 61 | def step(pair: tuple[A, W]) -> Monad[tuple[B, W]]: 62 | (a, log1) = pair 63 | return f(a).run.bind( 64 | lambda res: cast( 65 | Monad[tuple[B, W]], 66 | cast(Any, self.run).pure((res[0], self.combine(log1, res[1]))), 67 | ) 68 | ) 69 | 70 | return WriterT(self.run.bind(step), combine=self.combine) 71 | 72 | def __rshift__(self, f: Callable[[A], "WriterT[B, W]"]) -> "WriterT[B, W]": 73 | return self.bind(f) 74 | 75 | def __repr__(self) -> str: 76 | return f"WriterT({self.run!r})" 77 | 78 | def __eq__(self, other: object) -> bool: 79 | return isinstance(other, WriterT) and self.run == other.run 80 | -------------------------------------------------------------------------------- /darkcore/state_t.py: -------------------------------------------------------------------------------- 1 | """StateT monad transformer. 2 | 3 | Equality is extensional. Compare outputs of `run` on same inputs. 4 | """ 5 | from __future__ import annotations 6 | from typing import Callable, Generic, TypeVar, Any, cast 7 | from .core import Monad 8 | 9 | S = TypeVar("S") # state 10 | A = TypeVar("A") 11 | B = TypeVar("B") 12 | 13 | class StateT(Generic[S, A]): 14 | """ 15 | StateT m a ≅ S -> m (a, S) 16 | """ 17 | def __init__(self, run: Callable[[S], Monad[tuple[A, S]]]) -> None: 18 | self.run = run 19 | 20 | @classmethod 21 | def lift(cls, monad: Monad[A]) -> "StateT[S, A]": 22 | """ 23 | m a -> StateT m a 24 | 実装: s ↦ monad.bind(lambda a: monad.pure((a, s))) 25 | mypy に多相性が伝わらないため cast で橋渡し。 26 | """ 27 | def run(s: S) -> Monad[tuple[A, S]]: 28 | def step(a: A) -> Monad[tuple[A, S]]: 29 | return cast(Monad[tuple[A, S]], cast(Any, monad).pure((a, s))) 30 | return monad.bind(step) 31 | return StateT(run) 32 | 33 | @classmethod 34 | def pure_with( 35 | cls, pure: Callable[[tuple[A, S]], Monad[tuple[A, S]]], value: A 36 | ) -> "StateT[S, A]": 37 | """Construct ``StateT`` with provided ``pure`` (workaround for lack of HKTs).""" 38 | return StateT(lambda s: pure((value, s))) 39 | 40 | def fmap(self, f: Callable[[A], B]) -> "StateT[S, B]": 41 | def new_run(s: S) -> Monad[tuple[B, S]]: 42 | return self.run(s).fmap(lambda pair: (f(pair[0]), pair[1])) 43 | return StateT(new_run) 44 | 45 | map = fmap 46 | 47 | def ap(self: "StateT[S, Callable[[A], B]]", fa: "StateT[S, A]") -> "StateT[S, B]": 48 | def new_run(s: S) -> Monad[tuple[B, S]]: 49 | return self.run(s).bind( 50 | lambda pair_f: fa.run(pair_f[1]).bind( 51 | lambda pair_a: cast( 52 | Monad[tuple[B, S]], 53 | cast(Any, fa.run(pair_f[1])).pure( 54 | (pair_f[0](pair_a[0]), pair_a[1]) 55 | ), 56 | ) 57 | ) 58 | ) 59 | return StateT(new_run) 60 | 61 | def bind(self, f: Callable[[A], "StateT[S, B]"]) -> "StateT[S, B]": 62 | def new_run(state: S) -> Monad[tuple[B, S]]: 63 | return self.run(state).bind( 64 | lambda pair: f(pair[0]).run(pair[1]) 65 | ) 66 | return StateT(new_run) 67 | 68 | def __rshift__(self, f: Callable[[A], "StateT[S, B]"]) -> "StateT[S, B]": 69 | return self.bind(f) 70 | 71 | def __call__(self, state: S) -> Monad[tuple[A, S]]: 72 | return self.run(state) 73 | 74 | def __repr__(self) -> str: 75 | return f"StateT({self.run!r})" 76 | 77 | def __eq__(self, other: object) -> bool: 78 | """Structural equality for ``StateT`` is undefined. 79 | 80 | ``StateT`` wraps ``S -> m (a, S)`` functions. Comparing them directly 81 | would only check object identity. Tests should compare the results of 82 | ``run`` for the same initial state instead. 83 | """ 84 | return NotImplemented 85 | -------------------------------------------------------------------------------- /darkcore/writer.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import Callable, Generic, TypeVar, cast 3 | from .core import MonadOpsMixin 4 | 5 | A = TypeVar("A") 6 | B = TypeVar("B") 7 | W = TypeVar("W") 8 | 9 | 10 | class Writer(MonadOpsMixin[A], Generic[A, W]): 11 | """Writer モナド。 12 | 13 | ログ型 ``W`` はモノイドを想定し、デフォルトでは ``list`` を用いる。 14 | ``combine`` を差し替えることで他のモノイドにも対応できる。 15 | """ 16 | __match_args__ = ("value", "log") 17 | 18 | def __init__( 19 | self, 20 | value: A, 21 | log: W | None = None, 22 | *, 23 | combine: Callable[[W, W], W] | None = None, 24 | empty: Callable[[], W] | None = None, 25 | ) -> None: 26 | self.value = value 27 | 28 | if combine is None and empty is None: 29 | if log is None or isinstance(log, list): 30 | combine = cast(Callable[[W, W], W], lambda a, b: a + b) 31 | empty = cast(Callable[[], W], list) 32 | else: 33 | raise TypeError( 34 | "Writer for non-list logs requires explicit 'combine' and 'empty'" 35 | ) 36 | elif combine is None or empty is None: 37 | raise TypeError("Writer requires both 'combine' and 'empty'") 38 | 39 | assert combine is not None and empty is not None 40 | self.combine = combine 41 | self.empty = empty 42 | self.log: W = log if log is not None else self.empty() 43 | 44 | @classmethod 45 | def pure( 46 | cls, 47 | value: A, 48 | log: W | None = None, 49 | *, 50 | combine: Callable[[W, W], W] | None = None, 51 | empty: Callable[[], W] | None = None, 52 | ) -> "Writer[A, W]": 53 | return cls(value, log, combine=combine, empty=empty) 54 | 55 | def fmap(self, f: Callable[[A], B]) -> "Writer[B, W]": 56 | return Writer(f(self.value), self.log, combine=self.combine, empty=self.empty) 57 | 58 | map = fmap 59 | 60 | def ap(self: "Writer[Callable[[A], B], W]", fa: "Writer[A, W]") -> "Writer[B, W]": 61 | return Writer(self.value(fa.value), self.combine(self.log, fa.log), combine=self.combine, empty=self.empty) 62 | 63 | def bind(self, f: Callable[[A], "Writer[B, W]"]) -> "Writer[B, W]": 64 | result = f(self.value) 65 | return Writer(result.value, self.combine(self.log, result.log), combine=self.combine, empty=self.empty) 66 | 67 | def tell(self, msg: W) -> "Writer[A, W]": 68 | return Writer(self.value, self.combine(self.log, msg), combine=self.combine, empty=self.empty) 69 | 70 | def tell1(self: "Writer[A, list[B]]", msg: B) -> "Writer[A, list[B]]": 71 | """Append a single element to the log when ``W`` is ``list``.""" 72 | if self.empty is not list: 73 | raise TypeError("tell1 is only available when log type is list") 74 | return Writer(self.value, self.combine(self.log, [msg]), combine=self.combine, empty=self.empty) 75 | 76 | def __eq__(self, other: object) -> bool: 77 | return isinstance(other, Writer) and self.value == other.value and self.log == other.log 78 | 79 | def __repr__(self) -> str: 80 | return f"Writer({self.value!r}, log={self.log!r})" 81 | 82 | -------------------------------------------------------------------------------- /tests/test_state.py: -------------------------------------------------------------------------------- 1 | from darkcore.state import State 2 | 3 | from darkcore.state import State 4 | import pytest 5 | 6 | 7 | def test_state_basic(): 8 | inc = State(lambda s: (s, s + 1)) 9 | result, final = inc.run(0) 10 | assert result == 0 and final == 1 11 | 12 | 13 | def test_state_map_operator(): 14 | inc = State(lambda s: (s, s + 1)) 15 | result, final = (inc | (lambda x: x + 1)).run(0) 16 | assert (result, final) == (1, 1) 17 | 18 | 19 | def test_state_bind(): 20 | inc = State(lambda s: (s, s + 1)) 21 | prog = inc >> (lambda x: State(lambda s: (x + s, s))) 22 | result, final = prog.run(1) 23 | assert (result, final) == (3, 2) 24 | 25 | 26 | def test_state_get_put(): 27 | prog = State.get() >> (lambda s: State.put(s + 10) >> (lambda _: State.pure(s))) 28 | result, final = prog.run(5) 29 | assert result == 5 and final == 15 30 | 31 | 32 | def test_state_ap_operator(): 33 | sf = State(lambda s: (lambda x: x + s, s)) 34 | sx = State(lambda s: (s, s + 1)) 35 | result, final = (sf @ sx).run(1) 36 | assert (result, final) == (2, 2) 37 | 38 | 39 | # Functor laws 40 | @pytest.mark.parametrize("s", [0, 5]) 41 | def test_state_functor_identity(s): 42 | st = State(lambda state: (state + 1, state)) 43 | assert (st | (lambda x: x)).run(s) == st.run(s) 44 | 45 | 46 | @pytest.mark.parametrize("s", [1, 2]) 47 | def test_state_functor_composition(s): 48 | st = State(lambda state: (state, state)) 49 | f = lambda x: x + 3 50 | g = lambda x: x * 4 51 | lhs = st | (lambda x: f(g(x))) 52 | rhs = (st | g) | f 53 | assert lhs.run(s) == rhs.run(s) 54 | 55 | 56 | # Applicative laws 57 | @pytest.mark.parametrize("s", [0, 3]) 58 | def test_state_applicative_identity(s): 59 | v = State(lambda state: (state + 2, state)) 60 | assert (State.pure(lambda x: x) @ v).run(s) == v.run(s) 61 | 62 | 63 | @pytest.mark.parametrize("s", [4]) 64 | def test_state_applicative_homomorphism(s): 65 | f = lambda x: x + 1 66 | x = 3 67 | left = State.pure(f) @ State.pure(x) 68 | right = State.pure(f(x)) 69 | assert left.run(s) == right.run(s) 70 | 71 | 72 | @pytest.mark.parametrize("s", [2]) 73 | def test_state_applicative_interchange(s): 74 | u = State.pure(lambda x: x * 2) 75 | y = 7 76 | left = u @ State.pure(y) 77 | right = State.pure(lambda f: f(y)) @ u 78 | assert left.run(s) == right.run(s) 79 | 80 | 81 | # Monad laws 82 | @pytest.mark.parametrize("s", [0, 1]) 83 | def test_state_monad_left_identity(s): 84 | f = lambda x: State(lambda st: (x + st, st)) 85 | x = 5 86 | assert State.pure(x).bind(f).run(s) == f(x).run(s) 87 | 88 | 89 | @pytest.mark.parametrize("s", [2, 3]) 90 | def test_state_monad_right_identity(s): 91 | m = State(lambda st: (st + 1, st)) 92 | assert m.bind(State.pure).run(s) == m.run(s) 93 | 94 | 95 | @pytest.mark.parametrize("s", [4]) 96 | def test_state_monad_associativity(s): 97 | m = State(lambda st: (st + 1, st)) 98 | f = lambda x: State(lambda st: (x * 2, st)) 99 | g = lambda y: State(lambda st: (y - 1, st)) 100 | left = m.bind(f).bind(g) 101 | right = m.bind(lambda x: f(x).bind(g)) 102 | assert left.run(s) == right.run(s) 103 | -------------------------------------------------------------------------------- /tests/test_state_t.py: -------------------------------------------------------------------------------- 1 | from darkcore.state_t import StateT 2 | from darkcore.result import Ok, Err 3 | import pytest 4 | 5 | def test_state_t_lift_and_run(): 6 | st = StateT.lift(Ok(42)) 7 | result = st.run(0) 8 | assert result == Ok((42, 0)) 9 | 10 | def test_state_t_basic_bind(): 11 | inc = StateT(lambda s: Ok((s, s+1))) 12 | prog = inc >> (lambda x: StateT(lambda s: Ok((x+s, s)))) 13 | result = prog.run(1) 14 | # initial state = 1, inc -> (1,2), then x=1, s=2 -> (3,2) 15 | assert result == Ok((3, 2)) 16 | 17 | def test_state_t_err_propagates(): 18 | st = StateT(lambda s: Err("fail")) 19 | prog = st >> (lambda x: StateT(lambda s: Ok((x*2, s)))) 20 | result = prog.run(10) 21 | assert result == Err("fail") 22 | 23 | def test_state_t_composition(): 24 | def step1(x: int) -> StateT[int, int]: 25 | return StateT(lambda s: Ok((x+1, s+1))) 26 | 27 | def step2(x: int) -> StateT[int, str]: 28 | return StateT(lambda s: Ok((f"val={x}, state={s}", s*2))) 29 | 30 | prog = StateT(lambda s: Ok((s, s))) >> step1 >> step2 31 | result = prog.run(5) 32 | assert result == Ok(("val=6, state=6", 12)) 33 | 34 | 35 | def test_state_t_extensional_equality(): 36 | s1 = StateT(lambda s: Ok((s + 1, s))) 37 | s2 = StateT(lambda s: Ok((s + 1, s))) 38 | assert s1.run(2) == s2.run(2) 39 | 40 | 41 | # Functor laws 42 | @pytest.mark.parametrize("s", [0, 1]) 43 | def test_state_t_functor_identity(s): 44 | st = StateT(lambda state: Ok((state + 1, state))) 45 | assert st.fmap(lambda a: a).run(s) == st.run(s) 46 | 47 | 48 | @pytest.mark.parametrize("s", [2]) 49 | def test_state_t_functor_composition(s): 50 | st = StateT(lambda state: Ok((state, state))) 51 | f = lambda x: x + 3 52 | g = lambda x: x * 2 53 | lhs = st.fmap(lambda x: f(g(x))) 54 | rhs = st.fmap(g).fmap(f) 55 | assert lhs.run(s) == rhs.run(s) 56 | 57 | 58 | # Applicative laws 59 | @pytest.mark.parametrize("s", [0]) 60 | def test_state_t_applicative_identity(s): 61 | v = StateT(lambda state: Ok((state + 2, state))) 62 | pure_id = StateT(lambda state: Ok((lambda x: x, state))) 63 | assert pure_id.ap(v).run(s) == v.run(s) 64 | 65 | 66 | def test_state_t_applicative_homomorphism(): 67 | f = lambda x: x + 1 68 | x = 3 69 | left = StateT(lambda s: Ok((f, s))).ap(StateT(lambda s: Ok((x, s)))) 70 | right = StateT(lambda s: Ok((f(x), s))) 71 | assert left.run(0) == right.run(0) 72 | 73 | 74 | def test_state_t_applicative_interchange(): 75 | u = StateT(lambda s: Ok((lambda x: x * 2, s))) 76 | y = 7 77 | left = u.ap(StateT(lambda s: Ok((y, s)))) 78 | right = StateT(lambda s: Ok((lambda f: f(y), s))).ap(u) 79 | assert left.run(0) == right.run(0) 80 | 81 | 82 | # Monad laws 83 | @pytest.mark.parametrize("s", [0, 1]) 84 | def test_state_t_monad_left_identity(s): 85 | f = lambda x: StateT(lambda st: Ok((x + st, st))) 86 | x = 5 87 | assert StateT(lambda st: Ok((x, st))).bind(f).run(s) == f(x).run(s) 88 | 89 | 90 | def test_state_t_monad_right_identity(): 91 | m = StateT(lambda s: Ok((s + 1, s))) 92 | assert m.bind(lambda a: StateT(lambda s: Ok((a, s)))).run(0) == m.run(0) 93 | 94 | 95 | def test_state_t_monad_associativity(): 96 | m = StateT(lambda s: Ok((s + 1, s))) 97 | f = lambda x: StateT(lambda s: Ok((x * 2, s))) 98 | g = lambda y: StateT(lambda s: Ok((y - 1, s))) 99 | left = m.bind(f).bind(g).run(0) 100 | right = m.bind(lambda x: f(x).bind(g)).run(0) 101 | assert left == right 102 | -------------------------------------------------------------------------------- /tests/test_maybe_t.py: -------------------------------------------------------------------------------- 1 | from darkcore.maybe import Maybe 2 | from darkcore.maybe_t import MaybeT 3 | import pytest 4 | 5 | class DummyMonad: 6 | """テスト用の最小Monad実装(Just値しか持たない)""" 7 | def __init__(self, value): 8 | self.value = value 9 | 10 | @staticmethod 11 | def pure(x): 12 | return DummyMonad(x) 13 | 14 | def fmap(self, f): 15 | return DummyMonad(f(self.value)) 16 | 17 | def bind(self, f): 18 | return f(self.value) 19 | 20 | def __eq__(self, other): 21 | if not isinstance(other, DummyMonad): 22 | return False 23 | return self.value == other.value 24 | 25 | def __repr__(self): 26 | return f"DummyMonad({self.value!r})" 27 | 28 | 29 | def test_lift(): 30 | m = DummyMonad(10) 31 | mt = MaybeT.lift(m) 32 | assert isinstance(mt, MaybeT) 33 | assert mt.run == DummyMonad(Maybe(10)) 34 | 35 | def test_map(): 36 | m = DummyMonad(Maybe(3)) 37 | mt = MaybeT(m) 38 | mt2 = mt.map(lambda x: x + 1) 39 | assert mt2.run == DummyMonad(Maybe(4)) 40 | 41 | def test_bind_success(): 42 | m = DummyMonad(Maybe(2)) 43 | mt = MaybeT(m) 44 | 45 | def f(x): 46 | return MaybeT(DummyMonad(Maybe(x * 10))) 47 | 48 | result = mt.bind(f) 49 | assert result.run == DummyMonad(Maybe(20)) 50 | 51 | def test_bind_nothing(): 52 | m = DummyMonad(Maybe(None)) 53 | mt = MaybeT(m) 54 | 55 | def f(x): 56 | return MaybeT(DummyMonad(Maybe(x * 10))) 57 | 58 | result = mt.bind(f) 59 | assert result.run == DummyMonad(Maybe(None)) 60 | 61 | def test_ap(): 62 | mf = DummyMonad(Maybe(lambda x: x + 5)) 63 | mx = DummyMonad(Maybe(7)) 64 | 65 | mtf = MaybeT(mf) 66 | mtx = MaybeT(mx) 67 | 68 | result = mtf.ap(mtx) 69 | assert result.run == DummyMonad(Maybe(12)) 70 | 71 | 72 | # Functor laws 73 | @pytest.mark.parametrize("x", [1, 2]) 74 | def test_maybe_t_functor_identity(x): 75 | m = MaybeT.lift(DummyMonad(x)) 76 | assert m.fmap(lambda a: a) == m 77 | 78 | 79 | @pytest.mark.parametrize("x", [3]) 80 | def test_maybe_t_functor_composition(x): 81 | m = MaybeT.lift(DummyMonad(x)) 82 | f = lambda y: y + 1 83 | g = lambda y: y * 2 84 | assert m.fmap(lambda y: f(g(y))) == m.fmap(g).fmap(f) 85 | 86 | 87 | # Applicative laws 88 | def test_maybe_t_applicative_identity(): 89 | v = MaybeT.lift(DummyMonad(3)) 90 | pure_id = MaybeT.lift(DummyMonad(lambda x: x)) 91 | assert pure_id.ap(v) == v 92 | 93 | 94 | def test_maybe_t_applicative_homomorphism(): 95 | f = lambda x: x + 1 96 | x = 3 97 | left = MaybeT.lift(DummyMonad(f)).ap(MaybeT.lift(DummyMonad(x))) 98 | right = MaybeT.lift(DummyMonad(f(x))) 99 | assert left == right 100 | 101 | 102 | def test_maybe_t_applicative_interchange(): 103 | u = MaybeT.lift(DummyMonad(lambda x: x * 2)) 104 | y = 7 105 | left = u.ap(MaybeT.lift(DummyMonad(y))) 106 | right = MaybeT.lift(DummyMonad(lambda f: f(y))).ap(u) 107 | assert left == right 108 | 109 | 110 | # Monad laws 111 | def test_maybe_t_monad_left_identity(): 112 | f = lambda x: MaybeT.lift(DummyMonad(x + 1)) 113 | x = 5 114 | assert MaybeT.lift(DummyMonad(x)).bind(f) == f(x) 115 | 116 | 117 | def test_maybe_t_monad_right_identity(): 118 | m = MaybeT.lift(DummyMonad(4)) 119 | assert m.bind(lambda a: MaybeT.lift(DummyMonad(a))) == m 120 | 121 | 122 | def test_maybe_t_monad_associativity(): 123 | m = MaybeT.lift(DummyMonad(3)) 124 | f = lambda x: MaybeT.lift(DummyMonad(x + 1)) 125 | g = lambda y: MaybeT.lift(DummyMonad(y * 2)) 126 | assert m.bind(f).bind(g) == m.bind(lambda x: f(x).bind(g)) 127 | -------------------------------------------------------------------------------- /tests/test_result_t.py: -------------------------------------------------------------------------------- 1 | from darkcore.result import Ok, Err 2 | from darkcore.result_t import ResultT 3 | from darkcore.result_t import ResultT 4 | from darkcore.result import Ok, Err 5 | import pytest 6 | 7 | 8 | class DummyMonad: 9 | """Minimal monad for testing (only carries a value).""" 10 | def __init__(self, value): 11 | self.value = value 12 | 13 | @staticmethod 14 | def pure(x): 15 | return DummyMonad(x) 16 | 17 | def fmap(self, f): 18 | return DummyMonad(f(self.value)) 19 | 20 | def bind(self, f): 21 | return f(self.value) 22 | 23 | def __eq__(self, other): 24 | return isinstance(other, DummyMonad) and self.value == other.value 25 | 26 | def __repr__(self): 27 | return f"DummyMonad({self.value!r})" 28 | 29 | 30 | def test_lift(): 31 | m = DummyMonad(10) 32 | rt = ResultT.lift(m) 33 | assert isinstance(rt, ResultT) 34 | assert rt.run == DummyMonad(Ok(10)) 35 | 36 | 37 | def test_map(): 38 | m = DummyMonad(Ok(3)) 39 | rt = ResultT(m) 40 | rt2 = rt.map(lambda x: x + 1) 41 | assert rt2.run == DummyMonad(Ok(4)) 42 | 43 | 44 | def test_bind_ok(): 45 | m = DummyMonad(Ok(2)) 46 | rt = ResultT(m) 47 | 48 | def f(x): 49 | return ResultT(DummyMonad(Ok(x * 10))) 50 | 51 | result = rt.bind(f) 52 | assert result.run == DummyMonad(Ok(20)) 53 | 54 | 55 | def test_bind_err(): 56 | m = DummyMonad(Err("fail")) 57 | rt = ResultT(m) 58 | 59 | def f(x): 60 | return ResultT(DummyMonad(Ok(x * 10))) 61 | 62 | result = rt.bind(f) 63 | assert result.run == DummyMonad(Err("fail")) 64 | 65 | 66 | def test_ap(): 67 | mf = DummyMonad(Ok(lambda x: x + 5)) 68 | mx = DummyMonad(Ok(7)) 69 | rtf = ResultT(mf) 70 | rtx = ResultT(mx) 71 | result = rtf.ap(rtx) 72 | assert result.run == DummyMonad(Ok(12)) 73 | 74 | 75 | # Functor laws 76 | @pytest.mark.parametrize("x", [1, 2]) 77 | def test_result_t_functor_identity(x): 78 | m = ResultT.lift(DummyMonad(x)) 79 | assert m.fmap(lambda a: a) == m 80 | 81 | 82 | def test_result_t_functor_composition(): 83 | m = ResultT.lift(DummyMonad(3)) 84 | f = lambda y: y + 1 85 | g = lambda y: y * 2 86 | assert m.fmap(lambda y: f(g(y))) == m.fmap(g).fmap(f) 87 | 88 | 89 | # Applicative laws 90 | def test_result_t_applicative_identity(): 91 | v = ResultT.lift(DummyMonad(3)) 92 | pure_id = ResultT.lift(DummyMonad(lambda x: x)) 93 | assert pure_id.ap(v) == v 94 | 95 | 96 | def test_result_t_applicative_homomorphism(): 97 | f = lambda x: x + 1 98 | x = 3 99 | left = ResultT.lift(DummyMonad(f)).ap(ResultT.lift(DummyMonad(x))) 100 | right = ResultT.lift(DummyMonad(f(x))) 101 | assert left == right 102 | 103 | 104 | def test_result_t_applicative_interchange(): 105 | u = ResultT.lift(DummyMonad(lambda x: x * 2)) 106 | y = 7 107 | left = u.ap(ResultT.lift(DummyMonad(y))) 108 | right = ResultT.lift(DummyMonad(lambda f: f(y))).ap(u) 109 | assert left == right 110 | 111 | 112 | # Monad laws 113 | def test_result_t_monad_left_identity(): 114 | f = lambda x: ResultT.lift(DummyMonad(x + 1)) 115 | x = 5 116 | assert ResultT.lift(DummyMonad(x)).bind(f) == f(x) 117 | 118 | 119 | def test_result_t_monad_right_identity(): 120 | m = ResultT.lift(DummyMonad(4)) 121 | assert m.bind(lambda a: ResultT.lift(DummyMonad(a))) == m 122 | 123 | 124 | def test_result_t_monad_associativity(): 125 | m = ResultT.lift(DummyMonad(3)) 126 | f = lambda x: ResultT.lift(DummyMonad(x + 1)) 127 | g = lambda y: ResultT.lift(DummyMonad(y * 2)) 128 | assert m.bind(f).bind(g) == m.bind(lambda x: f(x).bind(g)) 129 | -------------------------------------------------------------------------------- /tests/test_writer_t.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from darkcore.writer_t import WriterT 3 | from darkcore.result import Ok, Err 4 | 5 | 6 | def wt_list(value, log=None): 7 | return WriterT(Ok((value, log if log is not None else []))) 8 | 9 | 10 | def wt_str(value, log=None): 11 | return WriterT(Ok((value, log if log is not None else "")), combine=str.__add__) 12 | 13 | 14 | def test_writer_t_lift_and_run(): 15 | wt = WriterT.lift(Ok(42), []) 16 | assert wt.run == Ok((42, [])) 17 | 18 | 19 | def test_writer_t_err_propagates(): 20 | wt = WriterT(Err("fail")) 21 | def step(x: int) -> WriterT[int, int]: 22 | return WriterT(Ok((x*2, ["doubled"]))) 23 | res = wt >> step 24 | assert res.run == Err("fail") 25 | 26 | # Functor laws 27 | @pytest.mark.parametrize("factory,log", [ 28 | (wt_list, ["log"]), 29 | (wt_str, "log"), 30 | ]) 31 | def test_writer_t_functor_identity(factory, log): 32 | w = factory(3, log) 33 | assert w.fmap(lambda a: a).run == w.run 34 | 35 | 36 | @pytest.mark.parametrize("factory,log", [ 37 | (wt_list, ["log"]), 38 | (wt_str, "log"), 39 | ]) 40 | def test_writer_t_functor_composition(factory, log): 41 | w = factory(2, log) 42 | f = lambda x: x + 1 43 | g = lambda x: x * 2 44 | assert w.fmap(lambda x: f(g(x))).run == w.fmap(g).fmap(f).run 45 | 46 | # Applicative laws 47 | @pytest.mark.parametrize("factory,log", [ 48 | (wt_list, ["v"]), 49 | (wt_str, "v"), 50 | ]) 51 | def test_writer_t_applicative_identity(factory, log): 52 | v = factory(5, log) 53 | pure_id = factory(lambda x: x) 54 | assert pure_id.ap(v).run == v.run 55 | 56 | 57 | @pytest.mark.parametrize("factory,log", [ 58 | (wt_list, None), 59 | (wt_str, None), 60 | ]) 61 | def test_writer_t_applicative_homomorphism(factory, log): 62 | f = lambda x: x + 1 63 | x = 3 64 | left = factory(f).ap(factory(x)) 65 | right = factory(f(x)) 66 | assert left.run == right.run 67 | 68 | 69 | @pytest.mark.parametrize("factory,log", [ 70 | (wt_list, None), 71 | (wt_str, None), 72 | ]) 73 | def test_writer_t_applicative_interchange(factory, log): 74 | u = factory(lambda x: x * 2) 75 | y = 7 76 | left = u.ap(factory(y)) 77 | right = factory(lambda f: f(y)).ap(u) 78 | assert left.run == right.run 79 | 80 | 81 | @pytest.mark.parametrize("factory,log", [ 82 | (wt_list, None), 83 | (wt_str, None), 84 | ]) 85 | def test_writer_t_applicative_composition(factory, log): 86 | compose = lambda f: lambda g: lambda x: f(g(x)) 87 | u = factory(lambda x: x + 1) 88 | v = factory(lambda x: x * 2) 89 | w = factory(3) 90 | left = factory(compose).ap(u).ap(v).ap(w) 91 | right = u.ap(v.ap(w)) 92 | assert left.run == right.run 93 | 94 | # Monad laws 95 | @pytest.mark.parametrize("factory,log", [ 96 | (wt_list, None), 97 | (wt_str, None), 98 | ]) 99 | def test_writer_t_monad_left_identity(factory, log): 100 | f = lambda x: factory(x + 1) 101 | x = 5 102 | assert factory(x).bind(f).run == f(x).run 103 | 104 | 105 | @pytest.mark.parametrize("factory,log", [ 106 | (wt_list, ["m"]), 107 | (wt_str, "m"), 108 | ]) 109 | def test_writer_t_monad_right_identity(factory, log): 110 | m = factory(4, log) 111 | assert m.bind(lambda a: factory(a)).run == m.run 112 | 113 | 114 | @pytest.mark.parametrize("factory,log", [ 115 | (wt_list, None), 116 | (wt_str, None), 117 | ]) 118 | def test_writer_t_monad_associativity(factory, log): 119 | m = factory(1) 120 | f = lambda x: factory(x + 1) 121 | g = lambda y: factory(y * 2) 122 | left = m.bind(f).bind(g).run 123 | right = m.bind(lambda x: f(x).bind(g)).run 124 | assert left == right 125 | -------------------------------------------------------------------------------- /tests/test_reader_t.py: -------------------------------------------------------------------------------- 1 | from darkcore.result import Ok, Err 2 | from darkcore.reader_t import ReaderT 3 | from darkcore.result import Ok 4 | import pytest 5 | 6 | def test_reader_t_lift(): 7 | rt = ReaderT.lift(Ok(42)) 8 | assert rt.run({"env": "dummy"}) == Ok(42) 9 | 10 | def test_reader_t_basic_bind(): 11 | # ReaderT returning threshold from env 12 | prog = ReaderT(lambda env: Ok(env["threshold"])) >> ( 13 | lambda t: ReaderT(lambda env: Ok(t * 2)) 14 | ) 15 | 16 | res = prog.run({"threshold": 10}) 17 | assert res == Ok(20) 18 | 19 | def test_reader_t_err_propagates(): 20 | prog = ReaderT(lambda env: Err("missing")) >> ( 21 | lambda t: ReaderT(lambda env: Ok(t * 2)) 22 | ) 23 | 24 | res = prog.run({"threshold": 10}) 25 | assert res == Err("missing") 26 | 27 | def test_reader_t_composition(): 28 | def step1(x: int) -> ReaderT[dict, int]: 29 | return ReaderT(lambda env: Ok(x + env.get("bonus", 0))) 30 | 31 | def step2(x: int) -> ReaderT[dict, str]: 32 | return ReaderT(lambda env: Ok(f"user={env['user']}, val={x}")) 33 | 34 | prog = ReaderT(lambda env: Ok(env["base"])) >> step1 >> step2 35 | 36 | res = prog.run({"base": 5, "bonus": 3, "user": "alice"}) 37 | assert res == Ok("user=alice, val=8") 38 | 39 | 40 | def test_reader_t_extensional_equality(): 41 | r1 = ReaderT(lambda env: Ok(env["x"] + 1)) 42 | r2 = ReaderT(lambda env: Ok(env["x"] + 1)) 43 | env = {"x": 2} 44 | assert r1.run(env) == r2.run(env) 45 | 46 | 47 | # Functor laws 48 | @pytest.mark.parametrize("env", [{"x": 1}, {"x": 2}]) 49 | def test_reader_t_functor_identity(env): 50 | r = ReaderT(lambda e: Ok(e["x"])) 51 | assert r.fmap(lambda a: a).run(env) == r.run(env) 52 | 53 | 54 | @pytest.mark.parametrize("env", [{"x": 3}]) 55 | def test_reader_t_functor_composition(env): 56 | r = ReaderT(lambda e: Ok(e["x"])) 57 | f = lambda y: y + 1 58 | g = lambda y: y * 2 59 | lhs = r.fmap(lambda y: f(g(y))) 60 | rhs = r.fmap(g).fmap(f) 61 | assert lhs.run(env) == rhs.run(env) 62 | 63 | 64 | # Applicative laws 65 | @pytest.mark.parametrize("env", [{"x": 5}]) 66 | def test_reader_t_applicative_identity(env): 67 | v = ReaderT(lambda e: Ok(e["x"])) 68 | pure_id = ReaderT(lambda _: Ok(lambda x: x)) 69 | assert pure_id.ap(v).run(env) == v.run(env) 70 | 71 | 72 | def test_reader_t_applicative_homomorphism(): 73 | f = lambda x: x + 1 74 | x = 3 75 | left = ReaderT(lambda _: Ok(f)).ap(ReaderT(lambda _: Ok(x))) 76 | right = ReaderT(lambda _: Ok(f(x))) 77 | assert left.run({}) == right.run({}) 78 | 79 | 80 | def test_reader_t_applicative_interchange(): 81 | u = ReaderT(lambda _: Ok(lambda x: x * 2)) 82 | y = 7 83 | left = u.ap(ReaderT(lambda _: Ok(y))) 84 | right = ReaderT(lambda _: Ok(lambda f: f(y))).ap(u) 85 | assert left.run({}) == right.run({}) 86 | 87 | 88 | # Monad laws 89 | @pytest.mark.parametrize("env", [{"x": 1}, {"x": 2}]) 90 | def test_reader_t_monad_left_identity(env): 91 | f = lambda x: ReaderT(lambda e: Ok(x + e["x"])) 92 | x = 5 93 | assert ReaderT(lambda _: Ok(x)).bind(f).run(env) == f(x).run(env) 94 | 95 | 96 | def test_reader_t_monad_right_identity(): 97 | m = ReaderT(lambda e: Ok(e["x"])) 98 | assert m.bind(lambda a: ReaderT(lambda _: Ok(a))).run({"x": 3}) == m.run({"x": 3}) 99 | 100 | 101 | def test_reader_t_monad_associativity(): 102 | m = ReaderT(lambda e: Ok(e["x"])) 103 | f = lambda x: ReaderT(lambda e: Ok(x + e["x"])) 104 | g = lambda y: ReaderT(lambda e: Ok(y * e["x"])) 105 | env = {"x": 4} 106 | left = m.bind(f).bind(g).run(env) 107 | right = m.bind(lambda x: f(x).bind(g)).run(env) 108 | assert left == right 109 | -------------------------------------------------------------------------------- /darkcore/validation.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import Callable, Generic, List, TypeVar, Any, cast 3 | from .core import MonadOpsMixin 4 | from .result import Result, Ok, Err 5 | 6 | E = TypeVar("E") 7 | A = TypeVar("A") 8 | B = TypeVar("B") 9 | 10 | class Validation(MonadOpsMixin[A], Generic[E, A]): 11 | """Validation[E, A] = Success(A) | Failure(NonEmpty[List[E]]). 12 | 13 | Primarily an :class:`~darkcore.core.Applicative` that accumulates errors. 14 | ``bind`` propagates the first failure but does not accumulate errors from ``f``. 15 | """ 16 | 17 | @classmethod 18 | def pure(cls, value: A) -> "Validation[E, A]": 19 | return Success(value) 20 | 21 | def fmap(self, f: Callable[[A], B]) -> "Validation[E, B]": # pragma: no cover - interface 22 | raise NotImplementedError 23 | 24 | map = fmap 25 | 26 | def ap(self: "Validation[E, Callable[[A], B]]", fa: "Validation[E, A]") -> "Validation[E, B]": # pragma: no cover - interface 27 | raise NotImplementedError 28 | 29 | def bind(self, f: Callable[[A], "Validation[E, B]"]) -> "Validation[E, B]": # pragma: no cover - interface 30 | raise NotImplementedError 31 | 32 | def __rshift__(self, f: Callable[[A], "Validation[E, B]"]) -> "Validation[E, B]": 33 | raise NotImplementedError( 34 | "Validation is not a full Monad; use Result for short-circuiting" 35 | ) 36 | 37 | 38 | class Success(Validation[E, A]): 39 | __slots__ = ("value",) 40 | 41 | def __init__(self, value: A) -> None: 42 | self.value = value 43 | 44 | def fmap(self, f: Callable[[A], B]) -> "Validation[E, B]": 45 | return Success(f(self.value)) 46 | 47 | def ap(self: "Success[E, Callable[[A], B]]", fa: "Validation[E, A]") -> "Validation[E, B]": 48 | if isinstance(fa, Success): 49 | func = self.value 50 | return Success(func(fa.value)) 51 | return cast(Validation[E, B], fa) 52 | 53 | def bind(self, f: Callable[[A], "Validation[E, B]"]) -> "Validation[E, B]": 54 | return f(self.value) 55 | 56 | def __repr__(self) -> str: 57 | return f"Success({self.value!r})" 58 | 59 | def __eq__(self, other: object) -> bool: 60 | return isinstance(other, Success) and self.value == other.value 61 | 62 | 63 | class Failure(Validation[E, Any]): 64 | __slots__ = ("errors",) 65 | 66 | def __init__(self, errors: List[E]) -> None: 67 | if not errors: 68 | raise ValueError("Failure requires a non-empty list of errors") 69 | self.errors = errors 70 | 71 | def fmap(self, f: Callable[[Any], B]) -> "Validation[E, B]": 72 | return cast(Validation[E, B], self) 73 | 74 | def ap(self, fa: "Validation[E, A]") -> "Validation[E, B]": 75 | if isinstance(fa, Failure): 76 | return Failure(self.errors + fa.errors) 77 | return cast(Validation[E, B], self) 78 | 79 | def bind(self, f: Callable[[Any], "Validation[E, B]"]) -> "Validation[E, B]": 80 | return cast(Validation[E, B], self) 81 | 82 | def __repr__(self) -> str: 83 | return f"Failure({self.errors!r})" 84 | 85 | def __eq__(self, other: object) -> bool: 86 | return isinstance(other, Failure) and self.errors == other.errors 87 | 88 | 89 | def from_result(res: Result[A]) -> "Validation[str, A]": 90 | if isinstance(res, Ok): 91 | return Success(res.value) 92 | err = cast(Err[Any], res) 93 | return Failure([str(err.error)]) 94 | 95 | 96 | def to_result(val: Validation[E, A]) -> Result[A]: 97 | if isinstance(val, Success): 98 | return Ok(val.value) 99 | fail = cast(Failure[E], val) 100 | joined = ", ".join(str(e) for e in fail.errors) 101 | return Err(joined) 102 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | echo ##active_line2## 3 | __pycache__/ 4 | echo ##active_line3## 5 | *.py[cod] 6 | echo ##active_line4## 7 | *.class 8 | echo ##active_line5## 9 | 10 | echo ##active_line6## 11 | # C extensions 12 | echo ##active_line7## 13 | *.so 14 | echo ##active_line8## 15 | 16 | echo ##active_line9## 17 | # Distribution / packaging 18 | echo ##active_line10## 19 | .Python 20 | echo ##active_line11## 21 | build/ 22 | echo ##active_line12## 23 | develop-eggs/ 24 | echo ##active_line13## 25 | dist/ 26 | echo ##active_line14## 27 | downloads/ 28 | echo ##active_line15## 29 | eggs/ 30 | echo ##active_line16## 31 | .eggs/ 32 | echo ##active_line17## 33 | lib/ 34 | echo ##active_line18## 35 | lib64/ 36 | echo ##active_line19## 37 | parts/ 38 | echo ##active_line20## 39 | sdist/ 40 | echo ##active_line21## 41 | var/ 42 | echo ##active_line22## 43 | *.egg-info/ 44 | echo ##active_line23## 45 | .installed.cfg 46 | echo ##active_line24## 47 | *.egg 48 | echo ##active_line25## 49 | 50 | echo ##active_line26## 51 | # PyInstaller 52 | echo ##active_line27## 53 | *.manifest 54 | echo ##active_line28## 55 | *.spec 56 | echo ##active_line29## 57 | 58 | echo ##active_line30## 59 | # Installer logs 60 | echo ##active_line31## 61 | pip-log.txt 62 | echo ##active_line32## 63 | pip-delete-this-directory.txt 64 | echo ##active_line33## 65 | 66 | echo ##active_line34## 67 | # Unit test / coverage reports 68 | echo ##active_line35## 69 | htmlcov/ 70 | echo ##active_line36## 71 | .tox/ 72 | echo ##active_line37## 73 | .nox/ 74 | echo ##active_line38## 75 | .coverage 76 | echo ##active_line39## 77 | .coverage.* 78 | echo ##active_line40## 79 | .cache 80 | echo ##active_line41## 81 | nosetests.xml 82 | echo ##active_line42## 83 | coverage.xml 84 | echo ##active_line43## 85 | *.cover 86 | echo ##active_line44## 87 | *.py,cover 88 | echo ##active_line45## 89 | .hypothesis/ 90 | echo ##active_line46## 91 | .pytest_cache/ 92 | echo ##active_line47## 93 | 94 | echo ##active_line48## 95 | # Translations 96 | echo ##active_line49## 97 | *.mo 98 | echo ##active_line50## 99 | *.pot 100 | echo ##active_line51## 101 | 102 | echo ##active_line52## 103 | # Django stuff: 104 | echo ##active_line53## 105 | *.log 106 | echo ##active_line54## 107 | local_settings.py 108 | echo ##active_line55## 109 | db.sqlite3 110 | echo ##active_line56## 111 | 112 | echo ##active_line57## 113 | # Flask stuff: 114 | echo ##active_line58## 115 | instance/ 116 | echo ##active_line59## 117 | .webassets-cache 118 | echo ##active_line60## 119 | 120 | echo ##active_line61## 121 | # Scrapy stuff: 122 | echo ##active_line62## 123 | .scrapy 124 | echo ##active_line63## 125 | 126 | echo ##active_line64## 127 | # Sphinx documentation 128 | echo ##active_line65## 129 | docs/_build/ 130 | echo ##active_line66## 131 | 132 | echo ##active_line67## 133 | # PyBuilder 134 | echo ##active_line68## 135 | target/ 136 | echo ##active_line69## 137 | 138 | echo ##active_line70## 139 | # Jupyter Notebook 140 | echo ##active_line71## 141 | .ipynb_checkpoints 142 | echo ##active_line72## 143 | 144 | echo ##active_line73## 145 | # IPython 146 | echo ##active_line74## 147 | .profile_default/ 148 | echo ##active_line75## 149 | ipython_config.py 150 | echo ##active_line76## 151 | 152 | echo ##active_line77## 153 | # Celery stuff 154 | echo ##active_line78## 155 | celerybeat-schedule 156 | echo ##active_line79## 157 | celerybeat.pid 158 | echo ##active_line80## 159 | 160 | echo ##active_line81## 161 | # SageMath parsed files 162 | echo ##active_line82## 163 | *.sage.py 164 | echo ##active_line83## 165 | 166 | echo ##active_line84## 167 | # Environments 168 | echo ##active_line85## 169 | .env 170 | echo ##active_line86## 171 | .venv 172 | echo ##active_line87## 173 | env/ 174 | echo ##active_line88## 175 | venv/ 176 | echo ##active_line89## 177 | ENV/ 178 | echo ##active_line90## 179 | env.bak/ 180 | echo ##active_line91## 181 | venv.bak/ 182 | echo ##active_line92## 183 | 184 | echo ##active_line93## 185 | # Spyder project settings 186 | echo ##active_line94## 187 | .spyderproject 188 | echo ##active_line95## 189 | .spyproject 190 | echo ##active_line96## 191 | 192 | echo ##active_line97## 193 | # Rope project settings 194 | echo ##active_line98## 195 | .ropeproject 196 | echo ##active_line99## 197 | 198 | echo ##active_line100## 199 | # mkdocs documentation 200 | echo ##active_line101## 201 | /site 202 | echo ##active_line102## 203 | 204 | echo ##active_line103## 205 | # mypy 206 | echo ##active_line104## 207 | .mypy_cache/ 208 | echo ##active_line105## 209 | 210 | echo ##active_line106## 211 | # Pycharm 212 | echo ##active_line107## 213 | .idea/ 214 | echo ##active_line108## 215 | 216 | echo ##active_line109## 217 | # macOS 218 | echo ##active_line110## 219 | .DS_Store 220 | echo ##active_line111## 221 | 222 | echo ##active_line112## 223 | # VS Code 224 | echo ##active_line113## 225 | .vscode/ 226 | -------------------------------------------------------------------------------- /darkcore/rwst.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import Any, Callable, Generic, TypeVar, cast 3 | from .core import Monad, MonadOpsMixin 4 | 5 | R = TypeVar("R") 6 | W = TypeVar("W") 7 | S = TypeVar("S") 8 | A = TypeVar("A") 9 | B = TypeVar("B") 10 | 11 | class RWST(MonadOpsMixin[A], Generic[R, W, S, A]): 12 | """Reader-Writer-State monad transformer.""" 13 | 14 | def __init__( 15 | self, 16 | run: Callable[[R, S], Monad[tuple[tuple[A, S], W]]], 17 | *, 18 | combine: Callable[[W, W], W], 19 | empty: Callable[[], W], 20 | ) -> None: 21 | self.run = run 22 | self.combine = combine 23 | self.empty = empty 24 | 25 | @classmethod 26 | def pure_with( 27 | cls, 28 | pure: Callable[[tuple[tuple[A, S], W]], Monad[tuple[tuple[A, S], W]]], 29 | value: A, 30 | *, 31 | combine: Callable[[W, W], W], 32 | empty: Callable[[], W], 33 | ) -> "RWST[R, W, S, A]": 34 | return RWST(lambda _r, s: pure(((value, s), empty())), combine=combine, empty=empty) 35 | 36 | def fmap(self, f: Callable[[A], B]) -> "RWST[R, W, S, B]": 37 | def new_run(r: R, s: S) -> Monad[tuple[tuple[B, S], W]]: 38 | return self.run(r, s).fmap(lambda res: ((f(res[0][0]), res[0][1]), res[1])) 39 | return RWST(new_run, combine=self.combine, empty=self.empty) 40 | 41 | map = fmap 42 | 43 | def ap( 44 | self: "RWST[R, W, S, Callable[[A], B]]", 45 | fa: "RWST[R, W, S, A]", 46 | ) -> "RWST[R, W, S, B]": 47 | def new_run(r: R, s: S) -> Monad[tuple[tuple[B, S], W]]: 48 | m1 = self.run(r, s) 49 | return m1.bind( 50 | lambda pair_f: fa.run(r, pair_f[0][1]).bind( 51 | lambda pair_a: cast( 52 | Monad[tuple[tuple[B, S], W]], 53 | cast(Any, m1).pure( 54 | ( 55 | (pair_f[0][0](pair_a[0][0]), pair_a[0][1]), 56 | self.combine(pair_f[1], pair_a[1]), 57 | ) 58 | ), 59 | ) 60 | ) 61 | ) 62 | return RWST(new_run, combine=self.combine, empty=self.empty) 63 | 64 | def bind(self, f: Callable[[A], "RWST[R, W, S, B]"]) -> "RWST[R, W, S, B]": 65 | def new_run(r: R, s: S) -> Monad[tuple[tuple[B, S], W]]: 66 | m1 = self.run(r, s) 67 | return m1.bind( 68 | lambda pair: f(pair[0][0]).run(r, pair[0][1]).bind( 69 | lambda res: cast( 70 | Monad[tuple[tuple[B, S], W]], 71 | cast(Any, m1).pure( 72 | ((res[0][0], res[0][1]), self.combine(pair[1], res[1])) 73 | ), 74 | ) 75 | ) 76 | ) 77 | return RWST(new_run, combine=self.combine, empty=self.empty) 78 | 79 | @classmethod 80 | def lift( 81 | cls, 82 | monad: Monad[A], 83 | *, 84 | combine: Callable[[W, W], W], 85 | empty: Callable[[], W], 86 | ) -> "RWST[R, W, S, A]": 87 | def run(r: R, s: S) -> Monad[tuple[tuple[A, S], W]]: 88 | def step(a: A) -> Monad[tuple[tuple[A, S], W]]: 89 | return cast(Monad[tuple[tuple[A, S], W]], cast(Any, monad).pure(((a, s), empty()))) 90 | return monad.bind(step) 91 | return RWST(run, combine=combine, empty=empty) 92 | 93 | @classmethod 94 | def ask( 95 | cls, 96 | pure: Callable[[tuple[tuple[R, S], W]], Monad[tuple[tuple[R, S], W]]], 97 | *, 98 | combine: Callable[[W, W], W], 99 | empty: Callable[[], W], 100 | ) -> "RWST[R, W, S, R]": 101 | return RWST(lambda r, s: pure(((r, s), empty())), combine=combine, empty=empty) 102 | 103 | @classmethod 104 | def tell( 105 | cls, 106 | w: W, 107 | pure: Callable[[tuple[tuple[None, S], W]], Monad[tuple[tuple[None, S], W]]], 108 | *, 109 | combine: Callable[[W, W], W], 110 | empty: Callable[[], W], 111 | ) -> "RWST[R, W, S, None]": 112 | return RWST(lambda _r, s: pure(((None, s), w)), combine=combine, empty=empty) 113 | 114 | @classmethod 115 | def get( 116 | cls, 117 | pure: Callable[[tuple[tuple[S, S], W]], Monad[tuple[tuple[S, S], W]]], 118 | *, 119 | combine: Callable[[W, W], W], 120 | empty: Callable[[], W], 121 | ) -> "RWST[R, W, S, S]": 122 | return RWST(lambda _r, s: pure(((s, s), empty())), combine=combine, empty=empty) 123 | 124 | @classmethod 125 | def put( 126 | cls, 127 | new_state: S, 128 | pure: Callable[[tuple[tuple[None, S], W]], Monad[tuple[tuple[None, S], W]]], 129 | *, 130 | combine: Callable[[W, W], W], 131 | empty: Callable[[], W], 132 | ) -> "RWST[R, W, S, None]": 133 | return RWST(lambda _r, _s: pure(((None, new_state), empty())), combine=combine, empty=empty) 134 | 135 | @classmethod 136 | def modify( 137 | cls, 138 | f: Callable[[S], S], 139 | pure: Callable[[tuple[tuple[None, S], W]], Monad[tuple[tuple[None, S], W]]], 140 | *, 141 | combine: Callable[[W, W], W], 142 | empty: Callable[[], W], 143 | ) -> "RWST[R, W, S, None]": 144 | return RWST(lambda _r, s: pure(((None, f(s)), empty())), combine=combine, empty=empty) 145 | 146 | def __call__(self, r: R, s: S) -> Monad[tuple[tuple[A, S], W]]: 147 | return self.run(r, s) 148 | 149 | def __repr__(self) -> str: 150 | return f"RWST({self.run!r})" 151 | -------------------------------------------------------------------------------- /tests/property/test_laws_property_based.py: -------------------------------------------------------------------------------- 1 | from hypothesis import given, strategies as st, settings 2 | from darkcore.maybe import Maybe 3 | from darkcore.result import Ok, Err, Result 4 | from darkcore.writer import Writer 5 | from darkcore.validation import Success 6 | from darkcore.rwst import RWST 7 | 8 | 9 | small_int = st.integers(-5, 5) 10 | small_str = st.text(min_size=0, max_size=5) 11 | max_examples = settings(max_examples=25) 12 | 13 | 14 | @max_examples 15 | @given(small_int) 16 | def test_functor_identity_maybe(x): 17 | m = Maybe(x) 18 | assert m.fmap(lambda a: a) == m 19 | 20 | 21 | @max_examples 22 | @given(small_int) 23 | def test_functor_composition_maybe(x): 24 | m = Maybe(x) 25 | f = lambda a: a + 1 26 | g = lambda a: a * 2 27 | assert m.fmap(f).fmap(g) == m.fmap(lambda a: g(f(a))) 28 | 29 | 30 | @max_examples 31 | @given(small_int, small_int, small_int) 32 | def test_applicative_composition_maybe(x, y, z): 33 | u = Maybe(lambda b: b + x) 34 | v = Maybe(lambda a: a * y) 35 | w = Maybe(z) 36 | compose = lambda f: lambda g: lambda a: f(g(a)) 37 | left = Maybe(compose).ap(u).ap(v).ap(w) 38 | right = u.ap(v.ap(w)) 39 | assert left == right 40 | 41 | 42 | @max_examples 43 | @given(small_int) 44 | def test_monad_associativity_maybe(x): 45 | m = Maybe(x) 46 | f = lambda a: Maybe(a + 1) 47 | g = lambda a: Maybe(a * 2) 48 | assert m.bind(f).bind(g) == m.bind(lambda a: f(a).bind(g)) 49 | 50 | 51 | @max_examples 52 | @given(small_int) 53 | def test_functor_identity_result(x): 54 | r: Result[int] = Ok(x) 55 | assert r.fmap(lambda a: a) == r 56 | 57 | 58 | @max_examples 59 | @given(small_int) 60 | def test_functor_composition_result(x): 61 | r: Result[int] = Ok(x) 62 | f = lambda a: a + 1 63 | g = lambda a: a * 2 64 | assert r.fmap(f).fmap(g) == r.fmap(lambda a: g(f(a))) 65 | 66 | 67 | @max_examples 68 | @given(small_int, small_int, small_int) 69 | def test_applicative_composition_result(x, y, z): 70 | u: Result[callable] = Ok(lambda b: b + x) 71 | v: Result[callable] = Ok(lambda a: a * y) 72 | w: Result[int] = Ok(z) 73 | compose = lambda f: lambda g: lambda a: f(g(a)) 74 | left = Ok(compose).ap(u).ap(v).ap(w) 75 | right = u.ap(v.ap(w)) 76 | assert left == right 77 | 78 | 79 | @max_examples 80 | @given(small_int) 81 | def test_monad_associativity_result(x): 82 | r: Result[int] = Ok(x) 83 | f = lambda a: Ok(a + 1) 84 | g = lambda a: Ok(a * 2) 85 | assert r.bind(f).bind(g) == r.bind(lambda a: f(a).bind(g)) 86 | 87 | 88 | @max_examples 89 | @given(small_int) 90 | def test_functor_identity_writer(x): 91 | w = Writer.pure(x) 92 | assert w.fmap(lambda a: a) == w 93 | 94 | 95 | @max_examples 96 | @given(small_int) 97 | def test_functor_composition_writer(x): 98 | w = Writer.pure(x) 99 | f = lambda a: a + 1 100 | g = lambda a: a * 2 101 | assert w.fmap(f).fmap(g) == w.fmap(lambda a: g(f(a))) 102 | 103 | 104 | @max_examples 105 | @given(small_int, small_int, small_int) 106 | def test_applicative_composition_writer(x, y, z): 107 | u = Writer.pure(lambda b: b + x) 108 | v = Writer.pure(lambda a: a * y) 109 | w = Writer.pure(z) 110 | compose = lambda f: lambda g: lambda a: f(g(a)) 111 | left = Writer.pure(compose).ap(u).ap(v).ap(w) 112 | right = u.ap(v.ap(w)) 113 | assert left == right 114 | 115 | 116 | @max_examples 117 | @given(small_int) 118 | def test_monad_associativity_writer(x): 119 | w = Writer.pure(x) 120 | f = lambda a: Writer.pure(a + 1) 121 | g = lambda a: Writer.pure(a * 2) 122 | assert w.bind(f).bind(g) == w.bind(lambda a: f(a).bind(g)) 123 | 124 | 125 | @max_examples 126 | @given(small_int) 127 | def test_functor_identity_validation(x): 128 | v = Success(x) 129 | assert v.fmap(lambda a: a) == v 130 | 131 | 132 | @max_examples 133 | @given(small_int) 134 | def test_functor_composition_validation(x): 135 | v = Success(x) 136 | f = lambda a: a + 1 137 | g = lambda a: a * 2 138 | assert v.fmap(f).fmap(g) == v.fmap(lambda a: g(f(a))) 139 | 140 | 141 | @max_examples 142 | @given(small_int, small_int, small_int) 143 | def test_applicative_composition_validation(x, y, z): 144 | u = Success(lambda b: b + x) 145 | v = Success(lambda a: a * y) 146 | w = Success(z) 147 | compose = lambda f: lambda g: lambda a: f(g(a)) 148 | left = Success(compose).ap(u).ap(v).ap(w) 149 | right = u.ap(v.ap(w)) 150 | assert left == right 151 | 152 | 153 | @max_examples 154 | @given(small_int) 155 | def test_functor_identity_rwst(x): 156 | pure = Ok.pure 157 | empty = list 158 | a = RWST.pure_with(pure, x, combine=lambda a, b: a + b, empty=empty) 159 | res1 = a.fmap(lambda y: y)(0, 0) 160 | res2 = a(0, 0) 161 | assert res1 == res2 162 | 163 | 164 | @max_examples 165 | @given(small_int) 166 | def test_functor_composition_rwst(x): 167 | pure = Ok.pure 168 | empty = list 169 | a = RWST.pure_with(pure, x, combine=lambda a, b: a + b, empty=empty) 170 | f = lambda y: y + 1 171 | g = lambda y: y * 2 172 | res1 = a.fmap(f).fmap(g)(0, 0) 173 | res2 = a.fmap(lambda y: g(f(y)))(0, 0) 174 | assert res1 == res2 175 | 176 | 177 | @max_examples 178 | @given(small_int, small_int, small_int) 179 | def test_applicative_composition_rwst(x, y, z): 180 | pure = Ok.pure 181 | empty = list 182 | u = RWST.pure_with(pure, lambda b: b + x, combine=lambda a, b: a + b, empty=empty) 183 | v = RWST.pure_with(pure, lambda a_: a_ * y, combine=lambda a, b: a + b, empty=empty) 184 | w = RWST.pure_with(pure, z, combine=lambda a, b: a + b, empty=empty) 185 | compose = lambda f: lambda g: lambda a_: f(g(a_)) 186 | left = RWST.pure_with(pure, compose, combine=lambda a, b: a + b, empty=empty).ap(u).ap(v).ap(w) 187 | right = u.ap(v.ap(w)) 188 | assert left(0, 0) == right(0, 0) 189 | 190 | 191 | @max_examples 192 | @given(small_int) 193 | def test_monad_associativity_rwst(x): 194 | pure = Ok.pure 195 | empty = list 196 | a = RWST.pure_with(pure, x, combine=lambda a, b: a + b, empty=empty) 197 | f = lambda y: RWST.pure_with(pure, y + 1, combine=lambda a, b: a + b, empty=empty) 198 | g = lambda y: RWST.pure_with(pure, y * 2, combine=lambda a, b: a + b, empty=empty) 199 | res1 = a.bind(f).bind(g)(0, 0) 200 | res2 = a.bind(lambda y: f(y).bind(g))(0, 0) 201 | assert res1 == res2 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # darkcore 2 | 3 | **darkcore** is a lightweight functional programming toolkit for Python. 4 | It brings **Functor / Applicative / Monad** abstractions, classic monads like **Maybe, Either/Result, Reader, Writer, State**, 5 | and an expressive **operator DSL** (`|`, `>>`, `@`) that makes Python feel almost like Haskell. 6 | 7 | --- 8 | 9 | ## ✨ Features 10 | 11 | - Functor / Applicative / Monad base abstractions 12 | - Core monads implemented: 13 | - `Maybe` — handle missing values 14 | - `Either` / `Result` — safe error handling 15 | - `Validation` — accumulate multiple errors 16 | - `Reader` — dependency injection / environment 17 | - `Writer` — accumulate logs 18 | - `State` — stateful computations 19 | - Monad transformers: `MaybeT`, `ResultT`, `ReaderT`, `StateT`, `WriterT` 20 | - Utilities: `traverse`/`sequence`, Applicative combinators 21 | - Advanced monads: `RWST` (Reader-Writer-State) 22 | - Operator overloads for concise DSL-style code: 23 | - `|` → `fmap` (map) 24 | - `>>` → `bind` (flatMap) 25 | - `@` → `ap` (applicative apply) 26 | - High test coverage, Monad law tests included 27 | 28 | --- 29 | 30 | ## 🚀 Installation 31 | 32 | ```bash 33 | pip install darkcore 34 | ``` 35 | 36 | (or use Poetry) 37 | 38 | --- 39 | 40 | ## 🧪 Quick Examples 41 | 42 | ### Maybe 43 | 44 | ```python 45 | from darkcore.maybe import Maybe 46 | 47 | m = Maybe(3) | (lambda x: x+1) >> (lambda y: Maybe(y*2)) 48 | print(m) # Just(8) 49 | 50 | n = Maybe(None) | (lambda x: x+1) 51 | print(n) # Nothing 52 | ``` 53 | 54 | --- 55 | 56 | ### Result 57 | 58 | ```python 59 | from darkcore.result import Ok, Err 60 | 61 | def parse_int(s: str): 62 | try: 63 | return Ok(int(s)) 64 | except ValueError: 65 | return Err(f"invalid int: {s}") 66 | 67 | res = parse_int("42") >> (lambda x: Ok(x * 2)) 68 | print(res) # Ok(84) 69 | 70 | res2 = parse_int("foo") >> (lambda x: Ok(x * 2)) 71 | print(res2) # Err("invalid int: foo") 72 | ``` 73 | 74 | --- 75 | 76 | ### Validation: accumulate errors via Applicative 77 | 78 | ```python 79 | from darkcore.validation import Success, Failure 80 | 81 | def positive(x: int): 82 | return Failure(["non-positive"]) if x <= 0 else Success(x) 83 | 84 | v = Success(lambda a: lambda b: a + b).ap(positive(-1)).ap(positive(0)) 85 | print(v) # Failure(['non-positive', 'non-positive']) 86 | 87 | # Result would stop at the first failure 88 | ``` 89 | 90 | Validation is primarily intended for Applicative composition; `bind` short-circuits like `Result` and is not recommended for error accumulation scenarios. 91 | 92 | ### Choosing between `Result`, `Either`, and `Validation` 93 | 94 | | Type | Error shape | Behavior on bind (`>>`) | Best use case | 95 | |------------|------------------------|--------------------------|----------------------------------------| 96 | | `Result` | Typically string/Exception-like | Short-circuits | IO boundaries, failing effects | 97 | | `Either` | Domain-typed error | Short-circuits | Domain errors with rich types | 98 | | `Validation` | Accumulates via Applicative | **Short-circuits monadically** | Form-style multi-error accumulation | 99 | 100 | > Note: `Validation` accumulates errors in `Applicative` flows (`@` / `ap`, `traverse`, `sequence_*`), but *monadically* (`>>`) it short-circuits. 101 | 102 | ### Equality of `ReaderT` / `StateT` 103 | These transformers represent computations. Equality is **extensional**: compare results of `run` under the same environment/state, not object identity. 104 | 105 | --- 106 | 107 | ### Reader 108 | 109 | ```python 110 | from darkcore.reader import Reader 111 | 112 | get_user = Reader(lambda env: env["user"]) 113 | greet = get_user | (lambda u: f"Hello {u}") 114 | 115 | print(greet.run({"user": "Alice"})) # "Hello Alice" 116 | ``` 117 | 118 | --- 119 | 120 | ### Writer 121 | 122 | ```python 123 | from darkcore.writer import Writer 124 | 125 | # list log by default 126 | w = Writer.pure(3).tell(["start"]) >> (lambda x: Writer(x + 1, ["inc"])) 127 | print(w) # Writer(4, log=['start', 'inc']) 128 | 129 | # for non-``list`` logs, pass ``empty`` and ``combine`` explicitly 130 | # ``empty`` provides the identity element and ``combine`` appends logs 131 | w2 = Writer("hi", empty=str, combine=str.__add__).tell("!") 132 | print(w2) # Writer('hi', log='!') 133 | 134 | # omitting these for a non-``list`` log raises ``TypeError`` 135 | try: 136 | Writer("hi", "!") # missing empty/combine 137 | except TypeError: 138 | print("expected TypeError") 139 | ``` 140 | 141 | --- 142 | 143 | ### State 144 | 145 | ```python 146 | from darkcore.state import State 147 | 148 | inc = State(lambda s: (s, s+1)) 149 | prog = inc >> (lambda x: State(lambda s: (x+s, s))) 150 | 151 | print(prog.run(1)) # (3, 2) 152 | ``` 153 | 154 | ### Traverse utilities 155 | 156 | ```python 157 | from darkcore.traverse import traverse_result 158 | from darkcore.result import Ok, Err 159 | 160 | def parse_int(s: str): 161 | try: 162 | return Ok(int(s)) 163 | except ValueError: 164 | return Err(f"bad: {s}") 165 | 166 | print(traverse_result(["1", "2"], parse_int)) # Ok([1, 2]) 167 | print(traverse_result(["1", "x"], parse_int)) # Err("bad: x") 168 | ``` 169 | 170 | `Result` short-circuits on the first `Err` in `traverse_*` / `sequence_*`, whereas `Validation` accumulates errors under Applicative composition. 171 | 172 | ### RWST 173 | 174 | ```python 175 | from darkcore.rwst import RWST 176 | from darkcore.result import Ok 177 | 178 | combine = lambda a, b: a + b 179 | 180 | action = RWST.ask(Ok.pure, combine=combine, empty=list).bind( 181 | lambda env: RWST.tell([env], Ok.pure, combine=combine, empty=list) 182 | ) 183 | 184 | print(action(1, 0)) # Ok(((None, 0), [1])) 185 | ``` 186 | 187 | ### Operator DSL 188 | 189 | ```python 190 | from darkcore.maybe import Maybe 191 | 192 | mf = Maybe(lambda x: x * 2) 193 | mx = Maybe(4) 194 | print((mf @ mx) | (lambda x: x + 1)) # Just(9) 195 | ``` 196 | 197 | ### Pattern Matching 198 | 199 | ```python 200 | from darkcore.result import Ok, Err 201 | from darkcore.maybe import Maybe 202 | from darkcore.either import Right, Left 203 | from darkcore.writer import Writer 204 | 205 | def classify(r): 206 | match r: 207 | case Ok(v) if v > 10: 208 | return ("big", v) 209 | case Ok(v): 210 | return ("ok", v) 211 | case Err(e): 212 | return ("err", e) 213 | 214 | def maybe_demo(m): 215 | match m: 216 | case Maybe(value=None): 217 | return "nothing" 218 | case Maybe(value=v): 219 | return v 220 | 221 | def either_demo(x): 222 | match x: 223 | case Right(v): 224 | return v 225 | case Left(e): 226 | return e 227 | 228 | w = Writer(3, ["a"], empty=list, combine=lambda a, b: a + b) 229 | match w: 230 | case Writer(v, log=ls): 231 | print(v, ls) 232 | ``` 233 | 234 | --- 235 | 236 | ## 📖 Integration Example 237 | 238 | ```python 239 | from darkcore.reader import Reader 240 | from darkcore.writer import Writer 241 | from darkcore.state import State 242 | from darkcore.result import Ok, Err 243 | 244 | # Reader: get user from environment 245 | get_user = Reader(lambda env: env.get("user")) 246 | 247 | # Result: validate existence 248 | to_result = lambda user: Err("no user") if user is None else Ok(user) 249 | 250 | # Writer: log user 251 | log_user = lambda user: Writer(user, [f"got user={user}"]) 252 | 253 | # State: update counter 254 | update_state = lambda user: State(lambda s: (f"{user}@{s}", s+1)) 255 | 256 | env = {"user": "alice"} 257 | 258 | user = get_user.run(env) 259 | res = to_result(user) >> (lambda u: Ok(log_user(u))) 260 | writer = res.value 261 | print(writer.log) # ['got user=alice'] 262 | 263 | out, s2 = update_state(writer.value).run(42) 264 | print(out, s2) # alice@42 43 265 | ``` 266 | 267 | --- 268 | 269 | ## Why? 270 | 271 | - **Safer business code** 272 | - Avoid nested `try/except` and `if None` checks 273 | - Express computations declaratively with monads 274 | - **Educational value** 275 | - Learn Haskell/FP concepts hands-on in Python 276 | - **Expressive DSL** 277 | - `|`, `>>`, `@` make pipelines concise and clear 278 | 279 | --- 280 | 281 | ## Development 282 | 283 | ```bash 284 | git clone https://github.com/minamorl/darkcore 285 | cd darkcore 286 | poetry install 287 | poetry run pytest -v --cov=darkcore 288 | ``` 289 | 290 | --- 291 | 292 | ## License 293 | 294 | MIT 295 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand. 2 | 3 | [[package]] 4 | name = "attrs" 5 | version = "25.3.0" 6 | description = "Classes Without Boilerplate" 7 | optional = false 8 | python-versions = ">=3.8" 9 | groups = ["dev"] 10 | files = [ 11 | {file = "attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3"}, 12 | {file = "attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b"}, 13 | ] 14 | 15 | [package.extras] 16 | benchmark = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] 17 | cov = ["cloudpickle ; platform_python_implementation == \"CPython\"", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] 18 | dev = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pre-commit-uv", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] 19 | docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier"] 20 | tests = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] 21 | tests-mypy = ["mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\""] 22 | 23 | [[package]] 24 | name = "colorama" 25 | version = "0.4.6" 26 | description = "Cross-platform colored terminal text." 27 | optional = false 28 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 29 | groups = ["dev"] 30 | markers = "sys_platform == \"win32\"" 31 | files = [ 32 | {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, 33 | {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, 34 | ] 35 | 36 | [[package]] 37 | name = "coverage" 38 | version = "7.8.0" 39 | description = "Code coverage measurement for Python" 40 | optional = false 41 | python-versions = ">=3.9" 42 | groups = ["dev"] 43 | files = [ 44 | {file = "coverage-7.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2931f66991175369859b5fd58529cd4b73582461877ecfd859b6549869287ffe"}, 45 | {file = "coverage-7.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:52a523153c568d2c0ef8826f6cc23031dc86cffb8c6aeab92c4ff776e7951b28"}, 46 | {file = "coverage-7.8.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c8a5c139aae4c35cbd7cadca1df02ea8cf28a911534fc1b0456acb0b14234f3"}, 47 | {file = "coverage-7.8.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5a26c0c795c3e0b63ec7da6efded5f0bc856d7c0b24b2ac84b4d1d7bc578d676"}, 48 | {file = "coverage-7.8.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:821f7bcbaa84318287115d54becb1915eece6918136c6f91045bb84e2f88739d"}, 49 | {file = "coverage-7.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a321c61477ff8ee705b8a5fed370b5710c56b3a52d17b983d9215861e37b642a"}, 50 | {file = "coverage-7.8.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:ed2144b8a78f9d94d9515963ed273d620e07846acd5d4b0a642d4849e8d91a0c"}, 51 | {file = "coverage-7.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:042e7841a26498fff7a37d6fda770d17519982f5b7d8bf5278d140b67b61095f"}, 52 | {file = "coverage-7.8.0-cp310-cp310-win32.whl", hash = "sha256:f9983d01d7705b2d1f7a95e10bbe4091fabc03a46881a256c2787637b087003f"}, 53 | {file = "coverage-7.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:5a570cd9bd20b85d1a0d7b009aaf6c110b52b5755c17be6962f8ccd65d1dbd23"}, 54 | {file = "coverage-7.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e7ac22a0bb2c7c49f441f7a6d46c9c80d96e56f5a8bc6972529ed43c8b694e27"}, 55 | {file = "coverage-7.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bf13d564d310c156d1c8e53877baf2993fb3073b2fc9f69790ca6a732eb4bfea"}, 56 | {file = "coverage-7.8.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5761c70c017c1b0d21b0815a920ffb94a670c8d5d409d9b38857874c21f70d7"}, 57 | {file = "coverage-7.8.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e5ff52d790c7e1628241ffbcaeb33e07d14b007b6eb00a19320c7b8a7024c040"}, 58 | {file = "coverage-7.8.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d39fc4817fd67b3915256af5dda75fd4ee10621a3d484524487e33416c6f3543"}, 59 | {file = "coverage-7.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b44674870709017e4b4036e3d0d6c17f06a0e6d4436422e0ad29b882c40697d2"}, 60 | {file = "coverage-7.8.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8f99eb72bf27cbb167b636eb1726f590c00e1ad375002230607a844d9e9a2318"}, 61 | {file = "coverage-7.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b571bf5341ba8c6bc02e0baeaf3b061ab993bf372d982ae509807e7f112554e9"}, 62 | {file = "coverage-7.8.0-cp311-cp311-win32.whl", hash = "sha256:e75a2ad7b647fd8046d58c3132d7eaf31b12d8a53c0e4b21fa9c4d23d6ee6d3c"}, 63 | {file = "coverage-7.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:3043ba1c88b2139126fc72cb48574b90e2e0546d4c78b5299317f61b7f718b78"}, 64 | {file = "coverage-7.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bbb5cc845a0292e0c520656d19d7ce40e18d0e19b22cb3e0409135a575bf79fc"}, 65 | {file = "coverage-7.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4dfd9a93db9e78666d178d4f08a5408aa3f2474ad4d0e0378ed5f2ef71640cb6"}, 66 | {file = "coverage-7.8.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f017a61399f13aa6d1039f75cd467be388d157cd81f1a119b9d9a68ba6f2830d"}, 67 | {file = "coverage-7.8.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0915742f4c82208ebf47a2b154a5334155ed9ef9fe6190674b8a46c2fb89cb05"}, 68 | {file = "coverage-7.8.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a40fcf208e021eb14b0fac6bdb045c0e0cab53105f93ba0d03fd934c956143a"}, 69 | {file = "coverage-7.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a1f406a8e0995d654b2ad87c62caf6befa767885301f3b8f6f73e6f3c31ec3a6"}, 70 | {file = "coverage-7.8.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:77af0f6447a582fdc7de5e06fa3757a3ef87769fbb0fdbdeba78c23049140a47"}, 71 | {file = "coverage-7.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f2d32f95922927186c6dbc8bc60df0d186b6edb828d299ab10898ef3f40052fe"}, 72 | {file = "coverage-7.8.0-cp312-cp312-win32.whl", hash = "sha256:769773614e676f9d8e8a0980dd7740f09a6ea386d0f383db6821df07d0f08545"}, 73 | {file = "coverage-7.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:e5d2b9be5b0693cf21eb4ce0ec8d211efb43966f6657807f6859aab3814f946b"}, 74 | {file = "coverage-7.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ac46d0c2dd5820ce93943a501ac5f6548ea81594777ca585bf002aa8854cacd"}, 75 | {file = "coverage-7.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:771eb7587a0563ca5bb6f622b9ed7f9d07bd08900f7589b4febff05f469bea00"}, 76 | {file = "coverage-7.8.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42421e04069fb2cbcbca5a696c4050b84a43b05392679d4068acbe65449b5c64"}, 77 | {file = "coverage-7.8.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:554fec1199d93ab30adaa751db68acec2b41c5602ac944bb19187cb9a41a8067"}, 78 | {file = "coverage-7.8.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5aaeb00761f985007b38cf463b1d160a14a22c34eb3f6a39d9ad6fc27cb73008"}, 79 | {file = "coverage-7.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:581a40c7b94921fffd6457ffe532259813fc68eb2bdda60fa8cc343414ce3733"}, 80 | {file = "coverage-7.8.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f319bae0321bc838e205bf9e5bc28f0a3165f30c203b610f17ab5552cff90323"}, 81 | {file = "coverage-7.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04bfec25a8ef1c5f41f5e7e5c842f6b615599ca8ba8391ec33a9290d9d2db3a3"}, 82 | {file = "coverage-7.8.0-cp313-cp313-win32.whl", hash = "sha256:dd19608788b50eed889e13a5d71d832edc34fc9dfce606f66e8f9f917eef910d"}, 83 | {file = "coverage-7.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:a9abbccd778d98e9c7e85038e35e91e67f5b520776781d9a1e2ee9d400869487"}, 84 | {file = "coverage-7.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:18c5ae6d061ad5b3e7eef4363fb27a0576012a7447af48be6c75b88494c6cf25"}, 85 | {file = "coverage-7.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:95aa6ae391a22bbbce1b77ddac846c98c5473de0372ba5c463480043a07bff42"}, 86 | {file = "coverage-7.8.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e013b07ba1c748dacc2a80e69a46286ff145935f260eb8c72df7185bf048f502"}, 87 | {file = "coverage-7.8.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d766a4f0e5aa1ba056ec3496243150698dc0481902e2b8559314368717be82b1"}, 88 | {file = "coverage-7.8.0-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad80e6b4a0c3cb6f10f29ae4c60e991f424e6b14219d46f1e7d442b938ee68a4"}, 89 | {file = "coverage-7.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b87eb6fc9e1bb8f98892a2458781348fa37e6925f35bb6ceb9d4afd54ba36c73"}, 90 | {file = "coverage-7.8.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:d1ba00ae33be84066cfbe7361d4e04dec78445b2b88bdb734d0d1cbab916025a"}, 91 | {file = "coverage-7.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f3c38e4e5ccbdc9198aecc766cedbb134b2d89bf64533973678dfcf07effd883"}, 92 | {file = "coverage-7.8.0-cp313-cp313t-win32.whl", hash = "sha256:379fe315e206b14e21db5240f89dc0774bdd3e25c3c58c2c733c99eca96f1ada"}, 93 | {file = "coverage-7.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2e4b6b87bb0c846a9315e3ab4be2d52fac905100565f4b92f02c445c8799e257"}, 94 | {file = "coverage-7.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fa260de59dfb143af06dcf30c2be0b200bed2a73737a8a59248fcb9fa601ef0f"}, 95 | {file = "coverage-7.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:96121edfa4c2dfdda409877ea8608dd01de816a4dc4a0523356067b305e4e17a"}, 96 | {file = "coverage-7.8.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b8af63b9afa1031c0ef05b217faa598f3069148eeee6bb24b79da9012423b82"}, 97 | {file = "coverage-7.8.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:89b1f4af0d4afe495cd4787a68e00f30f1d15939f550e869de90a86efa7e0814"}, 98 | {file = "coverage-7.8.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94ec0be97723ae72d63d3aa41961a0b9a6f5a53ff599813c324548d18e3b9e8c"}, 99 | {file = "coverage-7.8.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:8a1d96e780bdb2d0cbb297325711701f7c0b6f89199a57f2049e90064c29f6bd"}, 100 | {file = "coverage-7.8.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:f1d8a2a57b47142b10374902777e798784abf400a004b14f1b0b9eaf1e528ba4"}, 101 | {file = "coverage-7.8.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:cf60dd2696b457b710dd40bf17ad269d5f5457b96442f7f85722bdb16fa6c899"}, 102 | {file = "coverage-7.8.0-cp39-cp39-win32.whl", hash = "sha256:be945402e03de47ba1872cd5236395e0f4ad635526185a930735f66710e1bd3f"}, 103 | {file = "coverage-7.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:90e7fbc6216ecaffa5a880cdc9c77b7418c1dcb166166b78dbc630d07f278cc3"}, 104 | {file = "coverage-7.8.0-pp39.pp310.pp311-none-any.whl", hash = "sha256:b8194fb8e50d556d5849753de991d390c5a1edeeba50f68e3a9253fbd8bf8ccd"}, 105 | {file = "coverage-7.8.0-py3-none-any.whl", hash = "sha256:dbf364b4c5e7bae9250528167dfe40219b62e2d573c854d74be213e1e52069f7"}, 106 | {file = "coverage-7.8.0.tar.gz", hash = "sha256:7a3d62b3b03b4b6fd41a085f3574874cf946cb4604d2b4d3e8dca8cd570ca501"}, 107 | ] 108 | 109 | [package.dependencies] 110 | tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} 111 | 112 | [package.extras] 113 | toml = ["tomli ; python_full_version <= \"3.11.0a6\""] 114 | 115 | [[package]] 116 | name = "exceptiongroup" 117 | version = "1.3.0" 118 | description = "Backport of PEP 654 (exception groups)" 119 | optional = false 120 | python-versions = ">=3.7" 121 | groups = ["dev"] 122 | markers = "python_version == \"3.10\"" 123 | files = [ 124 | {file = "exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10"}, 125 | {file = "exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88"}, 126 | ] 127 | 128 | [package.dependencies] 129 | typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} 130 | 131 | [package.extras] 132 | test = ["pytest (>=6)"] 133 | 134 | [[package]] 135 | name = "hypothesis" 136 | version = "6.138.2" 137 | description = "A library for property-based testing" 138 | optional = false 139 | python-versions = ">=3.9" 140 | groups = ["dev"] 141 | files = [ 142 | {file = "hypothesis-6.138.2-py3-none-any.whl", hash = "sha256:2886c5e7569437781d1dc42973021f70f7facb7e410249dcb899b0edb249e5b8"}, 143 | {file = "hypothesis-6.138.2.tar.gz", hash = "sha256:82e3b2ac709ee3edda4aba2f4b11becfe764f51d104fcdb3e9f95aff1ac81595"}, 144 | ] 145 | 146 | [package.dependencies] 147 | attrs = ">=22.2.0" 148 | exceptiongroup = {version = ">=1.0.0", markers = "python_version < \"3.11\""} 149 | sortedcontainers = ">=2.1.0,<3.0.0" 150 | 151 | [package.extras] 152 | all = ["black (>=20.8b0)", "click (>=7.0)", "crosshair-tool (>=0.0.93)", "django (>=4.2)", "dpcontracts (>=0.4)", "hypothesis-crosshair (>=0.0.24)", "lark (>=0.10.1)", "libcst (>=0.3.16)", "numpy (>=1.19.3)", "pandas (>=1.1)", "pytest (>=4.6)", "python-dateutil (>=1.4)", "pytz (>=2014.1)", "redis (>=3.0.0)", "rich (>=9.0.0)", "tzdata (>=2025.2) ; sys_platform == \"win32\" or sys_platform == \"emscripten\"", "watchdog (>=4.0.0)"] 153 | cli = ["black (>=20.8b0)", "click (>=7.0)", "rich (>=9.0.0)"] 154 | codemods = ["libcst (>=0.3.16)"] 155 | crosshair = ["crosshair-tool (>=0.0.93)", "hypothesis-crosshair (>=0.0.24)"] 156 | dateutil = ["python-dateutil (>=1.4)"] 157 | django = ["django (>=4.2)"] 158 | dpcontracts = ["dpcontracts (>=0.4)"] 159 | ghostwriter = ["black (>=20.8b0)"] 160 | lark = ["lark (>=0.10.1)"] 161 | numpy = ["numpy (>=1.19.3)"] 162 | pandas = ["pandas (>=1.1)"] 163 | pytest = ["pytest (>=4.6)"] 164 | pytz = ["pytz (>=2014.1)"] 165 | redis = ["redis (>=3.0.0)"] 166 | watchdog = ["watchdog (>=4.0.0)"] 167 | zoneinfo = ["tzdata (>=2025.2) ; sys_platform == \"win32\" or sys_platform == \"emscripten\""] 168 | 169 | [[package]] 170 | name = "iniconfig" 171 | version = "2.1.0" 172 | description = "brain-dead simple config-ini parsing" 173 | optional = false 174 | python-versions = ">=3.8" 175 | groups = ["dev"] 176 | files = [ 177 | {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"}, 178 | {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, 179 | ] 180 | 181 | [[package]] 182 | name = "mypy" 183 | version = "1.15.0" 184 | description = "Optional static typing for Python" 185 | optional = false 186 | python-versions = ">=3.9" 187 | groups = ["dev"] 188 | files = [ 189 | {file = "mypy-1.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:979e4e1a006511dacf628e36fadfecbcc0160a8af6ca7dad2f5025529e082c13"}, 190 | {file = "mypy-1.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c4bb0e1bd29f7d34efcccd71cf733580191e9a264a2202b0239da95984c5b559"}, 191 | {file = "mypy-1.15.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be68172e9fd9ad8fb876c6389f16d1c1b5f100ffa779f77b1fb2176fcc9ab95b"}, 192 | {file = "mypy-1.15.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7be1e46525adfa0d97681432ee9fcd61a3964c2446795714699a998d193f1a3"}, 193 | {file = "mypy-1.15.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2e2c2e6d3593f6451b18588848e66260ff62ccca522dd231cd4dd59b0160668b"}, 194 | {file = "mypy-1.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:6983aae8b2f653e098edb77f893f7b6aca69f6cffb19b2cc7443f23cce5f4828"}, 195 | {file = "mypy-1.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2922d42e16d6de288022e5ca321cd0618b238cfc5570e0263e5ba0a77dbef56f"}, 196 | {file = "mypy-1.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2ee2d57e01a7c35de00f4634ba1bbf015185b219e4dc5909e281016df43f5ee5"}, 197 | {file = "mypy-1.15.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:973500e0774b85d9689715feeffcc980193086551110fd678ebe1f4342fb7c5e"}, 198 | {file = "mypy-1.15.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a95fb17c13e29d2d5195869262f8125dfdb5c134dc8d9a9d0aecf7525b10c2c"}, 199 | {file = "mypy-1.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1905f494bfd7d85a23a88c5d97840888a7bd516545fc5aaedff0267e0bb54e2f"}, 200 | {file = "mypy-1.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:c9817fa23833ff189db061e6d2eff49b2f3b6ed9856b4a0a73046e41932d744f"}, 201 | {file = "mypy-1.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:aea39e0583d05124836ea645f412e88a5c7d0fd77a6d694b60d9b6b2d9f184fd"}, 202 | {file = "mypy-1.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2f2147ab812b75e5b5499b01ade1f4a81489a147c01585cda36019102538615f"}, 203 | {file = "mypy-1.15.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ce436f4c6d218a070048ed6a44c0bbb10cd2cc5e272b29e7845f6a2f57ee4464"}, 204 | {file = "mypy-1.15.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8023ff13985661b50a5928fc7a5ca15f3d1affb41e5f0a9952cb68ef090b31ee"}, 205 | {file = "mypy-1.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1124a18bc11a6a62887e3e137f37f53fbae476dc36c185d549d4f837a2a6a14e"}, 206 | {file = "mypy-1.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:171a9ca9a40cd1843abeca0e405bc1940cd9b305eaeea2dda769ba096932bb22"}, 207 | {file = "mypy-1.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:93faf3fdb04768d44bf28693293f3904bbb555d076b781ad2530214ee53e3445"}, 208 | {file = "mypy-1.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:811aeccadfb730024c5d3e326b2fbe9249bb7413553f15499a4050f7c30e801d"}, 209 | {file = "mypy-1.15.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98b7b9b9aedb65fe628c62a6dc57f6d5088ef2dfca37903a7d9ee374d03acca5"}, 210 | {file = "mypy-1.15.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c43a7682e24b4f576d93072216bf56eeff70d9140241f9edec0c104d0c515036"}, 211 | {file = "mypy-1.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:baefc32840a9f00babd83251560e0ae1573e2f9d1b067719479bfb0e987c6357"}, 212 | {file = "mypy-1.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:b9378e2c00146c44793c98b8d5a61039a048e31f429fb0eb546d93f4b000bedf"}, 213 | {file = "mypy-1.15.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e601a7fa172c2131bff456bb3ee08a88360760d0d2f8cbd7a75a65497e2df078"}, 214 | {file = "mypy-1.15.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:712e962a6357634fef20412699a3655c610110e01cdaa6180acec7fc9f8513ba"}, 215 | {file = "mypy-1.15.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95579473af29ab73a10bada2f9722856792a36ec5af5399b653aa28360290a5"}, 216 | {file = "mypy-1.15.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f8722560a14cde92fdb1e31597760dc35f9f5524cce17836c0d22841830fd5b"}, 217 | {file = "mypy-1.15.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1fbb8da62dc352133d7d7ca90ed2fb0e9d42bb1a32724c287d3c76c58cbaa9c2"}, 218 | {file = "mypy-1.15.0-cp39-cp39-win_amd64.whl", hash = "sha256:d10d994b41fb3497719bbf866f227b3489048ea4bbbb5015357db306249f7980"}, 219 | {file = "mypy-1.15.0-py3-none-any.whl", hash = "sha256:5469affef548bd1895d86d3bf10ce2b44e33d86923c29e4d675b3e323437ea3e"}, 220 | {file = "mypy-1.15.0.tar.gz", hash = "sha256:404534629d51d3efea5c800ee7c42b72a6554d6c400e6a79eafe15d11341fd43"}, 221 | ] 222 | 223 | [package.dependencies] 224 | mypy_extensions = ">=1.0.0" 225 | tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} 226 | typing_extensions = ">=4.6.0" 227 | 228 | [package.extras] 229 | dmypy = ["psutil (>=4.0)"] 230 | faster-cache = ["orjson"] 231 | install-types = ["pip"] 232 | mypyc = ["setuptools (>=50)"] 233 | reports = ["lxml"] 234 | 235 | [[package]] 236 | name = "mypy-extensions" 237 | version = "1.1.0" 238 | description = "Type system extensions for programs checked with the mypy type checker." 239 | optional = false 240 | python-versions = ">=3.8" 241 | groups = ["dev"] 242 | files = [ 243 | {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"}, 244 | {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, 245 | ] 246 | 247 | [[package]] 248 | name = "packaging" 249 | version = "25.0" 250 | description = "Core utilities for Python packages" 251 | optional = false 252 | python-versions = ">=3.8" 253 | groups = ["dev"] 254 | files = [ 255 | {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, 256 | {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, 257 | ] 258 | 259 | [[package]] 260 | name = "pluggy" 261 | version = "1.5.0" 262 | description = "plugin and hook calling mechanisms for python" 263 | optional = false 264 | python-versions = ">=3.8" 265 | groups = ["dev"] 266 | files = [ 267 | {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, 268 | {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, 269 | ] 270 | 271 | [package.extras] 272 | dev = ["pre-commit", "tox"] 273 | testing = ["pytest", "pytest-benchmark"] 274 | 275 | [[package]] 276 | name = "pytest" 277 | version = "8.3.5" 278 | description = "pytest: simple powerful testing with Python" 279 | optional = false 280 | python-versions = ">=3.8" 281 | groups = ["dev"] 282 | files = [ 283 | {file = "pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820"}, 284 | {file = "pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845"}, 285 | ] 286 | 287 | [package.dependencies] 288 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 289 | exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} 290 | iniconfig = "*" 291 | packaging = "*" 292 | pluggy = ">=1.5,<2" 293 | tomli = {version = ">=1", markers = "python_version < \"3.11\""} 294 | 295 | [package.extras] 296 | dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] 297 | 298 | [[package]] 299 | name = "pytest-cov" 300 | version = "6.1.1" 301 | description = "Pytest plugin for measuring coverage." 302 | optional = false 303 | python-versions = ">=3.9" 304 | groups = ["dev"] 305 | files = [ 306 | {file = "pytest_cov-6.1.1-py3-none-any.whl", hash = "sha256:bddf29ed2d0ab6f4df17b4c55b0a657287db8684af9c42ea546b21b1041b3dde"}, 307 | {file = "pytest_cov-6.1.1.tar.gz", hash = "sha256:46935f7aaefba760e716c2ebfbe1c216240b9592966e7da99ea8292d4d3e2a0a"}, 308 | ] 309 | 310 | [package.dependencies] 311 | coverage = {version = ">=7.5", extras = ["toml"]} 312 | pytest = ">=4.6" 313 | 314 | [package.extras] 315 | testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"] 316 | 317 | [[package]] 318 | name = "sortedcontainers" 319 | version = "2.4.0" 320 | description = "Sorted Containers -- Sorted List, Sorted Dict, Sorted Set" 321 | optional = false 322 | python-versions = "*" 323 | groups = ["dev"] 324 | files = [ 325 | {file = "sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0"}, 326 | {file = "sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88"}, 327 | ] 328 | 329 | [[package]] 330 | name = "tomli" 331 | version = "2.2.1" 332 | description = "A lil' TOML parser" 333 | optional = false 334 | python-versions = ">=3.8" 335 | groups = ["dev"] 336 | markers = "python_full_version <= \"3.11.0a6\"" 337 | files = [ 338 | {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, 339 | {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, 340 | {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a"}, 341 | {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee"}, 342 | {file = "tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e"}, 343 | {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4"}, 344 | {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106"}, 345 | {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8"}, 346 | {file = "tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff"}, 347 | {file = "tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b"}, 348 | {file = "tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea"}, 349 | {file = "tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8"}, 350 | {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192"}, 351 | {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222"}, 352 | {file = "tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77"}, 353 | {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6"}, 354 | {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd"}, 355 | {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e"}, 356 | {file = "tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98"}, 357 | {file = "tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4"}, 358 | {file = "tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7"}, 359 | {file = "tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c"}, 360 | {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13"}, 361 | {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281"}, 362 | {file = "tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272"}, 363 | {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140"}, 364 | {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2"}, 365 | {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744"}, 366 | {file = "tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec"}, 367 | {file = "tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69"}, 368 | {file = "tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc"}, 369 | {file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"}, 370 | ] 371 | 372 | [[package]] 373 | name = "typing-extensions" 374 | version = "4.13.2" 375 | description = "Backported and Experimental Type Hints for Python 3.8+" 376 | optional = false 377 | python-versions = ">=3.8" 378 | groups = ["dev"] 379 | files = [ 380 | {file = "typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c"}, 381 | {file = "typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef"}, 382 | ] 383 | 384 | [metadata] 385 | lock-version = "2.1" 386 | python-versions = ">=3.10,<3.13" 387 | content-hash = "38dc8533fe2e1b7707a2683571f60e7fb04232940f9fddc9d737b683aeb4d9ae" 388 | --------------------------------------------------------------------------------