Skip to content

Commit

Permalink
fully add raise_on_unknown_key support
Browse files Browse the repository at this point in the history
  • Loading branch information
rnag committed Dec 11, 2024
1 parent 3a91837 commit 2c6868d
Show file tree
Hide file tree
Showing 5 changed files with 61 additions and 71 deletions.
19 changes: 14 additions & 5 deletions dataclass_wizard/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -313,7 +313,7 @@ def message(self) -> str:
return msg


class UnknownJSONKey(JSONWizardError):
class UnknownKeysError(JSONWizardError):
"""
Error raised when an unknown JSON key is encountered in the JSON load
process.
Expand All @@ -322,10 +322,10 @@ class UnknownJSONKey(JSONWizardError):
`raise_on_unknown_json_key` flag is enabled in the :class:`Meta` class.
"""

_TEMPLATE = ('A JSON key is missing from the dataclass schema for class `{cls}`.\n'
' unknown key: {json_key!r}\n'
' dataclass fields: {fields!r}\n'
' input JSON object: {json_string}')
_TEMPLATE = ('One or more JSON keys are not mapped to the dataclass schema for class `{cls}`.\n'
' Unknown key{s}: {json_key!r}\n'
' Dataclass fields: {fields!r}\n'
' Input JSON object: {json_string}')

