Skip to content

Commit e1649a9

Browse files
committed
refactor Meta to subclass from a dict with added value restrictions and key requirements
1 parent f28ee09 commit e1649a9

File tree

2 files changed

+85
-42
lines changed

2 files changed

+85
-42
lines changed

tests/test_page_inputs.py

Lines changed: 26 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import pytest
2+
import asyncio
3+
14
from web_poet.page_inputs import ResponseData, Meta
25

36

@@ -13,21 +16,30 @@ def test_html_response():
1316
assert response.headers["User-Agent"] == "test agent"
1417

1518

16-
def test_meta():
17-
meta = Meta(x="hi", y=2.2, z={"k": "v"})
18-
assert meta.x == "hi"
19-
assert meta.y == 2.2
20-
assert meta.z == {"k": "v"}
21-
assert meta.not_existing_field is None
19+
def test_meta_required_data():
20+
Meta() # By default, no fields are required.
21+
22+
class RequiredMeta(Meta):
23+
required_data = {"some_data"}
24+
25+
with pytest.raises(ValueError):
26+
RequiredMeta()
27+
28+
meta = RequiredMeta(some_data=123)
29+
assert meta["some_data"] == 123
30+
2231

23-
del meta.z
24-
assert meta.z is None
32+
def test_meta_restriction():
33+
# Any value that conforms with `Meta.restrictions` raises an error
34+
with pytest.raises(ValueError) as err:
35+
Meta(func=lambda x: x + 1)
2536

26-
# Deleting non-existing fields should not err out.
27-
del meta.no_existing_field
28-
assert meta.not_existing_field is None
37+
with pytest.raises(ValueError) as err:
38+
Meta(class_=ResponseData)
2939

30-
meta.new_field = "new"
31-
assert meta.new_field == "new"
40+
# These are allowed though
41+
m = Meta(x="hi", y=2.2, z={"k": "v"})
42+
m["allowed"] = [1, 2, 3]
3243

33-
str(meta) == "Meta(x='hi', y=2.2, new='new')"
44+
with pytest.raises(ValueError) as err:
45+
m["not_allowed"] = asyncio.sleep(1)

web_poet/page_inputs.py

Lines changed: 59 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
from typing import Optional, Dict, Any, ByteString, Union
1+
import inspect
2+
from typing import Optional, Dict, Any, ByteString, Union, Set
23
from contextlib import suppress
34

45
import attr
@@ -32,35 +33,65 @@ class ResponseData:
3233
headers: Optional[Dict[Union[str, ByteString], Any]] = None
3334

3435

35-
class Meta:
36-
"""Container class that could contain any arbitrary data.
36+
class Meta(dict):
37+
"""Container class that could contain any arbitrary data to be passed into
38+
a Page Object.
3739
38-
Using this is more useful to pass things around compared to a ``dict`` due
39-
to these following characteristics:
40+
This is basically a subclass of a ``dict`` but adds some additional
41+
functionalities to ensure consistent and compatible Page Objects across
42+
different use cases:
4043
41-
- You can use Python's "." attribute syntax for it.
42-
- Accessing attributes that are not existing won't result in errors.
43-
Instead, a ``None`` value will be returned.
44-
- The same goes for deleting attributes that don't exist wherein errors
45-
will be suppressed.
44+
* A class variable named ``required_data`` to ensure consistent
45+
arguments. If it's instantiated with missing ``keys`` from
46+
``required_data``, then a ``ValueError`` is raised.
4647
47-
This makes the code simpler by avoiding try/catch, checking an attribute's
48-
existence, using ``get()``, etc.
48+
* Ensures that some params with data types that are difficult to
49+
provide or pass like ``lambdas`` are checked. Otherwise, a ``ValueError``
50+
is raised.
4951
"""
5052

51-
def __init__(self, **kwargs):
52-
object.__setattr__(self, "_data", kwargs)
53-
54-
def __getattr__(self, key):
55-
return self._data.get(key)
56-
57-
def __delattr__(self, key):
58-
with suppress(KeyError):
59-
del self._data[key]
60-
61-
def __setattr__(self, key, value):
62-
self._data[key] = value
63-
64-
def __repr__(self):
65-
contents = ", ".join([f"{k}={v!r}" for k, v in self._data.items()])
66-
return f"Meta({contents})"
53+
# Contains the required "keys" when instantiating and setting attributes.
54+
required_data: Set = set()
55+
56+
# Any "value" that returns True for the functions here are not allowed.
57+
restrictions: Dict = {
58+
inspect.ismodule: "module",
59+
inspect.isclass: "class",
60+
inspect.ismethod: "method",
61+
inspect.isfunction: "function",
62+
inspect.isgenerator: "generator",
63+
inspect.isgeneratorfunction: "generator",
64+
inspect.iscoroutine: "coroutine",
65+
inspect.isawaitable: "awaitable",
66+
inspect.istraceback: "traceback",
67+
inspect.isframe: "frame",
68+
}
69+
70+
def __init__(self, *args, **kwargs) -> None:
71+
missing_required_keys = self.required_data - kwargs.keys()
72+
if missing_required_keys:
73+
raise ValueError(
74+
f"These keys are required for instantiation: {missing_required_keys}"
75+
)
76+
for val in kwargs.values():
77+
self.is_restricted_value(val)
78+
super().__init__(*args, **kwargs)
79+
80+
def __setitem__(self, key: Any, value: Any) -> None:
81+
self.is_restricted_value(value)
82+
super().__setattr__(key, value)
83+
84+
def is_restricted_value(self, value: Any) -> None:
85+
"""Raises an error if a given value isn't allowed inside the meta.
86+
87+
This behavior can be controlled by tweaking the class variable
88+
:meth:`~.web_poet.page_inputs.Meta.restrictions`.
89+
"""
90+
violations = []
91+
92+
for restrictor, err in self.restrictions.items():
93+
if restrictor(value):
94+
violations.append(f"{err} is not allowed: {value}")
95+
96+
if violations:
97+
raise ValueError(f"Found these issues: {', '.join(violations)}")

0 commit comments

Comments
 (0)