# SPDX-License-Identifier: MIT

"""
Integration tests for next-generation APIs.
"""

import re

from contextlib import contextmanager
from functools import partial

import pytest

import attr as _attr  # don't use it by accident
import attrs


@attrs.define
class C:
    x: str
    y: int


class TestNextGen:
    def test_simple(self):
        """
        Instantiation works.
        """
        C("1", 2)

    def test_field_type(self):
        """
        Make class with attrs.field and type parameter.
        """
        classFields = {"testint": attrs.field(type=int)}

        A = attrs.make_class("A", classFields)

        assert int == attrs.fields(A).testint.type

    def test_no_slots(self):
        """
        slots can be deactivated.
        """

        @attrs.define(slots=False)
        class NoSlots:
            x: int

        ns = NoSlots(1)

        assert {"x": 1} == ns.__dict__

    def test_validates(self):
        """
        Validators at __init__ and __setattr__ work.
        """

        @attrs.define
        class Validated:
            x: int = attrs.field(validator=attrs.validators.instance_of(int))

        v = Validated(1)

        with pytest.raises(TypeError):
            Validated(None)

        with pytest.raises(TypeError):
            v.x = "1"

    def test_no_order(self):
        """
        Order is off by default but can be added.
        """
        with pytest.raises(TypeError):
            C("1", 2) < C("2", 3)

        @attrs.define(order=True)
        class Ordered:
            x: int

        assert Ordered(1) < Ordered(2)

    def test_override_auto_attribs_true(self):
        """
        Don't guess if auto_attrib is set explicitly.

        Having an unannotated attrs.ib/attrs.field fails.
        """
        with pytest.raises(attrs.exceptions.UnannotatedAttributeError):

            @attrs.define(auto_attribs=True)
            class ThisFails:
                x = attrs.field()
                y: int

    def test_override_auto_attribs_false(self):
        """
        Don't guess if auto_attrib is set explicitly.

        Annotated fields that don't carry an attrs.ib are ignored.
        """

        @attrs.define(auto_attribs=False)
        class NoFields:
            x: int
            y: int

        assert NoFields() == NoFields()

    def test_auto_attribs_detect(self):
        """
        define correctly detects if a class lacks type annotations.
        """

        @attrs.define
        class OldSchool:
            x = attrs.field()

        assert OldSchool(1) == OldSchool(1)

        # Test with maybe_cls = None
        @attrs.define()
        class OldSchool2:
            x = attrs.field()

        assert OldSchool2(1) == OldSchool2(1)

    def test_auto_attribs_detect_fields_and_annotations(self):
        """
        define infers auto_attribs=True if fields have type annotations
        """

        @attrs.define
        class NewSchool:
            x: int
            y: list = attrs.field()

            @y.validator
            def _validate_y(self, attribute, value):
                if value < 0:
                    raise ValueError("y must be positive")

        assert NewSchool(1, 1) == NewSchool(1, 1)
        with pytest.raises(ValueError):
            NewSchool(1, -1)
        assert list(attrs.fields_dict(NewSchool).keys()) == ["x", "y"]

    def test_auto_attribs_partially_annotated(self):
        """
        define infers auto_attribs=True if any type annotations are found
        """

        @attrs.define
        class NewSchool:
            x: int
            y: list
            z = 10

        # fields are defined for any annotated attributes
        assert NewSchool(1, []) == NewSchool(1, [])
        assert list(attrs.fields_dict(NewSchool).keys()) == ["x", "y"]

        # while the unannotated attributes are left as class vars
        assert NewSchool.z == 10
        assert "z" in NewSchool.__dict__

    def test_auto_attribs_detect_annotations(self):
        """
        define correctly detects if a class has type annotations.
        """

        @attrs.define
        class NewSchool:
            x: int

        assert NewSchool(1) == NewSchool(1)

        # Test with maybe_cls = None
        @attrs.define()
        class NewSchool2:
            x: int

        assert NewSchool2(1) == NewSchool2(1)

    def test_exception(self):
        """
        Exceptions are detected and correctly handled.
        """

        @attrs.define
        class E(Exception):
            msg: str
            other: int

        with pytest.raises(E) as ei:
            raise E("yolo", 42)

        e = ei.value

        assert ("yolo", 42) == e.args
        assert "yolo" == e.msg
        assert 42 == e.other

    def test_frozen(self):
        """
        attrs.frozen freezes classes.
        """

        @attrs.frozen
        class F:
            x: str

        f = F(1)

        with pytest.raises(attrs.exceptions.FrozenInstanceError):
            f.x = 2

    def test_auto_detect_eq(self):
        """
        auto_detect=True works for eq.

        Regression test for #670.
        """

        @attrs.define
        class C:
            def __eq__(self, o):
                raise ValueError()

        with pytest.raises(ValueError):
            C() == C()

    def test_subclass_frozen(self):
        """
        It's possible to subclass an `attrs.frozen` class and the frozen-ness
        is inherited.
        """

        @attrs.frozen
        class A:
            a: int

        @attrs.frozen
        class B(A):
            b: int

        @attrs.define(on_setattr=attrs.setters.NO_OP)
        class C(B):
            c: int

        assert B(1, 2) == B(1, 2)
        assert C(1, 2, 3) == C(1, 2, 3)

        with pytest.raises(attrs.exceptions.FrozenInstanceError):
            A(1).a = 1

        with pytest.raises(attrs.exceptions.FrozenInstanceError):
            B(1, 2).a = 1

        with pytest.raises(attrs.exceptions.FrozenInstanceError):
            B(1, 2).b = 2

        with pytest.raises(attrs.exceptions.FrozenInstanceError):
            C(1, 2, 3).c = 3

    def test_catches_frozen_on_setattr(self):
        """
        Passing frozen=True and on_setattr hooks is caught, even if the
        immutability is inherited.
        """

        @attrs.define(frozen=True)
        class A:
            pass

        with pytest.raises(
            ValueError, match="Frozen classes can't use on_setattr."
        ):

            @attrs.define(frozen=True, on_setattr=attrs.setters.validate)
            class B:
                pass

        with pytest.raises(
            ValueError,
            match=re.escape(
                "Frozen classes can't use on_setattr "
                "(frozen-ness was inherited)."
            ),
        ):

            @attrs.define(on_setattr=attrs.setters.validate)
            class C(A):
                pass

    @pytest.mark.parametrize(
        "decorator",
        [
            partial(_attr.s, frozen=True, slots=True, auto_exc=True),
            attrs.frozen,
            attrs.define,
            attrs.mutable,
        ],
    )
    def test_discard_context(self, decorator):
        """
        raise from None works.

        Regression test for #703.
        """

        @decorator
        class MyException(Exception):
            x: str = attrs.field()

        with pytest.raises(MyException) as ei:
            try:
                raise ValueError()
            except ValueError:
                raise MyException("foo") from None

        assert "foo" == ei.value.x
        assert ei.value.__cause__ is None

    @pytest.mark.parametrize(
        "decorator",
        [
            partial(_attr.s, frozen=True, slots=True, auto_exc=True),
            attrs.frozen,
            attrs.define,
            attrs.mutable,
        ],
    )
    def test_setting_traceback_on_exception(self, decorator):
        """
        contextlib.contextlib (re-)sets __traceback__ on raised exceptions.

        Ensure that works, as well as if done explicitly
        """

        @decorator
        class MyException(Exception):
            pass

        @contextmanager
        def do_nothing():
            yield

        with do_nothing(), pytest.raises(MyException) as ei:
            raise MyException()

        assert isinstance(ei.value, MyException)

        # this should not raise an exception either
        ei.value.__traceback__ = ei.value.__traceback__

    def test_converts_and_validates_by_default(self):
        """
        If no on_setattr is set, assume setters.convert, setters.validate.
        """

        @attrs.define
        class C:
            x: int = attrs.field(converter=int)

            @x.validator
            def _v(self, _, value):
                if value < 10:
                    raise ValueError("must be >=10")

        inst = C(10)

        # Converts
        inst.x = "11"

        assert 11 == inst.x

        # Validates
        with pytest.raises(ValueError, match="must be >=10"):
            inst.x = "9"

    def test_mro_ng(self):
        """
        Attributes and methods are looked up the same way in NG by default.

        See #428
        """

        @attrs.define
        class A:
            x: int = 10

            def xx(self):
                return 10

        @attrs.define
        class B(A):
            y: int = 20

        @attrs.define
        class C(A):
            x: int = 50

            def xx(self):
                return 50

        @attrs.define
        class D(B, C):
            pass

        d = D()

        assert d.x == d.xx()


