diff --git a/requirements/tox-pin-base.txt b/requirements/tox-pin-base.txt index 2440a65c..b05d5d17 100644 --- a/requirements/tox-pin-base.txt +++ b/requirements/tox-pin-base.txt @@ -1,4 +1,4 @@ -attrs==23.2.0 +attrs==24.2.0 Automat==22.10.0 characteristic==14.3.0 constantly==15.1.0 diff --git a/src/klein/_attrs_zope.py b/src/klein/_attrs_zope.py new file mode 100644 index 00000000..4eb9a056 --- /dev/null +++ b/src/klein/_attrs_zope.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +import attrs +from zope.interface import Interface + + +@attrs.define(repr=False) +class _ProvidesValidator: + interface: type[Interface] = attrs.field() + + def __call__( + self, inst: object, attr: attrs.Attribute, value: object + ) -> None: + """ + We use a callable class to be able to change the ``__repr__``. + """ + if not self.interface.providedBy(value): + msg = ( + f"'{attr.name}' must provide {self.interface!r} " + f"which {value!r} doesn't." + ) + raise TypeError( + msg, + attr, + self.interface, + value, + ) + + def __repr__(self) -> str: + return f"" + + +def provides(interface: type[Interface]) -> _ProvidesValidator: + """ + A validator that raises a `TypeError` if the initializer is called + with an object that does not provide the requested *interface* (checks are + performed using ``interface.providedBy(value)`` (see `zope.interface + `_). + + :param interface: The interface to check for. + :type interface: ``zope.interface.Interface`` + + :raises TypeError: With a human readable error message, the attribute + (of type `attrs.Attribute`), the expected interface, and the + value it got. + """ + return _ProvidesValidator(interface) diff --git a/src/klein/_request.py b/src/klein/_request.py index 1a511902..6ccb86f7 100644 --- a/src/klein/_request.py +++ b/src/klein/_request.py @@ -8,11 +8,12 @@ from typing import Union from attr import Factory, attrib, attrs -from attr.validators import instance_of, provides +from attr.validators import instance_of from hyperlink import DecodedURL from tubes.itube import IFount from zope.interface import implementer +from ._attrs_zope import provides from ._imessage import IHTTPHeaders, IHTTPRequest from ._message import MessageState, bodyAsBytes, bodyAsFount, validateBody diff --git a/src/klein/_request_compat.py b/src/klein/_request_compat.py index 5de3e35f..6434cafb 100644 --- a/src/klein/_request_compat.py +++ b/src/klein/_request_compat.py @@ -9,7 +9,6 @@ from typing import cast from attr import Factory, attrib, attrs -from attr.validators import provides from hyperlink import DecodedURL from tubes.itube import IFount from zope.interface import implementer @@ -17,6 +16,7 @@ from twisted.python.compat import nativeString from twisted.web.iweb import IRequest +from ._attrs_zope import provides from ._headers import IHTTPHeaders from ._headers_compat import HTTPHeadersWrappingHeaders from ._message import FountAlreadyAccessedError, MessageState diff --git a/src/klein/_response.py b/src/klein/_response.py index 353f5028..c91ddd05 100644 --- a/src/klein/_response.py +++ b/src/klein/_response.py @@ -8,10 +8,11 @@ from typing import Union from attr import Factory, attrib, attrs -from attr.validators import instance_of, provides +from attr.validators import instance_of from tubes.itube import IFount from zope.interface import implementer +from ._attrs_zope import provides from ._imessage import IHTTPHeaders, IHTTPResponse from ._message import MessageState, bodyAsBytes, bodyAsFount, validateBody diff --git a/src/klein/_tubes.py b/src/klein/_tubes.py index 59702283..34b7756a 100644 --- a/src/klein/_tubes.py +++ b/src/klein/_tubes.py @@ -8,7 +8,7 @@ from typing import Any, BinaryIO from attr import attrib, attrs -from attr.validators import instance_of, optional, provides +from attr.validators import instance_of, optional from tubes.itube import IDrain, IFount, ISegment from tubes.kit import Pauser, beginFlowingTo from tubes.undefer import fountToDeferred @@ -16,6 +16,8 @@ from twisted.python.failure import Failure +from ._attrs_zope import provides + __all__ = () diff --git a/src/klein/test/test_attrs_zope.py b/src/klein/test/test_attrs_zope.py new file mode 100644 index 00000000..9a89a126 --- /dev/null +++ b/src/klein/test/test_attrs_zope.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import attrs +from zope.interface import Interface, implementer + +from twisted.trial.unittest import SynchronousTestCase + +from .._attrs_zope import provides + + +class IWhatever(Interface): + ... + + +@implementer(IWhatever) +class YesWhatever: + ... + + +class NoWhatever: + ... + + +@attrs.define() +class WhateverContainer: + whatever: object = attrs.field(validator=provides(IWhatever)) + + +class ProvidesTestCase(SynchronousTestCase): + def test_yes(self) -> None: + WhateverContainer(YesWhatever()) + + def test_no(self) -> None: + with self.assertRaises(TypeError): + WhateverContainer(NoWhatever()) + + def test_repr(self) -> None: + self.assertIn("provides validator for", repr(provides(IWhatever))) + self.assertIn("IWhatever", repr(provides(IWhatever)))