Skip to content

Commit d0de0d3

Browse files
add setter support (#11)
* add setter support * some more tests * add some random chatgpt tests * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * add type hints * test against python 3.13 * test non-dataclass behaviour * test setter only * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 732b365 commit d0de0d3

5 files changed

Lines changed: 257 additions & 13 deletions

File tree

.github/workflows/pytest.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ jobs:
1515
fail-fast: false
1616
matrix:
1717
python-version:
18+
- "3.13"
1819
- "3.12"
1920
- "3.11"
2021
- "3.10"

README.md

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44

55
# znfields
66

7-
Provide a `getter` for `dataclasses.fields` to allow e.g. for lazy evaluation.
7+
Provide a `getter` and `setter` for `dataclasses.fields` to allow e.g. for lazy
8+
evaluation or field content validation.
89

910
```bash
1011
pip install znfields
@@ -19,10 +20,15 @@ additional `getter` argument.
1920
import dataclasses
2021
import znfields
2122

22-
def parameter_getter(self, name):
23+
def getter(self, name) -> str:
2324
return f"{name}:{self.__dict__[name]}"
2425

26+
def setter(self, name, value) -> None:
27+
if not isinstance(value, float):
28+
raise ValueError(f"Value {value} is not a float")
29+
self.__dict__[name] = value
30+
2531
@dataclasses.dataclass
26-
class ClassWithParameter(znfields.Base):
27-
parameter: float = znfields.field(getter=parameter_getter)
32+
class MyModel(znfields.Base):
33+
parameter: float = znfields.field(getter=getter, setter=setter)
2834
```

tests/test_readme.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import dataclasses
2+
3+
import pytest
4+
5+
import znfields
6+
7+
8+
def getter(self, name):
9+
return f"{name}:{self.__dict__[name]}"
10+
11+
12+
def setter(self, name, value):
13+
if not isinstance(value, float):
14+
raise ValueError(f"Value {value} is not a float")
15+
self.__dict__[name] = value
16+
17+
18+
@dataclasses.dataclass
19+
class MyModel(znfields.Base):
20+
parameter: float = znfields.field(getter=getter, setter=setter)
21+
22+
23+
def test_readme():
24+
model = MyModel(parameter=3.14)
25+
assert model.parameter == "parameter:3.14"
26+
with pytest.raises(ValueError):
27+
model.parameter = 42

tests/test_znfields.py

Lines changed: 130 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,32 +5,52 @@
55
import znfields
66

77

8-
def example1_parameter_getter(self, name):
8+
def getter_01(self, name):
99
return f"{name}:{self.__dict__[name]}"
1010

1111

12+
def setter_01(self, name, value):
13+
if not isinstance(value, float):
14+
raise ValueError(f"Value {value} is not a float")
15+
self.__dict__[name] = value
16+
17+
1218
def stringify_list(self, name):
1319
content = self.__dict__[name]
1420
self.__dict__[name] = [str(x) for x in content]
1521
# Can not return a copy to append to, but must be the same object
1622
return self.__dict__[name]
1723

1824

25+
@dataclasses.dataclass
26+
class SetterGetterNoInit(znfields.Base):
27+
parameter: float = znfields.field(getter=getter_01, setter=setter_01, init=False)
28+
29+
30+
@dataclasses.dataclass
31+
class SetterOnly(znfields.Base):
32+
parameter: float = znfields.field(setter=setter_01)
33+
34+
1935
@dataclasses.dataclass
2036
class Example1(znfields.Base):
21-
parameter: float = znfields.field(getter=example1_parameter_getter)
37+
parameter: float = znfields.field(getter=getter_01)
2238

2339

2440
@dataclasses.dataclass
2541
class Example1WithDefault(znfields.Base):
26-
parameter: float = znfields.field(getter=example1_parameter_getter, default=1)
42+
parameter: float = znfields.field(getter=getter_01, default=1)
2743

2844

2945
@dataclasses.dataclass
3046
class Example1WithDefaultFactory(znfields.Base):
3147
parameter: list = znfields.field(getter=stringify_list, default_factory=list)
3248

3349

50+
class NoDataClass(znfields.Base):
51+
parameter: float = znfields.field(getter=getter_01, setter=setter_01)
52+
53+
3454
def test_example1():
3555
example = Example1(parameter=1)
3656
assert example.parameter == "parameter:1"
@@ -58,14 +78,15 @@ def test_example2():
5878

5979
def test_wrong_metadata():
6080
with pytest.raises(TypeError):
61-
znfields.field(getter=example1_parameter_getter, metadata="Hello")
81+
znfields.field(getter=getter_01, metadata="Hello")
82+
83+
with pytest.raises(TypeError):
84+
znfields.field(setter=setter_01, metadata="Hello")
6285

6386

6487
@dataclasses.dataclass
6588
class Example3(znfields.Base):
66-
parameter: float = znfields.field(
67-
getter=example1_parameter_getter, metadata={"category": "test"}
68-
)
89+
parameter: float = znfields.field(getter=getter_01, metadata={"category": "test"})
6990

7091

7192
def test_example3():
@@ -75,7 +96,7 @@ def test_example3():
7596
field = dataclasses.fields(example)[0]
7697
assert field.metadata == {
7798
"category": "test",
78-
znfields.ZNFIELDS_GETTER_TYPE: example1_parameter_getter,
99+
znfields.ZNFIELDS_GETTER_TYPE: getter_01,
79100
}
80101

81102

@@ -154,3 +175,104 @@ def test_default_factory():
154175
assert example.parameter == []
155176
example.parameter.append(1)
156177
assert example.parameter == ["1"]
178+
179+
180+
def test_getter_setter_no_init():
181+
example = SetterGetterNoInit()
182+
with pytest.raises(ValueError):
183+
example.parameter = "text"
184+
185+
example.parameter = 3.14
186+
assert example.parameter == "parameter:3.14"
187+
188+
# test non-field attributes
189+
example.some_attribute = 42
190+
assert example.some_attribute == 42
191+
192+
193+
@dataclasses.dataclass
194+
class ParentClass(znfields.Base):
195+
parent_field: str = znfields.field(getter=getter_01)
196+
197+
198+
@dataclasses.dataclass
199+
class ChildClass(ParentClass):
200+
child_field: str = znfields.field(getter=getter_01)
201+
202+
203+
def test_inherited_getter():
204+
instance = ChildClass(parent_field="parent", child_field="child")
205+
assert instance.parent_field == "parent_field:parent"
206+
assert instance.child_field == "child_field:child"
207+
208+
209+
def test_setter_validation():
210+
example = SetterGetterNoInit()
211+
212+
with pytest.raises(ValueError):
213+
example.parameter = "invalid value"
214+
215+
with pytest.raises(KeyError):
216+
# dict is not set, getter raises KeyError instead of AttributeError
217+
assert example.parameter is None
218+
219+
example.parameter = 2.71
220+
assert example.parameter == "parameter:2.71"
221+
222+
223+
@dataclasses.dataclass
224+
class NoDefaultField(znfields.Base):
225+
parameter: float = znfields.field(getter=getter_01, setter=setter_01)
226+
227+
228+
def test_no_default_field():
229+
with pytest.raises(TypeError):
230+
NoDefaultField() # should raise because no default is provided
231+
obj = NoDefaultField(parameter=1.23)
232+
assert obj.parameter == "parameter:1.23"
233+
234+
235+
@dataclasses.dataclass
236+
class CombinedGetterSetter(znfields.Base):
237+
parameter: float = znfields.field(getter=getter_01, setter=setter_01)
238+
239+
240+
def test_combined_getter_setter():
241+
obj = CombinedGetterSetter(parameter=2.5)
242+
assert obj.parameter == "parameter:2.5"
243+
obj.parameter = 3.5
244+
assert obj.parameter == "parameter:3.5"
245+
246+
with pytest.raises(ValueError):
247+
obj.parameter = "invalid value"
248+
249+
250+
@dataclasses.dataclass
251+
class Nested(znfields.Base):
252+
inner_field: float = znfields.field(getter=getter_01)
253+
254+
255+
@dataclasses.dataclass
256+
class Outer(znfields.Base):
257+
outer_field: Nested = dataclasses.field(default_factory=lambda: Nested(1.0))
258+
259+
260+
def test_nested_dataclass():
261+
obj = Outer()
262+
assert obj.outer_field.inner_field == "inner_field:1.0"
263+
264+
265+
def test_no_dataclass():
266+
x = NoDataClass()
267+
with pytest.raises(TypeError, match="is not a dataclass"):
268+
x.parameter = 5
269+
270+
with pytest.raises(TypeError, match="is not a dataclass"):
271+
assert x.parameter is None
272+
273+
274+
def test_setter_only():
275+
x = SetterOnly(parameter=5.5)
276+
with pytest.raises(ValueError):
277+
x.parameter = "5"
278+
assert x.parameter == 5.5

znfields/__init__.py

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,45 @@
44

55

66
class _ZNFIELDS_GETTER_TYPE:
7+
"""Sentinel class to identify the getter type."""
8+
9+
pass
10+
11+
12+
class _ZNFIELDS_SETTER_TYPE:
13+
"""Sentinel class used to identify the setter type."""
14+
715
pass
816

917

18+
# Sentinels to identify the getter and setter types
1019
ZNFIELDS_GETTER_TYPE = _ZNFIELDS_GETTER_TYPE()
20+
ZNFIELDS_SETTER_TYPE = _ZNFIELDS_SETTER_TYPE()
1121

1222

1323
class Base:
24+
"""Base class to extend dataclasses with custom getter and setter behavior
25+
through field metadata.
26+
27+
Methods
28+
-------
29+
__getattribute__(name: str) -> Any
30+
Overrides the default behavior of attribute access to allow for
31+
custom getter functionality defined via field metadata.
32+
__setattr__(name: str, value: Any) -> None
33+
Overrides the default behavior of attribute assignment to allow for
34+
custom setter functionality defined via field metadata.
35+
"""
36+
1437
def __getattribute__(self, name: str) -> Any:
38+
"""Overrides the default behavior of attribute access.
39+
40+
Allow for custom getter functionality defined via field metadata.
41+
42+
Raises
43+
------
44+
TypeError: If the class is not a dataclass.
45+
"""
1546
if name.startswith("__") and name.endswith("__"):
1647
return super().__getattribute__(name)
1748
if not dataclasses.is_dataclass(self):
@@ -27,11 +58,59 @@ def __getattribute__(self, name: str) -> Any:
2758
return lazy(self, name)
2859
return super().__getattribute__(name)
2960

61+
def __setattr__(self, name: str, value: Any) -> None:
62+
"""Overrides the default behavior of attribute assignment.
63+
64+
Allow for custom setter functionality defined via field metadata.
65+
66+
Raises
67+
------
68+
TypeError: If the class is not a dataclass.
69+
"""
70+
if not dataclasses.is_dataclass(self):
71+
raise TypeError(f"{self} is not a dataclass")
72+
try:
73+
field = next(
74+
field for field in dataclasses.fields(self) if field.name == name
75+
)
76+
except StopIteration:
77+
return super().__setattr__(name, value)
78+
setter = field.metadata.get(ZNFIELDS_SETTER_TYPE)
79+
if setter:
80+
setter(self, name, value)
81+
else:
82+
super().__setattr__(name, value)
83+
3084

3185
@functools.wraps(dataclasses.field)
3286
def field(
33-
*, getter: Optional[Callable[[Any, str], Any]] = None, **kwargs
87+
*,
88+
getter: Optional[Callable[[Any, str], Any]] = None,
89+
setter: Optional[Callable[[Any, str, Any], None]] = None,
90+
**kwargs,
3491
) -> dataclasses.Field:
92+
"""Wrapper around `dataclasses.field` to allow for defining custom
93+
getter and setter functions via metadata.
94+
95+
Attributes
96+
----------
97+
getter : Optional[Callable[[Any, str], Any]]
98+
A function that takes the instance and attribute name as arguments
99+
and returns the value of the attribute.
100+
setter : Optional[Callable[[Any, str, Any], None]]
101+
A function that takes the instance, attribute name, and value as
102+
arguments and sets the value of the attribute.
103+
104+
Returns
105+
-------
106+
dataclasses.Field
107+
A field object with custom getter and setter functionality defined
108+
via metadata.
109+
110+
Raises
111+
------
112+
TypeError: If the metadata is not a dictionary.
113+
"""
35114
if getter is not None:
36115
if "metadata" in kwargs:
37116
if not isinstance(kwargs["metadata"], dict):
@@ -41,4 +120,13 @@ def field(
41120
kwargs["metadata"][ZNFIELDS_GETTER_TYPE] = getter
42121
else:
43122
kwargs["metadata"] = {ZNFIELDS_GETTER_TYPE: getter}
123+
if setter is not None:
124+
if "metadata" in kwargs:
125+
if not isinstance(kwargs["metadata"], dict):
126+
raise TypeError(
127+
f"metadata must be a dict, not {type(kwargs['metadata'])}"
128+
)
129+
kwargs["metadata"][ZNFIELDS_SETTER_TYPE] = setter
130+
else:
131+
kwargs["metadata"] = {ZNFIELDS_SETTER_TYPE: setter}
44132
return dataclasses.field(**kwargs)

0 commit comments

Comments
 (0)