class TestAsTuple:
    def test_smoke(self):
        """
        `attrs.astuple` only changes defaults, so we just call it and compare.
        """
        inst = C("foo", 42)

        assert attrs.astuple(inst) == _attr.astuple(inst)


class TestAsDict:
    def test_smoke(self):
        """
        `attrs.asdict` only changes defaults, so we just call it and compare.
        """
        inst = C("foo", {(1,): 42})

        assert attrs.asdict(inst) == _attr.asdict(
            inst, retain_collection_types=True
        )


class TestImports:
    """
    Verify our re-imports and mirroring works.
    """

    def test_converters(self):
        """
        Importing from attrs.converters works.
        """
        from attrs.converters import optional

        assert optional is _attr.converters.optional

    def test_exceptions(self):
        """
        Importing from attrs.exceptions works.
        """
        from attrs.exceptions import FrozenError

        assert FrozenError is _attr.exceptions.FrozenError

    def test_filters(self):
        """
        Importing from attrs.filters works.
        """
        from attrs.filters import include

        assert include is _attr.filters.include

    def test_setters(self):
        """
        Importing from attrs.setters works.
        """
        from attrs.setters import pipe

        assert pipe is _attr.setters.pipe

    def test_validators(self):
        """
        Importing from attrs.validators works.
        """
        from attrs.validators import and_

        assert and_ is _attr.validators.and_
