From cd9bba594d5b0aecadd6a30e2c13feda2ff28d17 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Wed, 11 Oct 2023 20:17:49 +0400 Subject: [PATCH 01/10] Initial support for typing.Annotated. --- scrapy_poet/injection.py | 22 ++++++++--- tests/test_injection.py | 81 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 97 insertions(+), 6 deletions(-) diff --git a/scrapy_poet/injection.py b/scrapy_poet/injection.py index 92de6a12..80c94c81 100644 --- a/scrapy_poet/injection.py +++ b/scrapy_poet/injection.py @@ -2,11 +2,12 @@ import logging import os import pprint +import sys import warnings from typing import Any, Callable, Dict, List, Mapping, Optional, Set, cast import andi -from andi.typeutils import issubclass_safe +from andi.typeutils import issubclass_safe, strip_annotated from scrapy import Request, Spider from scrapy.crawler import Crawler from scrapy.http import Response @@ -117,7 +118,7 @@ def discover_callback_providers( result = set() for cls, _ in plan: for provider in self.providers: - if provider.is_provided(cls): + if provider.is_provided(strip_annotated(cls)): result.add(provider) return result @@ -193,7 +194,9 @@ def build_instances_from_providers( objs: List[Any] for provider in self.providers: provided_classes = { - cls for cls in dependencies_set if provider.is_provided(cls) + cls + for cls in dependencies_set + if provider.is_provided(strip_annotated(cls)) } # ignore already provided types if provider doesn't need to use them @@ -261,7 +264,16 @@ def build_instances_from_providers( self.crawler.stats.inc_value("poet/cache/firsthand") raise - objs_by_type: Dict[Callable, Any] = {type(obj): obj for obj in objs} + objs_by_type: Dict[Callable, Any] = {} + for obj in objs: + cls = type(obj) + if sys.version_info >= (3, 9) and ( + metadata := getattr(obj, "__metadata__", None) + ): + from typing import Annotated + + cls = Annotated[cls, *metadata] + objs_by_type[cls] = obj extra_classes = objs_by_type.keys() - provided_classes if extra_classes: raise UndeclaredProvidedTypeError( @@ -330,7 +342,7 @@ def is_class_provided_by_any_provider_fn( def is_provided_fn(type: Callable) -> bool: for is_provided in individual_is_callable: - if is_provided(type): + if is_provided(strip_annotated(type)): return True return False diff --git a/tests/test_injection.py b/tests/test_injection.py index 9a0f0eba..0d7090db 100644 --- a/tests/test_injection.py +++ b/tests/test_injection.py @@ -1,4 +1,5 @@ import shutil +import sys import attr import parsel @@ -37,7 +38,13 @@ def __init__(self, crawler): self.crawler = crawler def __call__(self, to_provide): - return [cls(content) if content else cls() for cls in classes] + result = [] + for cls in to_provide: + obj = cls(content) if content else cls() + if metadata := getattr(cls, "__metadata__", None): + obj.__metadata__ = metadata + result.append(obj) + return result return Provider @@ -267,6 +274,78 @@ def callback( "d": ClsNoProviderRequired, } + @pytest.mark.skipif( + sys.version_info < (3, 9), reason="No Annotated support in Python < 3.9" + ) + def test_annotated_provide(self, injector): + from typing import Annotated + + assert injector.is_class_provided_by_any_provider(Annotated[Cls1, 42]) + + @pytest.mark.skipif( + sys.version_info < (3, 9), reason="No Annotated support in Python < 3.9" + ) + @inlineCallbacks + def test_annotated_build(self, injector): + from typing import Annotated + + def callback( + a: Cls1, + b: Annotated[Cls2, 42], + ): + pass + + response = get_response_for_testing(callback) + request = response.request + + plan = injector.build_plan(response.request) + instances = yield from injector.build_instances(request, response, plan) + assert instances == { + Cls1: Cls1(), + Annotated[Cls2, 42]: Cls2(), + } + + kwargs = yield from injector.build_callback_dependencies(request, response) + assert kwargs == { + "a": Cls1(), + "b": Cls2(), + } + + @pytest.mark.skipif( + sys.version_info < (3, 9), reason="No Annotated support in Python < 3.9" + ) + @inlineCallbacks + def test_annotated_build_duplicate(self, injector): + from typing import Annotated + + def callback( + a: Cls1, + b: Cls2, + c: Annotated[Cls2, 42], + d: Annotated[Cls2, 43], + ): + pass + + response = get_response_for_testing(callback) + request = response.request + + plan = injector.build_plan(response.request) + instances = yield from injector.build_instances(request, response, plan) + assert instances == { + Cls1: Cls1(), + Cls2: Cls2(), + Annotated[Cls2, 42]: Cls2(), + Annotated[Cls2, 43]: Cls2(), + } + + kwargs = yield from injector.build_callback_dependencies(request, response) + assert kwargs == { + "a": Cls1(), + "b": Cls2(), + "c": Cls2(), + "d": Cls2(), + } + class Html(Injectable): url = "http://example.com" From dc81782fcda0e89fbed1462845bbd732c230fd25 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Wed, 11 Oct 2023 20:38:17 +0400 Subject: [PATCH 02/10] Add one more test. --- tests/test_injection.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/test_injection.py b/tests/test_injection.py index 0d7090db..a7a8596d 100644 --- a/tests/test_injection.py +++ b/tests/test_injection.py @@ -311,6 +311,32 @@ def callback( "b": Cls2(), } + @pytest.mark.skipif( + sys.version_info < (3, 9), reason="No Annotated support in Python < 3.9" + ) + @inlineCallbacks + def test_annotated_build_only(self, injector): + from typing import Annotated + + def callback( + a: Annotated[Cls1, 42], + ): + pass + + response = get_response_for_testing(callback) + request = response.request + + plan = injector.build_plan(response.request) + instances = yield from injector.build_instances(request, response, plan) + assert instances == { + Annotated[Cls1, 42]: Cls1(), + } + + kwargs = yield from injector.build_callback_dependencies(request, response) + assert kwargs == { + "a": Cls1(), + } + @pytest.mark.skipif( sys.version_info < (3, 9), reason="No Annotated support in Python < 3.9" ) From 9be4e58322b9cf755c47a730c833f1b0c4443267 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Wed, 11 Oct 2023 22:09:53 +0400 Subject: [PATCH 03/10] Don't use a variadic generic syntax. --- scrapy_poet/injection.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scrapy_poet/injection.py b/scrapy_poet/injection.py index 80c94c81..693d7170 100644 --- a/scrapy_poet/injection.py +++ b/scrapy_poet/injection.py @@ -272,7 +272,7 @@ def build_instances_from_providers( ): from typing import Annotated - cls = Annotated[cls, *metadata] + cls = Annotated[(cls, *metadata)] objs_by_type[cls] = obj extra_classes = objs_by_type.keys() - provided_classes if extra_classes: From 65bd3e6cb312de74552291c491201a1bf2843548 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Fri, 13 Oct 2023 18:45:20 +0400 Subject: [PATCH 04/10] Improve returning annotated deps. --- scrapy_poet/__init__.py | 2 +- scrapy_poet/api.py | 14 +++++++++++++- scrapy_poet/injection.py | 15 ++++++--------- tests/test_injection.py | 3 ++- 4 files changed, 22 insertions(+), 12 deletions(-) diff --git a/scrapy_poet/__init__.py b/scrapy_poet/__init__.py index 337188a9..6b948a29 100644 --- a/scrapy_poet/__init__.py +++ b/scrapy_poet/__init__.py @@ -1,4 +1,4 @@ -from .api import DummyResponse, callback_for +from .api import AnnotatedResult, DummyResponse, callback_for from .downloadermiddlewares import InjectionMiddleware from .page_input_providers import HttpResponseProvider, PageObjectInputProvider from .spidermiddlewares import RetryMiddleware diff --git a/scrapy_poet/api.py b/scrapy_poet/api.py index 6efbf113..62eb7d16 100644 --- a/scrapy_poet/api.py +++ b/scrapy_poet/api.py @@ -1,5 +1,6 @@ +from dataclasses import dataclass from inspect import iscoroutinefunction -from typing import Callable, Optional, Type +from typing import Any, Callable, Optional, Tuple, Type from scrapy.http import Request, Response from web_poet.pages import ItemPage @@ -133,3 +134,14 @@ def parse(*args, item: page_or_item_cls, **kwargs): # type:ignore setattr(parse, _CALLBACK_FOR_MARKER, True) return parse + + +@dataclass +class AnnotatedResult: + result: Any + metadata: Tuple[Any, ...] + + def get_annotated_cls(self): + from typing import Annotated + + return Annotated[(type(self.result), *self.metadata)] diff --git a/scrapy_poet/injection.py b/scrapy_poet/injection.py index 693d7170..89150b19 100644 --- a/scrapy_poet/injection.py +++ b/scrapy_poet/injection.py @@ -2,7 +2,6 @@ import logging import os import pprint -import sys import warnings from typing import Any, Callable, Dict, List, Mapping, Optional, Set, cast @@ -23,7 +22,7 @@ from web_poet.serialization.api import deserialize_leaf, load_class, serialize from web_poet.utils import get_fq_class_name -from scrapy_poet.api import _CALLBACK_FOR_MARKER, DummyResponse +from scrapy_poet.api import _CALLBACK_FOR_MARKER, AnnotatedResult, DummyResponse from scrapy_poet.cache import SerializedDataCache from scrapy_poet.injection_errors import ( InjectionError, @@ -266,13 +265,11 @@ def build_instances_from_providers( objs_by_type: Dict[Callable, Any] = {} for obj in objs: - cls = type(obj) - if sys.version_info >= (3, 9) and ( - metadata := getattr(obj, "__metadata__", None) - ): - from typing import Annotated - - cls = Annotated[(cls, *metadata)] + if isinstance(obj, AnnotatedResult): + cls = obj.get_annotated_cls() + obj = obj.result + else: + cls = type(obj) objs_by_type[cls] = obj extra_classes = objs_by_type.keys() - provided_classes if extra_classes: diff --git a/tests/test_injection.py b/tests/test_injection.py index a7a8596d..40558026 100644 --- a/tests/test_injection.py +++ b/tests/test_injection.py @@ -15,6 +15,7 @@ from scrapy_poet import DummyResponse, HttpResponseProvider, PageObjectInputProvider from scrapy_poet.injection import ( + AnnotatedResult, check_all_providers_are_callable, get_injector_for_testing, get_response_for_testing, @@ -42,7 +43,7 @@ def __call__(self, to_provide): for cls in to_provide: obj = cls(content) if content else cls() if metadata := getattr(cls, "__metadata__", None): - obj.__metadata__ = metadata + obj = AnnotatedResult(obj, metadata) result.append(obj) return result From d95b077f56133e97dc3142c1abcd0107af442c8f Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Mon, 16 Oct 2023 19:08:18 +0400 Subject: [PATCH 05/10] Add more Annotated tests. --- scrapy_poet/injection.py | 2 +- tests/test_injection.py | 72 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/scrapy_poet/injection.py b/scrapy_poet/injection.py index 89150b19..3b8d9bf5 100644 --- a/scrapy_poet/injection.py +++ b/scrapy_poet/injection.py @@ -276,7 +276,7 @@ def build_instances_from_providers( raise UndeclaredProvidedTypeError( f"{provider} has returned instances of types {extra_classes} " "that are not among the declared supported classes in the " - f"provider: {provider.provided_classes}" + f"provider: {provided_classes}" ) instances.update(objs_by_type) diff --git a/tests/test_injection.py b/tests/test_injection.py index 40558026..ef1d3285 100644 --- a/tests/test_injection.py +++ b/tests/test_injection.py @@ -4,6 +4,7 @@ import attr import parsel import pytest +from andi.typeutils import strip_annotated from pytest_twisted import inlineCallbacks from scrapy import Request from scrapy.http import Response @@ -373,6 +374,77 @@ def callback( "d": Cls2(), } + @pytest.mark.skipif( + sys.version_info < (3, 9), reason="No Annotated support in Python < 3.9" + ) + @inlineCallbacks + def test_annotated_build_no_support(self, injector): + from typing import Annotated + + # get_provider_requiring_response() returns a provider that doesn't support Annotated + def callback( + a: Cls1, + b: Annotated[ClsReqResponse, 42], + ): + pass + + response = get_response_for_testing(callback) + request = response.request + + plan = injector.build_plan(response.request) + with pytest.raises(UndeclaredProvidedTypeError) as ex: + yield from injector.build_instances(request, response, plan) + assert "typing.Annotated" in str(ex) + + @pytest.mark.skipif( + sys.version_info < (3, 9), reason="No Annotated support in Python < 3.9" + ) + @inlineCallbacks + def test_annotated_build_duplicate_forbidden( + self, + ): + from typing import Annotated + + class Provider(PageObjectInputProvider): + provided_classes = {Cls1} + require_response = False + + def __init__(self, crawler): + self.crawler = crawler + + def __call__(self, to_provide): + result = [] + processed_classes = set() + for cls in to_provide: + if (cls_stripped := strip_annotated(cls)) in processed_classes: + raise ValueError( + f"Different instances of {cls_stripped.__name__} requested" + ) + processed_classes.add(cls_stripped) + obj = cls() + if metadata := getattr(cls, "__metadata__", None): + obj = AnnotatedResult(obj, metadata) + result.append(obj) + return result + + def callback( + a: Annotated[Cls1, 42], + b: Annotated[Cls1, 43], + ): + pass + + response = get_response_for_testing(callback) + request = response.request + + providers = { + Provider: 1, + } + injector = get_injector_for_testing(providers) + + plan = injector.build_plan(response.request) + with pytest.raises(ValueError, match="Different instances of Cls1 requested"): + yield from injector.build_instances(request, response, plan) + class Html(Injectable): url = "http://example.com" From 7b28f0283fb061e3e84ccce5ae052d8260334040 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Tue, 14 Nov 2023 16:51:22 +0400 Subject: [PATCH 06/10] Move strip_annontated inside providers, unify is_provided code. --- scrapy_poet/injection.py | 37 +++++++---------------------- scrapy_poet/page_input_providers.py | 15 ++++++++++-- tests/test_injection.py | 30 ++++++++++++++++------- 3 files changed, 43 insertions(+), 39 deletions(-) diff --git a/scrapy_poet/injection.py b/scrapy_poet/injection.py index 3b8d9bf5..7b6baa3f 100644 --- a/scrapy_poet/injection.py +++ b/scrapy_poet/injection.py @@ -6,7 +6,7 @@ from typing import Any, Callable, Dict, List, Mapping, Optional, Set, cast import andi -from andi.typeutils import issubclass_safe, strip_annotated +from andi.typeutils import issubclass_safe from scrapy import Request, Spider from scrapy.crawler import Crawler from scrapy.http import Response @@ -25,7 +25,6 @@ from scrapy_poet.api import _CALLBACK_FOR_MARKER, AnnotatedResult, DummyResponse from scrapy_poet.cache import SerializedDataCache from scrapy_poet.injection_errors import ( - InjectionError, NonCallableProviderError, UndeclaredProvidedTypeError, ) @@ -117,7 +116,7 @@ def discover_callback_providers( result = set() for cls, _ in plan: for provider in self.providers: - if provider.is_provided(strip_annotated(cls)): + if provider.is_provided(cls): result.add(provider) return result @@ -193,9 +192,7 @@ def build_instances_from_providers( objs: List[Any] for provider in self.providers: provided_classes = { - cls - for cls in dependencies_set - if provider.is_provided(strip_annotated(cls)) + cls for cls in dependencies_set if provider.is_provided(cls) } # ignore already provided types if provider doesn't need to use them @@ -315,31 +312,15 @@ def is_class_provided_by_any_provider_fn( Return a function of type ``Callable[[Type], bool]`` that return True if the given type is provided by any of the registered providers. - The attribute ``provided_classes`` from each provided is used. - This attribute can be a :class:`set` or a ``Callable``. All sets are - joined together for efficiency. + The ``is_provided`` method from each provider is used. """ - sets_of_types: Set[Callable] = set() # caching all sets found - individual_is_callable: List[Callable[[Callable], bool]] = [ - sets_of_types.__contains__ - ] + callables: List[Callable[[Callable], bool]] = [] for provider in providers: - provided_classes = provider.provided_classes - - if isinstance(provided_classes, (Set, frozenset)): - sets_of_types.update(provided_classes) - elif callable(provider.provided_classes): - individual_is_callable.append(provided_classes) - else: - raise InjectionError( - f"Unexpected type '{type(provided_classes)}' for " - f"'{type(provider)}.provided_classes'. Expected either 'set' " - f"or 'callable'" - ) + callables.append(provider.is_provided) - def is_provided_fn(type: Callable) -> bool: - for is_provided in individual_is_callable: - if is_provided(strip_annotated(type)): + def is_provided_fn(type_: Callable) -> bool: + for is_provided in callables: + if is_provided(type_): return True return False diff --git a/scrapy_poet/page_input_providers.py b/scrapy_poet/page_input_providers.py index 672909f0..4b57ad00 100644 --- a/scrapy_poet/page_input_providers.py +++ b/scrapy_poet/page_input_providers.py @@ -11,7 +11,18 @@ import asyncio from dataclasses import make_dataclass from inspect import isclass -from typing import Any, Callable, ClassVar, Dict, List, Optional, Set, Type, Union +from typing import ( + Any, + Callable, + ClassVar, + Dict, + FrozenSet, + List, + Optional, + Set, + Type, + Union, +) from warnings import warn from weakref import WeakKeyDictionary @@ -117,7 +128,7 @@ def is_provided(self, type_: Callable) -> bool: Return ``True`` if the given type is provided by this provider based on the value of the attribute ``provided_classes`` """ - if isinstance(self.provided_classes, Set): + if isinstance(self.provided_classes, (Set, FrozenSet)): return type_ in self.provided_classes elif callable(self.provided_classes): return self.provided_classes(type_) diff --git a/tests/test_injection.py b/tests/test_injection.py index ef1d3285..8b3ef970 100644 --- a/tests/test_injection.py +++ b/tests/test_injection.py @@ -1,5 +1,6 @@ import shutil import sys +from typing import Callable import attr import parsel @@ -24,6 +25,7 @@ ) from scrapy_poet.injection_errors import ( InjectionError, + MalformedProvidedClassesError, NonCallableProviderError, UndeclaredProvidedTypeError, ) @@ -39,6 +41,9 @@ class Provider(PageObjectInputProvider): def __init__(self, crawler): self.crawler = crawler + def is_provided(self, type_: Callable) -> bool: + return super().is_provided(strip_annotated(type_)) + def __call__(self, to_provide): result = [] for cls in to_provide: @@ -392,9 +397,12 @@ def callback( request = response.request plan = injector.build_plan(response.request) - with pytest.raises(UndeclaredProvidedTypeError) as ex: - yield from injector.build_instances(request, response, plan) - assert "typing.Annotated" in str(ex) + instances = yield from injector.build_instances_from_providers( + request, response, plan + ) + assert instances == { + Cls1: Cls1(), + } @pytest.mark.skipif( sys.version_info < (3, 9), reason="No Annotated support in Python < 3.9" @@ -412,6 +420,9 @@ class Provider(PageObjectInputProvider): def __init__(self, crawler): self.crawler = crawler + def is_provided(self, type_: Callable) -> bool: + return super().is_provided(strip_annotated(type_)) + def __call__(self, to_provide): result = [] processed_classes = set() @@ -623,11 +634,12 @@ def test_check_all_providers_are_callable(): assert "not callable" in str(exinf.value) -def test_is_class_provided_by_any_provider_fn(): +def test_is_class_provided_by_any_provider_fn(injector): + crawler = injector.crawler providers = [ - get_provider({str}), - get_provider(lambda x: issubclass(x, InjectionError)), - get_provider(frozenset({int, float})), + get_provider({str})(crawler), + get_provider(lambda self, x: issubclass(x, InjectionError))(crawler), + get_provider(frozenset({int, float}))(crawler), ] is_provided = is_class_provided_by_any_provider_fn(providers) is_provided_empty = is_class_provided_by_any_provider_fn([]) @@ -643,8 +655,8 @@ def test_is_class_provided_by_any_provider_fn(): class WrongProvider(PageObjectInputProvider): provided_classes = [str] # Lists are not allowed, only sets or funcs - with pytest.raises(InjectionError): - is_class_provided_by_any_provider_fn([WrongProvider]) + with pytest.raises(MalformedProvidedClassesError): + is_class_provided_by_any_provider_fn([WrongProvider(injector)])(str) def get_provider_for_cache(classes, a_name, content=None, error=ValueError): From 7d8a750433cd8162d878709d1584d4b1079d4ccb Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Tue, 14 Nov 2023 17:02:57 +0400 Subject: [PATCH 07/10] Refactor annotated tests. --- tests/test_injection.py | 63 +++++++++++++++++++++++------------------ 1 file changed, 35 insertions(+), 28 deletions(-) diff --git a/tests/test_injection.py b/tests/test_injection.py index 8b3ef970..3da2933b 100644 --- a/tests/test_injection.py +++ b/tests/test_injection.py @@ -1,6 +1,6 @@ import shutil import sys -from typing import Callable +from typing import Any, Callable, Dict import attr import parsel @@ -18,6 +18,7 @@ from scrapy_poet import DummyResponse, HttpResponseProvider, PageObjectInputProvider from scrapy_poet.injection import ( AnnotatedResult, + Injector, check_all_providers_are_callable, get_injector_for_testing, get_response_for_testing, @@ -281,6 +282,24 @@ def callback( "d": ClsNoProviderRequired, } + @staticmethod + @inlineCallbacks + def _assert_instances( + injector: Injector, + callback: Callable, + expected_instances: Dict[type, Any], + expected_kwargs: Dict[str, Any], + ) -> None: + response = get_response_for_testing(callback) + request = response.request + + plan = injector.build_plan(response.request) + instances = yield from injector.build_instances(request, response, plan) + assert instances == expected_instances + + kwargs = yield from injector.build_callback_dependencies(request, response) + assert kwargs == expected_kwargs + @pytest.mark.skipif( sys.version_info < (3, 9), reason="No Annotated support in Python < 3.9" ) @@ -302,21 +321,17 @@ def callback( ): pass - response = get_response_for_testing(callback) - request = response.request - - plan = injector.build_plan(response.request) - instances = yield from injector.build_instances(request, response, plan) - assert instances == { + expected_instances = { Cls1: Cls1(), Annotated[Cls2, 42]: Cls2(), } - - kwargs = yield from injector.build_callback_dependencies(request, response) - assert kwargs == { + expected_kwargs = { "a": Cls1(), "b": Cls2(), } + yield self._assert_instances( + injector, callback, expected_instances, expected_kwargs + ) @pytest.mark.skipif( sys.version_info < (3, 9), reason="No Annotated support in Python < 3.9" @@ -330,19 +345,15 @@ def callback( ): pass - response = get_response_for_testing(callback) - request = response.request - - plan = injector.build_plan(response.request) - instances = yield from injector.build_instances(request, response, plan) - assert instances == { + expected_instances = { Annotated[Cls1, 42]: Cls1(), } - - kwargs = yield from injector.build_callback_dependencies(request, response) - assert kwargs == { + expected_kwargs = { "a": Cls1(), } + yield self._assert_instances( + injector, callback, expected_instances, expected_kwargs + ) @pytest.mark.skipif( sys.version_info < (3, 9), reason="No Annotated support in Python < 3.9" @@ -359,25 +370,21 @@ def callback( ): pass - response = get_response_for_testing(callback) - request = response.request - - plan = injector.build_plan(response.request) - instances = yield from injector.build_instances(request, response, plan) - assert instances == { + expected_instances = { Cls1: Cls1(), Cls2: Cls2(), Annotated[Cls2, 42]: Cls2(), Annotated[Cls2, 43]: Cls2(), } - - kwargs = yield from injector.build_callback_dependencies(request, response) - assert kwargs == { + expected_kwargs = { "a": Cls1(), "b": Cls2(), "c": Cls2(), "d": Cls2(), } + yield self._assert_instances( + injector, callback, expected_instances, expected_kwargs + ) @pytest.mark.skipif( sys.version_info < (3, 9), reason="No Annotated support in Python < 3.9" From 1e841d7676fd2e0d3c5681805e7329ce9d3f5602 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Wed, 15 Nov 2023 12:34:03 +0400 Subject: [PATCH 08/10] Fix a typing issue. --- tests/test_injection.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_injection.py b/tests/test_injection.py index 3da2933b..60692155 100644 --- a/tests/test_injection.py +++ b/tests/test_injection.py @@ -1,6 +1,6 @@ import shutil import sys -from typing import Any, Callable, Dict +from typing import Any, Callable, Dict, Generator import attr import parsel @@ -289,7 +289,7 @@ def _assert_instances( callback: Callable, expected_instances: Dict[type, Any], expected_kwargs: Dict[str, Any], - ) -> None: + ) -> Generator[Any, Any, None]: response = get_response_for_testing(callback) request = response.request From d16c8b47bf19e739488a97bae94039a7ac10e68e Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Wed, 15 Nov 2023 17:06:57 +0400 Subject: [PATCH 09/10] Add docs for annotated support. --- docs/providers.rst | 44 ++++++++++++++++++++++++++++++++++++++++++++ scrapy_poet/api.py | 14 ++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/docs/providers.rst b/docs/providers.rst index dc3a1d69..6fbbf2fe 100644 --- a/docs/providers.rst +++ b/docs/providers.rst @@ -312,3 +312,47 @@ but not the others. To have other settings respected, in addition to ``CONCURRENT_REQUESTS``, you'd need to use ``crawler.engine.download`` or something like that. Alternatively, you could implement those limits in the library itself. + +Attaching metadata to dependencies +================================== + +.. note:: This feature requires Python 3.9+. + +Providers can support dependencies with arbitrary metadata attached and use +that metadata when creating them. Attaching the metadata is done by wrapping +the dependency class in :data:`typing.Annotated`: + +.. code-block:: python + + @attr.define + class MyPageObject(ItemPage): + response: Annotated[HtmlResponse, "foo", "bar"] + +To handle this you need the following changes in your provider: + +.. code-block:: python + + from andi.typeutils import strip_annotated + from scrapy_poet import AnnotatedResult, PageObjectInputProvider + + + class Provider(PageObjectInputProvider): + ... + + def is_provided(self, type_: Callable) -> bool: + # needed so that you can list just the base type in provided_classes + return super().is_provided(strip_annotated(type_)) + + def __call__(self, to_provide): + result = [] + for cls in to_provide: + metadata = getattr(cls, "__metadata__", None) + obj = ... # create the instance using cls and metadata + if metadata: + # wrap the instance into a scrapy_poet.AnnotatedResult object + obj = AnnotatedResult(obj, metadata) + result.append(obj) + return result + +.. autoclass:: scrapy_poet.AnnotatedResult + :members: diff --git a/scrapy_poet/api.py b/scrapy_poet/api.py index 62eb7d16..f74843b3 100644 --- a/scrapy_poet/api.py +++ b/scrapy_poet/api.py @@ -138,10 +138,24 @@ def parse(*args, item: page_or_item_cls, **kwargs): # type:ignore @dataclass class AnnotatedResult: + """Wrapper for annotated dependencies. + + When a provider gets a :data:`typing.Annotated` type as a dependency type, + it will return an ``AnnotatedResult`` instance for it so that the caller + can match the dependency to its annotation. + + :param result: The wrapped dependency instance. + :type result: Any + + :param metadata: The copy of the annotation. + :type metadata: Tuple[Any, ...] + """ + result: Any metadata: Tuple[Any, ...] def get_annotated_cls(self): + """Returns a re-created :class:`typing.Annotated` type.""" from typing import Annotated return Annotated[(type(self.result), *self.metadata)] From 88c58bc9023d0df04c236463356e58262cefaf94 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Tue, 12 Dec 2023 15:55:05 +0400 Subject: [PATCH 10/10] Bump the andi version. --- setup.py | 2 +- tox.ini | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index f0e5431e..e2726246 100755 --- a/setup.py +++ b/setup.py @@ -21,7 +21,7 @@ package_data={"scrapy_poet": ["VERSION"]}, python_requires=">=3.8", install_requires=[ - "andi >= 0.4.1", + "andi >= 0.5.0", "attrs >= 21.3.0", "parsel >= 1.5.0", "scrapy >= 2.6.0", diff --git a/tox.ini b/tox.ini index eb86d3fa..b3830667 100644 --- a/tox.ini +++ b/tox.ini @@ -17,7 +17,7 @@ commands = [pinned] deps = {[testenv]deps} - andi==0.4.1 + andi==0.5.0 attrs==21.3.0 parsel==1.5.0 sqlitedict==1.5.0