diff --git a/HISTORY.rst b/HISTORY.rst index e82e518d..702feb61 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -2,6 +2,13 @@ History ======= +Unreleased +---------- + +- Add opt-in ``Meta.strict_mode_for_literals`` for exact type and value + matching of ``Literal`` arguments during loading (:issue:`170`). + The default value-only behavior is unchanged. + 1.0.0 (2026-07-02) ------------------ diff --git a/README.rst b/README.rst index 873df68d..f608d798 100644 --- a/README.rst +++ b/README.rst @@ -168,6 +168,8 @@ Supported Types 🧑‍💻 - 📋 **Collections**: Handle ``list``, ``dict``, and ``set`` effortlessly. - 🔢 **Typing Generics**: Manage ``Union``, ``Any``, and other types from the `typing`_ module. - 🌟 **Advanced Types**: Work with ``Enum``, ``defaultdict``, and ``datetime`` with ease. +- **Strict Literals**: Opt in to exact type and value matching with + ``Meta.strict_mode_for_literals = True``. For more info, check out the `Supported Types`_ section in the docs for detailed insights into each type and the load/dump process! diff --git a/dataclass_wizard/_bases.py b/dataclass_wizard/_bases.py index 784273fd..c35bd3be 100644 --- a/dataclass_wizard/_bases.py +++ b/dataclass_wizard/_bases.py @@ -298,6 +298,10 @@ class BaseMeta(metaclass=ABCOrAndMeta): # This option enforces strict shape matching for performance reasons. namedtuple_as_dict: ClassVar[bool | None] = None + # Require both the value and its exact type to match a Literal argument + # while loading. False preserves the default value-only comparison. + strict_mode_for_literals: ClassVar[bool] = False + # If True (default: False), ``None`` is coerced to an empty # string (``""``) # when loading ``str`` fields. diff --git a/dataclass_wizard/_bases.pyi b/dataclass_wizard/_bases.pyi index e5831254..244f3f3e 100644 --- a/dataclass_wizard/_bases.pyi +++ b/dataclass_wizard/_bases.pyi @@ -44,6 +44,7 @@ class BaseMeta: dump_date_time_as: _ClassVar[DateTimeTo | str | None] = ... assume_naive_datetime_tz: _ClassVar[tzinfo | None] = ... namedtuple_as_dict: _ClassVar[bool | None] = ... + strict_mode_for_literals: _ClassVar[bool] = ... coerce_none_to_empty_str: _ClassVar[bool | None] = ... leaf_handling: _ClassVar[ typing.Literal['exact', 'issubclass'] | None] = ... diff --git a/dataclass_wizard/_bases_meta.pyi b/dataclass_wizard/_bases_meta.pyi index 2e680754..f5289414 100644 --- a/dataclass_wizard/_bases_meta.pyi +++ b/dataclass_wizard/_bases_meta.pyi @@ -85,6 +85,7 @@ def LoadMeta(*, on_unknown_key: KeyAction | str | None = KeyAction.IGNORE, unsafe_parse_dataclass_in_union: bool = ..., namedtuple_as_dict: bool = ..., + strict_mode_for_literals: bool = ..., coerce_none_to_empty_str: bool = ..., leaf_handling: Literal['exact', 'issubclass'] = ...) -> META: ... @@ -134,6 +135,7 @@ def EnvMeta(*, dump_date_time_as: DateTimeTo | str = ..., assume_naive_datetime_tz: tzinfo | None = ..., namedtuple_as_dict: bool = ..., + strict_mode_for_literals: bool = ..., coerce_none_to_empty_str: bool = ..., leaf_handling: Literal['exact', 'issubclass'] = ...) -> ENV_META: ... diff --git a/dataclass_wizard/_env.py b/dataclass_wizard/_env.py index b0e3b400..05cc2428 100644 --- a/dataclass_wizard/_env.py +++ b/dataclass_wizard/_env.py @@ -209,6 +209,7 @@ def load_func_for_dataclass( # noinspection PyTypeChecker extras: Extras = { 'config': config, + 'strict_mode_for_literals': meta.strict_mode_for_literals, 'cls': cls, 'cls_name': cls_name, 'locals': new_locals, diff --git a/dataclass_wizard/_loaders.py b/dataclass_wizard/_loaders.py index 996977a5..09c387b8 100644 --- a/dataclass_wizard/_loaders.py +++ b/dataclass_wizard/_loaders.py @@ -490,7 +490,7 @@ def load_to_typed_dict(cls, tp: TypeInfo, extras: Extras): return f'{{{dict_body}}}' @classmethod - @setup_recursive_safe_function + @setup_recursive_safe_function(per_class_cache=True) def _load_to_typed_dict_fn(cls, tp: TypeInfo, extras: Extras): fn_gen = extras['fn_gen'] @@ -717,36 +717,25 @@ def load_to_literal(tp: TypeInfo, extras: Extras): fn_gen.add_line(f"raise ParseError(e, {v}, fields, 'load', " f'allowed_values=list(fields))') - # TODO Checks for Literal equivalence, as mentioned here: - # https://www.python.org/dev/peps/pep-0586/#equivalence-of-two-literals + @staticmethod + @setup_recursive_safe_function_for_generic(prefix='load_strict') + def _load_to_literal_strict(tp: TypeInfo, extras: Extras): + """Build a Literal loader that matches both exact type and value.""" + fn_gen = extras['fn_gen'] + v = tp.v_for_def() + _locals = extras['locals'] + # Keep equal-valued alternatives of distinct types, such as 0/False. + _locals['fields'] = tp.args + _locals['typed_fields'] = frozenset((type(a), a) for a in tp.args) - # extras_cp['locals'][fields] = { - # a: type(a) for a in tp.args - # } - # - # with fn_gen.function(fn_name, ['v1'], None, _locals): - # - # with fn_gen.try_(): - # with fn_gen.if_(f'type({tp.v()}) is {fields}[{tp.v()}]'): - # fn_gen.add_line('return v1') - # - # # The value of `o` is in the ones defined for the Literal, but - # # also confirm the type matches the one defined for the Literal. - # fn_gen.add_line("e = TypeError('Value did not match expected type for the Literal')") - # - # fn_gen.add_line('raise ParseError(' - # f'e, v1, {fields}, ' - # 'have_type=type(v1), ' - # f'desired_type={fields}[v1], ' - # f'desired_value=next(v for v in {fields} if v == v1), ' - # f'allowed_values=list({fields})' - # ')') - # with fn_gen.except_(KeyError): - # # No such Literal with the value of `o` - # fn_gen.add_line("e = ValueError('Value not in expected Literal values')") - # fn_gen.add_line('raise ParseError(' - # f'e, v1, {fields}, allowed_values=list({fields})' - # f')') + with fn_gen.if_(f'(type({v}), {v}) in typed_fields', + comment=repr(tp.args)): + fn_gen.add_line(f'return {v}') + + fn_gen.add_line("e = ValueError(" + "'Value or type not in expected Literal values')") + fn_gen.add_line(f"raise ParseError(e, {v}, fields, 'load', " + f'allowed_values=list(fields))') @staticmethod def load_to_decimal(tp: TypeInfo, extras: Extras): @@ -943,7 +932,9 @@ def load_dispatcher_for_annotation(cls, # -> Literal[X, Y, ...] elif origin is Literal: - load_hook = cls.load_to_literal + load_hook = (cls._load_to_literal_strict + if extras.get('strict_mode_for_literals', False) + else cls.load_to_literal) args = get_args(type_ann) # https://stackoverflow.com/questions/76520264/dataclasswizard-after-upgrading-to-python3-11-is-not-working-as-expected @@ -1191,6 +1182,8 @@ def load_func_for_dataclass( # we are being run for a nested dataclass else: is_main_class = False + # Keep each class's effective mode local to its code generation. + extras = extras.copy() # config for nested dataclasses config = extras['config'] @@ -1214,6 +1207,8 @@ def load_func_for_dataclass( # Added for a `EnvWizard` main class, which doesn't set this in globals fn_gen.globals.setdefault('raise_missing_fields', check_and_raise_missing_fields) + extras['strict_mode_for_literals'] = meta.strict_mode_for_literals + key_case: KeyCase | None = cls_loader.transform_json_field auto_key_case = key_case is KeyCase.AUTO diff --git a/dataclass_wizard/_models.py b/dataclass_wizard/_models.py index f05fbc06..a2598f98 100644 --- a/dataclass_wizard/_models.py +++ b/dataclass_wizard/_models.py @@ -274,6 +274,7 @@ class Extras(TypedDict): cls_name: str fn_gen: FunctionBuilder locals: dict[str, Any] + strict_mode_for_literals: PyNotRequired[bool] pattern: PyNotRequired['PatternBase'] recursion_guard: dict[type, str] diff --git a/dataclass_wizard/_models.pyi b/dataclass_wizard/_models.pyi index 3784ea5d..138494f8 100644 --- a/dataclass_wizard/_models.pyi +++ b/dataclass_wizard/_models.pyi @@ -78,6 +78,7 @@ class Extras(TypedDict): cls_name: str fn_gen: FunctionBuilder locals: dict[str, Any] + strict_mode_for_literals: NotRequired[bool] pattern: NotRequired[PatternBase] recursion_guard: dict[Any, str] diff --git a/docs/common_use_cases/meta.rst b/docs/common_use_cases/meta.rst index fd21fec3..820c157f 100644 --- a/docs/common_use_cases/meta.rst +++ b/docs/common_use_cases/meta.rst @@ -575,3 +575,43 @@ as shown in the example below. if __name__ == '__main__': main() + + +Strict Literal Loading +---------------------- + +``strict_mode_for_literals`` defaults to ``False``, preserving value-only +matching. Enable it to require both the exact type and value of a +``Literal`` argument, so that ``False`` does not match ``Literal[0]`` and +an integer does not match an ``IntEnum`` member with the same value. +Mixed alternatives such as ``Literal[0, False]`` accept both values. + +.. code:: python3 + + from dataclasses import dataclass + from typing import Literal + + from dataclass_wizard import JSONWizard + + @dataclass + class Request(JSONWizard): + class _(JSONWizard.Meta): + strict_mode_for_literals = True + + mode: Literal[0, 1] + + Request.from_dict({'mode': 0}) # Request(mode=0) + Request.from_dict({'mode': False}) # raises ParseError + +This loading option can also be set with +``LoadMeta(strict_mode_for_literals=True).bind_to(Request)``. It follows the +usual recursive Meta settings: the main class's setting cascades when +``recursive=True``; a nested class can explicitly choose its own mode. +It does not make other field types strict or change dumping behavior. +The legacy ``dataclass_wizard.v0`` implementation is unchanged. + +The same option is available on ``EnvWizard.Meta`` and ``EnvMeta``. It +validates the value passed to the Literal loader; it does not add environment +string coercion. For example, an environment string ``'0'`` still does not +match ``Literal[0]``, while ``'ready'`` matches ``Literal['ready']`` in both +modes. Typed constructor values follow the selected mode. diff --git a/tests/unit/test_literal_strict.py b/tests/unit/test_literal_strict.py new file mode 100644 index 00000000..1b16a88a --- /dev/null +++ b/tests/unit/test_literal_strict.py @@ -0,0 +1,355 @@ +"""Opt-in exact Literal loading and metadata isolation regressions.""" + +from dataclasses import dataclass +from enum import IntEnum +from typing import Literal, TypedDict, Union + +import pytest + +from dataclass_wizard import EnvMeta, EnvWizard, JSONWizard, LoadMeta, fromdict +from dataclass_wizard.env import env_config +from dataclass_wizard.errors import ParseError + + +class Number(IntEnum): + """Integer enum values that compare equal to ints and bools.""" + ZERO = 0 + ONE = 1 + + +@pytest.mark.parametrize( + 'annotation,value,accepted', + [ + (Literal[0], 0, True), + (Literal[0], False, False), + (Literal[0], 0.0, False), + (Literal[0], Number.ZERO, False), + (Literal[False], False, True), + (Literal[False], 0, False), + (Literal[1], 1, True), + (Literal[1], True, False), + (Literal[1], 1.0, False), + (Literal[True], True, True), + (Literal[True], 1, False), + (Literal[Number.ONE], Number.ONE, True), + (Literal[Number.ONE], 1, False), + (Literal[Number.ONE], True, False), + (Literal['ready'], 'ready', True), + (Literal['ready'], 'other', False), + (Literal[None], None, True), + ], +) +def test_strict_literal_type_and_value(annotation, value, accepted): + """Require an exact type and value after opting in.""" + @dataclass + class Model(JSONWizard): + """Local model whose metadata is isolated to this test.""" + class _(JSONWizard.Meta): + strict_mode_for_literals = True + + item: annotation + + if accepted: + result = Model.from_dict({'item': value}) + assert result.item is value + else: + with pytest.raises(ParseError) as caught: + Model.from_dict({'item': value}) + assert caught.value.field_name == 'item' + + +@pytest.mark.parametrize('value', [0, False, Number.ZERO]) +def test_strict_literal_keeps_equal_values_of_distinct_types(value): + """Keep equal-valued alternatives with different types distinct.""" + @dataclass + class Model(JSONWizard): + """Local model whose metadata is isolated to this test.""" + class _(JSONWizard.Meta): + strict_mode_for_literals = True + + item: Literal[0, False, Number.ZERO] + + assert Model.from_dict({'item': value}).item is value + with pytest.raises(ParseError): + Model.from_dict({'item': 0.0}) + + +@pytest.mark.parametrize('explicit_false', [False, True]) +def test_literal_default_remains_permissive(explicit_false): + """Preserve the default and explicitly disabled value-only mode.""" + @dataclass + class Model(JSONWizard): + """Local model whose metadata is isolated to this test.""" + item: Literal[0] + + if explicit_false: + LoadMeta(strict_mode_for_literals=False).bind_to(Model) + + assert Model.from_dict({'item': False}).item is False + + +def test_loadmeta_can_enable_strict_literals(): + """Allow explicit LoadMeta binding on a plain dataclass.""" + @dataclass + class Model: + """Local model whose metadata is isolated to this test.""" + item: Literal[0] + + LoadMeta(strict_mode_for_literals=True).bind_to(Model) + assert fromdict(Model, {'item': 0}).item == 0 + with pytest.raises(ParseError): + fromdict(Model, {'item': False}) + + +def test_strict_literal_inside_containers(): + """Apply the selected mode inside nested collection elements.""" + @dataclass + class Model(JSONWizard): + """Local model whose metadata is isolated to this test.""" + class _(JSONWizard.Meta): + strict_mode_for_literals = True + + items: list[dict[str, Literal[0]]] + + assert Model.from_dict({'items': [{'x': 0}]}).items == [{'x': 0}] + with pytest.raises(ParseError): + Model.from_dict({'items': [{'x': False}]}) + + +@pytest.mark.parametrize('strict_first', [False, True]) +def test_nested_literal_helpers_preserve_each_class_mode(strict_first): + """Keep sibling class metadata independent in either field order.""" + @dataclass + class Strict(JSONWizard): + """Nested model with exact Literal matching enabled.""" + class _(JSONWizard.Meta): + strict_mode_for_literals = True + + item: Literal[0] + + @dataclass + class Loose(JSONWizard): + """Nested model preserving value-only Literal matching.""" + class _(JSONWizard.Meta): + strict_mode_for_literals = False + + item: Literal[0] + + first_type, second_type = ( + (Strict, Loose) if strict_first else (Loose, Strict) + ) + + @dataclass + class Model(JSONWizard): + """Local model whose metadata is isolated to this test.""" + first: first_type + second: second_type + + good = {'first': {'item': 0}, 'second': {'item': 0}} + good['second' if strict_first else 'first']['item'] = False + result = Model.from_dict(good) + loose = result.second if strict_first else result.first + assert loose.item is False + + bad = {'first': {'item': 0}, 'second': {'item': 0}} + bad['first' if strict_first else 'second']['item'] = False + with pytest.raises(ParseError): + Model.from_dict(bad) + + +@pytest.mark.parametrize('recursive', [False, True]) +def test_strict_literal_meta_cascades_only_when_recursive(recursive): + """Apply own metadata and only cascade it when configured.""" + @dataclass + class Child: + """Shared child model containing a Literal field.""" + item: Literal[0] + + @dataclass + class Model(JSONWizard): + """Local model whose metadata is isolated to this test.""" + class _(JSONWizard.Meta): + strict_mode_for_literals = True + + child: Child + own: Literal[0] + + LoadMeta(recursive=recursive).bind_to(Model) + assert Model.from_dict({'child': {'item': 0}, 'own': 0}).own == 0 + with pytest.raises(ParseError): + Model.from_dict({'child': {'item': 0}, 'own': False}) + if recursive: + with pytest.raises(ParseError): + Model.from_dict({'child': {'item': False}, 'own': 0}) + else: + result = Model.from_dict({'child': {'item': False}, 'own': 0}) + assert result.child.item is False + + +@pytest.mark.parametrize('strict_first', [False, True]) +def test_shared_child_keeps_each_root_mode(strict_first): + """Keep reused child types independent across root loaders.""" + @dataclass + class Child: + """Shared child model containing a Literal field.""" + item: Literal[0] + + @dataclass + class Strict(JSONWizard): + """Nested model with exact Literal matching enabled.""" + class _(JSONWizard.Meta): + strict_mode_for_literals = True + + child: Child + + @dataclass + class Loose(JSONWizard): + """Nested model preserving value-only Literal matching.""" + child: Child + + roots = (Strict, Loose) if strict_first else (Loose, Strict) + for root in roots: + assert root.from_dict({'child': {'item': 0}}).child.item == 0 + if root is Strict: + with pytest.raises(ParseError): + root.from_dict({'child': {'item': False}}) + else: + result = root.from_dict({'child': {'item': False}}) + assert result.child.item is False + + +def test_literal_mode_inherited_from_meta_base(): + """Honor explicitly inherited Meta configuration.""" + class Parent(JSONWizard): + """A base model carrying inherited strict configuration.""" + class _(JSONWizard.Meta): + strict_mode_for_literals = True + + @dataclass + class Model(Parent): + """Local model whose metadata is isolated to this test.""" + class _(Parent._): + pass + + item: Literal[0] + + assert Model.from_dict({'item': 0}).item == 0 + with pytest.raises(ParseError): + Model.from_dict({'item': False}) + + +def test_strict_literal_optional_and_union_values(): + """Preserve optional and union alternatives with strict matching.""" + @dataclass + class Model(JSONWizard): + """Local model whose metadata is isolated to this test.""" + class _(JSONWizard.Meta): + strict_mode_for_literals = True + + item: Union[Literal[0], Literal[1], None] + + for value in (0, 1, None): + assert Model.from_dict({'item': value}).item is value + for value in (False, True, 0.0, 1.0): + with pytest.raises(ParseError): + Model.from_dict({'item': value}) + + +@pytest.mark.parametrize('strict', [None, False, True]) +def test_env_literal_typed_values_and_default_mode(strict): + """Validate typed Env values without changing the default mode.""" + class Model(EnvWizard): + """Local model whose metadata is isolated to this test.""" + item: Literal[0] + + if strict is not None: + EnvMeta(strict_mode_for_literals=strict).bind_to(Model) + + assert Model(__env__=env_config(mapping={}), item=0).item == 0 + if strict: + with pytest.raises(ParseError): + Model(__env__=env_config(mapping={}), item=False) + else: + assert Model(__env__=env_config(mapping={}), item=False).item is False + + +def test_env_literal_main_meta_applies_without_recursive_config(): + """Apply Env root metadata even when recursion is disabled.""" + class Model(EnvWizard): + """Local model whose metadata is isolated to this test.""" + class _(EnvWizard.Meta): + strict_mode_for_literals = True + recursive = False + + item: Literal[0] + + assert Model(__env__=env_config(mapping={}), item=0).item == 0 + with pytest.raises(ParseError): + Model(__env__=env_config(mapping={}), item=False) + + +@pytest.mark.parametrize('strict', [False, True]) +def test_env_literal_string_values_remain_strings(strict): + """Load only declared string alternatives from synthetic Env data.""" + class Model(EnvWizard): + """Local model whose metadata is isolated to this test.""" + item: Literal['ready'] + + EnvMeta(strict_mode_for_literals=strict).bind_to(Model) + assert Model(__env__=env_config(mapping={'ITEM': 'ready'})).item == 'ready' + with pytest.raises(ParseError): + Model(__env__=env_config(mapping={'ITEM': 'other'})) + + +@pytest.mark.parametrize('strict', [False, True]) +def test_env_literal_raw_integer_string_is_not_coerced(strict): + """Do not introduce integer coercion for raw environment strings.""" + class Model(EnvWizard): + """Local model whose metadata is isolated to this test.""" + item: Literal[0] + + EnvMeta(strict_mode_for_literals=strict).bind_to(Model) + with pytest.raises(ParseError): + Model(__env__=env_config(mapping={'ITEM': '0'})) + + +@pytest.mark.parametrize('strict_first', [False, True]) +def test_optional_typed_dict_keeps_each_class_literal_mode(strict_first): + """Keep cached optional TypedDict loaders scoped to each class.""" + class Payload(TypedDict, total=False): + """An optional literal field reused across loading modes.""" + code: Literal[0] + + @dataclass + class Strict(JSONWizard): + """Nested model with exact Literal matching enabled.""" + class _(JSONWizard.Meta): + strict_mode_for_literals = True + + value: Payload + + @dataclass + class Loose(JSONWizard): + """Nested model preserving value-only Literal matching.""" + value: Payload + + first_type, second_type = ((Strict, Loose) if strict_first + else (Loose, Strict)) + + @dataclass + class Outer(JSONWizard): + """A root model loading both sibling metadata modes.""" + first: first_type + second: second_type + + strict_field, loose_field = (('first', 'second') if strict_first + else ('second', 'first')) + valid = {'first': {'value': {}}, 'second': {'value': {}}} + valid[strict_field]['value']['code'] = 0 + valid[loose_field]['value']['code'] = False + result = Outer.from_dict(valid) + assert getattr(result, strict_field).value['code'] == 0 + assert getattr(result, loose_field).value['code'] is False + valid[strict_field]['value']['code'] = False + with pytest.raises(ParseError): + Outer.from_dict(valid)