diff --git a/docs/advanced/meta.rst b/docs/advanced/meta.rst index 831100cf..4ea76042 100644 --- a/docs/advanced/meta.rst +++ b/docs/advanced/meta.rst @@ -129,3 +129,26 @@ From the example above, we can see how :class:`~.Meta` is able to arbitrarily limit the pagination behavior by passing an optional **max_pages** info. Take note that a ``default_max_pages`` value is also present in the Page Object in case the :class:`~.Meta` instance did not provide it. + +Value Restrictions +------------------ + +From the examples above, you may notice that we can access :class:`~.Meta` with +a ``dict`` interface since it's simply a subclass of it. However, :class:`~.Meta` +posses some extendable features on top of being a ``dict``. + +Specifically, :class:`~.Meta` is able to restrict any value passed based on its +type. For example, if any of these values are passed, then a ``ValueError`` is +raised: + + * module + * class + * method or function + * generator + * coroutine or awaitable + * traceback + * frame + +This is to ensure that frameworks using **web-poet** are able safely use values +passed into :class:`~.Meta` as they could be passed via CLI, web forms, HTTP API +calls, etc. diff --git a/tests/test_page_inputs.py b/tests/test_page_inputs.py index 7988e69f..9f5926ca 100644 --- a/tests/test_page_inputs.py +++ b/tests/test_page_inputs.py @@ -1,4 +1,7 @@ -from web_poet.page_inputs import ResponseData +import pytest +import asyncio + +from web_poet.page_inputs import ResponseData, Meta def test_html_response(): @@ -11,3 +14,19 @@ def test_html_response(): response = ResponseData("url", "content", 200, {"User-Agent": "test agent"}) assert response.status == 200 assert response.headers["User-Agent"] == "test agent" + + +def test_meta_restriction(): + # Any value that conforms with `Meta.restrictions` raises an error + with pytest.raises(ValueError) as err: + Meta(func=lambda x: x + 1) + + with pytest.raises(ValueError) as err: + Meta(class_=ResponseData) + + # These are allowed though + m = Meta(x="hi", y=2.2, z={"k": "v"}) + m["allowed"] = [1, 2, 3] + + with pytest.raises(ValueError) as err: + m["not_allowed"] = asyncio.sleep(1) diff --git a/web_poet/page_inputs.py b/web_poet/page_inputs.py index 083bb4ad..a660494b 100644 --- a/web_poet/page_inputs.py +++ b/web_poet/page_inputs.py @@ -1,4 +1,6 @@ -from typing import Optional, Dict, Any, ByteString, Union +import inspect +from typing import Optional, Dict, Any, ByteString, Union, Set +from contextlib import suppress import attr @@ -34,6 +36,50 @@ class ResponseData: class Meta(dict): """Container class that could contain any arbitrary data to be passed into a Page Object. + + This is basically a subclass of a ``dict`` that adds the ability to check + if any of the assigned values are not allowed. This ensures that some input + parameters with data types that are difficult to provide or pass via CLI + like ``lambdas`` are checked. Otherwise, a ``ValueError`` is raised. """ - pass + # Any "value" that returns True for the functions here are not allowed. + restrictions: Dict = { + inspect.ismodule: "module", + inspect.isclass: "class", + inspect.ismethod: "method", + inspect.isfunction: "function", + inspect.isgenerator: "generator", + inspect.isgeneratorfunction: "generator", + inspect.iscoroutine: "coroutine", + inspect.isawaitable: "awaitable", + inspect.istraceback: "traceback", + inspect.isframe: "frame", + } + + def __init__(self, *args, **kwargs) -> None: + for val in kwargs.values(): + self.enforce_value_restriction(val) + super().__init__(*args, **kwargs) + + def __setitem__(self, key: Any, value: Any) -> None: + self.enforce_value_restriction(value) + super().__setattr__(key, value) + + def enforce_value_restriction(self, value: Any) -> None: + """Raises a ``ValueError`` if a given value isn't allowed inside the meta. + + This method is called during :class:`~.Meta` instantiation and setting + new values in an existing instance. + + This behavior can be controlled by tweaking the class variable named + ``restrictions``. + """ + violations = [] + + for restrictor, err in self.restrictions.items(): + if restrictor(value): + violations.append(f"{err} is not allowed: {value}") + + if violations: + raise ValueError(f"Found these issues: {', '.join(violations)}")