Skip to content

Commit 47d3e1f

Browse files
authored
Merge pull request #213 from scrapy-plugins/custom-attrs
Handle custom attributes received in the API response.
2 parents c1ba9c0 + b7e22e7 commit 47d3e1f

File tree

8 files changed

+301
-18
lines changed

8 files changed

+301
-18
lines changed

scrapy_zyte_api/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
install_reactor("twisted.internet.asyncioreactor.AsyncioSelectorReactor")
77

8-
from ._annotations import ExtractFrom, actions
8+
from ._annotations import ExtractFrom, actions, custom_attrs
99
from ._middlewares import (
1010
ScrapyZyteAPIDownloaderMiddleware,
1111
ScrapyZyteAPISpiderMiddleware,

scrapy_zyte_api/_annotations.py

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from enum import Enum
2-
from typing import Iterable, List, Optional, TypedDict
2+
from typing import Any, Dict, FrozenSet, Iterable, List, Optional, Tuple, TypedDict
33

44

55
class ExtractFrom(str, Enum):
@@ -56,7 +56,8 @@ class _ActionResult(TypedDict, total=False):
5656
error: Optional[str]
5757

5858

59-
def make_hashable(obj):
59+
def make_hashable(obj: Any) -> Any:
60+
"""Converts input into hashable form, to use in ``Annotated``."""
6061
if isinstance(obj, (tuple, list)):
6162
return tuple((make_hashable(e) for e in obj))
6263

@@ -66,7 +67,26 @@ def make_hashable(obj):
6667
return obj
6768

6869

69-
def actions(value: Iterable[Action]):
70+
def _from_hashable(obj: Any) -> Any:
71+
"""Converts a result of ``make_hashable`` back to original form."""
72+
if isinstance(obj, tuple):
73+
return [_from_hashable(o) for o in obj]
74+
75+
if isinstance(obj, frozenset):
76+
return {_from_hashable(k): _from_hashable(v) for k, v in obj}
77+
78+
return obj
79+
80+
81+
def actions(value: Iterable[Action]) -> Tuple[Any, ...]:
7082
"""Convert an iterable of :class:`~scrapy_zyte_api.Action` dicts into a hashable value."""
7183
# both lists and dicts are not hashable and we need dep types to be hashable
7284
return tuple(make_hashable(action) for action in value)
85+
86+
87+
def custom_attrs(
88+
input: Dict[str, Any], options: Optional[Dict[str, Any]] = None
89+
) -> Tuple[FrozenSet[Any], Optional[FrozenSet[Any]]]:
90+
input_wrapped = make_hashable(input)
91+
options_wrapped = make_hashable(options) if options else None
92+
return input_wrapped, options_wrapped

scrapy_zyte_api/providers.py

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@
2626
AutoProductListPage,
2727
AutoProductNavigationPage,
2828
AutoProductPage,
29+
CustomAttributes,
30+
CustomAttributesMetadata,
31+
CustomAttributesValues,
2932
Item,
3033
JobPosting,
3134
Product,
@@ -35,7 +38,7 @@
3538
from zyte_common_items.fields import is_auto_field
3639

3740
from scrapy_zyte_api import Actions, ExtractFrom, Geolocation, Screenshot
38-
from scrapy_zyte_api._annotations import _ActionResult
41+
from scrapy_zyte_api._annotations import _ActionResult, _from_hashable
3942
from scrapy_zyte_api.responses import ZyteAPITextResponse
4043

4144
try:
@@ -76,6 +79,8 @@ class ZyteApiProvider(PageObjectInputProvider):
7679
ArticleNavigation,
7780
BrowserHtml,
7881
BrowserResponse,
82+
CustomAttributes,
83+
CustomAttributesValues,
7984
Geolocation,
8085
JobPosting,
8186
Product,
@@ -175,15 +180,14 @@ async def __call__( # noqa: C901
175180
)
176181
zyte_api_meta["actions"] = []
177182
for action in cls.__metadata__[0]: # type: ignore[attr-defined]
178-
zyte_api_meta["actions"].append(
179-
{
180-
k: (
181-
dict(v)
182-
if isinstance(v, frozenset)
183-
else list(v) if isinstance(v, tuple) else v
184-
)
185-
for k, v in action
186-
}
183+
zyte_api_meta["actions"].append(_from_hashable(action))
184+
continue
185+
if cls_stripped in {CustomAttributes, CustomAttributesValues}:
186+
custom_attrs_input, custom_attrs_options = cls.__metadata__[0] # type: ignore[attr-defined]
187+
zyte_api_meta["customAttributes"] = _from_hashable(custom_attrs_input)
188+
if custom_attrs_options:
189+
zyte_api_meta["customAttributesOptions"] = _from_hashable(
190+
custom_attrs_options
187191
)
188192
continue
189193
kw = _ITEM_KEYWORDS.get(cls_stripped)
@@ -322,6 +326,27 @@ async def __call__( # noqa: C901
322326
result = AnnotatedInstance(Actions(actions_result), cls.__metadata__) # type: ignore[attr-defined]
323327
results.append(result)
324328
continue
329+
if cls_stripped is CustomAttributes and is_typing_annotated(cls):
330+
custom_attrs_result = api_response.raw_api_response["customAttributes"]
331+
result = AnnotatedInstance(
332+
CustomAttributes(
333+
CustomAttributesValues(custom_attrs_result["values"]),
334+
CustomAttributesMetadata.from_dict(
335+
custom_attrs_result["metadata"]
336+
),
337+
),
338+
cls.__metadata__, # type: ignore[attr-defined]
339+
)
340+
results.append(result)
341+
continue
342+
if cls_stripped is CustomAttributesValues and is_typing_annotated(cls):
343+
custom_attrs_result = api_response.raw_api_response["customAttributes"]
344+
result = AnnotatedInstance(
345+
CustomAttributesValues(custom_attrs_result["values"]),
346+
cls.__metadata__, # type: ignore[attr-defined]
347+
)
348+
results.append(result)
349+
continue
325350
kw = _ITEM_KEYWORDS.get(cls_stripped)
326351
if not kw:
327352
continue

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ def get_version():
3333
"andi>=0.6.0",
3434
"scrapy-poet>=0.22.3",
3535
"web-poet>=0.17.0",
36-
"zyte-common-items>=0.20.0",
36+
"zyte-common-items>=0.23.0",
3737
]
3838
},
3939
classifiers=[

tests/mockserver.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,17 @@ def render_POST(self, request):
230230
"name"
231231
] += f" (country {request_data['geolocation']})"
232232

233+
if "customAttributes" in request_data:
234+
response_data["customAttributes"] = {
235+
"metadata": {
236+
"textInputTokens": 1000,
237+
},
238+
"values": {
239+
"attr1": "foo",
240+
"attr2": 42,
241+
},
242+
}
243+
233244
return json.dumps(response_data).encode()
234245

235246

tests/test_annotations.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import pytest
2+
3+
from scrapy_zyte_api._annotations import (
4+
_from_hashable,
5+
actions,
6+
custom_attrs,
7+
make_hashable,
8+
)
9+
10+
11+
@pytest.mark.parametrize(
12+
"input,expected",
13+
[
14+
([], ()),
15+
({}, frozenset()),
16+
("foo", "foo"),
17+
(["foo"], ("foo",)),
18+
(42, 42),
19+
(
20+
{"action": "foo", "id": "xx"},
21+
frozenset({("action", "foo"), ("id", "xx")}),
22+
),
23+
(
24+
[{"action": "foo", "id": "xx"}, {"action": "bar"}],
25+
(
26+
frozenset({("action", "foo"), ("id", "xx")}),
27+
frozenset({("action", "bar")}),
28+
),
29+
),
30+
(
31+
{"action": "foo", "options": {"a": "b", "c": ["d", "e"]}},
32+
frozenset(
33+
{
34+
("action", "foo"),
35+
("options", frozenset({("a", "b"), ("c", ("d", "e"))})),
36+
}
37+
),
38+
),
39+
],
40+
)
41+
def test_make_hashable(input, expected):
42+
assert make_hashable(input) == expected
43+
44+
45+
@pytest.mark.parametrize(
46+
"input,expected",
47+
[
48+
((), []),
49+
(frozenset(), {}),
50+
("foo", "foo"),
51+
(("foo",), ["foo"]),
52+
(42, 42),
53+
(
54+
frozenset({("action", "foo"), ("id", "xx")}),
55+
{"action": "foo", "id": "xx"},
56+
),
57+
(
58+
(
59+
frozenset({("action", "foo"), ("id", "xx")}),
60+
frozenset({("action", "bar")}),
61+
),
62+
[{"action": "foo", "id": "xx"}, {"action": "bar"}],
63+
),
64+
(
65+
frozenset(
66+
{
67+
("action", "foo"),
68+
("options", frozenset({("a", "b"), ("c", ("d", "e"))})),
69+
}
70+
),
71+
{"action": "foo", "options": {"a": "b", "c": ["d", "e"]}},
72+
),
73+
],
74+
)
75+
def test_from_hashable(input, expected):
76+
assert _from_hashable(input) == expected
77+
78+
79+
@pytest.mark.parametrize(
80+
"input,expected",
81+
[
82+
([], ()),
83+
([{}], (frozenset(),)),
84+
(
85+
[{"action": "foo"}, {"action": "bar"}],
86+
(
87+
frozenset({("action", "foo")}),
88+
frozenset({("action", "bar")}),
89+
),
90+
),
91+
],
92+
)
93+
def test_actions(input, expected):
94+
assert actions(input) == expected
95+
96+
97+
@pytest.mark.parametrize(
98+
"input,options,expected",
99+
[
100+
({}, None, (frozenset(), None)),
101+
({"foo": "bar"}, None, (frozenset({("foo", "bar")}), None)),
102+
(
103+
{"foo": "bar"},
104+
{"tokens": 42},
105+
(frozenset({("foo", "bar")}), frozenset({("tokens", 42)})),
106+
),
107+
],
108+
)
109+
def test_custom_attrs(input, options, expected):
110+
assert custom_attrs(input, options) == expected

0 commit comments

Comments
 (0)