#!/usr/bin/env python3
# --------------------( LICENSE                            )--------------------
# Copyright (c) 2014-2025 Beartype authors.
# See "LICENSE" for further details.

'''
Beartype decorator :pep:`525`-compliant asynchronous generator unit tests.

This submodule unit tests :pep:`525` support for asynchronous generators
implemented in the :func:`beartype.beartype` decorator.
'''

# ....................{ IMPORTS                            }....................
#!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
# WARNING: To raise human-readable test errors, avoid importing from
# package-specific submodules at module scope.
#!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
from beartype.roar import BeartypeDecorHintPep585DeprecationWarning
from beartype_test._util.mark.pytmark import ignore_warnings

# ....................{ TESTS                              }....................
# Prevent pytest from capturing and displaying all expected non-fatal
# beartype-specific warnings emitted by the @beartype decorator below. Hurk!
@ignore_warnings(BeartypeDecorHintPep585DeprecationWarning)
async def test_decor_pep525_async_generator_check() -> None:
    '''
    Test :func:`beartype.beartype`-decorated asynchronous generators with
    respect to core type-checking.
    '''

    # ....................{ IMPORTS                        }....................
    # Defer test-specific imports.
    from asyncio import sleep
    from beartype import beartype
    from beartype.roar import (
        BeartypeCallHintParamViolation,
        BeartypeDecorHintPep484585Exception,
    )
    from beartype_test._util.pytroar import raises_uncached
    from collections.abc import (
        AsyncGenerator as Pep585AsyncGenerator,
        AsyncIterable as Pep585AsyncIterable,
        AsyncIterator as Pep585AsyncIterator,
    )
    from inspect import isasyncgenfunction
    from typing import (
        Union,
        AsyncGenerator as Pep484AsyncGenerator,
        AsyncIterable as Pep484AsyncIterable,
        AsyncIterator as Pep484AsyncIterator,
    )

    # ....................{ LOCALS                         }....................
    # Tuple of all PEP 484- or 585-compliant type hints annotating the returns
    # of asynchronous generators -- exercising all possible edge cases.
    HINTS_RETURN = (
        # ....................{ NON-PEP                    }....................
        # The unsubscripted "collections.abc.AsyncGenerator" superclass is a
        # PEP-noncompliant hint.
        Pep585AsyncGenerator,

        # ....................{ PEP 484                    }....................
        Pep484AsyncGenerator[Union[str, float], None],
        Pep484AsyncIterable[Union[str, float]],
        Pep484AsyncIterator[Union[str, float]],

        # ....................{ PEP 585                    }....................
        Pep585AsyncGenerator[Union[str, float], None],
        Pep585AsyncIterable[Union[str, float]],
        Pep585AsyncIterator[Union[str, float]],
    )

    # ....................{ PASS                           }....................
    # For each return type hint defined above...
    for hint_return in HINTS_RETURN:
        @beartype
        async def some_kind_of_spiritual_thing(
            said_the: Union[str, int], bigger_greener_bat: Union[str, float]
        ) -> hint_return:
            '''
            :func:`beartype.beartype`-decorated asynchronous generator whose
            return is annotated by a :pep:`484`- or :pep:`585`-compliant type
            hint deeply type-checking the value yielded by this generator.
            '''

            # Silently reduce to an asynchronous noop. See above for details.
            await sleep(0)

            # Return an arbitrary combination of the passed parameters.
            yield said_the + bigger_greener_bat

        # Assert that the high-level wrapper generated by the @beartype
        # decorator type-checking the low-level asynchronous generator defined
        # above preserves "inspect"-based metadata describing this generator.
        # Specifically, assert that this wrapper is still a generator.
        assert isasyncgenfunction(some_kind_of_spiritual_thing) is True

        # Assert that asynchronously iterating the asynchronous generator
        # created by passing this function valid parameters yields the expected
        # items. For safety, we intentionally iterate over *ALL* items of this
        # asynchronous generator -- of which there is exactly one. Although
        # asynchronous generators are technically iterators and thus support
        # manual iteration via the aiter() and anext() builtins, these builtins
        # are mostly *NOT* intended to be called manually. Whereas synchronous
        # generators *CAN* be (and often are) manually iterated by calling
        # iter() and next(), asynchronous generators technically also *CAN* be
        # (but pragmatically *NEVER* are) manually iterated by similarly calling
        # aiter() and anext(). Thus, aiter() and anext() are useless for most
        # practical intents and purposes. Whereas synchronous generators do
        # *NOT* explicitly require finalization, asynchronous generators do.
        # Why? Because asynchronous generators are registered with a parent
        # "asyncio"-based event loop and *MUST* be cleanly closed before being
        # garbage-collected. If this is *NOT* done, then Python raises obscure
        # "asyncio"-specific exceptions resembling:
        #     RuntimeWarning: coroutine method 'aclose' of
        #     'test_decor_async_generator.<locals>.some_kind_of_spiritual_thing'
        #     was never awaited
        #
        # Pytest then responds with an ever *MORE* obscure exception resembling:
        #     pytest.PytestUnraisableExceptionWarning: Exception ignored while
        #     finalizing async generator <async_generator object
        #     test_decor_async_generator.<locals>.some_kind_of_spiritual_thing
        #     at 0x7f4f959d1020>: None
        #
        # See also this relevant StackOverflow post:
        #     https://stackoverflow.com/a/42561322/2809027
        async for some_kind_of_spiritual in some_kind_of_spiritual_thing(
            'I should be trying to do some kind of spiritual thing ',
            'involving radical acceptance and enlightenment and such.',
        ):
            assert some_kind_of_spiritual == (
                'I should be trying to do some kind of spiritual thing '
                'involving radical acceptance and enlightenment and such.'
            )

        # Assert that passing this function invalid parameters raises the
        # expected violation.
        with raises_uncached(BeartypeCallHintParamViolation):
            await anext(some_kind_of_spiritual_thing(
                b'A thousand stars of sertraline ',
                b'whirled round quetiapine moons'
            ))

    # ....................{ FAIL                           }....................
    # Assert that @beartype raises the expected exception when decorating an
    # asynchronous generator annotating its return as anything *EXCEPT*
    # "AsyncGenerator[...]", "AsyncIterable[...]", or "AsyncIterator[...]".
    with raises_uncached(BeartypeDecorHintPep484585Exception):
        @beartype
        async def upside_down_trees(
            roots_in_the_breeze: str, branches_underground: str) -> str:
            # Silently reduce to an asynchronous noop. See above for details.
            await sleep(0)

            # Return an arbitrary object (sorta) satisfying this return hint.
            yield roots_in_the_breeze + branches_underground