def __init__(self,
json_key: str,
Expand All @@ -345,9 +345,14 @@ def __init__(self,
@property
def message(self) -> str:
from .utils.json_util import safe_dumps
if not isinstance(self.json_key, str) and len(self.json_key) > 1:
s = 's'
else:
s = ''

msg = self._TEMPLATE.format(
cls=self.class_name,
s=s,
json_string=safe_dumps(self.obj),
fields=self.fields,
json_key=self.json_key)
Expand All @@ -360,6 +365,10 @@ def message(self) -> str:
return msg


# Alias for backwards-compatibility.
UnknownJSONKey = UnknownKeysError


class MissingData(ParseError):
"""
Error raised when unable to create a class instance, as the JSON object
Expand Down
8 changes: 4 additions & 4 deletions dataclass_wizard/loaders.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
)
from .constants import SINGLE_ARG_ALIAS, IDENTITY, CATCH_ALL
from .decorators import _alias, _single_arg_alias, resolve_alias_func, _identity
from .errors import (ParseError, MissingFields, UnknownJSONKey,
from .errors import (ParseError, MissingFields, UnknownKeysError,
MissingData, RecursiveClassError)
from .loader_selection import fromdict, get_loader
from .log import LOG
Expand Down Expand Up @@ -677,7 +677,7 @@ def load_func_for_dataclass(
# Note this logic only runs the initial time, i.e. the first time
# we encounter the key in a JSON object.
#
# :raises UnknownJSONKey: If there is no resolved field name for the
# :raises UnknownKeysError: If there is no resolved field name for the
# JSON key, and`raise_on_unknown_json_key` is enabled in the Meta
# config for the class.

Expand Down Expand Up @@ -705,8 +705,8 @@ def load_func_for_dataclass(

# Raise an error here (if needed)
if meta.raise_on_unknown_json_key:
_globals['UnknownJSONKey'] = UnknownJSONKey
fn_gen.add_line("raise UnknownJSONKey(json_key, o, cls, cls_fields) from None")
_globals['UnknownKeysError'] = UnknownKeysError
fn_gen.add_line("raise UnknownKeysError(json_key, o, cls, cls_fields) from None")

# Exclude JSON keys that don't map to any fields.
with fn_gen.if_('field is not ExplicitNull'):
Expand Down
93 changes: 37 additions & 56 deletions dataclass_wizard/v1/loaders.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
from ..constants import CATCH_ALL, TAG
from ..decorators import _identity
from .enums import KeyAction, KeyCase
from ..errors import (ParseError, MissingFields, UnknownJSONKey,
from ..errors import (ParseError, MissingFields, UnknownKeysError,
MissingData, JSONWizardError)
from ..loader_selection import get_loader, fromdict
from ..log import LOG
Expand Down Expand Up @@ -934,6 +934,16 @@ def load_func_for_dataclass(
_locals['f2k'] = field_to_alias
_locals['to_key'] = to_json_key

pre_assign = ''
on_unknown_key = meta.v1_on_unknown_key
if on_unknown_key is not None:
should_raise = on_unknown_key is KeyAction.RAISE
should_warn = on_unknown_key is KeyAction.WARN
if should_warn or should_raise:
pre_assign = 'i+=1; '
else:
should_raise = should_warn = None

if has_json_paths:
# loop_over_o = num_paths != len(cls_init_fields)
_locals['safe_get'] = safe_get
Expand Down Expand Up @@ -967,6 +977,8 @@ def load_func_for_dataclass(
fn_gen.add_line('init_kwargs = {}')
if has_catch_all:
fn_gen.add_line('catch_all = {}')
if pre_assign:
fn_gen.add_line('i = 0')

if has_json_paths:

Expand Down Expand Up @@ -1027,7 +1039,7 @@ def load_func_for_dataclass(
fn_gen.add_line(f_assign)

with fn_gen.if_(f'{val} is not MISSING'):
fn_gen.add_line(f'init_kwargs[field] = {string}')
fn_gen.add_line(f'{pre_assign}init_kwargs[field] = {string}')

else:
# TODO confirm this is ok
Expand All @@ -1036,56 +1048,10 @@ def load_func_for_dataclass(

fn_gen.add_line(f_assign)
with fn_gen.if_(f'{val} is not MISSING'):
fn_gen.add_line(f'{var} = {string}')
# Note: pass the original cased field to the class constructor;
# don't use the lowercase result from `py_case`
# fn_gen.add_line("init_kwargs[field] = field_to_parser[field](o[json_key])")

# with fn_gen.try_():
# # Get the resolved dataclass field name
# fn_gen.add_line("field = json_to_field[json_key]")
#
# with fn_gen.except_(KeyError):
# fn_gen.add_line('# Lookup Field for JSON Key')
# # Determines the dataclass field which a JSON key should map to.
# # Note this logic only runs the initial time, i.e. the first time
# # we encounter the key in a JSON object.
# #
# # :raises UnknownJSONKey: If there is no resolved field name for the
# # JSON key, and`raise_on_unknown_json_key` is enabled in the Meta
# # config for the class.
#
# # Short path: an identical-cased field name exists for the JSON key
# with fn_gen.if_('json_key in field_to_parser'):
# fn_gen.add_line("field = json_to_field[json_key] = json_key")
#
# with fn_gen.else_():
# # Transform JSON field name (typically camel-cased) to the
# # snake-cased variant which is convention in Python.
# fn_gen.add_line("py_field = py_case(json_key)")
#
# with fn_gen.try_():
# # Do a case-insensitive lookup of the dataclass field, and
# # cache the mapping, so we have it for next time
# fn_gen.add_line("field "
# "= json_to_field[json_key] "
# "= field_to_parser.get_key(py_field)")
#
# with fn_gen.except_(KeyError):
# # Else, we see an unknown field in the dictionary object
# fn_gen.add_line("field = json_to_field[json_key] = ExplicitNull")
# fn_gen.add_line("LOG.warning('JSON field %r missing from dataclass schema, "
# "class=%r, parsed field=%r',json_key,cls,py_field)")
#
# # Raise an error here (if needed)
# if meta.raise_on_unknown_json_key:
# _globals['UnknownJSONKey'] = UnknownJSONKey
# fn_gen.add_line("raise UnknownJSONKey(json_key, o, cls, fields) from None")

# Exclude JSON keys that don't map to any fields.
# with fn_gen.if_('field is not ExplicitNull'):
fn_gen.add_line(f'{pre_assign}{var} = {string}')


# TODO
if has_catch_all:
line = 'catch_all[json_key] = o[json_key]'
if has_tag_assigned:
Expand Down Expand Up @@ -1113,12 +1079,27 @@ def load_func_for_dataclass(
fn_gen.add_line(f'init_kwargs[{catch_all_field!r}] = catch_all')

# TODO
if meta.v1_on_unknown_key is KeyAction.RAISE:
# Raise an error here (if needed)
_locals['UnknownJSONKey'] = UnknownJSONKey
_locals['f2k'] = field_to_alias
with fn_gen.if_('extras := set(o).difference(f2k.values())'):
fn_gen.add_line("raise UnknownJSONKey(extras, o, cls, fields) from None")

if should_warn or should_raise:
if 'f2k' in _locals:
# If this is the case, then `AUTO` key transform mode is enabled
line = 'extra_keys = o.keys() - f2k.values()'
else:
_locals['aliases'] = set(field_to_alias.values())
line = 'extra_keys = set(o) - aliases'

with fn_gen.if_('len(o) != i'):
fn_gen.add_line(line)

if should_raise:
# Raise an error here (if needed)
_locals['UnknownKeysError'] = UnknownKeysError
fn_gen.add_line("raise UnknownKeysError(extra_keys, o, cls, fields) from None")
elif should_warn:
# Show a warning here
_locals['LOG'] = LOG
fn_gen.add_line(r"LOG.warning('Found %d unknown keys %r not mapped to the dataclass schema.\n"
r" Class: %r\n Dataclass fields: %r', len(extra_keys), extra_keys, cls.__qualname__, [f.name for f in fields])")

# Now pass the arguments to the constructor method, and return
# the new dataclass instance. If there are any missing fields,
Expand Down
4 changes: 2 additions & 2 deletions tests/unit/test_load.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
from dataclass_wizard import *
from dataclass_wizard.constants import TAG
from dataclass_wizard.errors import (
ParseError, MissingFields, UnknownJSONKey, MissingData, InvalidConditionError
ParseError, MissingFields, UnknownKeysError, MissingData, InvalidConditionError
)
from dataclass_wizard.models import Extras, _PatternBase
from dataclass_wizard.parsers import (
Expand Down Expand Up @@ -69,7 +69,7 @@ class MyClass:

# Technically we don't need to pass `load_cfg`, but we'll pass it in as
# that's how we'd typically expect to do it.
with pytest.raises(UnknownJSONKey) as exc_info:
with pytest.raises(UnknownKeysError) as exc_info:
_ = fromdict(MyClass, d)

e = exc_info.value
Expand Down
8 changes: 4 additions & 4 deletions tests/unit/v1/test_loaders.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
from dataclass_wizard import *
from dataclass_wizard.constants import TAG
from dataclass_wizard.errors import (
ParseError, MissingFields, UnknownJSONKey, MissingData, InvalidConditionError
ParseError, MissingFields, UnknownKeysError, MissingData, InvalidConditionError
)
from dataclass_wizard.v1.models import Extras
from dataclass_wizard.models import _PatternBase
Expand Down Expand Up @@ -176,7 +176,7 @@ class MyClass:

# Technically we don't need to pass `load_cfg`, but we'll pass it in as
# that's how we'd typically expect to do it.
with pytest.raises(UnknownJSONKey) as exc_info:
with pytest.raises(UnknownKeysError) as exc_info:
_ = fromdict(MyClass, d)

e = exc_info.value
Expand Down Expand Up @@ -219,7 +219,7 @@ class _(JSONWizard.Meta):
'my_bool': 'F',
'my_str': 'test2', 'myBoolTest': True, 'MyInt': 123}

with pytest.raises(UnknownJSONKey) as exc_info:
with pytest.raises(UnknownKeysError) as exc_info:
_ = Test.from_dict(d)

e = exc_info.value
Expand All @@ -238,7 +238,7 @@ class _(JSONWizard.Meta):
# 'my_sub': {'MyStr': 'test', 'my_bool': False, 'myBoolTest': False},
# }

with pytest.raises(UnknownJSONKey) as exc_info:
with pytest.raises(UnknownKeysError) as exc_info:
_ = Test.from_dict(d)

e = exc_info.value
Expand Down

0 comments on commit 2c6868d

Please sign in to comment.