diff --git a/tests/test_fields.py b/tests/test_fields.py index d4190f94..f84a2851 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -1,5 +1,6 @@ import asyncio import random +from typing import Optional import attrs import pytest @@ -26,7 +27,7 @@ item_from_fields, item_from_fields_sync, ) -from web_poet.fields import get_fields_dict +from web_poet.fields import FieldInfo, get_fields_dict @attrs.define @@ -522,3 +523,110 @@ def b(self): return None assert set(get_fields_dict(B)) == {"a", "b", "mixin"} + + +@pytest.mark.asyncio +async def test_field_disabled() -> None: + @attrs.define + class Item: + x: int + y: Optional[int] = None + z: Optional[int] = None + + class Page(ItemPage[Item]): + @field + def x(self) -> int: + return 1 + + @field(disabled=False) + def y(self) -> int: + return 2 + + @field(disabled=True) + def z(self) -> int: + return 3 + + page = Page() + assert await page.to_item() == Item(x=1, y=2) + assert page.x == 1 + assert page.y == 2 + assert page.z == 3 + + fields_dict_instance = get_fields_dict(page) + fields_dict_class = get_fields_dict(Page) + + for info in [fields_dict_class, fields_dict_instance]: + assert info["x"] == FieldInfo(name="x", meta=None, out=None, disabled=False) + assert info["y"] == FieldInfo(name="y", meta=None, out=None, disabled=False) + + fields_dict_instance = get_fields_dict(page, include_disabled=True) + fields_dict_class = get_fields_dict(Page, include_disabled=True) + + for info in [fields_dict_class, fields_dict_instance]: + assert info["x"] == FieldInfo(name="x", meta=None, out=None, disabled=False) + assert info["y"] == FieldInfo(name="y", meta=None, out=None, disabled=False) + assert info["z"] == FieldInfo(name="z", meta=None, out=None, disabled=True) + + # The subclass should properly reflect any changes to the ``disable`` value + + class SubPage(Page): + """Flicks the switch for ``y`` and ``z``.""" + + @field(disabled=True) + def y(self) -> int: + return 2 + + @field(disabled=False) + def z(self) -> int: + return 3 + + subpage = SubPage() + assert await subpage.to_item() == Item(x=1, z=3) + assert subpage.x == 1 + assert subpage.y == 2 + assert subpage.z == 3 + + fields_dict_instance = get_fields_dict(subpage) + fields_dict_class = get_fields_dict(SubPage) + + for info in [fields_dict_class, fields_dict_instance]: + assert info["x"] == FieldInfo(name="x", meta=None, out=None, disabled=False) + assert info["z"] == FieldInfo(name="z", meta=None, out=None, disabled=False) + + fields_dict_instance = get_fields_dict(subpage, include_disabled=True) + fields_dict_class = get_fields_dict(SubPage, include_disabled=True) + + for info in [fields_dict_class, fields_dict_instance]: + assert info["x"] == FieldInfo(name="x", meta=None, out=None, disabled=False) + assert info["y"] == FieldInfo(name="y", meta=None, out=None, disabled=True) + assert info["z"] == FieldInfo(name="z", meta=None, out=None, disabled=False) + + # Disabling fields that are required in the item cls would error out. + + class BadSubPage(Page): + @field(disabled=True) + def x(self) -> int: + return 1 + + badsubpage = BadSubPage() + + with pytest.raises(TypeError): + await badsubpage.to_item() + + assert badsubpage.x == 1 + assert badsubpage.y == 2 + assert badsubpage.z == 3 + + fields_dict_instance = get_fields_dict(badsubpage) + fields_dict_class = get_fields_dict(BadSubPage) + + for info in [fields_dict_class, fields_dict_instance]: + assert info["y"] == FieldInfo(name="y", meta=None, out=None, disabled=False) + + fields_dict_instance = get_fields_dict(badsubpage, include_disabled=True) + fields_dict_class = get_fields_dict(BadSubPage, include_disabled=True) + + for info in [fields_dict_class, fields_dict_instance]: + assert info["x"] == FieldInfo(name="x", meta=None, out=None, disabled=True) + assert info["y"] == FieldInfo(name="y", meta=None, out=None, disabled=False) + assert info["z"] == FieldInfo(name="z", meta=None, out=None, disabled=True) diff --git a/web_poet/fields.py b/web_poet/fields.py index d85fd93d..ca92d4ac 100644 --- a/web_poet/fields.py +++ b/web_poet/fields.py @@ -16,6 +16,10 @@ _FIELDS_INFO_ATTRIBUTE_WRITE = "_web_poet_fields_info_temp" +def _fields_template(): + return {"enabled": {}, "disabled": {}} + + @attrs.define class FieldInfo: """Information about a field""" @@ -29,6 +33,9 @@ class FieldInfo: #: field processors out: Optional[List[Callable]] = None + #: when set to ``True``, the field is not populated on ``.to_item()`` calls. + disabled: bool = False + class FieldsMixin: """A mixin which is required for a class to support fields""" @@ -39,11 +46,31 @@ def __init_subclass__(cls, **kwargs): # between subclasses, i.e. a decorator in a subclass doesn't affect # the base class. This is done by making decorator write to a # temporary location, and then merging it all on subclass creation. - this_class_fields = getattr(cls, _FIELDS_INFO_ATTRIBUTE_WRITE, {}) - base_class_fields = getattr(cls, _FIELDS_INFO_ATTRIBUTE_READ, {}) + this_class_fields = getattr( + cls, _FIELDS_INFO_ATTRIBUTE_WRITE, _fields_template() + ) + base_class_fields = getattr( + cls, _FIELDS_INFO_ATTRIBUTE_READ, _fields_template() + ) if base_class_fields or this_class_fields: - fields = {**base_class_fields, **this_class_fields} - setattr(cls, _FIELDS_INFO_ATTRIBUTE_READ, fields) + enabled = {**base_class_fields["enabled"], **this_class_fields["enabled"]} + for name in this_class_fields["disabled"]: + if name in enabled: + del enabled[name] + + disabled = { + **base_class_fields["disabled"], + **this_class_fields["disabled"], + } + for name in base_class_fields["disabled"]: + if name in enabled: + del disabled[name] + + setattr( + cls, + _FIELDS_INFO_ATTRIBUTE_READ, + {"enabled": enabled, "disabled": disabled}, + ) with suppress(AttributeError): delattr(cls, _FIELDS_INFO_ATTRIBUTE_WRITE) @@ -54,6 +81,7 @@ def field( cached: bool = False, meta: Optional[dict] = None, out: Optional[List[Callable]] = None, + disabled: bool = False, ): """ Page Object method decorated with ``@field`` decorator becomes a property, @@ -85,10 +113,11 @@ def __init__(self, method): def __set_name__(self, owner, name): if not hasattr(owner, _FIELDS_INFO_ATTRIBUTE_WRITE): - setattr(owner, _FIELDS_INFO_ATTRIBUTE_WRITE, {}) + setattr(owner, _FIELDS_INFO_ATTRIBUTE_WRITE, _fields_template()) - field_info = FieldInfo(name=name, meta=meta, out=out) - getattr(owner, _FIELDS_INFO_ATTRIBUTE_WRITE)[name] = field_info + field_info = FieldInfo(name=name, meta=meta, out=out, disabled=disabled) + switch = "disabled" if disabled else "enabled" + getattr(owner, _FIELDS_INFO_ATTRIBUTE_WRITE)[switch][name] = field_info def __get__(self, instance, owner=None): return self.unbound_method(instance) @@ -125,12 +154,21 @@ def processed(*args, **kwargs): return _field -def get_fields_dict(cls_or_instance) -> Dict[str, FieldInfo]: +def get_fields_dict( + cls_or_instance, include_disabled: bool = False +) -> Dict[str, FieldInfo]: """Return a dictionary with information about the fields defined for the class: keys are field names, and values are :class:`web_poet.fields.FieldInfo` instances. """ - return getattr(cls_or_instance, _FIELDS_INFO_ATTRIBUTE_READ, {}) + fields_info = getattr( + cls_or_instance, _FIELDS_INFO_ATTRIBUTE_READ, _fields_template() + ) + fields_dict = {} + fields_dict.update(fields_info["enabled"]) + if include_disabled: + fields_dict.update(fields_info["disabled"]) + return fields_dict T = TypeVar("T")