diff --git a/.github/workflows/publish-prod.yml b/.github/workflows/publish-prod.yml index 5e7793b..09d10e0 100644 --- a/.github/workflows/publish-prod.yml +++ b/.github/workflows/publish-prod.yml @@ -3,38 +3,121 @@ name: publish-prod on: push: tags: - - 'v[0-9]+.[0-9]+.[0-9]+' + - 'v*' workflow_dispatch: + inputs: + tag: + description: Stable PEP 440 release tag to publish (for example, v1.2.3) + required: true + type: string jobs: - pypi-publish: - name: publish release to PyPI + quality: + name: validate, test, and build release runs-on: ubuntu-latest - environment: - name: publish-prod permissions: - id-token: write + contents: read env: - TRUSTED_PUBLISHING: always + RELEASE_TAG: ${{ inputs.tag || github.ref_name }} steps: - # https://github.com/actions/checkout - - uses: actions/checkout@v6 + - name: Checkout release tag + uses: actions/checkout@v6 with: fetch-depth: 0 + ref: ${{ inputs.tag && format('refs/tags/{0}', inputs.tag) || github.ref }} - name: Set up Python - # https://github.com/actions/setup-python uses: actions/setup-python@v6 with: python-version-file: "pyproject.toml" + - name: Install uv - # uses: astral-sh/setup-uv@v7 with: enable-cache: true - # Install a specific version of uv. version: "0.11.4" - - run: uv build + + - name: Install dependencies + run: uv sync --frozen + + - name: Validate stable release tag + run: | + uv run --frozen python - <<'PY' + import os + from packaging.version import InvalidVersion, Version + + tag = os.environ["RELEASE_TAG"] + if not tag.startswith("v"): + raise SystemExit(f"release tag must start with 'v': {tag!r}") + try: + version = Version(tag[1:]) + except InvalidVersion as error: + raise SystemExit(f"release tag is not a valid PEP 440 version: {tag!r}") from error + if version.is_prerelease: + raise SystemExit(f"pre-release tags cannot be published to production PyPI: {tag!r}") + PY + + - name: Run ruff format check + run: uv run --frozen ruff format --check . + + - name: Run ruff linting + run: uv run --frozen ruff check . + + - name: Run type checking with pyright + run: uv run --frozen pyright + + - name: Run tests + run: uv run --frozen pytest --cov --cov-report=term-missing + + - name: Build distributions + run: uv build + + - name: Verify built version and wheel installation + run: | + uv run --frozen python - <<'PY' + import email + import os + import zipfile + from pathlib import Path + from packaging.version import Version + + wheels = list(Path("dist").glob("*.whl")) + if len(wheels) != 1: + raise SystemExit(f"expected exactly one wheel, found: {wheels}") + with zipfile.ZipFile(wheels[0]) as archive: + metadata_path = next(name for name in archive.namelist() if name.endswith(".dist-info/METADATA")) + metadata = email.message_from_bytes(archive.read(metadata_path)) + tag_version = Version(os.environ["RELEASE_TAG"][1:]) + built_version = Version(metadata["Version"]) + if built_version != tag_version: + raise SystemExit(f"built version {built_version} does not match tag version {tag_version}") + PY + python -m venv .wheel-venv + .wheel-venv/bin/python -m pip install --no-deps dist/*.whl + .wheel-venv/bin/python -c "import flow_res" + + - name: Store verified distributions + uses: actions/upload-artifact@v4 + with: + name: release-distributions + path: dist/ + if-no-files-found: error + + pypi-publish: + name: publish release to PyPI + needs: quality + runs-on: ubuntu-latest + environment: + name: publish-prod + permissions: + actions: read + id-token: write + steps: + - name: Download verified distributions + uses: actions/download-artifact@v4 + with: + name: release-distributions + path: dist/ + - name: Publish package distributions to PyPI - # https://docs.pypi.org/trusted-publishers/using-a-publisher/ uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.github/workflows/publish-test.yml b/.github/workflows/publish-test.yml index e06cca6..f8fec6c 100644 --- a/.github/workflows/publish-test.yml +++ b/.github/workflows/publish-test.yml @@ -4,41 +4,142 @@ on: push: tags: - 'v*' - - '!v[0-9]+.[0-9]+.[0-9]+' workflow_dispatch: + inputs: + tag: + description: Pre-release PEP 440 tag to publish (for example, v1.2.3rc1) + required: true + type: string jobs: - pypi-test-publish: - name: publish release to PyPI Test + classify: + name: classify release tag + runs-on: ubuntu-latest + outputs: + is_prerelease: ${{ steps.version.outputs.is_prerelease }} + env: + RELEASE_TAG: ${{ inputs.tag || github.ref_name }} + steps: + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + version: "0.11.4" + + - name: Classify PEP 440 version + id: version + run: | + uv run --no-project --with packaging==26.0 python - <<'PY' + import os + from pathlib import Path + from packaging.version import InvalidVersion, Version + + tag = os.environ["RELEASE_TAG"] + if not tag.startswith("v"): + raise SystemExit(f"release tag must start with 'v': {tag!r}") + try: + version = Version(tag[1:]) + except InvalidVersion as error: + raise SystemExit(f"release tag is not a valid PEP 440 version: {tag!r}") from error + output = Path(os.environ["GITHUB_OUTPUT"]) + with output.open("a", encoding="utf-8") as stream: + print(f"is_prerelease={str(version.is_prerelease).lower()}", file=stream) + PY + + quality: + name: validate, test, and build pre-release + needs: classify + if: needs.classify.outputs.is_prerelease == 'true' runs-on: ubuntu-latest - environment: - name: publish-test - url: https://test.pypi.org/p/flow-res permissions: - id-token: write + contents: read env: - TRUSTED_PUBLISHING: always + RELEASE_TAG: ${{ inputs.tag || github.ref_name }} steps: - # https://github.com/actions/checkout - - uses: actions/checkout@v6 + - name: Checkout release tag + uses: actions/checkout@v6 with: fetch-depth: 0 + ref: ${{ inputs.tag && format('refs/tags/{0}', inputs.tag) || github.ref }} - name: Set up Python - # https://github.com/actions/setup-python uses: actions/setup-python@v6 with: python-version-file: "pyproject.toml" + - name: Install uv - # uses: astral-sh/setup-uv@v7 with: enable-cache: true - # Install a specific version of uv. version: "0.11.4" - - run: uv build + + - name: Install dependencies + run: uv sync --frozen + + - name: Run ruff format check + run: uv run --frozen ruff format --check . + + - name: Run ruff linting + run: uv run --frozen ruff check . + + - name: Run type checking with pyright + run: uv run --frozen pyright + + - name: Run tests + run: uv run --frozen pytest --cov --cov-report=term-missing + + - name: Build distributions + run: uv build + + - name: Verify built version and wheel installation + run: | + uv run --frozen python - <<'PY' + import email + import os + import zipfile + from pathlib import Path + from packaging.version import Version + + wheels = list(Path("dist").glob("*.whl")) + if len(wheels) != 1: + raise SystemExit(f"expected exactly one wheel, found: {wheels}") + with zipfile.ZipFile(wheels[0]) as archive: + metadata_path = next(name for name in archive.namelist() if name.endswith(".dist-info/METADATA")) + metadata = email.message_from_bytes(archive.read(metadata_path)) + tag_version = Version(os.environ["RELEASE_TAG"][1:]) + built_version = Version(metadata["Version"]) + if built_version != tag_version: + raise SystemExit(f"built version {built_version} does not match tag version {tag_version}") + PY + python -m venv .wheel-venv + .wheel-venv/bin/python -m pip install --no-deps dist/*.whl + .wheel-venv/bin/python -c "import flow_res" + + - name: Store verified distributions + uses: actions/upload-artifact@v4 + with: + name: release-distributions + path: dist/ + if-no-files-found: error + + pypi-test-publish: + name: publish release to TestPyPI + needs: quality + runs-on: ubuntu-latest + environment: + name: publish-test + url: https://test.pypi.org/p/flow-res + permissions: + actions: read + id-token: write + steps: + - name: Download verified distributions + uses: actions/download-artifact@v4 + with: + name: release-distributions + path: dist/ + - name: Publish package distributions to TestPyPI - # https://docs.pypi.org/trusted-publishers/using-a-publisher/ uses: pypa/gh-action-pypi-publish@release/v1 with: repository-url: https://test.pypi.org/legacy/ diff --git a/README.md b/README.md index e1d2394..ea1ee7e 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ match result: `map` や `and_then` を用いることで、命令的な条件分岐を排除し、処理のパイプラインを構築できます。 ```python -from flow_res import Result +from flow_res import Err, Ok, Result def validate_positive(x: int) -> Result[int, ValueError]: if x < 0: @@ -85,10 +85,11 @@ print(result) # Err(error=ValueError("invalid literal for int() with base 10: ' ### 4. 非同期処理の統合 (@async_result) `@async_result` デコレータを使用することで、非同期関数の実行結果に対しても await 前にメソッドチェーンを適用できます。 +`AwaitableResult` は内部のコルーチンを一つだけ保持する単一消費型です。同じインスタンスを複数回 await したり、複数のチェーンへ分岐させたりせず、一つのチェーンで消費してください。 ```python import asyncio -from flow_res import Result, async_result +from flow_res import Err, Ok, Result, async_result @async_result async def fetch_user(user_id: int) -> Result[dict, ValueError]: diff --git a/pyproject.toml b/pyproject.toml index d37b1f3..7f92453 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,7 @@ Issues = "https://github.com/aiagate/flow-res/issues" [dependency-groups] dev = [ "anyio>=4.11.0", + "packaging>=26.0", "pre-commit>=4.5.0", "pyright>=1.1.407", "pytest>=8.3.5", diff --git a/src/flow_res/async_result.py b/src/flow_res/async_result.py index 248da90..9229697 100644 --- a/src/flow_res/async_result.py +++ b/src/flow_res/async_result.py @@ -1,7 +1,7 @@ import inspect from collections.abc import Awaitable, Callable, Coroutine, Generator from functools import wraps -from typing import Any +from typing import Any, cast from .result import Err, Ok, Result @@ -12,6 +12,10 @@ class AwaitableResult[T, E: Exception]: This allows elegant syntax like: message = await Mediator.send_async(query).map(...).unwrap() + + An ``AwaitableResult`` owns one native coroutine and is therefore single-use. + Await it directly or consume it through one chain only. Awaiting it twice, or + branching multiple chains from the same instance, raises ``RuntimeError``. """ def __init__(self, coro: Coroutine[Any, Any, Result[T, E]]) -> None: @@ -32,7 +36,7 @@ def __repr__(self) -> str: coro_repr = repr(self._coro) except Exception: coro_repr = "" - return f"ResultAwaitable(coro={coro_repr})" + return f"AwaitableResult(coro={coro_repr})" def __await__(self) -> Generator[Any, None, Result[T, E]]: """Make this object awaitable, returning the underlying Result.""" @@ -51,7 +55,7 @@ def map[U](self, f: Callable[[T], U]) -> "AwaitableResult[U, E]": f: Function to apply to the Ok value (T -> U) Returns: - ResultAwaitable[U, E] wrapping the transformed result + AwaitableResult[U, E] wrapping the transformed result Example: user_id = await Mediator.send_async(cmd).map(lambda v: v.user_id) @@ -63,9 +67,9 @@ async def mapped() -> Result[U, E]: return AwaitableResult(mapped()) - def and_then[U]( - self, f: Callable[[T], Awaitable[Result[U, E]] | Result[U, E]] - ) -> "AwaitableResult[U, E]": + def and_then[U, F: Exception]( + self, f: Callable[[T], Awaitable[Result[U, F]] | Result[U, F]] + ) -> "AwaitableResult[U, E | F]": """ Apply a function (sync or async) that returns a Result, flattening the nested Result. @@ -73,10 +77,10 @@ def and_then[U]( Args: f: Function that takes the Ok value and returns a new Result - (T -> Awaitable[Result[U, E]] | Result[U, E]) + (T -> Awaitable[Result[U, F]] | Result[U, F]) Returns: - ResultAwaitable[U, E] wrapping the result of applying the function + AwaitableResult[U, E | F] wrapping the result of applying the function Example: await ( @@ -87,16 +91,16 @@ def and_then[U]( ) """ - async def chained() -> Result[U, E]: + async def chained() -> Result[U, E | F]: _result: Result[T, E] = await self match _result: case Ok(value): res = f(value) if inspect.isawaitable(res): - return await res - return res + res = await res + return cast(Result[U, E | F], res) case Err(): - return _result + return cast(Result[U, E | F], _result) return AwaitableResult(chained()) @@ -127,7 +131,7 @@ def map_err[F: Exception](self, f: Callable[[E], F]) -> "AwaitableResult[T, F]": f: Function to apply to the error value (E -> F) Returns: - ResultAwaitable[T, F] wrapping the result with transformed error type + AwaitableResult[T, F] wrapping the result with transformed error type Example: await ( @@ -144,9 +148,9 @@ async def mapped() -> Result[T, F]: return AwaitableResult(mapped()) -def async_result[T, E: Exception]( - func: Callable[..., Coroutine[Any, Any, Result[T, E]]], -) -> Callable[..., AwaitableResult[T, E]]: +def async_result[**P, T, E: Exception]( + func: Callable[P, Coroutine[Any, Any, Result[T, E]]], +) -> Callable[P, AwaitableResult[T, E]]: """ Decorator that wraps an async function returning Result in AwaitableResult. @@ -176,7 +180,7 @@ async def fetch_user(user_id: int) -> Result[dict, Exception]: """ @wraps(func) - def wrapper(*args: Any, **kwargs: Any) -> AwaitableResult[T, E]: + def wrapper(*args: P.args, **kwargs: P.kwargs) -> AwaitableResult[T, E]: return AwaitableResult( coro=func(*args, **kwargs), ) diff --git a/src/flow_res/combinators.py b/src/flow_res/combinators.py index 754daf0..ff3658e 100644 --- a/src/flow_res/combinators.py +++ b/src/flow_res/combinators.py @@ -159,6 +159,12 @@ def combine[E: Exception]( return Ok(tuple(values)) +@overload +def combine_all( + results: tuple[()], +) -> Result[tuple[()], ExceptionGroup]: ... + + @overload def combine_all[T1, E: Exception]( results: tuple[Result[T1, E]], diff --git a/src/flow_res/safe.py b/src/flow_res/safe.py index 378917a..b05dd3b 100644 --- a/src/flow_res/safe.py +++ b/src/flow_res/safe.py @@ -1,74 +1,81 @@ -from collections.abc import Callable +import inspect +from collections.abc import Callable, Coroutine from functools import wraps -from typing import Any, overload +from typing import Any, Protocol, overload from .result import Err, Ok, Result -@overload -def safe( - *exceptions: type[Exception], -) -> Callable[[Callable[..., Any]], Callable[..., Result[Any, Exception]]]: ... +class _SafeDecorator(Protocol): + """Type-preserving decorator returned by ``safe(...)``.""" + + @overload + def __call__[**P, T]( # pyright: ignore[reportOverlappingOverload] + self, func: Callable[P, Coroutine[Any, Any, T]], / + ) -> Callable[P, Coroutine[Any, Any, Result[T, Exception]]]: ... + + @overload + def __call__[**P, T]( + self, func: Callable[P, T], / + ) -> Callable[P, Result[T, Exception]]: ... @overload -def safe[T](__func: Callable[..., T]) -> Callable[..., Result[T, Exception]]: ... +def safe(*exceptions: type[Exception]) -> _SafeDecorator: ... -def safe(*args: Any) -> Any: - """ - Decorator to convert a function that raises exceptions into one that returns a Result. +@overload +def safe[**P, T]( # pyright: ignore[reportOverlappingOverload] + __func: Callable[P, Coroutine[Any, Any, T]], / +) -> Callable[P, Coroutine[Any, Any, Result[T, Exception]]]: ... - 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)). +@overload +def safe[**P, T](__func: Callable[P, T], /) -> Callable[P, Result[T, Exception]]: ... - 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 +def safe(*args: Any) -> Any: + """Convert exceptions raised by a function into ``Result`` values. - @safe(ValueError, TypeError) - def parse_data(data: dict) -> int: - return int(data["value"]) + The decorator preserves the decorated function's parameter signature. It may be + used as ``@safe`` to catch every ``Exception``, or with exception classes such as + ``@safe(ValueError, TypeError)``. Async functions remain async; their body is + awaited inside the exception handler before an ``Ok`` or ``Err`` is returned. """ 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,) + return _wrap(args[0], (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) + exceptions = args if args else (Exception,) - return wrapper + def decorator(func: Callable[..., Any]) -> Callable[..., Any]: + return _wrap(func, exceptions) - # Called as @safe(ValueError, TypeError) - exceptions = args if args else (Exception,) + return decorator + + +def _wrap( + func: Callable[..., Any], exceptions: tuple[type[Exception], ...] +) -> Callable[..., Any]: + if inspect.iscoroutinefunction(func): - def decorator(func: Callable[..., Any]) -> Callable[..., Result[Any, Exception]]: @wraps(func) - def wrapper(*w_args: Any, **w_kwargs: Any) -> Result[Any, Exception]: + async def async_wrapper(*args: Any, **kwargs: Any) -> Result[Any, Exception]: try: - return Ok(func(*w_args, **w_kwargs)) - except exceptions as e: - return Err(e) + return Ok(await func(*args, **kwargs)) + except exceptions as error: + return Err(error) - return wrapper + return async_wrapper - return decorator + @wraps(func) + def sync_wrapper(*args: Any, **kwargs: Any) -> Result[Any, Exception]: + try: + return Ok(func(*args, **kwargs)) + except exceptions as error: + return Err(error) + + return sync_wrapper diff --git a/tests/test_async_result_decorator.py b/tests/test_async_result_decorator.py index 2096d95..25d78f4 100644 --- a/tests/test_async_result_decorator.py +++ b/tests/test_async_result_decorator.py @@ -1,10 +1,11 @@ """Tests for the @async_result decorator.""" import asyncio +from typing import assert_type import pytest -from flow_res import Err, Ok, Result, async_result +from flow_res import AwaitableResult, Err, Ok, Result, async_result @async_result @@ -30,6 +31,7 @@ async def test_async_result_decorator_returns_awaitable_result(): # The decorated function returns AwaitableResult, which has .map() method assert hasattr(result, "map") assert hasattr(result, "and_then") + assert (await result).unwrap() == 10 @pytest.mark.asyncio @@ -131,3 +133,58 @@ async def test_async_result_decorator_with_match_expression(): assert value == 10 case _: pytest.fail("Expected Ok result") + + +@pytest.mark.asyncio +async def test_async_result_preserves_signature_and_combines_error_types() -> None: + @async_result + async def stringify(value: int, *, prefix: str) -> Result[str, RuntimeError]: + return Ok(f"{prefix}{value}") + + initial = successful_operation(value=5) + assert_type(initial, AwaitableResult[int, ValueError]) + + chained = initial.and_then(lambda value: stringify(value, prefix="value=")) + assert_type(chained, AwaitableResult[str, ValueError | RuntimeError]) + assert await chained == Ok("value=10") + + +@pytest.mark.asyncio +async def test_async_result_and_then_returns_new_error_type() -> None: + error = RuntimeError("next operation failed") + failure = Err(error) + + async def fail(_value: int) -> Result[str, RuntimeError]: + return failure + + result = await successful_operation(5).and_then(fail) + + assert isinstance(result, Err) + assert result is failure + assert result.error is error + + +@pytest.mark.asyncio +async def test_async_result_and_then_preserves_original_error_in_union() -> None: + original_error = ValueError("initial operation failed") + initial_failure = Err(original_error) + + @async_result + async def initially_fails(value: int) -> Result[int, ValueError]: + return initial_failure + + called = False + + async def next_operation(_value: int) -> Result[str, RuntimeError]: + nonlocal called + called = True + return Ok("unexpected") + + chained = initially_fails(value=5).and_then(next_operation) + assert_type(chained, AwaitableResult[str, ValueError | RuntimeError]) + + result = await chained + assert isinstance(result, Err) + assert result is initial_failure + assert result.error is original_error + assert not called diff --git a/tests/test_awaitable_result.py b/tests/test_awaitable_result.py index dd6060e..dacf87a 100644 --- a/tests/test_awaitable_result.py +++ b/tests/test_awaitable_result.py @@ -1,4 +1,4 @@ -"""Tests for ResultAwaitable type.""" +"""Tests for AwaitableResult type.""" import pytest @@ -18,7 +18,7 @@ async def async_add_ten(x: int) -> Result[int, TestErr]: @pytest.mark.anyio async def test_result_awaitable_await_ok() -> None: - """Test that ResultAwaitable can be awaited to get Result.""" + """Test that AwaitableResult can be awaited to get Result.""" async def get_result() -> Result[int, Exception]: return Ok(42) @@ -32,7 +32,7 @@ async def get_result() -> Result[int, Exception]: @pytest.mark.anyio async def test_result_awaitable_await_err() -> None: - """Test that ResultAwaitable can be awaited to get Err.""" + """Test that AwaitableResult can be awaited to get Err.""" error = TestErr(type=ErrType.NOT_FOUND, message="Not found") async def get_result() -> Result[int, TestErr]: @@ -47,7 +47,7 @@ async def get_result() -> Result[int, TestErr]: @pytest.mark.anyio async def test_result_awaitable_map_ok() -> None: - """Test that ResultAwaitable.map transforms Ok value.""" + """Test that AwaitableResult.map transforms Ok value.""" async def get_result() -> Result[int, TestErr]: return Ok(5) @@ -60,13 +60,13 @@ async def get_result() -> Result[int, TestErr]: @pytest.mark.anyio async def test_result_awaitable_map_err() -> None: - """Test that ResultAwaitable.map passes through Err.""" + """Test that AwaitableResult.map passes through Err.""" error = TestErr(type=ErrType.VALIDATION_ERROR, message="Invalid") async def get_result() -> Result[int, TestErr]: return Err(error) - result = await AwaitableResult(get_result()).map(lambda x: x * 2) # type: ignore[arg-type] + result = await AwaitableResult(get_result()).map(lambda x: x * 2) assert isinstance(result, Err) assert result.error is error @@ -139,14 +139,14 @@ async def get_result() -> Result[int, TestErr]: return Err(error) with pytest.raises(TestErr) as exc_info: - await AwaitableResult(get_result()).map(lambda x: x * 2).unwrap() # type: ignore[arg-type, return-value] + await AwaitableResult(get_result()).map(lambda x: x * 2).unwrap() assert exc_info.value is error @pytest.mark.anyio async def test_result_awaitable_and_then_ok() -> None: - """Test that ResultAwaitable.and_then applies function for Ok.""" + """Test that AwaitableResult.and_then applies function for Ok.""" async def get_initial() -> Result[int, TestErr]: return Ok(5) @@ -160,7 +160,7 @@ async def get_initial() -> Result[int, TestErr]: @pytest.mark.anyio async def test_result_awaitable_and_then_err() -> None: - """Test that ResultAwaitable.and_then passes through Err.""" + """Test that AwaitableResult.and_then passes through Err.""" error = TestErr(type=ErrType.NOT_FOUND, message="Not found") async def get_error() -> Result[int, TestErr]: @@ -230,7 +230,7 @@ async def returns_error(x: int) -> Result[int, TestErr]: @pytest.mark.anyio async def test_result_awaitable_map_err_ok() -> None: - """Test that ResultAwaitable.map_err passes through Ok unchanged.""" + """Test that AwaitableResult.map_err passes through Ok unchanged.""" async def get_result() -> Result[int, Exception]: return Ok(42) @@ -245,7 +245,7 @@ async def get_result() -> Result[int, Exception]: @pytest.mark.anyio async def test_result_awaitable_map_err_err() -> None: - """Test that ResultAwaitable.map_err transforms Err value.""" + """Test that AwaitableResult.map_err transforms Err value.""" async def get_result() -> Result[int, Exception]: return Err(Exception("original error")) @@ -323,7 +323,7 @@ def sync_double(x: int) -> Result[int, TestErr]: @pytest.mark.anyio async def test_result_awaitable_and_then_sync() -> None: - """Test that ResultAwaitable.and_then applies sync function for Ok.""" + """Test that AwaitableResult.and_then applies sync function for Ok.""" async def get_initial() -> Result[int, TestErr]: return Ok(5) diff --git a/tests/test_combine_all.py b/tests/test_combine_all.py index 51d47b6..749ed34 100644 --- a/tests/test_combine_all.py +++ b/tests/test_combine_all.py @@ -1,7 +1,18 @@ +from typing import assert_type + from flow_res import Err, Ok, Result, combine_all from tests.testutils.error import ErrType, TestErr +def test_combine_all_empty_tuple() -> None: + """An empty input succeeds with an accurately typed empty tuple.""" + + combined = combine_all(()) + + assert_type(combined, Result[tuple[()], ExceptionGroup]) + assert combined == Ok(()) + + def test_combine_all_all_ok() -> None: """Test that combine_all returns Ok with tuple of values when all are Ok.""" diff --git a/tests/test_readme.py b/tests/test_readme.py new file mode 100644 index 0000000..999b510 --- /dev/null +++ b/tests/test_readme.py @@ -0,0 +1,35 @@ +import re +from pathlib import Path + +import pytest + +README_PATH = Path(__file__).parents[1] / "README.md" + + +def _python_examples() -> list[tuple[int, str]]: + readme = README_PATH.read_text(encoding="utf-8") + examples: list[tuple[int, str]] = [] + for match in re.finditer(r"```python\s*\n(.*?)```", readme, re.DOTALL): + line_number = readme.count("\n", 0, match.start(1)) + 1 + examples.append((line_number, match.group(1))) + return examples + + +PYTHON_EXAMPLES = _python_examples() + + +def test_readme_contains_python_examples() -> None: + """Fail explicitly if extraction stops finding the documented examples.""" + + assert PYTHON_EXAMPLES + + +@pytest.mark.parametrize( + ("line_number", "source"), + [pytest.param(line, source, id=f"line-{line}") for line, source in PYTHON_EXAMPLES], +) +def test_python_example_runs_independently(line_number: int, source: str) -> None: + """Keep every Python README example self-contained and executable.""" + + padded_source = "\n" * (line_number - 1) + source + exec(compile(padded_source, str(README_PATH), "exec"), {}) diff --git a/tests/test_result.py b/tests/test_result.py index bd7c30e..f789279 100644 --- a/tests/test_result.py +++ b/tests/test_result.py @@ -185,7 +185,7 @@ def test_and_then_chain_with_error() -> None: final = ( result.and_then(lambda x: Ok(x * 3)) .and_then(lambda x: Err(error)) - .and_then(lambda x: Ok(x + 10)) # type: ignore[arg-type] + .and_then(lambda x: Ok(x + 10)) ) assert isinstance(final, Err) diff --git a/tests/test_safe.py b/tests/test_safe.py index 02bbabe..df00695 100644 --- a/tests/test_safe.py +++ b/tests/test_safe.py @@ -1,6 +1,10 @@ -from flow_res import Err, Ok, safe +from collections.abc import Coroutine +from typing import Any, assert_type + import pytest +from flow_res import Err, Ok, Result, safe + def test_safe_decorator_wraps_exception() -> None: """Test that @safe decorator converts exceptions to Err.""" @@ -24,7 +28,7 @@ def test_safe_decorator_returns_ok() -> None: def safe_function(x: int) -> int: return x * 2 - result = safe_function(5) + result = assert_type(safe_function(5), Result[int, Exception]) assert isinstance(result, Ok) assert result.value == 10 @@ -45,6 +49,9 @@ def risky_function(x: int) -> int: assert isinstance(result, Err) assert isinstance(result.error, ValueError) + success = assert_type(risky_function(2), Result[int, Exception]) + assert success == Ok(4) + # Should NOT catch TypeError with pytest.raises(TypeError): risky_function(0) @@ -70,3 +77,57 @@ def risky_function(x: int) -> int: # Should NOT catch RuntimeError with pytest.raises(RuntimeError): risky_function(1) + + +@pytest.mark.asyncio +async def test_safe_decorator_awaits_async_function_and_wraps_exception() -> None: + @safe(ValueError) + async def risky_function(value: int, *, fail: bool = False) -> int: + if fail: + raise ValueError("failed asynchronously") + return value * 2 + + pending = risky_function(value=5) + pending = assert_type( + pending, + Coroutine[Any, Any, Result[int, Exception]], + ) + assert await pending == Ok(10) + + result = await risky_function(5, fail=True) + assert isinstance(result, Err) + assert isinstance(result.error, ValueError) + + +@pytest.mark.asyncio +async def test_safe_without_arguments_supports_async_function() -> None: + @safe + async def risky_function(value: int) -> int: + if value < 0: + raise RuntimeError("failed asynchronously") + return value + + assert await risky_function(1) == Ok(1) + assert isinstance(await risky_function(-1), Err) + + +@pytest.mark.asyncio +async def test_safe_empty_parentheses_supports_async_function() -> None: + @safe() + async def risky_function(value: int) -> int: + if value < 0: + raise ValueError("failed asynchronously") + return value + + assert await risky_function(1) == Ok(1) + assert isinstance(await risky_function(-1), Err) + + +@pytest.mark.asyncio +async def test_safe_async_function_does_not_catch_unlisted_exception() -> None: + @safe(ValueError) + async def risky_function() -> int: + raise TypeError("not handled") + + with pytest.raises(TypeError, match="not handled"): + await risky_function() diff --git a/tests/testutils/error.py b/tests/testutils/error.py index 8beae63..7f302d2 100644 --- a/tests/testutils/error.py +++ b/tests/testutils/error.py @@ -15,6 +15,8 @@ class ErrType(Enum): class TestErr(Exception): """Represents a specific error from a use case.""" + __test__ = False + type: ErrType message: str diff --git a/uv.lock b/uv.lock index 202bdf9..4087c4b 100644 --- a/uv.lock +++ b/uv.lock @@ -126,6 +126,7 @@ source = { editable = "." } [package.dev-dependencies] dev = [ { name = "anyio" }, + { name = "packaging" }, { name = "pre-commit" }, { name = "pyright" }, { name = "pytest" }, @@ -140,6 +141,7 @@ dev = [ [package.metadata.requires-dev] dev = [ { name = "anyio", specifier = ">=4.11.0" }, + { name = "packaging", specifier = ">=26.0" }, { name = "pre-commit", specifier = ">=4.5.0" }, { name = "pyright", specifier = ">=1.1.407" }, { name = "pytest", specifier = ">=8.3.5" },