async def test_decor_pep525_async_generator_api() -> None:
    '''
    Test :func:`beartype.beartype`-decorated asynchronous generators with
    respect to **bidirectional communication** (i.e., the pure-Python
    implementation of a hypothetical ``async yield from`` expression dynamically
    injected into the bodies of higher-level wrapper functions type-checking
    lower-level :func:`beartype.beartype`-decorated asynchronous generator
    factories).

    This unit test validates that higher-level wrapper functions type-checking
    lower-level :func:`beartype.beartype`-decorated asynchronous generator
    factories continue to comply with :pep:`525` semantics, despite the
    syntactic lack of a ``async yield from`` expression comparable to the
    existing :pep:`380`-compliant ``yield from`` expression. Specifically, this
    unit test validates comformance with:

    * :meth:`types.AsyncGeneratorType.aclose` semantics.
    * :meth:`types.AsyncGeneratorType.asend` semantics.
    * :meth:`types.AsyncGeneratorType.athrow` semantics.
    '''

    # ....................{ IMPORTS                        }....................
    # Defer test-specific imports.
    from asyncio import sleep
    from beartype import beartype
    from collections.abc import AsyncGenerator
    from pytest import raises

    # ....................{ EXCEPTIONS                     }....................
    # Arbitrary list to be appended to by the "finally:" block of the non-empty
    # asynchronous generator why_do_i_know_ye() defined below, enabling logic
    # below to validate that that block was run as expected *AFTER* the caller
    # prematurely closes that generator by calling its aclose() method.
    to_see_and_to_behold = []

    # Arbitrary string to be inserted into this list below.
    HORRORS_NEW = 'To see and to behold these horrors new?'

    # ....................{ EXCEPTIONS                     }....................
    class _BeartypeAsyncGeneratorThrowException(Exception):
        '''
        Arbitrary exception asynchronously thrown below into an asynchronous
        generator during iteration.
        '''

        pass

    # ....................{ CALLABLES                      }....................
    @beartype
    async def is_my_eternal_essence() -> AsyncGenerator:
        '''
        :func:`beartype.beartype`-decorated asynchronous generator guaranteed to
        finalize *before* yielding anything.

        See Also
        --------
        https://stackoverflow.com/a/13243870/2809027
            StackOverflow answer inspiring this implementation.
        '''

        # Silently reduce to an asynchronous noop. See above for details.
        await sleep(0)

        # Immediately return *BEFORE* yielding. (Look. Don't ask.)
        return

        # Declare this function to be an asynchronous generator factory rather
        # than an asynchronous coroutine. Without this "yield" statement, this
        # function would instead be interpreted as the latter.
        #
        # This asynchronous generator is guaranteed to return before yielding.
        # This asynchronous generator is guaranteed to thus be empty.
        yield


    @beartype
    async def why_do_i_know_ye(yield_int_max: int) -> AsyncGenerator[int, str]:
        '''
        :func:`beartype.beartype`-decorated non-empty asynchronous generator
        yielding:

        * When sent an arbitrary string, the length of that string.
        * Else, non-negative integers in the inclusive range
          ``[0, yield_int_max]``.

        Parameters
        ----------
        yield_int_max : int
            Maximum non-negative integer to be yielded.
        '''
        assert yield_int_max >= 0

        # Silently reduce to an asynchronous noop. See above for details.
        await sleep(0)

        # Attempt to...
        try:
            # For each integer in this range to be yielded...
            for yield_int in range(yield_int_max + 1):
                # Attempt to...
                try:
                    # Yield this integer to the caller *AND* possibly capture a
                    # string sent by the caller into this generator.
                    sent_str = yield yield_int

                    # While the caller continues sending strings into this
                    # generator, yield the length of each such string as
                    # confirmation of receipt to the caller.
                    while sent_str is not None:
                        sent_str = yield len(sent_str)
                    # Else, the caller sent *NO* strings into this generator.
                # If the caller explicitly threw an exception into this
                # generator by calling this generator's athrow() method, yield
                # the negation of the length of this exception's message as
                # confirmation of receipt.
                except _BeartypeAsyncGeneratorThrowException as exception:
                    yield -len(str(exception))  # <-- wat lol
        # Append an arbitrary object to the list defined above, enabling
        # logic below to validate this finalizer was run as expected after
        # the caller prematurely closes this generator with aclose().
        finally:
            to_see_and_to_behold.append(HORRORS_NEW)

    # ....................{ ASSERTS ~ empty : yield        }....................
    # Validate that a @beartype-decorated empty asynchronous generator factory
    # preserves PEP 580-compliant "yield" semantics.

    # True only if this generator truly is empty.
    is_my_eternal_essence_empty = True

    # If this generator yields *ANY* value, note this generator to be non-empty.
    async for yielded_value in is_my_eternal_essence():
        is_my_eternal_essence_empty = False

    # Assert that this generator truly is empty.
    assert is_my_eternal_essence_empty is True

    # ....................{ ASSERTS ~ non-empty : yield    }....................
    # Validate that a @beartype-decorated non-empty asynchronous generator
    # factory preserves PEP 580-compliant "yield" semantics.

    # List of all values subsequently yielded by iterating over this generator.
    yielded_values = []

    # Iteratively append each value yielded by this generator when passed a
    # small (but still non-zero) maximum value to be yielded.
    async for yielded_value in why_do_i_know_ye(3):
        yielded_values.append(yielded_value)

    # Assert that this generator yielded the expected values.
    assert yielded_values == [0, 1, 2, 3]

    # Assert that this generator's "finally:" block finalized this generator by
    # appending the expected string to this closure list.
    assert to_see_and_to_behold == [HORRORS_NEW]

    # Clear this list for sanity.
    to_see_and_to_behold.clear()

    # ....................{ ASSERTS ~ non-empty : send     }....................
    # Validate that a @beartype-decorated non-empty asynchronous generator
    # factory preserves PEP 580-compliant asend() semantics.

    # Asynchronous generator produced by priming this factory as above.
    why_have_i_seen_ye = why_do_i_know_ye(3)
    # print(f'why_have_i_seen_ye: {dir(why_have_i_seen_ye)}')

    # Assert that the first value yielded by this generator is the expected.
    # Rather than iterate this generator manually via the standard idiom,
    # validate that sending the "None" singleton into this generator is
    # semantically equivalent to manually iterating this generator.
    #
    # Note that:
    # * This obtuse alternative to explicit iteration is equivalent to this
    #   standard idiom for manually iterating an asynchronous generator:
    #     assert await anext(why_have_i_seen_ye) == 0
    # * One *CANNOT* send a non-"None" value into an asynchronous generator
    #   before that generator yields its first value after being iterated at
    #   least once. Violating that maxim raises this exception:
    #     TypeError: can't send non-None value to a just-started async generator
    assert await why_have_i_seen_ye.asend(None) == 0

    # Assert that the next value yielded by this generator is the expected.
    assert await anext(why_have_i_seen_ye) == 1

    # Assert that the value yielded by this generator after sending a string
    # into this generator is the length of this string.
    assert await why_have_i_seen_ye.asend(
        'Why do I know ye? why have I seen ye? why') == 41

    # Assert that the value yielded by this generator after sending another
    # string into this generator is the length of this string.
    assert await why_have_i_seen_ye.asend(
        'Is my eternal essence thus distraught') == 37

    # Assert that the next value yielded by this generator is the expected.
    assert await anext(why_have_i_seen_ye) == 2

    # ....................{ ASSERTS ~ non-empty : throw    }....................
    # Assert that the value yielded by this generator after throwing an
    # exception into this generator is the negation of the length of this
    # exception's message. Just accept it.
    assert await why_have_i_seen_ye.athrow(
        _BeartypeAsyncGeneratorThrowException(
            'To see and to behold these horrors new?')) == -39

    # ....................{ ASSERTS ~ non-empty : send END }....................
    # Assert that the final value yielded by this generator is the expected.
    assert await anext(why_have_i_seen_ye) == 3

    # Assert that iterating this generator one final time raises the expected
    # PEP 525-compliant exception -- implying this generator has nothing further
    # to yield and has thus been exhausted (i.e., finalized, closed).
    with raises(StopAsyncIteration):
        await anext(why_have_i_seen_ye)

    # Assert that this generator's "finally:" block finalized this generator by
    # appending the expected string to this closure list.
    assert to_see_and_to_behold == [HORRORS_NEW]

    # Clear this list for sanity.
    to_see_and_to_behold.clear()

    # ....................{ ASSERTS ~ non-empty : close    }....................
    # Asynchronous generator produced by priming this factory as above.
    thus_distraught = why_do_i_know_ye(3)

    # Assert that the first value yielded by this generator is the expected.
    assert await anext(thus_distraught) == 0

    # Prematurely close this generator.
    await thus_distraught.aclose()

    # Assert that this generator's "finally:" block finalized this generator by
    # appending the expected string to this closure list.
    assert to_see_and_to_behold == [HORRORS_NEW]

    # Clear this list for sanity.
    to_see_and_to_behold.clear()

    # Assert that iterating this generator one final time raises the expected
    # PEP 525-compliant exception -- implying this generator has nothing further
    # to yield and has thus been exhausted (i.e., finalized, closed).
    with raises(StopAsyncIteration):
        await anext(thus_distraught)
