diff --git a/README.md b/README.md index e69de29..e1d2394 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,149 @@ +# flow-res + +Rust 言語の `Result` 型に範を仰いだ、Python 向けの高機能かつ型安全なエラーハンドリングライブラリです。 + +従来の例外駆動型(Exception-driven)から、Result 駆動型(Result-driven)へのパラダイムシフトを強力に支援します。明示的なエラーハンドリングを導入することで、コードの堅牢性と可読性を飛躍的に向上させることが可能です。 + +## 主要な特徴 + +* **厳密な型安全性**: ジェネリクスを活用し、成功値とエラー値の双方に対して静的解析(mypy, pyright 等)を適用可能です。 +* **鉄道指向プログラミング(ROP)**: `map` や `and_then` によるメソッドチェーンにより、宣言的なエラーハンドリングを実現します。 +* **非同期処理のネイティブサポート**: `@async_result` デコレータを通じて、非同期処理を `AwaitableResult` として透過的にチェーン可能です。 +* **メタプログラミングによる統合**: `@safe` デコレータを用いることで、既存の例外送出型関数を容易に Result 型へ変換できます。 +* **高度な結果集約**: `combine`(早期失敗)および `combine_all`(全エラー集約)により、複数の処理結果を合理的に統合します。 +* **軽量設計(ゼロ依存)**: 外部ライブラリへの依存はなく、プロジェクトへの導入障壁が極めて低く抑えられています。 + +## インストール + +```bash +pip install flow_res +``` + +※ Python 3.13 以上が必要です。 + +## 実装ガイド + +### 1. 基本的な定義とハンドリング + +関数の戻り値に `Result` を指定することで、呼び出し側に対してエラー処理の検討を強制(明示)させます。Python 3.10 以降の構造的パターンマッチングを利用することで、エレガントに結果を処理できます。 + +```python +from flow_res import Result, Ok, Err + +def divide(a: int, b: int) -> Result[float, ValueError]: + """2つの数値の除算を行い、結果を Result 型で返却する""" + if b == 0: + return Err(ValueError("Division by zero")) + return Ok(a / b) + +result = divide(10, 2) +match result: + case Ok(value): + print(f"Success: {value}") + case Err(error): + print(f"Failure: {error}") +``` + +### 2. 関数型インターフェースによる連鎖処理 (Railroad-Oriented Programming) + +`map` や `and_then` を用いることで、命令的な条件分岐を排除し、処理のパイプラインを構築できます。 + +```python +from flow_res import Result + +def validate_positive(x: int) -> Result[int, ValueError]: + if x < 0: + return Err(ValueError("Must be positive")) + return Ok(x) + +# 依存関係のある処理の連結 +result = ( + Ok(5) + .and_then(validate_positive) + .map(lambda x: x * 2) + .map(lambda x: x + 3) +) +print(result.unwrap()) # 13 +``` + +### 3. @safe デコレータによる例外のラップ + +既存の例外を発生させる可能性のある関数を、低コストで Result 駆動型へ移行させます。 + +```python +from flow_res import safe + +@safe +def parse_int(s: str) -> int: + return int(s) + +# 例外は送出されず、Err として返却される +result = parse_int("not_a_number") +print(result) # Err(error=ValueError("invalid literal for int() with base 10: 'not_a_number'")) +``` + +### 4. 非同期処理の統合 (@async_result) + +`@async_result` デコレータを使用することで、非同期関数の実行結果に対しても await 前にメソッドチェーンを適用できます。 + +```python +import asyncio +from flow_res import Result, async_result + +@async_result +async def fetch_user(user_id: int) -> Result[dict, ValueError]: + await asyncio.sleep(0.1) + if user_id < 0: + return Err(ValueError("Invalid user ID")) + return Ok({"id": user_id, "name": f"User{user_id}"}) + +async def main(): + # 処理を連結した後に一括で await + result = await ( + fetch_user(1) + .map(lambda u: u["name"]) + .map(str.upper) + ) + print(result.unwrap()) # USER1 + +asyncio.run(main()) +``` + +### 5. 複数結果の集約ロジック (combine / combine_all) + +バリデーションなど、複数の検証結果を一括で扱うためのインターフェースを提供します。 + +* `combine`: 最初に遭遇した `Err` を返却する(短絡評価・早期失敗) +* `combine_all`: すべての `Err` を集約して複数の例外を保持する `Err` を返却する(全件チェック) + +```python +from flow_res import Result, combine, combine_all, Ok, Err + +results = ( + Ok(1), + Err(ValueError("error1")), + Err(RuntimeError("error2")), +) + +# 最初のエラー (error1) のみを返す +print(combine(results)) + +# すべてのエラーを集約して返す +match combine_all(results): + case Err(error): + for e in error.exceptions: + print(f"Error: {e}") +``` + +## 動作環境 + +* **Python バージョン**: 3.13 以上 +* **型ヒント**: 完全対応(Static Type Checking を推奨) + +## ライセンス + +本プロジェクトは MIT License の下に公開されています。 + +## 協力・貢献 + +不具合報告や機能拡張の提案は、[GitHub Issues](https://github.com/aiagate/flow-res/issues) にて承っております。 diff --git a/examples/01_basic_usage.py b/examples/01_basic_usage.py new file mode 100644 index 0000000..763beed --- /dev/null +++ b/examples/01_basic_usage.py @@ -0,0 +1,55 @@ +""" +基本的な使い方のサンプル +OK と Err の作成、map、and_then のチェーニング +""" + +from flow_res import Err, Ok, Result + + +def divide(a: int, b: int) -> Result[float, ValueError]: + """2つの数値を除算し、結果を返す""" + if b == 0: + return Err(ValueError("Division by zero")) + return Ok(a / b) + + +def main(): + # 成功ケース + result = divide(10, 2) + match result: + case Ok(value): + print(f"Success: 10 / 2 = {value}") + case Err(error): + print(f"Error: {error}") + + # エラーケース + result = divide(10, 0) + match result: + case Ok(value): + print(f"Success: {value}") + case Err(error): + print(f"Error: {error}") + + # map でチェーニング + print("\n--- Chaining with map ---") + result = Ok(5).map(lambda x: x * 2).map(lambda x: x + 3) + print(f"(5 * 2) + 3 = {result.unwrap()}") + + # and_then でチェーニング + print("\n--- Chaining with and_then ---") + + def validate_positive(x: int) -> Result[int, ValueError]: + if x < 0: + return Err(ValueError("Must be positive")) + return Ok(x) + + result = Ok(-5).and_then(validate_positive).map(lambda x: x * 2) + match result: + case Ok(value): + print(f"Value: {value}") + case Err(error): + print(f"Validation error: {error}") + + +if __name__ == "__main__": + main() diff --git a/examples/02_safe_decorator.py b/examples/02_safe_decorator.py new file mode 100644 index 0000000..b39c601 --- /dev/null +++ b/examples/02_safe_decorator.py @@ -0,0 +1,55 @@ +""" +@safe デコレータの使用例 +例外を自動的に Result に変換 +""" + +from flow_res import safe + + +@safe +def parse_int(s: str) -> int: + """文字列を整数に変換(例外を Result に変換)""" + return int(s) + + +@safe +def get_dict_value(d: dict, key: str): + """辞書のキーを取得(KeyError を Result に変換)""" + return d[key] + + +@safe +def divide(a: float, b: float) -> float: + """除算(ZeroDivisionError を Result に変換)""" + return a / b + + +def main(): + # parse_int の使用 + print("--- Parse Integer ---") + result = parse_int("42") + print(f"parse_int('42'): {result}") + + result = parse_int("not_a_number") + print(f"parse_int('not_a_number'): {result}") + + # get_dict_value の使用 + print("\n--- Get Dict Value ---") + data = {"name": "Alice", "age": 30} + result = get_dict_value(data, "name") + print(f"get_dict_value(data, 'name'): {result}") + + result = get_dict_value(data, "email") + print(f"get_dict_value(data, 'email'): {result}") + + # divide の使用 + print("\n--- Division ---") + result = divide(10, 2) + print(f"divide(10, 2): {result}") + + result = divide(10, 0) + print(f"divide(10, 0): {result}") + + +if __name__ == "__main__": + main() diff --git a/examples/03_form_validation.py b/examples/03_form_validation.py new file mode 100644 index 0000000..ab85f7f --- /dev/null +++ b/examples/03_form_validation.py @@ -0,0 +1,89 @@ +""" +form_validation.py: フォームバリデーション例 +複数のバリデーション結果を集約して表示 +""" + +from flow_res import Err, Ok, combine_all, safe + + +@safe +def validate_email(email: str) -> str: + """メールアドレスをバリデーション""" + if not email: + raise ValueError("Email cannot be empty") + if "@" not in email: + raise ValueError("Invalid email format") + if "." not in email.split("@")[1]: + raise ValueError("Invalid domain") + return email + + +@safe +def validate_age(age: int) -> int: + """年齢をバリデーション""" + if age < 0: + raise ValueError("Age cannot be negative") + if age < 18: + raise ValueError("Must be 18 or older") + if age > 150: + raise ValueError("Age seems invalid") + return age + + +@safe +def validate_username(username: str) -> str: + """ユーザー名をバリデーション""" + if not username: + raise ValueError("Username cannot be empty") + if len(username) < 3: + raise ValueError("Username must be at least 3 characters") + if len(username) > 20: + raise ValueError("Username must be at most 20 characters") + if not username.replace("_", "").isalnum(): + raise ValueError("Username can only contain letters, numbers, and underscores") + return username + + +def validate_user_form(email: str, age: int, username: str): + """複数のフォームフィールドをバリデーション""" + validation_results = combine_all( + ( + validate_email(email), + validate_age(age), + validate_username(username), + ) + ) + + match validation_results: + case Ok((valid_email, valid_age, valid_username)): + print("Success: All validations passed!") + print(f" Email: {valid_email}") + print(f" Age: {valid_age}") + print(f" Username: {valid_username}") + return True + case Err(errors): + print("Validation failed:") + for error in errors.exceptions: + print(f" - {error}") + return False + + +def main(): + print("--- Test Case 1: Valid Input ---") + validate_user_form("user@example.com", 25, "alice_123") + + print("\n--- Test Case 2: Invalid Email ---") + validate_user_form("invalid-email", 25, "alice_123") + + print("\n--- Test Case 3: Too Young ---") + validate_user_form("user@example.com", 15, "alice_123") + + print("\n--- Test Case 4: Short Username ---") + validate_user_form("user@example.com", 25, "al") + + print("\n--- Test Case 5: Multiple Errors ---") + validate_user_form("invalid", 10, "a") + + +if __name__ == "__main__": + main() diff --git a/examples/04_async_example.py b/examples/04_async_example.py new file mode 100644 index 0000000..2b0ded7 --- /dev/null +++ b/examples/04_async_example.py @@ -0,0 +1,69 @@ +""" +async_example.py: 非同期操作のサンプル +AwaitableResult を使用したメソッドチェーニング +""" + +import asyncio + +from flow_res import Err, Ok, Result, async_result + + +@async_result +async def fetch_user(user_id: int) -> Result[dict, Exception]: + """ユーザー情報を取得(シミュレーション)""" + await asyncio.sleep(0.1) + if user_id < 0: + return Err(ValueError("Invalid user ID")) + return Ok( + {"id": user_id, "name": f"User{user_id}", "email": f"user{user_id}@example.com"} + ) + + +@async_result +async def fetch_posts(user_id: int) -> Result[list[dict], Exception]: + """ユーザーの投稿を取得(シミュレーション)""" + await asyncio.sleep(0.1) + if user_id == 0: + return Err(ValueError("User not found")) + return Ok( + [ + {"id": 1, "title": "Post 1", "user_id": user_id}, + {"id": 2, "title": "Post 2", "user_id": user_id}, + ] + ) + + +async def main(): + print("--- Async Example 1: Get User Name ---") + result = await fetch_user(1).map(lambda u: u["name"]).map(str.upper) + print(f"Result: {result}") + + print("\n--- Async Example 2: Combine User and Posts ---") + user_result = await ( + fetch_user(2) + .and_then(lambda u: fetch_posts(u["id"]).map(lambda posts: (u, posts))) + .map(lambda data: f"{data[0]['name']} has {len(data[1])} posts") + ) + print(f"Result: {user_result}") + + print("\n--- Async Example 3: Error Handling ---") + result = await ( + fetch_user(-1) + .and_then(lambda u: fetch_posts(u["id"])) + .map(lambda posts: f"Got {len(posts)} posts") + ) + print(f"Result: {result}") + + print("\n--- Async Example 4: Transform User Data ---") + result = await fetch_user(3).map( + lambda u: {"id": u["id"], "display_name": f"{u['name']} ({u['email']})"} + ) + match result: + case Ok(user): + print(f"Transformed user: {user}") + case Err(error): + print(f"Error: {error}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/05_railroad_oriented.py b/examples/05_railroad_oriented.py new file mode 100644 index 0000000..df596f0 --- /dev/null +++ b/examples/05_railroad_oriented.py @@ -0,0 +1,65 @@ +""" +railroad_oriented.py: Railroad-Oriented Programming パターン +複数のステップをパイプラインとしてチェーン +""" + +from flow_res import Err, Ok, Result, safe + + +@safe +def parse_int(s: str) -> int: + """文字列を整数に解析""" + return int(s) + + +def validate_range(x: int) -> Result[int, ValueError]: + """値が10~100の範囲内か確認""" + if x < 10 or x > 100: + return Err(ValueError(f"Value must be between 10 and 100, got {x}")) + return Ok(x) + + +def apply_tax(price: int) -> Result[float, ValueError]: + """税金を適用(10%)""" + if price < 0: + return Err(ValueError("Price cannot be negative")) + return Ok(price * 1.1) + + +def apply_discount(price: float) -> Result[float, ValueError]: + """割引を適用(15%)""" + if price < 0: + return Err(ValueError("Price cannot be negative")) + return Ok(price * 0.85) + + +def process_price(price_str: str) -> Result[str, Exception]: + """ + Railroad-Oriented Programming のパターン: + 正常系と異常系が"線路"のように分かれる + """ + return ( + parse_int(price_str) # 文字列を解析 + .and_then(validate_range) # 範囲チェック + .and_then(apply_tax) # 税金適用 + .and_then(apply_discount) # 割引適用 + .map(lambda x: f"${x:.2f}") # フォーマット + .map_err(lambda e: Exception(f"Processing error: {e}")) # エラーメッセージ変換 + ) + + +def main(): + test_cases = ["50", "5", "150", "100", "invalid"] + + print("--- Price Processing Pipeline ---\n") + for price_input in test_cases: + result = process_price(price_input) + match result: + case Ok(price): + print(f"Success: '{price_input}' → {price}") + case Err(error): + print(f"Error: '{price_input}' → {error}") + + +if __name__ == "__main__": + main() diff --git a/examples/06_combine_results.py b/examples/06_combine_results.py new file mode 100644 index 0000000..efb437f --- /dev/null +++ b/examples/06_combine_results.py @@ -0,0 +1,111 @@ +""" +combine_results.py: 複数の Result を統合 +combine と combine_all の使い方 +""" + +from flow_res import Err, Ok, Result, combine, combine_all + + +def example_combine(): + """ + combine: 最初のエラーで失敗 + すべてが成功した場合のみ Ok を返す + """ + print("--- combine: All Success ---") + results = (Ok(1), Ok(2), Ok(3)) + combined = combine(results) + print(f"combine({results})") + print(f"Result: {combined}\n") + + print("--- combine: First Error Stops ---") + results = ( + Ok(1), + Err(ValueError("error1")), + Ok(3), + Err(ValueError("error2")), + ) + combined = combine(results) + print(f"combine({results})") + print(f"Result: {combined}") + print("Note: Only first error is returned\n") + + +def example_combine_all(): + """ + combine_all: すべてのエラーを集約 + すべてのエラーを収集して返す + """ + print("--- combine_all: All Success ---") + results = (Ok(1), Ok(2), Ok(3)) + combined = combine_all(results) + print(f"combine_all({results})") + print(f"Result: {combined}\n") + + print("--- combine_all: Collect All Errors ---") + results = ( + Ok(1), + Err(ValueError("error1")), + Ok(3), + Err(ValueError("error2")), + Err(ValueError("error3")), + ) + combined = combine_all(results) + print(f"combine_all({results})") + match combined: + case Ok(values): + print(f"Result: Ok{values}") + case Err(error): + print(f"Result: Err with {len(error.exceptions)} errors") + for error in error.exceptions: + print(f" - {error}") + + +def validate_credentials(username: str, password: str, email: str): + """バリデーション例:すべてのエラーを一度に表示""" + + def validate_username(u: str) -> Result[str, ValueError]: + if len(u) < 3: + return Err(ValueError("Username too short")) + return Ok(u) + + def validate_password(p: str) -> Result[str, ValueError]: + if len(p) < 8: + return Err(ValueError("Password too short")) + return Ok(p) + + def validate_email(e: str) -> Result[str, ValueError]: + if "@" not in e: + return Err(ValueError("Invalid email")) + return Ok(e) + + results = combine_all( + ( + validate_username(username), + validate_password(password), + validate_email(email), + ) + ) + + match results: + case Ok((u, p, e)): + print(f"All credentials valid: {u}, {p}, {e}") + case Err(errors): + print("Validation failed:") + for error in errors.exceptions: + print(f" - {error}") + + +def main(): + example_combine() + example_combine_all() + + print("\n--- Use Case: Multiple Validations ---") + print("Valid:") + validate_credentials("alice", "password123", "alice@example.com") + + print("\nInvalid:") + validate_credentials("ab", "pass", "invalid-email") + + +if __name__ == "__main__": + main() diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..a35c408 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,105 @@ +# flow_res Examples + +このフォルダには、`flow_res` ライブラリの各機能を示すサンプルコードが含まれています。 + +## サンプル一覧 + +### 1. `01_basic_usage.py` - 基本的な使い方 +`Ok`、`Err`、`map`、`and_then` の基本的な使用方法を学びます。 + +**学習ポイント:** +- `Ok` と `Err` の作成 +- `map` でチェーニング +- `and_then` で Result を返す操作をチェーン + +実行: +```bash +python examples/01_basic_usage.py +``` + +### 2. `02_safe_decorator.py` - @safe デコレータ +例外を自動的に `Result` に変換する `@safe` デコレータの使い方。 + +**学習ポイント:** +- 既存の例外を発生させる関数を `Result` に変換 +- 例外型が自動的にキャプチャされる + +実行: +```bash +python examples/02_safe_decorator.py +``` + +### 3. `03_form_validation.py` - フォームバリデーション +複数のバリデーションを集約し、すべてのエラーを一度に表示する実践的な例。 + +**学習ポイント:** +- `combine_all` で複数結果を統合 +- バリデーション場面での使用方法 +- ユーザーフレンドリーなエラー表示 + +実行: +```bash +python examples/03_form_validation.py +``` + +### 4. `04_async_example.py` - 非同期操作 +`AwaitableResult` を使用して async/await と統合する方法。 + +**学習ポイント:** +- `AwaitableResult` で非同期チェーニング +- await の前にメソッドチェーンが可能 +- 非同期処理のエラーハンドリング + +実行: +```bash +python examples/04_async_example.py +``` + +### 5. `05_railroad_oriented.py` - Railroad-Oriented Programming +複数のステップを"線路"のようにチェーンしていくパターン。 + +**学習ポイント:** +- 正常系と異常系の分岐 +- `map_err` でエラーを変換 +- パイプラインパターン + +実行: +```bash +python examples/05_railroad_oriented.py +``` + +### 6. `06_combine_results.py` - 複数結果の統合 +`combine` と `combine_all` の違いと使い分け。 + +**学習ポイント:** +- `combine`: 最初のエラーで停止 +- `combine_all`: すべてのエラーを集約 +- 複数バリデーションの実装方法 + +実行: +```bash +python examples/06_combine_results.py +``` + +## すべてのサンプルを実行 + +```bash +for file in examples/0*.py; do + echo "=== Running $file ===" + python "$file" + echo +done +``` + +## 推奨される学習順序 + +1. **01_basic_usage.py** - 基本を理解 +2. **02_safe_decorator.py** - デコレータを学ぶ +3. **05_railroad_oriented.py** - パターンを理解 +4. **03_form_validation.py** - 実践的な例 +5. **06_combine_results.py** - 高度な統合 +6. **04_async_example.py** - 非同期処理 + +## さらに詳しく + +詳細は [README.md](../README.md) を参照してください。 diff --git a/pyproject.toml b/pyproject.toml index 066b007..f3e95ed 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] -name = "fluentres" -version = "0.0.2" +name = "flow-res" +version = "0.1.0" authors = [ { name="Shimae", email="50893541+aiagate@users.noreply.github.com" }, ] @@ -20,8 +20,8 @@ requires = ["uv_build >= 0.10.10, <0.11.0"] build-backend = "uv_build" [project.urls] -Homepage = "https://github.com/aiagate/fluentres" -Issues = "https://github.com/aiagate/fluentres/issues" +Homepage = "https://github.com/aiagate/flow-res" +Issues = "https://github.com/aiagate/flow-res/issues" [dependency-groups] dev = [ @@ -37,12 +37,13 @@ dev = [ [tool.coverage.run] source = ["src"] +omit = ["tests/*"] [tool.coverage.report] precision = 2 show_missing = true skip_empty = true -fail_under = 75 +fail_under = 95 exclude_lines = [ "pragma: no cover", "def __repr__", diff --git a/src/flow_res/__init__.py b/src/flow_res/__init__.py new file mode 100644 index 0000000..1154974 --- /dev/null +++ b/src/flow_res/__init__.py @@ -0,0 +1,18 @@ +from .async_result import AwaitableResult, async_result +from .combinators import combine, combine_all +from .guards import is_err, is_ok +from .result import Err, Ok, Result +from .safe import safe + +__all__ = [ + "Result", + "AwaitableResult", + "Ok", + "Err", + "is_err", + "is_ok", + "safe", + "combine", + "combine_all", + "async_result", +] diff --git a/src/flow_res/async_result.py b/src/flow_res/async_result.py new file mode 100644 index 0000000..248da90 --- /dev/null +++ b/src/flow_res/async_result.py @@ -0,0 +1,184 @@ +import inspect +from collections.abc import Awaitable, Callable, Coroutine, Generator +from functools import wraps +from typing import Any + +from .result import Err, Ok, Result + + +class AwaitableResult[T, E: Exception]: + """ + Awaitable wrapper for Result that enables method chaining before await. + + This allows elegant syntax like: + message = await Mediator.send_async(query).map(...).unwrap() + """ + + def __init__(self, coro: Coroutine[Any, Any, Result[T, E]]) -> None: + """ + Initialize with a coroutine that returns a Result. + + Args: + coro: Coroutine that will return Result[T, E] + """ + self._coro = coro + + def __repr__(self) -> str: + """Return a helpful representation for debugging. + + Shows the wrapped coroutine's repr to aid debugging without awaiting. + """ + try: + coro_repr = repr(self._coro) + except Exception: + coro_repr = "" + return f"ResultAwaitable(coro={coro_repr})" + + def __await__(self) -> Generator[Any, None, Result[T, E]]: + """Make this object awaitable, returning the underlying Result.""" + return self._coro.__await__() + + def map[U](self, f: Callable[[T], U]) -> "AwaitableResult[U, E]": + """ + Transform the Ok value using the provided function. + + This method chains onto the coroutine, creating a new coroutine that: + 1. Awaits the current Result + 2. Applies .map() to transform the value + 3. Returns the transformed Result + + Args: + f: Function to apply to the Ok value (T -> U) + + Returns: + ResultAwaitable[U, E] wrapping the transformed result + + Example: + user_id = await Mediator.send_async(cmd).map(lambda v: v.user_id) + """ + + async def mapped() -> Result[U, E]: + _result: Result[T, E] = await self + return _result.map(f) + + return AwaitableResult(mapped()) + + def and_then[U]( + self, f: Callable[[T], Awaitable[Result[U, E]] | Result[U, E]] + ) -> "AwaitableResult[U, E]": + """ + Apply a function (sync or async) that returns a Result, flattening the nested Result. + + This enables chaining operations that may fail. + + Args: + f: Function that takes the Ok value and returns a new Result + (T -> Awaitable[Result[U, E]] | Result[U, E]) + + Returns: + ResultAwaitable[U, E] wrapping the result of applying the function + + Example: + await ( + Mediator.send_async(create_cmd) + .and_then(lambda result: Mediator.send_async(GetQuery(result.id))) + .map(lambda value: format_message(value)) + .unwrap() + ) + """ + + async def chained() -> Result[U, E]: + _result: Result[T, E] = await self + match _result: + case Ok(value): + res = f(value) + if inspect.isawaitable(res): + return await res + return res + case Err(): + return _result + + return AwaitableResult(chained()) + + def unwrap(self) -> Awaitable[T]: + """ + Return the Ok value or raise the Err as an exception. + + This is a terminal operation that unwraps the Result. + + Returns: + Awaitable[T] that will return the value or raise the error + + Example: + message = await Mediator.send_async(query).map(...).unwrap() + """ + + async def unwrapped() -> T: + _result: Result[T, E] = await self + return _result.unwrap() + + return unwrapped() + + def map_err[F: Exception](self, f: Callable[[E], F]) -> "AwaitableResult[T, F]": + """ + Transform the error value using the provided function asynchronously. + + Args: + f: Function to apply to the error value (E -> F) + + Returns: + ResultAwaitable[T, F] wrapping the result with transformed error type + + Example: + await ( + Mediator.send_async(cmd) + .map_err(lambda e: DatabaseError(f"DB error: {e}")) + .unwrap() + ) + """ + + async def mapped() -> Result[T, F]: + _result = await self + return _result.map_err(f) + + return AwaitableResult(mapped()) + + +def async_result[T, E: Exception]( + func: Callable[..., Coroutine[Any, Any, Result[T, E]]], +) -> Callable[..., AwaitableResult[T, E]]: + """ + Decorator that wraps an async function returning Result in AwaitableResult. + + This allows async functions to return Result types while automatically + wrapping them in AwaitableResult for method chaining (.map, .and_then, etc.) + + The decorated function must be async and return a Result. The decorator + will wrap the coroutine in AwaitableResult, allowing method chaining without + an explicit await. + + Args: + func: An async function that returns Result[T, E] + + Returns: + A wrapped function that returns AwaitableResult[T, E] + + Example: + @async_result + async def fetch_user(user_id: int) -> Result[dict, Exception]: + if user_id < 0: + return Err(ValueError("Invalid user ID")) + return Ok({"id": user_id}) + + # Can now use method chaining without await: + result = fetch_user(1).map(lambda u: u["id"]).map(str.upper) + print(await result) # Ok({"id": 1}) + """ + + @wraps(func) + def wrapper(*args: Any, **kwargs: Any) -> AwaitableResult[T, E]: + return AwaitableResult( + coro=func(*args, **kwargs), + ) + + return wrapper diff --git a/src/flow_res/combinators.py b/src/flow_res/combinators.py new file mode 100644 index 0000000..754daf0 --- /dev/null +++ b/src/flow_res/combinators.py @@ -0,0 +1,310 @@ +from collections.abc import Sequence +from typing import Any, overload + +from .result import Result, Ok, Err + + +@overload +def combine[E: Exception](results: tuple[()]) -> Result[tuple[()], E]: ... + + +@overload +def combine[T1, E: Exception]( + results: tuple[Result[T1, E]], +) -> Result[tuple[T1], E]: ... + + +@overload +def combine[T1, T2, E: Exception]( + results: tuple[Result[T1, E], Result[T2, E]], +) -> Result[tuple[T1, T2], E]: ... + + +@overload +def combine[T1, T2, T3, E: Exception]( + results: tuple[Result[T1, E], Result[T2, E], Result[T3, E]], +) -> Result[tuple[T1, T2, T3], E]: ... + + +@overload +def combine[T1, T2, T3, T4, E: Exception]( + results: tuple[Result[T1, E], Result[T2, E], Result[T3, E], Result[T4, E]], +) -> Result[tuple[T1, T2, T3, T4], E]: ... + + +@overload +def combine[T1, T2, T3, T4, T5, E: Exception]( + results: tuple[ + Result[T1, E], + Result[T2, E], + Result[T3, E], + Result[T4, E], + Result[T5, E], + ], +) -> Result[tuple[T1, T2, T3, T4, T5], E]: ... + + +@overload +def combine[T1, T2, T3, T4, T5, T6, E: Exception]( + results: tuple[ + Result[T1, E], + Result[T2, E], + Result[T3, E], + Result[T4, E], + Result[T5, E], + Result[T6, E], + ], +) -> Result[tuple[T1, T2, T3, T4, T5, T6], E]: ... + + +@overload +def combine[T1, T2, T3, T4, T5, T6, T7, E: Exception]( + results: tuple[ + Result[T1, E], + Result[T2, E], + Result[T3, E], + Result[T4, E], + Result[T5, E], + Result[T6, E], + Result[T7, E], + ], +) -> Result[tuple[T1, T2, T3, T4, T5, T6, T7], E]: ... + + +@overload +def combine[T1, T2, T3, T4, T5, T6, T7, T8, E: Exception]( + results: tuple[ + Result[T1, E], + Result[T2, E], + Result[T3, E], + Result[T4, E], + Result[T5, E], + Result[T6, E], + Result[T7, E], + Result[T8, E], + ], +) -> Result[tuple[T1, T2, T3, T4, T5, T6, T7, T8], E]: ... + + +@overload +def combine[T1, T2, T3, T4, T5, T6, T7, T8, T9, E: Exception]( + results: tuple[ + Result[T1, E], + Result[T2, E], + Result[T3, E], + Result[T4, E], + Result[T5, E], + Result[T6, E], + Result[T7, E], + Result[T8, E], + Result[T9, E], + ], +) -> Result[tuple[T1, T2, T3, T4, T5, T6, T7, T8, T9], E]: ... + + +@overload +def combine[T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, E: Exception]( + results: tuple[ + Result[T1, E], + Result[T2, E], + Result[T3, E], + Result[T4, E], + Result[T5, E], + Result[T6, E], + Result[T7, E], + Result[T8, E], + Result[T9, E], + Result[T10, E], + ], +) -> Result[tuple[T1, T2, T3, T4, T5, T6, T7, T8, T9, T10], E]: ... + + +def combine[E: Exception]( + results: Sequence[Result[Any, E]], +) -> Result[tuple[Any, ...], E]: + """ + Aggregates a sequence of Result objects. + + If all results are Ok, returns an Ok containing a tuple of all success values. + If any result is an Err, returns the first Err encountered. + + Args: + results: A sequence of Result objects. + + Returns: + A single Result object. Ok(tuple of success values) or the first Err. + + Examples: + Heterogeneous types (use tuple): + >>> user_id: Result[int, Exception] = Ok(123) + >>> email: Result[str, Exception] = Ok("user@example.com") + >>> combine((user_id, email)) + Ok((123, "user@example.com")) + + Homogeneous types (use list or tuple): + >>> results = [Ok(1), Ok(2), Ok(3)] + >>> combine(results) + Ok((1, 2, 3)) + + Error handling (first error returned): + >>> results = [Ok(1), Err(Exception("error")), Ok(3)] + >>> combine(results) + Err(Exception("error")) + """ + values: list[Any] = [] + for r in results: + if isinstance(r, Err): + return r # Return the first error found + values.append(r.unwrap()) + return Ok(tuple(values)) + + +@overload +def combine_all[T1, E: Exception]( + results: tuple[Result[T1, E]], +) -> Result[tuple[T1], ExceptionGroup]: ... + + +@overload +def combine_all[T1, T2, E: Exception]( + results: tuple[Result[T1, E], Result[T2, E]], +) -> Result[tuple[T1, T2], ExceptionGroup]: ... + + +@overload +def combine_all[T1, T2, T3, E: Exception]( + results: tuple[Result[T1, E], Result[T2, E], Result[T3, E]], +) -> Result[tuple[T1, T2, T3], ExceptionGroup]: ... + + +@overload +def combine_all[T1, T2, T3, T4, E: Exception]( + results: tuple[Result[T1, E], Result[T2, E], Result[T3, E], Result[T4, E]], +) -> Result[tuple[T1, T2, T3, T4], ExceptionGroup]: ... + + +@overload +def combine_all[T1, T2, T3, T4, T5, E: Exception]( + results: tuple[ + Result[T1, E], + Result[T2, E], + Result[T3, E], + Result[T4, E], + Result[T5, E], + ], +) -> Result[tuple[T1, T2, T3, T4, T5], ExceptionGroup]: ... + + +@overload +def combine_all[T1, T2, T3, T4, T5, T6, E: Exception]( + results: tuple[ + Result[T1, E], + Result[T2, E], + Result[T3, E], + Result[T4, E], + Result[T5, E], + Result[T6, E], + ], +) -> Result[tuple[T1, T2, T3, T4, T5, T6], ExceptionGroup]: ... + + +@overload +def combine_all[T1, T2, T3, T4, T5, T6, T7, E: Exception]( + results: tuple[ + Result[T1, E], + Result[T2, E], + Result[T3, E], + Result[T4, E], + Result[T5, E], + Result[T6, E], + Result[T7, E], + ], +) -> Result[tuple[T1, T2, T3, T4, T5, T6, T7], ExceptionGroup]: ... + + +@overload +def combine_all[T1, T2, T3, T4, T5, T6, T7, T8, E: Exception]( + results: tuple[ + Result[T1, E], + Result[T2, E], + Result[T3, E], + Result[T4, E], + Result[T5, E], + Result[T6, E], + Result[T7, E], + Result[T8, E], + ], +) -> Result[tuple[T1, T2, T3, T4, T5, T6, T7, T8], ExceptionGroup]: ... + + +@overload +def combine_all[T1, T2, T3, T4, T5, T6, T7, T8, T9, E: Exception]( + results: tuple[ + Result[T1, E], + Result[T2, E], + Result[T3, E], + Result[T4, E], + Result[T5, E], + Result[T6, E], + Result[T7, E], + Result[T8, E], + Result[T9, E], + ], +) -> Result[tuple[T1, T2, T3, T4, T5, T6, T7, T8, T9], ExceptionGroup]: ... + + +@overload +def combine_all[T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, E: Exception]( + results: tuple[ + Result[T1, E], + Result[T2, E], + Result[T3, E], + Result[T4, E], + Result[T5, E], + Result[T6, E], + Result[T7, E], + Result[T8, E], + Result[T9, E], + Result[T10, E], + ], +) -> Result[tuple[T1, T2, T3, T4, T5, T6, T7, T8, T9, T10], ExceptionGroup]: ... + + +def combine_all[E: Exception]( + results: Sequence[Result[Any, E]], +) -> Result[tuple[Any, ...], ExceptionGroup]: + """ + Aggregates a tuple of Results, collecting all errors. + + If all results are Ok, returns an Ok containing a tuple of all success values. + If any result is an Err, returns an Err containing an ExceptionGroup with all errors. + + This is a "fail complete" strategy - useful for validation where you want to show + all errors to the user at once, rather than one at a time. + + Args: + results: A tuple of Result objects. + + Returns: + Ok(tuple of success values) if all succeed, + or Err(ExceptionGroup("Multiple errors occurred", list of errors)) if any fail. + + Example: + >>> from app.core.result import combine_all, Ok, Err + >>> results = (Ok(1), Err(Exception("error1")), Ok(3), Err(Exception("error2"))) + >>> combined = combine_all(results) + >>> # Returns Err(ExceptionGroup("Multiple errors occurred", [Exception("error1"), Exception("error2")])) + """ + values: list[Any] = [] + errors: list[E] = [] + + for r in results: + if isinstance(r, Err): + errors.append(r.error) + else: + values.append(r.unwrap()) + + if errors: + return Err(ExceptionGroup("Multiple errors occurred", errors)) + + return Ok(tuple(values)) diff --git a/src/flow_res/guards.py b/src/flow_res/guards.py new file mode 100644 index 0000000..3e25c8b --- /dev/null +++ b/src/flow_res/guards.py @@ -0,0 +1,23 @@ +from typing import TypeIs + +from .result import Err, Ok, Result + + +def is_ok[T, E: Exception = Exception](result: Result[T, E]) -> TypeIs[Ok[T]]: + """ + Return true if the result is ok. + + Uses TypeIs for bidirectional type narrowing - when this returns False, + the type checker knows the result must be Err. + """ + return isinstance(result, Ok) + + +def is_err[T, E: Exception = Exception](result: Result[T, E]) -> TypeIs[Err[E]]: + """ + Return true if the result is an error. + + Uses TypeIs for bidirectional type narrowing - when this returns False, + the type checker knows the result must be Ok. + """ + return isinstance(result, Err) diff --git a/src/flow_res/result.py b/src/flow_res/result.py new file mode 100644 index 0000000..5e3b48a --- /dev/null +++ b/src/flow_res/result.py @@ -0,0 +1,191 @@ +"""Generic Result type, inspired by Rust's Result type.""" + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any, Never + + +@dataclass(frozen=True) +class Ok[T]: + """Represents a successful result.""" + + value: T + __match_args__ = ("value",) + + def map[V](self, f: Callable[[T], V]) -> "Ok[V]": + """ + Transform the Ok value using the provided function. + + Args: + f: Function to apply to the Ok value (T -> V) + + Returns: + Ok[V] containing the transformed value + + Example: + Ok(5).map(lambda x: x * 2) # Returns Ok(10) + """ + return Ok(f(self.value)) + + def and_then[V, F_Exc: Exception]( + self, f: Callable[[T], "Result[V, F_Exc]"] + ) -> "Result[V, F_Exc]": + """ + Apply a function that returns a Result, flattening the nested Result. + + This is the monadic bind operation (flatMap in some languages). + Enables chaining operations that may fail. + + Args: + f: Function that takes the Ok value and returns a new Result (T -> Result[V, F]) + + Returns: + Result[V, F] - The result of applying the function + + Example: + Ok(5).and_then(lambda x: Ok(x * 2)) # Returns Ok(10) + Ok(5).and_then(lambda x: Err(Exception("failed"))) # Returns Err(Exception("failed")) + """ + return f(self.value) + + def unwrap(self) -> T: + """ + Return the Ok value. + + Returns: + The wrapped value + """ + return self.value + + def expect(self, msg: str) -> T: + """ + Return the value. + + Compatible with Err.expect signature to allow usage without type guards, + but requires a message explaining why success is expected. + + Args: + msg: Message explaining why this Result is expected to be Ok (for consistency) + + Returns: + The wrapped value + """ + return self.value + + def map_err[F_Exc: Exception](self, f: Callable[[Any], F_Exc]) -> "Ok[T]": + """ + Pass through the Ok value unchanged. + + Args: + f: Function to map error (ignored for Ok). + + Returns: + Self (unchanged Ok value). + """ + return self + + def unwrap_or[T_Def](self, default: T_Def) -> T | T_Def: + """ + Return the Ok value. + + Args: + default: Value to return if Err. (Ignored for Ok) + + Returns: + The wrapped value. + """ + return self.value + + +@dataclass(frozen=True) +class Err[E: Exception = Exception]: + """Represents a failure result.""" + + error: E + __match_args__ = ("error",) + + def map[V](self, f: Callable[[Any], Any]) -> "Err[E]": + """ + Pass through the error unchanged (Railway-oriented programming pattern). + + Args: + f: Function that would be applied (ignored for Err) + + Returns: + Self (unchanged Err) + """ + return self + + def and_then[V, F_Exc: Exception]( + self, f: Callable[[Any], "Result[V, F_Exc]"] + ) -> "Err[E]": + """ + Pass through the error unchanged (Railway-oriented programming pattern). + + Since this is an Err, the function is not called and the error propagates. + + Args: + f: Function that would be applied (ignored for Err) + + Returns: + Self (unchanged Err) + + Example: + Err(Exception("error")).and_then(lambda x: Ok(x * 2)) # Returns Err(Exception("error")) + """ + return self + + def expect(self, msg: str) -> Never: + """ + Raise an exception with the provided message. + + Used when you want to assert that this Result should be Ok. + Requires a message explaining why the Result was expected to be Ok. + + Args: + msg: Message explaining why this was expected to be Ok + + Raises: + RuntimeError: Always raised with the provided message and underlying error + + Example: + result.expect("User should exist in database") + """ + raise RuntimeError(f"{msg}: {self.error}") from self.error + + def map_err[F_Exc: Exception](self, f: Callable[[E], F_Exc]) -> "Err[F_Exc]": + """ + Transform the Err value using the provided function. + + Args: + f: Function to apply to the Err value (E -> F). + + Returns: + Err[F] containing the transformed error. + """ + new_error = f(self.error) + return Err(new_error) + + def unwrap(self) -> Never: + """ + Raise the underlying error. + + This ensures that unwrap() can be called on Result (Ok | Err). + """ + raise self.error + + def unwrap_or[T_Def](self, default: T_Def) -> T_Def: + """ + Return the default value. + + Args: + default: Value to return. + + Returns: + The default value. + """ + return default + + +# The main Result type alias +type Result[T, E: Exception = Exception] = Ok[T] | Err[E] diff --git a/src/flow_res/safe.py b/src/flow_res/safe.py new file mode 100644 index 0000000..378917a --- /dev/null +++ b/src/flow_res/safe.py @@ -0,0 +1,74 @@ +from collections.abc import Callable +from functools import wraps +from typing import Any, overload + +from .result import Err, Ok, Result + + +@overload +def safe( + *exceptions: type[Exception], +) -> Callable[[Callable[..., Any]], Callable[..., Result[Any, Exception]]]: ... + + +@overload +def safe[T](__func: Callable[..., T]) -> Callable[..., Result[T, Exception]]: ... + + +def safe(*args: Any) -> Any: + """ + Decorator to convert a function that raises exceptions into one that returns a Result. + + Can be used without arguments to catch all Exceptions, or with specific exception types + to only catch those. + + Args: + *args: Either a single function (when used as @safe), or one or more Exception types + (when used as @safe(ValueError, TypeError)). + + Returns: + A wrapped function that returns Result[T, Exception] instead of raising + + Example: + @safe + def risky_operation(x: int) -> int: + if x < 0: + raise ValueError("Negative number") + return x * 2 + + @safe(ValueError, TypeError) + def parse_data(data: dict) -> int: + return int(data["value"]) + """ + if ( + len(args) == 1 + and callable(args[0]) + and not (isinstance(args[0], type) and issubclass(args[0], Exception)) + ): + # Called as @safe without parentheses + func = args[0] + exceptions = (Exception,) + + @wraps(func) + def wrapper(*w_args: Any, **w_kwargs: Any) -> Result[Any, Exception]: + try: + return Ok(func(*w_args, **w_kwargs)) + except exceptions as e: + return Err(e) + + return wrapper + + # Called as @safe(ValueError, TypeError) + exceptions = args if args else (Exception,) + + def decorator(func: Callable[..., Any]) -> Callable[..., Result[Any, Exception]]: + @wraps(func) + def wrapper(*w_args: Any, **w_kwargs: Any) -> Result[Any, Exception]: + try: + return Ok(func(*w_args, **w_kwargs)) + except exceptions as e: + return Err(e) + + return wrapper + + return decorator diff --git a/src/fluentres/__init__.py b/src/fluentres/__init__.py deleted file mode 100644 index 0005aa7..0000000 --- a/src/fluentres/__init__.py +++ /dev/null @@ -1,20 +0,0 @@ -from .core.combine import combine -from .core.combine_all import combine_all -from .core.is_err import is_err -from .core.is_ok import is_ok -from .core.safe import safe -from .error.aggregate_err import AggregateErr -from .result import AwaitableResult, Err, Ok, Result - -__all__ = [ - "Result", - "AwaitableResult", - "Ok", - "Err", - "is_err", - "is_ok", - "safe", - "AggregateErr", - "combine", - "combine_all", -] diff --git a/src/fluentres/core/combine.py b/src/fluentres/core/combine.py deleted file mode 100644 index 217f2ee..0000000 --- a/src/fluentres/core/combine.py +++ /dev/null @@ -1,160 +0,0 @@ -from collections.abc import Sequence -from typing import Any, overload - -from ..core.is_err import is_err -from ..result import Ok, Result - - -@overload -def combine[E: Exception](results: tuple[()]) -> Result[tuple[()], E]: ... - - -@overload -def combine[T1, E: Exception]( - results: tuple[Result[T1, E]], -) -> Result[tuple[T1], E]: ... - - -@overload -def combine[T1, T2, E: Exception]( - results: tuple[Result[T1, E], Result[T2, E]], -) -> Result[tuple[T1, T2], E]: ... - - -@overload -def combine[T1, T2, T3, E: Exception]( - results: tuple[Result[T1, E], Result[T2, E], Result[T3, E]], -) -> Result[tuple[T1, T2, T3], E]: ... - - -@overload -def combine[T1, T2, T3, T4, E: Exception]( - results: tuple[Result[T1, E], Result[T2, E], Result[T3, E], Result[T4, E]], -) -> Result[tuple[T1, T2, T3, T4], E]: ... - - -@overload -def combine[T1, T2, T3, T4, T5, E: Exception]( - results: tuple[ - Result[T1, E], - Result[T2, E], - Result[T3, E], - Result[T4, E], - Result[T5, E], - ], -) -> Result[tuple[T1, T2, T3, T4, T5], E]: ... - - -@overload -def combine[T1, T2, T3, T4, T5, T6, E: Exception]( - results: tuple[ - Result[T1, E], - Result[T2, E], - Result[T3, E], - Result[T4, E], - Result[T5, E], - Result[T6, E], - ], -) -> Result[tuple[T1, T2, T3, T4, T5, T6], E]: ... - - -@overload -def combine[T1, T2, T3, T4, T5, T6, T7, E: Exception]( - results: tuple[ - Result[T1, E], - Result[T2, E], - Result[T3, E], - Result[T4, E], - Result[T5, E], - Result[T6, E], - Result[T7, E], - ], -) -> Result[tuple[T1, T2, T3, T4, T5, T6, T7], E]: ... - - -@overload -def combine[T1, T2, T3, T4, T5, T6, T7, T8, E: Exception]( - results: tuple[ - Result[T1, E], - Result[T2, E], - Result[T3, E], - Result[T4, E], - Result[T5, E], - Result[T6, E], - Result[T7, E], - Result[T8, E], - ], -) -> Result[tuple[T1, T2, T3, T4, T5, T6, T7, T8], E]: ... - - -@overload -def combine[T1, T2, T3, T4, T5, T6, T7, T8, T9, E: Exception]( - results: tuple[ - Result[T1, E], - Result[T2, E], - Result[T3, E], - Result[T4, E], - Result[T5, E], - Result[T6, E], - Result[T7, E], - Result[T8, E], - Result[T9, E], - ], -) -> Result[tuple[T1, T2, T3, T4, T5, T6, T7, T8, T9], E]: ... - - -@overload -def combine[T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, E: Exception]( - results: tuple[ - Result[T1, E], - Result[T2, E], - Result[T3, E], - Result[T4, E], - Result[T5, E], - Result[T6, E], - Result[T7, E], - Result[T8, E], - Result[T9, E], - Result[T10, E], - ], -) -> Result[tuple[T1, T2, T3, T4, T5, T6, T7, T8, T9, T10], E]: ... - - -def combine[E: Exception]( - results: Sequence[Result[Any, E]], -) -> Result[tuple[Any, ...], E]: - """ - Aggregates a sequence of Result objects. - - If all results are Ok, returns an Ok containing a tuple of all success values. - If any result is an Err, returns the first Err encountered. - - Args: - results: A sequence of Result objects. - - Returns: - A single Result object. Ok(tuple of success values) or the first Err. - - Examples: - Heterogeneous types (use tuple): - >>> user_id: Result[int, Exception] = Ok(123) - >>> email: Result[str, Exception] = Ok("user@example.com") - >>> combine((user_id, email)) - Ok((123, "user@example.com")) - - Homogeneous types (use list or tuple): - >>> results = [Ok(1), Ok(2), Ok(3)] - >>> combine(results) - Ok((1, 2, 3)) - - Error handling (first error returned): - >>> results = [Ok(1), Err(Exception("error")), Ok(3)] - >>> combine(results) - Err(Exception("error")) - """ - values: list[Any] = [] - for r in results: - if is_err(r): - return r # Return the first error found - values.append(r.unwrap()) - return Ok(tuple(values)) diff --git a/src/fluentres/core/combine_all.py b/src/fluentres/core/combine_all.py deleted file mode 100644 index b29c09a..0000000 --- a/src/fluentres/core/combine_all.py +++ /dev/null @@ -1,157 +0,0 @@ -from collections.abc import Sequence -from typing import Any, overload - -from ..core.is_err import is_err -from ..error.aggregate_err import AggregateErr -from ..result import Err, Ok, Result - - -@overload -def combine_all[T1, E: Exception]( - results: tuple[Result[T1, E]], -) -> Result[tuple[T1], AggregateErr[E]]: ... - - -@overload -def combine_all[T1, T2, E: Exception]( - results: tuple[Result[T1, E], Result[T2, E]], -) -> Result[tuple[T1, T2], AggregateErr[E]]: ... - - -@overload -def combine_all[T1, T2, T3, E: Exception]( - results: tuple[Result[T1, E], Result[T2, E], Result[T3, E]], -) -> Result[tuple[T1, T2, T3], AggregateErr[E]]: ... - - -@overload -def combine_all[T1, T2, T3, T4, E: Exception]( - results: tuple[Result[T1, E], Result[T2, E], Result[T3, E], Result[T4, E]], -) -> Result[tuple[T1, T2, T3, T4], AggregateErr[E]]: ... - - -@overload -def combine_all[T1, T2, T3, T4, T5, E: Exception]( - results: tuple[ - Result[T1, E], - Result[T2, E], - Result[T3, E], - Result[T4, E], - Result[T5, E], - ], -) -> Result[tuple[T1, T2, T3, T4, T5], AggregateErr[E]]: ... - - -@overload -def combine_all[T1, T2, T3, T4, T5, T6, E: Exception]( - results: tuple[ - Result[T1, E], - Result[T2, E], - Result[T3, E], - Result[T4, E], - Result[T5, E], - Result[T6, E], - ], -) -> Result[tuple[T1, T2, T3, T4, T5, T6], AggregateErr[E]]: ... - - -@overload -def combine_all[T1, T2, T3, T4, T5, T6, T7, E: Exception]( - results: tuple[ - Result[T1, E], - Result[T2, E], - Result[T3, E], - Result[T4, E], - Result[T5, E], - Result[T6, E], - Result[T7, E], - ], -) -> Result[tuple[T1, T2, T3, T4, T5, T6, T7], AggregateErr[E]]: ... - - -@overload -def combine_all[T1, T2, T3, T4, T5, T6, T7, T8, E: Exception]( - results: tuple[ - Result[T1, E], - Result[T2, E], - Result[T3, E], - Result[T4, E], - Result[T5, E], - Result[T6, E], - Result[T7, E], - Result[T8, E], - ], -) -> Result[tuple[T1, T2, T3, T4, T5, T6, T7, T8], AggregateErr[E]]: ... - - -@overload -def combine_all[T1, T2, T3, T4, T5, T6, T7, T8, T9, E: Exception]( - results: tuple[ - Result[T1, E], - Result[T2, E], - Result[T3, E], - Result[T4, E], - Result[T5, E], - Result[T6, E], - Result[T7, E], - Result[T8, E], - Result[T9, E], - ], -) -> Result[tuple[T1, T2, T3, T4, T5, T6, T7, T8, T9], AggregateErr[E]]: ... - - -@overload -def combine_all[T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, E: Exception]( - results: tuple[ - Result[T1, E], - Result[T2, E], - Result[T3, E], - Result[T4, E], - Result[T5, E], - Result[T6, E], - Result[T7, E], - Result[T8, E], - Result[T9, E], - Result[T10, E], - ], -) -> Result[tuple[T1, T2, T3, T4, T5, T6, T7, T8, T9, T10], AggregateErr[E]]: ... - - -def combine_all[E: Exception]( - results: Sequence[Result[Any, E]], -) -> Result[tuple[Any, ...], AggregateErr[E]]: - """ - Aggregates a tuple of Results, collecting all errors. - - If all results are Ok, returns an Ok containing a tuple of all success values. - If any result is an Err, returns an Err containing an AggregateErr with all errors. - - This is a "fail complete" strategy - useful for validation where you want to show - all errors to the user at once, rather than one at a time. - - Args: - results: A tuple of Result objects. - - Returns: - Ok(tuple of success values) if all succeed, - or Err(AggregateErr(list of errors)) if any fail. - - Example: - >>> from app.core.result import combine_all, Ok, Err - >>> results = (Ok(1), Err(Exception("error1")), Ok(3), Err(Exception("error2"))) - >>> combined = combine_all(results) - >>> # Returns Err(AggregateErr([Exception("error1"), Exception("error2")])) - """ - values: list[Any] = [] - errors: list[E] = [] - - for r in results: - if is_err(r): - errors.append(r.error) - else: - values.append(r.unwrap()) - - if errors: - return Err(AggregateErr(errors)) - - return Ok(tuple(values)) diff --git a/src/fluentres/core/is_err.py b/src/fluentres/core/is_err.py deleted file mode 100644 index 3368109..0000000 --- a/src/fluentres/core/is_err.py +++ /dev/null @@ -1,13 +0,0 @@ -from typing_extensions import TypeIs - -from ..result import Err, Result - - -def is_err[T, E: Exception](result: Result[T, E]) -> TypeIs[Err[E]]: - """ - Return true if the result is an error. - - Uses TypeIs for bidirectional type narrowing - when this returns False, - the type checker knows the result must be Ok. - """ - return isinstance(result, Err) diff --git a/src/fluentres/core/is_ok.py b/src/fluentres/core/is_ok.py deleted file mode 100644 index 240f343..0000000 --- a/src/fluentres/core/is_ok.py +++ /dev/null @@ -1,13 +0,0 @@ -from typing_extensions import TypeIs - -from ..result import Ok, Result - - -def is_ok[T, E: Exception](result: Result[T, E]) -> TypeIs[Ok[T]]: - """ - Return true if the result is ok. - - Uses TypeIs for bidirectional type narrowing - when this returns False, - the type checker knows the result must be Err. - """ - return isinstance(result, Ok) diff --git a/src/fluentres/core/safe.py b/src/fluentres/core/safe.py deleted file mode 100644 index 6f37704..0000000 --- a/src/fluentres/core/safe.py +++ /dev/null @@ -1,39 +0,0 @@ -from collections.abc import Callable -from functools import wraps -from typing import Any - -from ..result import Err, Ok, Result - - -def safe[T](func: Callable[..., T]) -> Callable[..., Result[T, Exception]]: - """ - Decorator to convert a function that raises exceptions into one that returns a Result. - - This is inspired by the @safe decorator from dry-python/returns. - It wraps any exceptions raised by the decorated function into an Err. - - Args: - func: Function that may raise exceptions - - Returns: - A wrapped function that returns Result[T, Exception] instead of raising - - Example: - @safe - def risky_operation(x: int) -> int: - if x < 0: - raise ValueError("Negative number") - return x * 2 - - result = risky_operation(-5) # Returns Err(ValueError("Negative number")) - result = risky_operation(5) # Returns Ok(10) - """ - - @wraps(func) - def wrapper(*args: Any, **kwargs: Any) -> Result[T, Exception]: - try: - return Ok(func(*args, **kwargs)) - except Exception as e: - return Err(e) - - return wrapper diff --git a/src/fluentres/error/aggregate_err.py b/src/fluentres/error/aggregate_err.py deleted file mode 100644 index e982aa4..0000000 --- a/src/fluentres/error/aggregate_err.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Generic Result type, inspired by Rust's Result type.""" - -from dataclasses import dataclass - - -@dataclass(frozen=True) -class AggregateErr[E](Exception): - """ - Represents multiple errors collected during a validation process. - - Used primarily with combine_all to collect all validation errors - so users can see all issues at once, rather than one at a time. - """ - - exceptions: list[E] - - def __str__(self) -> str: - """Return a string representation of the aggregate error.""" - return f"Multiple errors occurred ({len(self.exceptions)}): {self.exceptions}" diff --git a/src/fluentres/result.py b/src/fluentres/result.py deleted file mode 100644 index 0a3bd55..0000000 --- a/src/fluentres/result.py +++ /dev/null @@ -1,312 +0,0 @@ -"""Generic Result type, inspired by Rust's Result type.""" - -from collections.abc import Awaitable, Callable, Coroutine, Generator -from dataclasses import dataclass -from typing import Any, Never, TypeVar - -T = TypeVar("T") # Success type -E = TypeVar("E", bound=Exception) # Error type -U = TypeVar("U") # Success type for map/and_then - - -@dataclass(frozen=True) -class Ok[T]: - """Represents a successful result.""" - - value: T - - def map[V](self, f: Callable[[T], V]) -> "Ok[V]": - """ - Transform the Ok value using the provided function. - - Args: - f: Function to apply to the Ok value (T -> V) - - Returns: - Ok[V] containing the transformed value - - Example: - Ok(5).map(lambda x: x * 2) # Returns Ok(10) - """ - return Ok(f(self.value)) - - def and_then[V, F: Exception]( - self, f: Callable[[T], "Result[V, F]"] - ) -> "Result[V, F]": - """ - Apply a function that returns a Result, flattening the nested Result. - - This is the monadic bind operation (flatMap in some languages). - Enables chaining operations that may fail. - - Args: - f: Function that takes the Ok value and returns a new Result (T -> Result[V, F]) - - Returns: - Result[V, F] - The result of applying the function - - Example: - Ok(5).and_then(lambda x: Ok(x * 2)) # Returns Ok(10) - Ok(5).and_then(lambda x: Err(Exception("failed"))) # Returns Err(Exception("failed")) - """ - return f(self.value) - - def unwrap(self) -> T: - """ - Return the Ok value. - - Returns: - The wrapped value - """ - return self.value - - def expect(self, msg: str) -> T: - """ - Return the value. - - Compatible with Err.expect signature to allow usage without type guards, - but requires a message explaining why success is expected. - - Args: - msg: Message explaining why this Result is expected to be Ok (for consistency) - - Returns: - The wrapped value - """ - return self.value - - def map_err[F: Exception](self, f: Callable[[Any], F]) -> "Ok[T]": - """ - Pass through the Ok value unchanged. - - Args: - f: Function to map error (ignored for Ok). - - Returns: - Self (unchanged Ok value). - """ - return self - - -@dataclass(frozen=True) -class Err[E: Exception]: - """Represents a failure result.""" - - error: E - - def map[V](self, f: Callable[[Any], Any]) -> "Err[E]": - """ - Pass through the error unchanged (Railway-oriented programming pattern). - - Args: - f: Function that would be applied (ignored for Err) - - Returns: - Self (unchanged Err) - """ - return self - - def and_then[V, F: Exception](self, f: Callable[[Any], "Result[V, F]"]) -> "Err[E]": - """ - Pass through the error unchanged (Railway-oriented programming pattern). - - Since this is an Err, the function is not called and the error propagates. - - Args: - f: Function that would be applied (ignored for Err) - - Returns: - Self (unchanged Err) - - Example: - Err(Exception("error")).and_then(lambda x: Ok(x * 2)) # Returns Err(Exception("error")) - """ - return self - - def expect(self, msg: str) -> Never: - """ - Raise an exception with the provided message. - - Used when you want to assert that this Result should be Ok. - Requires a message explaining why the Result was expected to be Ok. - - Args: - msg: Message explaining why this was expected to be Ok - - Raises: - RuntimeError: Always raised with the provided message and underlying error - - Example: - result.expect("User should exist in database") - """ - raise RuntimeError(f"{msg}: {self.error}") from self.error - - def map_err[F: Exception](self, f: Callable[[E], F]) -> "Err[F]": - """ - Transform the Err value using the provided function. - - Args: - f: Function to apply to the Err value (E -> F). - - Returns: - Err[F] containing the transformed error. - """ - return Err(f(self.error)) - - def unwrap(self) -> Never: - """ - Raise the underlying error. - - This ensures that unwrap() can be called on Result (Ok | Err). - """ - raise self.error - - def unwrap_or[T](self, default: T) -> T: - """ - Return the default value. - - Args: - default: Value to return. - - Returns: - The default value. - """ - return default - - -Result = Ok[T] | Err[E] - - -class AwaitableResult[T, E: Exception]: - """ - Awaitable wrapper for Result that enables method chaining before await. - - This allows elegant syntax like: - message = await Mediator.send_async(query).map(...).unwrap() - """ - - def __init__(self, coro: Coroutine[Any, Any, Result[T, E]]) -> None: - """ - Initialize with a coroutine that returns a Result. - - Args: - coro: Coroutine that will return Result[T, E] - """ - self._coro = coro - - def __repr__(self) -> str: - """Return a helpful representation for debugging. - - Shows the wrapped coroutine's repr to aid debugging without awaiting. - """ - try: - coro_repr = repr(self._coro) - except Exception: - coro_repr = "" - return f"ResultAwaitable(coro={coro_repr})" - - def __await__(self) -> Generator[Any, None, Result[T, E]]: - """Make this object awaitable, returning the underlying Result.""" - return self._coro.__await__() - - def map(self, f: Callable[[T], U]) -> "AwaitableResult[U, E]": - """ - Transform the Ok value using the provided function. - - This method chains onto the coroutine, creating a new coroutine that: - 1. Awaits the current Result - 2. Applies .map() to transform the value - 3. Returns the transformed Result - - Args: - f: Function to apply to the Ok value (T -> U) - - Returns: - ResultAwaitable[U, E] wrapping the transformed result - - Example: - user_id = await Mediator.send_async(cmd).map(lambda v: v.user_id) - """ - - async def mapped() -> Result[U, E]: - _result: Result[T, E] = await self - return _result.map(f) - - return AwaitableResult(mapped()) - - def and_then( - self, f: Callable[[T], Awaitable[Result[U, E]]] - ) -> "AwaitableResult[U, E]": - """ - Apply an async function that returns a Result, flattening the nested Result. - - This enables chaining async operations that may fail. - - Args: - f: Async function that takes the Ok value and returns a new Result - (T -> Awaitable[Result[U, E]]) - - Returns: - ResultAwaitable[U, E] wrapping the result of applying the function - - Example: - await ( - Mediator.send_async(create_cmd) - .and_then(lambda result: Mediator.send_async(GetQuery(result.id))) - .map(lambda value: format_message(value)) - .unwrap() - ) - """ - - async def chained() -> Result[U, E]: - _result: Result[T, E] = await self - match _result: - case Ok(value): - return await f(value) - case Err(): - return _result - - return AwaitableResult(chained()) - - def unwrap(self) -> Awaitable[T]: - """ - Return the Ok value or raise the Err as an exception. - - This is a terminal operation that unwraps the Result. - - Returns: - Awaitable[T] that will return the value or raise the error - - Example: - message = await Mediator.send_async(query).map(...).unwrap() - """ - - async def unwrapped() -> T: - _result: Result[T, E] = await self - return _result.unwrap() - - return unwrapped() - - def map_err[F: Exception](self, f: Callable[[E], F]) -> "AwaitableResult[T, F]": - """ - Transform the error value using the provided function asynchronously. - - Args: - f: Function to apply to the error value (E -> F) - - Returns: - ResultAwaitable[T, F] wrapping the result with transformed error type - - Example: - await ( - Mediator.send_async(cmd) - .map_err(lambda e: DatabaseError(f"DB error: {e}")) - .unwrap() - ) - """ - - async def mapped() -> Result[T, F]: - _result: Result[T, E] = await self - return _result.map_err(f) - - return AwaitableResult(mapped()) diff --git a/tests/core/test_safe.py b/tests/core/test_safe.py deleted file mode 100644 index e92d213..0000000 --- a/tests/core/test_safe.py +++ /dev/null @@ -1,28 +0,0 @@ -from fluentres import Err, Ok, safe - - -def test_safe_decorator_wraps_exception() -> None: - """Test that @safe decorator converts exceptions to Err.""" - - @safe - def risky_function(x: int) -> int: - if x < 0: - raise ValueError("Negative number") - return x * 2 - - result = risky_function(-5) - assert isinstance(result, Err) - assert isinstance(result.error, ValueError) - assert "Negative number" in str(result.error) - - -def test_safe_decorator_returns_ok() -> None: - """Test that @safe decorator returns Ok for successful execution.""" - - @safe - def safe_function(x: int) -> int: - return x * 2 - - result = safe_function(5) - assert isinstance(result, Ok) - assert result.value == 10 diff --git a/tests/error/test_aggregate_err.py b/tests/error/test_aggregate_err.py deleted file mode 100644 index d64fb0b..0000000 --- a/tests/error/test_aggregate_err.py +++ /dev/null @@ -1,12 +0,0 @@ -from fluentres import AggregateErr -from tests.testutils.error import ErrType, TestErr - - -def test_aggregate_err_str() -> None: - """Test AggregateErr string representation.""" - - error1 = TestErr(type=ErrType.NOT_FOUND, message="First error") - error2 = TestErr(type=ErrType.VALIDATION_ERROR, message="Second error") - aggregate = AggregateErr([error1, error2]) - - assert "Multiple errors occurred (2)" in str(aggregate) diff --git a/tests/test_async_result_decorator.py b/tests/test_async_result_decorator.py new file mode 100644 index 0000000..2096d95 --- /dev/null +++ b/tests/test_async_result_decorator.py @@ -0,0 +1,133 @@ +"""Tests for the @async_result decorator.""" + +import asyncio + +import pytest + +from flow_res import Err, Ok, Result, async_result + + +@async_result +async def successful_operation(value: int) -> Result[int, ValueError]: + """An async operation that succeeds.""" + await asyncio.sleep(0.001) + return Ok(value * 2) + + +@async_result +async def failing_operation(value: int) -> Result[int, ValueError]: + """An async operation that fails.""" + await asyncio.sleep(0.001) + if value < 0: + return Err(ValueError("Value cannot be negative")) + return Ok(value) + + +@pytest.mark.asyncio +async def test_async_result_decorator_returns_awaitable_result(): + """Test that @async_result decorator returns AwaitableResult.""" + result = successful_operation(5) + # The decorated function returns AwaitableResult, which has .map() method + assert hasattr(result, "map") + assert hasattr(result, "and_then") + + +@pytest.mark.asyncio +async def test_async_result_decorator_map_success(): + """Test that .map() works on successful async operation.""" + result = await successful_operation(5).map(lambda x: x + 1) + assert result.unwrap() == 11 + + +@pytest.mark.asyncio +async def test_async_result_decorator_map_error(): + """Test that .map() passes through errors.""" + result = await failing_operation(-1).map(lambda x: x + 1) + assert isinstance(result, Err) + assert "cannot be negative" in str(result.error) + + +@pytest.mark.asyncio +async def test_async_result_decorator_map_chain(): + """Test chaining multiple .map() calls.""" + result = await ( + successful_operation(3) + .map(lambda x: x + 1) + .map(lambda x: x * 2) + .map(lambda x: str(x)) + ) + assert result.unwrap() == "14" + + +@pytest.mark.asyncio +async def test_async_result_decorator_and_then_success(): + """Test that .and_then() works with async operations.""" + + @async_result + async def chain_operation(x: int) -> Result[int, ValueError]: + await asyncio.sleep(0.001) + return Ok(x * 10) + + result = await successful_operation(5).and_then(lambda x: chain_operation(x)) + assert result.unwrap() == 100 + + +@pytest.mark.asyncio +async def test_async_result_decorator_and_then_error_propagation(): + """Test that .and_then() propagates errors.""" + + @async_result + async def chain_operation(x: int) -> Result[int, ValueError]: + await asyncio.sleep(0.001) + return Ok(x * 10) + + result = await failing_operation(-1).and_then(lambda x: chain_operation(x)) + assert isinstance(result, Err) + + +@pytest.mark.asyncio +async def test_async_result_decorator_map_err(): + """Test that .map_err() works for error transformation.""" + result = await failing_operation(-1).map_err( + lambda e: RuntimeError(f"Transformed: {e}") + ) + assert isinstance(result, Err) + assert "Transformed" in str(result.error) + + +@pytest.mark.asyncio +async def test_async_result_decorator_complex_chain(): + """Test complex chaining of multiple operations.""" + + @async_result + async def fetch_user(user_id: int) -> Result[dict, ValueError]: + await asyncio.sleep(0.001) + if user_id <= 0: + return Err(ValueError("Invalid user ID")) + return Ok({"id": user_id, "name": f"User{user_id}"}) + + @async_result + async def fetch_posts(user_id: int) -> Result[list[str], ValueError]: + await asyncio.sleep(0.001) + return Ok([f"Post {i}" for i in range(user_id)]) + + result = await ( + fetch_user(3) + .map(lambda u: u["id"]) + .and_then(lambda uid: fetch_posts(uid)) + .map(lambda posts: len(posts)) + ) + assert result.unwrap() == 3 + + +@pytest.mark.asyncio +async def test_async_result_decorator_with_match_expression(): + """Test that decorated functions work with pattern matching.""" + from flow_res import Ok as OkType + + result = await successful_operation(5) + match result: + case OkType(value): + assert value == 10 + case _: + pytest.fail("Expected Ok result") diff --git a/tests/test_awaitable_result.py b/tests/test_awaitable_result.py index 11fb0bc..dd6060e 100644 --- a/tests/test_awaitable_result.py +++ b/tests/test_awaitable_result.py @@ -2,7 +2,7 @@ import pytest -from fluentres import AwaitableResult, Err, Ok, Result +from flow_res import AwaitableResult, Err, Ok, Result from tests.testutils.error import ErrType, TestErr @@ -20,7 +20,7 @@ async def async_add_ten(x: int) -> Result[int, TestErr]: async def test_result_awaitable_await_ok() -> None: """Test that ResultAwaitable can be awaited to get Result.""" - async def get_result() -> Ok[int]: + async def get_result() -> Result[int, Exception]: return Ok(42) awaitable = AwaitableResult(get_result()) @@ -35,7 +35,7 @@ async def test_result_awaitable_await_err() -> None: """Test that ResultAwaitable can be awaited to get Err.""" error = TestErr(type=ErrType.NOT_FOUND, message="Not found") - async def get_result() -> Err[TestErr]: + async def get_result() -> Result[int, TestErr]: return Err(error) awaitable = AwaitableResult(get_result()) @@ -49,7 +49,7 @@ async def get_result() -> Err[TestErr]: async def test_result_awaitable_map_ok() -> None: """Test that ResultAwaitable.map transforms Ok value.""" - async def get_result() -> Ok[int]: + async def get_result() -> Result[int, TestErr]: return Ok(5) result = await AwaitableResult(get_result()).map(lambda x: x * 2) @@ -63,7 +63,7 @@ async def test_result_awaitable_map_err() -> None: """Test that ResultAwaitable.map passes through Err.""" error = TestErr(type=ErrType.VALIDATION_ERROR, message="Invalid") - async def get_result() -> Err[TestErr]: + async def get_result() -> Result[int, TestErr]: return Err(error) result = await AwaitableResult(get_result()).map(lambda x: x * 2) # type: ignore[arg-type] @@ -76,7 +76,7 @@ async def get_result() -> Err[TestErr]: async def test_result_awaitable_map_chain() -> None: """Test that multiple map calls can be chained.""" - async def get_result() -> Ok[int]: + async def get_result() -> Result[int, TestErr]: return Ok(5) result = await ( @@ -94,7 +94,7 @@ async def get_result() -> Ok[int]: async def test_result_awaitable_unwrap_ok() -> None: """Test that unwrap returns value for Ok.""" - async def get_result() -> Ok[int]: + async def get_result() -> Result[int, TestErr]: return Ok(42) value = await AwaitableResult(get_result()).unwrap() @@ -107,7 +107,7 @@ async def test_result_awaitable_unwrap_err() -> None: """Test that unwrap raises exception for Err.""" error = TestErr(type=ErrType.NOT_FOUND, message="Not found") - async def get_result() -> Err[TestErr]: + async def get_result() -> Result[int, TestErr]: return Err(error) with pytest.raises(TestErr) as exc_info: @@ -120,7 +120,7 @@ async def get_result() -> Err[TestErr]: async def test_result_awaitable_full_chain() -> None: """Test complete chain: map -> map -> unwrap.""" - async def get_result() -> Ok[int]: + async def get_result() -> Result[int, TestErr]: return Ok(10) value = await ( @@ -135,7 +135,7 @@ async def test_result_awaitable_full_chain_with_err() -> None: """Test complete chain with Err raises exception.""" error = TestErr(type=ErrType.UNEXPECTED, message="Error") - async def get_result() -> Err[TestErr]: + async def get_result() -> Result[int, TestErr]: return Err(error) with pytest.raises(TestErr) as exc_info: @@ -314,3 +314,22 @@ async def get_error() -> Result[int, Exception]: assert isinstance(result, Err) assert str(result.error) == "Error: failure" + + +def sync_double(x: int) -> Result[int, TestErr]: + """Helper sync function that returns Ok with doubled value.""" + return Ok(x * 2) + + +@pytest.mark.anyio +async def test_result_awaitable_and_then_sync() -> None: + """Test that ResultAwaitable.and_then applies sync function for Ok.""" + + async def get_initial() -> Result[int, TestErr]: + return Ok(5) + + result = AwaitableResult(get_initial()) + final = await result.and_then(sync_double) + + assert isinstance(final, Ok) + assert final.value == 10 diff --git a/tests/core/test_combine.py b/tests/test_combine.py similarity index 79% rename from tests/core/test_combine.py rename to tests/test_combine.py index 5f6f1ec..ef42ab8 100644 --- a/tests/core/test_combine.py +++ b/tests/test_combine.py @@ -1,10 +1,11 @@ -from fluentres import Err, Ok, Result +from flow_res import Err, Ok, Result + from tests.testutils.error import ErrType, TestErr def test_combine_all_ok() -> None: """Test that combine returns Ok with tuple of values when all are Ok.""" - from fluentres import combine + from flow_res import combine results = (Ok(1), Ok(2), Ok(3)) combined = combine(results) @@ -15,11 +16,16 @@ def test_combine_all_ok() -> None: def test_combine_with_err() -> None: """Test that combine returns first Err when any result is Err.""" - from fluentres import combine + from flow_res import combine error1 = TestErr(type=ErrType.NOT_FOUND, message="First error") error2 = TestErr(type=ErrType.VALIDATION_ERROR, message="Second error") - results = (Ok(1), Err(error1), Ok(3), Err(error2)) + results = ( + Ok(1), + Err(error1), + Ok(3), + Err(error2), + ) combined = combine(results) assert isinstance(combined, Err) @@ -28,7 +34,7 @@ def test_combine_with_err() -> None: def test_combine_empty_sequence() -> None: """Test that combine returns Ok with empty tuple for empty sequence.""" - from fluentres import combine + from flow_res import combine results: tuple[Result[int, TestErr], ...] = () combined = combine(results) @@ -39,7 +45,7 @@ def test_combine_empty_sequence() -> None: def test_combine_single_ok() -> None: """Test that combine returns Ok with single-element tuple for one Ok.""" - from fluentres import combine + from flow_res import combine results = (Ok(42),) combined = combine(results) @@ -50,7 +56,7 @@ def test_combine_single_ok() -> None: def test_combine_single_err() -> None: """Test that combine returns the Err when given a single Err.""" - from fluentres import combine + from flow_res import combine error = TestErr(type=ErrType.NOT_FOUND, message="Not found") results = (Err(error),) @@ -62,12 +68,16 @@ def test_combine_single_err() -> None: def test_combine_multiple_errors_returns_first() -> None: """Test that combine returns first Err when multiple errors exist.""" - from fluentres import combine + from flow_res import combine error1 = TestErr(type=ErrType.NOT_FOUND, message="First") error2 = TestErr(type=ErrType.VALIDATION_ERROR, message="Second") error3 = TestErr(type=ErrType.UNEXPECTED, message="Third") - results = (Err(error1), Err(error2), Err(error3)) + results = ( + Err(error1), + Err(error2), + Err(error3), + ) combined = combine(results) assert isinstance(combined, Err) @@ -77,7 +87,7 @@ def test_combine_multiple_errors_returns_first() -> None: def test_combine_preserves_string_type() -> None: """Test that combine preserves type of Ok values (string example).""" - from fluentres import combine + from flow_res import combine results = (Ok("hello"), Ok("world"), Ok("test")) combined = combine(results) @@ -88,7 +98,7 @@ def test_combine_preserves_string_type() -> None: def test_combine_error_after_ok_values() -> None: """Test that combine returns first Err even after Ok values.""" - from fluentres import combine + from flow_res import combine error = TestErr(type=ErrType.VALIDATION_ERROR, message="Failed") results = (Ok(1), Ok(2), Err(error), Ok(4)) @@ -100,10 +110,10 @@ def test_combine_error_after_ok_values() -> None: def test_combine_heterogeneous_two_types() -> None: """Test that combine handles two different types correctly.""" - from fluentres import combine + from flow_res import combine - user_id: Result[int, TestErr] = Ok(123) - email: Result[str, TestErr] = Ok("user@example.com") + user_id = Ok(123) + email = Ok("user@example.com") combined = combine((user_id, email)) @@ -116,11 +126,11 @@ def test_combine_heterogeneous_two_types() -> None: def test_combine_heterogeneous_three_types() -> None: """Test that combine handles three different types correctly.""" - from fluentres import combine + from flow_res import combine - name: Result[str, TestErr] = Ok("Alice") - age: Result[int, TestErr] = Ok(30) - active: Result[bool, TestErr] = Ok(True) + name = Ok("Alice") + age = Ok(30) + active = Ok(True) combined = combine((name, age, active)) @@ -134,12 +144,12 @@ def test_combine_heterogeneous_three_types() -> None: def test_combine_heterogeneous_with_error() -> None: """Test that combine returns first error with heterogeneous types.""" - from fluentres import combine + from flow_res import combine error = TestErr(type=ErrType.VALIDATION_ERROR, message="Invalid age") - name: Result[str, TestErr] = Ok("Bob") - age: Result[int, TestErr] = Err(error) - active: Result[bool, TestErr] = Ok(False) + name = Ok("Bob") + age = Err(error) + active = Ok(False) combined = combine((name, age, active)) @@ -149,7 +159,7 @@ def test_combine_heterogeneous_with_error() -> None: def test_combine_homogeneous_list_still_works() -> None: """Test that combine still works for homogeneous lists (backward compat).""" - from fluentres import combine + from flow_res import combine results = (Ok(1), Ok(2), Ok(3), Ok(4)) combined = combine(results) @@ -160,12 +170,12 @@ def test_combine_homogeneous_list_still_works() -> None: def test_combine_complex_heterogeneous_types() -> None: """Test combine with complex heterogeneous types.""" - from fluentres import combine + from flow_res import combine - user_id: Result[int, TestErr] = Ok(456) - email: Result[str, TestErr] = Ok("test@example.com") - age: Result[int, TestErr] = Ok(25) - is_active: Result[bool, TestErr] = Ok(True) + user_id = Ok(456) + email = Ok("test@example.com") + age = Ok(25) + is_active = Ok(True) combined = combine((user_id, email, age, is_active)) diff --git a/tests/core/test_combine_all.py b/tests/test_combine_all.py similarity index 82% rename from tests/core/test_combine_all.py rename to tests/test_combine_all.py index bc16ea5..51d47b6 100644 --- a/tests/core/test_combine_all.py +++ b/tests/test_combine_all.py @@ -1,10 +1,9 @@ -from fluentres import Err, Ok, Result +from flow_res import Err, Ok, Result, combine_all from tests.testutils.error import ErrType, TestErr def test_combine_all_all_ok() -> None: """Test that combine_all returns Ok with tuple of values when all are Ok.""" - from fluentres import combine_all results = (Ok(1), Ok(2), Ok(3)) combined = combine_all(results) @@ -15,16 +14,21 @@ def test_combine_all_all_ok() -> None: def test_combine_all_collects_all_errors() -> None: """Test that combine_all collects ALL errors.""" - from fluentres import AggregateErr, combine_all error1 = TestErr(type=ErrType.NOT_FOUND, message="First") error2 = TestErr(type=ErrType.VALIDATION_ERROR, message="Second") error3 = TestErr(type=ErrType.UNEXPECTED, message="Third") - results = (Err(error1), Ok(2), Err(error2), Ok(4), Err(error3)) + results = ( + Err(error1), + Ok(2), + Err(error2), + Ok(4), + Err(error3), + ) combined = combine_all(results) assert isinstance(combined, Err) - assert isinstance(combined.error, AggregateErr) + assert isinstance(combined.error, ExceptionGroup) assert len(combined.error.exceptions) == 3 assert combined.error.exceptions[0] is error1 assert combined.error.exceptions[1] is error2 @@ -33,7 +37,6 @@ def test_combine_all_collects_all_errors() -> None: def test_combine_all_heterogeneous_types() -> None: """Test that combine_all handles heterogeneous types correctly.""" - from fluentres import combine_all user_id: Result[int, TestErr] = Ok(123) email: Result[str, TestErr] = Ok("test@example.com") @@ -47,7 +50,6 @@ def test_combine_all_heterogeneous_types() -> None: def test_combine_all_heterogeneous_with_errors() -> None: """Test that combine_all collects all errors with heterogeneous types.""" - from fluentres import AggregateErr, combine_all error1 = TestErr(type=ErrType.NOT_FOUND, message="Error 1") error2 = TestErr(type=ErrType.VALIDATION_ERROR, message="Error 2") @@ -59,5 +61,5 @@ def test_combine_all_heterogeneous_with_errors() -> None: combined = combine_all((user_id, email, age)) assert isinstance(combined, Err) - assert isinstance(combined.error, AggregateErr) + assert isinstance(combined.error, ExceptionGroup) assert len(combined.error.exceptions) == 2 diff --git a/tests/core/test_is_err.py b/tests/test_is_err.py similarity index 91% rename from tests/core/test_is_err.py rename to tests/test_is_err.py index 4403dda..37cf630 100644 --- a/tests/core/test_is_err.py +++ b/tests/test_is_err.py @@ -1,4 +1,5 @@ -from fluentres import Err, Ok, Result, is_err +from flow_res import Result, Ok, Err, is_err + from tests.testutils.error import ErrType, TestErr diff --git a/tests/core/test_is_ok.py b/tests/test_is_ok.py similarity index 91% rename from tests/core/test_is_ok.py rename to tests/test_is_ok.py index f66908f..f0a06e1 100644 --- a/tests/core/test_is_ok.py +++ b/tests/test_is_ok.py @@ -1,4 +1,5 @@ -from fluentres import Err, Ok, Result, is_ok +from flow_res import Result, Ok, Err, is_ok + from tests.testutils.error import ErrType, TestErr diff --git a/tests/test_result.py b/tests/test_result.py index 99b989a..bd7c30e 100644 --- a/tests/test_result.py +++ b/tests/test_result.py @@ -1,14 +1,14 @@ """Tests for Result type functional methods.""" import pytest +from flow_res import Err, Ok, Result -from fluentres import Err, Ok, Result, is_ok from tests.testutils.error import ErrType, TestErr def test_ok_map_transforms_value() -> None: """Test that Ok.map transforms the value.""" - result: Ok[int] = Ok(5) + result = Ok(5) mapped = result.map(lambda x: x * 2) assert isinstance(mapped, Ok) @@ -17,7 +17,7 @@ def test_ok_map_transforms_value() -> None: def test_ok_map_changes_type() -> None: """Test that Ok.map can change the type of the value.""" - result: Ok[int] = Ok(42) + result = Ok(42) mapped = result.map(lambda x: f"Number: {x}") assert isinstance(mapped, Ok) @@ -37,7 +37,7 @@ def test_err_map_passes_through() -> None: def test_ok_unwrap_returns_value() -> None: """Test that Ok.unwrap returns the value.""" - result: Ok[int] = Ok(42) + result = Ok(42) value = result.unwrap() assert value == 42 @@ -79,7 +79,7 @@ def test_err_expect_raises_exception() -> None: def test_map_chain() -> None: """Test that multiple map calls can be chained.""" - result: Ok[int] = Ok(5) + result = Ok(5) final = ( result.map(lambda x: x * 2).map(lambda x: x + 3).map(lambda x: f"Result: {x}") ) @@ -102,7 +102,7 @@ def test_map_chain_with_err() -> None: def test_map_unwrap_chain() -> None: """Test that map and unwrap can be chained together.""" - result: Ok[int] = Ok(10) + result = Ok(10) value = result.map(lambda x: x * 5).map(lambda x: x + 10).unwrap() assert value == 60 @@ -136,24 +136,9 @@ def test_usecase_error_str() -> None: assert str(error) == "Resource not found" -def test_is_ok_returns_true_for_ok() -> None: - """Test that is_ok returns True for Ok result.""" - - result = Ok(42) - assert is_ok(result) is True - - -def test_is_ok_returns_false_for_err() -> None: - """Test that is_ok returns False for Err result.""" - - error = TestErr(type=ErrType.NOT_FOUND, message="Not found") - result: Result[int, TestErr] = Err(error) - assert is_ok(result) is False - - def test_ok_and_then_returns_new_result() -> None: """Test that Ok.and_then applies the function and returns the new Result.""" - result: Ok[int] = Ok(5) + result = Ok(5) new_result = result.and_then(lambda x: Ok(x * 2)) assert isinstance(new_result, Ok) @@ -182,7 +167,7 @@ def test_err_and_then_passes_through() -> None: def test_and_then_chain() -> None: """Test that multiple and_then calls can be chained.""" - result: Ok[int] = Ok(2) + result = Ok(2) final = ( result.and_then(lambda x: Ok(x * 3)) .and_then(lambda x: Ok(x + 10)) @@ -195,7 +180,7 @@ def test_and_then_chain() -> None: def test_and_then_chain_with_error() -> None: """Test that error in and_then chain stops further processing.""" - result: Ok[int] = Ok(2) + result = Ok(2) error = TestErr(type=ErrType.UNEXPECTED, message="Error occurred") final = ( result.and_then(lambda x: Ok(x * 3)) @@ -307,11 +292,10 @@ def test_map_err_with_map_chain() -> None: def test_map_err_error_wrapping() -> None: """Test map_err for wrapping exceptions (use case from spec).""" - def find_record(record_id: int) -> Err[KeyError]: + def find_record(record_id: int) -> Result[str, KeyError]: """Simulate a function that returns KeyError.""" return Err(KeyError(f"ID {record_id}")) - # Transform KeyError to UseCaseError result = find_record(999).map_err( lambda e: TestErr(type=ErrType.NOT_FOUND, message=f"Record missing: {e}") ) diff --git a/tests/test_safe.py b/tests/test_safe.py new file mode 100644 index 0000000..02bbabe --- /dev/null +++ b/tests/test_safe.py @@ -0,0 +1,72 @@ +from flow_res import Err, Ok, safe +import pytest + + +def test_safe_decorator_wraps_exception() -> None: + """Test that @safe decorator converts exceptions to Err.""" + + @safe + def risky_function(x: int) -> int: + if x < 0: + raise ValueError("Negative number") + return x * 2 + + result = risky_function(-5) + assert isinstance(result, Err) + assert isinstance(result.error, ValueError) + assert "Negative number" in str(result.error) + + +def test_safe_decorator_returns_ok() -> None: + """Test that @safe decorator returns Ok for successful execution.""" + + @safe + def safe_function(x: int) -> int: + return x * 2 + + result = safe_function(5) + assert isinstance(result, Ok) + assert result.value == 10 + + +def test_safe_decorator_with_specific_exception() -> None: + """Test that @safe decorator with specific exception only catches that exception.""" + + @safe(ValueError) + def risky_function(x: int) -> int: + if x < 0: + raise ValueError("Negative number") + if x == 0: + raise TypeError("Zero is not allowed here") + return x * 2 + + # Should catch ValueError + result = risky_function(-5) + assert isinstance(result, Err) + assert isinstance(result.error, ValueError) + + # Should NOT catch TypeError + with pytest.raises(TypeError): + risky_function(0) + + +def test_safe_decorator_with_multiple_exceptions() -> None: + """Test that @safe decorator with multiple exceptions catches any of them.""" + + @safe(ValueError, TypeError) + def risky_function(x: int) -> int: + if x < 0: + raise ValueError("Negative number") + if x == 0: + raise TypeError("Zero is not allowed here") + if x == 1: + raise RuntimeError("One is a runtime error") + return x * 2 + + # Should catch ValueError + assert isinstance(risky_function(-5), Err) + # Should catch TypeError + assert isinstance(risky_function(0), Err) + # Should NOT catch RuntimeError + with pytest.raises(RuntimeError): + risky_function(1) diff --git a/uv.lock b/uv.lock index e02c04e..eab27dd 100644 --- a/uv.lock +++ b/uv.lock @@ -120,8 +120,8 @@ wheels = [ ] [[package]] -name = "fluentres" -version = "0.0.2" +name = "flow-res" +version = "0.1.0" source = { editable = "." } [package.dev-dependencies]