Skip to content

Commit

Permalink
Merge pull request #605 from yukinarit/deny-unknown-fields
Browse files Browse the repository at this point in the history
Implement deny_unknown_fields class attribute
  • Loading branch information
yukinarit authored Oct 28, 2024
2 parents f93cafa + dac6c33 commit 6aeb49e
Show file tree
Hide file tree
Showing 7 changed files with 154 additions and 3 deletions.
20 changes: 20 additions & 0 deletions docs/en/class-attributes.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,3 +163,23 @@ class Foo:
See [examples/class_var.py](https://github.com/yukinarit/pyserde/blob/main/examples/class_var.py) for complete example.

[^1]: [dataclasses.fields](https://docs.python.org/3/library/dataclasses.html#dataclasses.fields)

### **`deny_unknown_fields`**

New in v0.22.0, the `deny_unknown_fields` option in the pyserde decorator allows you to enforce strict field validation during deserialization. When this option is enabled, any fields in the input data that are not defined in the target class will cause deserialization to fail with a `SerdeError`.

Consider the following example:
```python
@serde(deny_unknown_fields=True)
class Foo:
a: int
b: str
```

With `deny_unknown_fields=True`, attempting to deserialize data containing fields beyond those defined (a and b in this case) will raise an error. For instance:
```
from_json(Foo, '{"a": 10, "b": "foo", "c": 100.0, "d": true}')
```
This will raise a `SerdeError` since fields c and d are not recognized members of Foo.

See [examples/deny_unknown_fields.py](https://github.com/yukinarit/pyserde/blob/main/examples/deny_unknown_fields.py) for complete example.
22 changes: 21 additions & 1 deletion docs/ja/class-attributes.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,26 @@ class Foo:
a: ClassVar[int] = 10
```

完全な例については、[examples/class_var.py](https://github.com/yukinarit/pyserde/blob/main/examples/class_var.py) を参照してください。
完全な例については、[examples/class_var.py](https://github.com/yukinarit/pyserde/blob/main/examples/class_var.py)を参照してください。

[^1]: [dataclasses.fields](https://docs.python.org/3/library/dataclasses.html#dataclasses.fields)

### **`deny_unknown_fields`**

バージョン0.22.0で新規追加。 pyserdeデコレータの`deny_unknown_fields`オプションはデシリアライズ時のより厳格なフィールドチェックを制御できます。このオプションをTrueにするとデシリアライズ時に宣言されていないフィールドが見つかると`SerdeError`を投げることができます。

以下の例を考えてください。
```python
@serde(deny_unknown_fields=True)
class Foo:
a: int
b: str
```

`deny_unknown_fields=True`が指定されていると、 宣言されているフィールド(この場合aとb)以外がインプットにあると例外を投げます。例えば、
```
from_json(Foo, '{"a": 10, "b": "foo", "c": 100.0, "d": true}')
```
上記のコードはフィールドcとdという宣言されていないフィールドがあるためエラーとなります。

完全な例については、[examples/deny_unknown_fields.py](https://github.com/yukinarit/pyserde/blob/main/examples/deny_unknown_fields.py)を参照してください。
20 changes: 20 additions & 0 deletions examples/deny_unknown_fields.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
from serde import serde, SerdeError
from serde.json import from_json


@serde(deny_unknown_fields=True)
class Foo:
a: int
b: str


def main() -> None:
try:
s = '{"a": 10, "b": "foo", "c": 100.0, "d": true}'
print(f"From Json: {from_json(Foo, s)}")
except SerdeError:
pass


if __name__ == "__main__":
main()
4 changes: 3 additions & 1 deletion examples/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ def run_all() -> None:
import pep681
import plain_dataclass
import plain_dataclass_class_attribute
import deny_unknown_fields
import python_pickle
import recursive
import recursive_list
Expand Down Expand Up @@ -107,6 +108,7 @@ def run_all() -> None:
run(class_var)
run(plain_dataclass)
run(plain_dataclass_class_attribute)
run(deny_unknown_fields)
run(msg_pack)
run(primitive_subclass)
run(kw_only)
Expand All @@ -133,6 +135,6 @@ def run(module: typing.Any) -> None:
try:
run_all()
print("-----------------")
print("all tests passed successfully!")
print("all examples completed successfully!")
except Exception:
sys.exit(1)
4 changes: 4 additions & 0 deletions serde/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ def serde(
serialize_class_var: bool = False,
class_serializer: Optional[ClassSerializer] = None,
class_deserializer: Optional[ClassDeserializer] = None,
deny_unknown_fields: bool = False,
) -> Type[T]: ...


Expand All @@ -140,6 +141,7 @@ def serde(
serialize_class_var: bool = False,
class_serializer: Optional[ClassSerializer] = None,
class_deserializer: Optional[ClassDeserializer] = None,
deny_unknown_fields: bool = False,
) -> Callable[[type[T]], type[T]]: ...


Expand All @@ -156,6 +158,7 @@ def serde(
serialize_class_var: bool = False,
class_serializer: Optional[ClassSerializer] = None,
class_deserializer: Optional[ClassDeserializer] = None,
deny_unknown_fields: bool = False,
) -> Any:
"""
serde decorator. Keyword arguments are passed in `serialize` and `deserialize`.
Expand Down Expand Up @@ -187,6 +190,7 @@ def wrap(cls: Any) -> Any:
type_check=type_check,
serialize_class_var=serialize_class_var,
class_deserializer=class_deserializer,
deny_unknown_fields=deny_unknown_fields,
)
return cls

Expand Down
26 changes: 25 additions & 1 deletion serde/de.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ def deserialize(
tagging: Tagging = DefaultTagging,
type_check: TypeCheck = strict,
class_deserializer: Optional[ClassDeserializer] = None,
deny_unknown_fields: bool = False,
**kwargs: Any,
) -> type[T]:
"""
Expand Down Expand Up @@ -329,7 +330,12 @@ def wrap(cls: type[T]) -> type[T]:
scope,
FROM_DICT,
render_from_dict(
cls, rename_all, deserializer, type_check, class_deserializer=class_deserializer
cls,
rename_all,
deserializer,
type_check,
class_deserializer=class_deserializer,
deny_unknown_fields=deny_unknown_fields,
),
g,
)
Expand Down Expand Up @@ -1041,6 +1047,13 @@ def {{func}}(cls=cls, maybe_generic=None, maybe_generic_type_vars=None, data=Non
if reuse_instances is None:
reuse_instances = {{serde_scope.reuse_instances_default}}
{% if deny_unknown_fields %}
known_fields = {{ known_fields }}
unknown_fields = set((data or {}).keys()) - known_fields
if unknown_fields:
raise SerdeError(f'unknown fields: {unknown_fields}, expected one of {known_fields}')
{% endif %}
maybe_generic_type_vars = maybe_generic_type_vars or {{cls_type_vars}}
{% for f in fields %}
Expand Down Expand Up @@ -1143,12 +1156,18 @@ def render_from_iter(
return res


def get_known_fields(f: DeField[Any], rename_all: Optional[str]) -> list[str]:
names: list[str] = [f.conv_name(rename_all)]
return names + f.alias


def render_from_dict(
cls: type[Any],
rename_all: Optional[str] = None,
legacy_class_deserializer: Optional[DeserializeFunc] = None,
type_check: TypeCheck = strict,
class_deserializer: Optional[ClassDeserializer] = None,
deny_unknown_fields: bool = False,
) -> str:
renderer = Renderer(
FROM_DICT,
Expand All @@ -1159,6 +1178,9 @@ def render_from_dict(
class_name=typename(cls),
)
fields = list(filter(renderable, defields(cls)))
known_fields = set(
itertools.chain.from_iterable([get_known_fields(f, rename_all) for f in fields])
)
res = jinja2_env.get_template("dict").render(
func=FROM_DICT,
serde_scope=getattr(cls, SERDE_SCOPE),
Expand All @@ -1167,6 +1189,8 @@ def render_from_dict(
cls_type_vars=get_type_var_names(cls),
rvalue=renderer.render,
arg=functools.partial(to_arg, rename_all=rename_all),
deny_unknown_fields=deny_unknown_fields,
known_fields=known_fields,
)

if renderer.import_numpy:
Expand Down
61 changes: 61 additions & 0 deletions tests/test_de.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import pytest
from decimal import Decimal
from typing import Union, Optional
from serde import serde, SerdeError, field
from serde.json import from_json
from serde.de import deserialize, from_obj, Renderer, DeField


Expand Down Expand Up @@ -125,3 +128,61 @@ class Foo:
rendered_foo = f"Foo.__serde__.funcs['foo'](data=data[\"f\"], {kwargs})"
rendered_opt = f'({rendered_foo}) if data.get("f") is not None else None'
assert rendered == rendered_opt


def test_deny_unknown_fields() -> None:
@serde(deny_unknown_fields=True)
class Foo:
a: int
b: str

with pytest.raises(SerdeError):
from_json(Foo, '{"a": 10, "b": "foo", "c": 100.0, "d": true}')

f = from_json(Foo, '{"a": 10, "b": "foo"}')
assert f.a == 10
assert f.b == "foo"


def test_deny_renamed_unknown_fields() -> None:
@serde(deny_unknown_fields=True)
class Foo:
a: int
b: str = field(rename="B")

with pytest.raises(SerdeError):
from_json(Foo, '{"a": 10, "b": "foo"}')

f = from_json(Foo, '{"a": 10, "B": "foo"}')
assert f.a == 10
assert f.b == "foo"

@serde(rename_all="constcase", deny_unknown_fields=True)
class Bar:
a: int
b: str

with pytest.raises(SerdeError):
from_json(Bar, '{"a": 10, "b": "foo"}')

b = from_json(Bar, '{"A": 10, "B": "foo"}')
assert b.a == 10
assert b.b == "foo"


def test_deny_aliased_unknown_fields() -> None:
@serde(deny_unknown_fields=True)
class Foo:
a: int
b: str = field(alias=["B"]) # type: ignore

with pytest.raises(SerdeError):
from_json(Foo, '{"a": 10, "b": "foo", "c": 100.0, "d": true}')

f = from_json(Foo, '{"a": 10, "b": "foo"}')
assert f.a == 10
assert f.b == "foo"

f = from_json(Foo, '{"a": 10, "B": "foo"}')
assert f.a == 10
assert f.b == "foo"

0 comments on commit 6aeb49e

Please sign in to comment.