Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions HISTORY.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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)
------------------

Expand Down
2 changes: 2 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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!

Expand Down
4 changes: 4 additions & 0 deletions dataclass_wizard/_bases.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions dataclass_wizard/_bases.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -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] = ...
Expand Down
2 changes: 2 additions & 0 deletions dataclass_wizard/_bases_meta.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -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:
...
Expand Down Expand Up @@ -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:
...
1 change: 1 addition & 0 deletions dataclass_wizard/_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
57 changes: 26 additions & 31 deletions dataclass_wizard/_loaders.py
Original file line number Diff line number Diff line change
Expand Up @@ -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']

Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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']
Expand All @@ -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

Expand Down
1 change: 1 addition & 0 deletions dataclass_wizard/_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down
1 change: 1 addition & 0 deletions dataclass_wizard/_models.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down
40 changes: 40 additions & 0 deletions docs/common_use_cases/meta.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Loading