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

'''
**Beartype** :pep:`544` **unit tests.**

This submodule unit tests :pep:`544` support 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_test.util.mark.pytskip import skip_if_python_version_less_than
from pytest import raises

# ....................{ TESTS                             }....................
@skip_if_python_version_less_than('3.8.0')
def test_pep544_pass() -> None:
    '''
    Test successful usage of :pep:`544` support implemented in the
    :func:`beartype.beartype` decorator if the active Python interpreter
    targets at least Python 3.8.0 (i.e., the first major Python version to
    support :pep:`544`) *or* skip otherwise.
    '''

    # Defer heavyweight imports.
    from abc import abstractmethod
    from beartype import beartype
    from typing import Protocol, runtime_checkable

    # User-defined runtime protocol declaring arbitrary methods.
    @runtime_checkable
    class Easter1916(Protocol):
        def i_have_met_them_at_close_of_day(self) -> str:
            return 'Coming with vivid faces'

        @abstractmethod
        def from_counter_or_desk_among_grey(self) -> str: pass

    # User-defined class structurally (i.e., implicitly) satisfying *WITHOUT*
    # explicitly subclassing this user-defined protocol.
    class Easter1916Structural(object):
        def i_have_met_them_at_close_of_day(self) -> str:
            return 'Eighteenth-century houses.'

        def from_counter_or_desk_among_grey(self) -> str:
            return 'I have passed with a nod of the head'

    # @beartype-decorated callable annotated by this user-defined protocol.
    @beartype
    def or_polite_meaningless_words(lingered_awhile: Easter1916) -> str:
        return (
            lingered_awhile.i_have_met_them_at_close_of_day() +
            lingered_awhile.from_counter_or_desk_among_grey()
        )

    # Assert this callable returns the expected string when passed this
    # user-defined class structurally satisfying this protocol.
    assert or_polite_meaningless_words(Easter1916Structural()) == (
        'Eighteenth-century houses.'
        'I have passed with a nod of the head'
    )


@skip_if_python_version_less_than('3.8.0')
def test_pep544_fail() -> None:
    '''
    Test unsuccessful usage of :pep:`544` support implemented in the
    :func:`beartype.beartype` decorator if the active Python interpreter
    targets at least Python 3.8.0 (i.e., the first major Python version to
    support :pep:`544`) *or* skip otherwise.
    '''

    # Defer heavyweight imports.
    from abc import abstractmethod
    from beartype import beartype
    from beartype.roar import BeartypeDecorHintPep3119Exception
    from typing import Protocol

    # User-defined protocol declaring arbitrary methods, but intentionally
    # *NOT* decorated by the @typing.runtime_checkable decorator and thus
    # unusable at runtime by @beartype.
    class TwoTrees(Protocol):
        def holy_tree(self) -> str:
            return 'Beloved, gaze in thine own heart,'

        @abstractmethod
        def bitter_glass(self) -> str: pass

    # @beartype-decorated callable annotated by this user-defined protocol.
    with raises(BeartypeDecorHintPep3119Exception):
        @beartype
        def times_of_old(god_slept: TwoTrees) -> str:
            return god_slept.holy_tree() + god_slept.bitter_glass()

# ....................{ TESTS ~ protocol                  }....................
@skip_if_python_version_less_than('3.8.0')
def test_pep544_hint_subprotocol_elision() -> None:
    '''
    Test that type-checking wrappers generated by the :func:`beartype.beartype`
    decorator for callables annotated by one or more **subprotocols** (i.e.,
    :pep:`544`-compliant subclasses subclassing one or more
    :pep:`544`-compliant superclasses themselves directly subclassing the root
    :class:`typing.Protocol` superclass) are optimized to perform only single
    :func:`isinstance` checks against those subprotocols rather than multiple
    :func:`isinstance` checks against both those subprotocols and one or more
    superclasses of those subprotocols.

    This unit test guards against non-trivial performance regressions that
    previously crippled the third-party :mod:`numerary` package, whose caching
    algorithm requires that :mod:`beartype` be optimized in this way.

    See Also
    ----------
    This relevant issue: https://github.com/beartype/beartype/issues/76
    '''

    # ..................{ IMPORTS                           }..................
    # Defer heavyweight imports.
    from abc import abstractmethod
    from beartype import beartype
    from typing import (
        Protocol,
        runtime_checkable,
    )

    # Private metaclass of the root "typing.Protocol" superclass, accessed
    # without violating privacy encapsulation.
    _ProtocolMeta = type(Protocol)

    # ..................{ METACLASSES                       }..................
    class UndesirableProtocolMeta(_ProtocolMeta):
        '''
        Undesirable :class:`typing.Protocol`-compliant metaclass additionally
        recording when classes with this metaclass are passed as the second
        parameter to the :func:`isinstance` builtin, enabling subsequent logic
        to validate this is *not* happening as expected.
        '''

        def __instancecheck__(cls, obj: object) -> bool:
            '''
            Unconditionally return ``False``.

            Additionally, this dunder method unconditionally records that this
            method has been called by setting an instance variable of the
            passed object set only by this method.
            '''

            # Set an instance variable of this object set only by this method.
            obj.is_checked_by_undesirable_protocol_meta = True

            # Print a string to standard output as an additional sanity check.
            print("Save when the eagle brings some hunter's bone,")

            # Unconditionally return false.
            return False


    class DesirableProtocolMeta(UndesirableProtocolMeta):
        '''
        Desirable :class:`typing.Protocol`-compliant metaclass.
        '''

        def __instancecheck__(cls, obj: object) -> bool:
            '''
            Unconditionally return ``True``.
            '''

            # Unconditionally return true.
            return True

    # ..................{ PROTOCOLS                         }..................
    @runtime_checkable
    class PileAroundIt(
        Protocol,
        metaclass=UndesirableProtocolMeta,
    ):
        '''
        Arbitrary protocol whose metaclass is undesirable.
        '''

        @abstractmethod
        def ice_and_rock(self, broad_vales_between: str) -> str:
            pass


    @runtime_checkable
    class OfFrozenFloods(
        PileAroundIt,
        Protocol,  # <-- Subprotocols *MUST* explicitly relist this. /facepalm/
        metaclass=DesirableProtocolMeta,
    ):
        '''
        Arbitrary subprotocol of the protocol declared above whose metaclass
        has been replaced with a more desirable metaclass.
        '''

        @abstractmethod
        def and_wind(self, among_the_accumulated_steeps: str) -> str:
            pass

    # ..................{ STRUCTURAL                        }..................
    class ADesertPeopledBy(object):
        '''
        Arbitrary concrete class structurally satisfying *without* subclassing
        the subprotocol declared above.
        '''

        def and_wind(self, among_the_accumulated_steeps: str) -> str:
            return among_the_accumulated_steeps + 'how hideously'

        def ice_and_rock(self, broad_vales_between: str) -> str:
            return broad_vales_between + 'unfathomable deeps,'

    # Arbitrary instance of this class.
    the_storms_alone = ADesertPeopledBy()

    # ..................{ BEARTYPE                          }..................
    @beartype
    def blue_as_the_overhanging_heaven(that_spread: OfFrozenFloods) -> str:
        '''
        Arbitrary callable annotated by the subprotocol declared above.
        '''

        return that_spread.ice_and_rock('Of frozen floods, ')

    # Assert this callable returns the expected string.
    assert blue_as_the_overhanging_heaven(that_spread=the_storms_alone) == (
        'Of frozen floods, unfathomable deeps,')

    # Critically, assert the instance variable *ONLY* defined by the
    # "UndesirableProtocolMeta" metaclass to remain undefined, implying the
    # DesirableProtocolMeta.__instancecheck__() rather than
    # UndesirableProtocolMeta.__instancecheck__() dunder method to have been
    # implicitly called by the single isinstance() call in the body of the
    # type-checking wrapper generated by @beartype above.
    assert not hasattr(
        the_storms_alone, 'is_checked_by_undesirable_protocol_meta')

# ....................{ TESTS ~ testers                   }....................
def test_is_hint_pep544_protocol() -> None:
    '''
    Test the
    :func:`beartype._util.hint.pep.proposal.utilpep544.is_hint_pep544_protocol`
    tester.
    '''

    # Defer heavyweight imports.
    from beartype._util.hint.pep.proposal.utilpep544 import (
        is_hint_pep544_protocol)
    from beartype._util.py.utilpyversion import IS_PYTHON_AT_LEAST_3_8
    from beartype_test.a00_unit.data.data_type import TYPES_BUILTIN
    from typing import (
        SupportsAbs,
        SupportsBytes,
        SupportsComplex,
        SupportsFloat,
        SupportsInt,
        SupportsRound,
        Union,
    )

    # Set of all PEP 544-compliant "typing" protocols.
    TYPING_PROTOCOLS = {
        SupportsAbs,
        SupportsBytes,
        SupportsComplex,
        SupportsFloat,
        SupportsInt,
        SupportsRound,
    }

    # Assert this tester accepts these classes *ONLY* if the active Python
    # interpreter targets at least Python >= 3.8 and thus supports PEP 544.
    for typing_protocol in TYPING_PROTOCOLS:
        assert is_hint_pep544_protocol(typing_protocol) is (
            IS_PYTHON_AT_LEAST_3_8)

    # Assert this tester rejects all builtin types. For unknown reasons, some
    # but *NOT* all builtin types (e.g., "int") erroneously present themselves
    # to be PEP 544-compliant protocols. *sigh*
    for class_builtin in TYPES_BUILTIN:
        assert is_hint_pep544_protocol(class_builtin) is False

    # Assert this tester rejects standard type hints in either case.
    assert is_hint_pep544_protocol(Union[int, str]) is False


def test_is_hint_pep544_io_generic() -> None:
    '''
    Test the
    :func:`beartype._util.hint.pep.proposal.utilpep544.is_hint_pep484_generic_io`
    tester.
    '''

    # Defer heavyweight imports.
    from beartype._util.hint.pep.proposal.utilpep544 import (
        is_hint_pep484_generic_io)
    from beartype._util.py.utilpyversion import IS_PYTHON_AT_LEAST_3_8
    from beartype_test.a00_unit.data.hint.pep.proposal.data_pep484 import (
        PEP484_GENERICS_IO)
    from typing import Union

    # Assert this tester accepts these classes *ONLY* if the active Python
    # interpreter targets at least Python >= 3.8 and thus supports PEP 544.
    for pep484_generic_io in PEP484_GENERICS_IO:
        assert is_hint_pep484_generic_io(pep484_generic_io) is (
            IS_PYTHON_AT_LEAST_3_8)

    # Assert this tester rejects standard type hints in either case.
    assert is_hint_pep484_generic_io(Union[int, str]) is False

# ....................{ TESTS ~ getters                   }....................
def test_get_hint_pep544_io_protocol_from_generic() -> None:
    '''
    Test the
    :func:`beartype._util.hint.pep.proposal.utilpep544.reduce_hint_pep484_generic_io_to_pep544_protocol`
    tester.
    '''

    # Defer heavyweight imports.
    from beartype.roar import BeartypeDecorHintPep544Exception
    from beartype._util.hint.pep.proposal.utilpep544 import (
        reduce_hint_pep484_generic_io_to_pep544_protocol)
    from beartype._util.hint.pep.proposal.utilpep593 import is_hint_pep593
    from beartype._util.py.utilpyversion import IS_PYTHON_AT_LEAST_3_8
    from beartype_test.a00_unit.data.hint.pep.proposal.data_pep484 import (
        PEP484_GENERICS_IO)
    from typing import Union

    # For each PEP 484-compliant "typing" IO generic base class...
    for pep484_generic_io in PEP484_GENERICS_IO:
        # If the active Python interpreter targets at least Python >= 3.8 and
        # thus supports PEP 544...
        if IS_PYTHON_AT_LEAST_3_8:
            # Defer version-dependent imports.
            from typing import Protocol

            # Equivalent protocol reduced from this generic.
            pep544_protocol_io = (
                reduce_hint_pep484_generic_io_to_pep544_protocol(
                    pep484_generic_io, ''))

            # Assert this protocol is either...
            assert (
                # A PEP 593-compliant type metahint generalizing a protocol
                # *OR*...
                is_hint_pep593(pep544_protocol_io) or
                # A PEP 544-compliant protocol.
                issubclass(pep544_protocol_io, Protocol)
            )
        # Else, assert this function raises an exception.
        else:
            with raises(BeartypeDecorHintPep544Exception):
                reduce_hint_pep484_generic_io_to_pep544_protocol(
                    pep484_generic_io, '')

    # Assert this function rejects standard type hints in either case.
    with raises(BeartypeDecorHintPep544Exception):
        reduce_hint_pep484_generic_io_to_pep544_protocol(Union[int, str], '')
