Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add 3.13 compatibility #129

Merged
merged 5 commits into from
Nov 5, 2024
Merged
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
3 changes: 3 additions & 0 deletions dataclass_wizard/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@
# Check if currently running Python 3.11 or higher
PY311_OR_ABOVE = _PY_VERSION >= (3, 11)

# Check if currently running Python 3.13 or higher
PY313_OR_ABOVE = _PY_VERSION >= (3, 13)

# The name of the dictionary object that contains `load` hooks for each
# object type. Also used to check if a class is a :class:`BaseLoadHook`
_LOAD_HOOKS = '__LOAD_HOOKS__'
Expand Down
5 changes: 3 additions & 2 deletions dataclass_wizard/models.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import json
# noinspection PyProtectedMember
from dataclasses import MISSING, Field, _create_fn
from dataclasses import MISSING, Field
from datetime import date, datetime, time
from typing import (cast, Collection, Callable,
Optional, List, Union, Type, Generic)
Expand All @@ -9,6 +8,8 @@
from .constants import PY310_OR_ABOVE
from .decorators import cached_property
from .type_def import T, DT, Encoder, PyTypedDict, FileEncoder
# noinspection PyProtectedMember
from .utils.dataclass_compat import _create_fn
from .utils.type_conv import as_datetime, as_time, as_date


Expand Down
4 changes: 2 additions & 2 deletions dataclass_wizard/serial_json.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import json
# noinspection PyProtectedMember
from dataclasses import _create_fn, _set_new_attribute
from typing import Type, List, Union, AnyStr

from .abstractions import AbstractJSONWizard, W
Expand All @@ -10,6 +8,8 @@
from .dumpers import asdict
from .loaders import fromdict, fromlist
from .type_def import Decoder, Encoder, JSONObject, ListOfJSONObject
# noinspection PyProtectedMember
from .utils.dataclass_compat import _create_fn, _set_new_attribute


class JSONSerializable(AbstractJSONWizard):
Expand Down
59 changes: 59 additions & 0 deletions dataclass_wizard/utils/dataclass_compat.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"""
Pulling some functions removed in recent versions of Python into the module for continued compatibility.
All function names and bodies are left exactly as they were prior to being removed.
"""

from dataclasses import MISSING
from types import FunctionType


def _set_qualname(cls, value):
# Removed in Python 3.13
# Original: `dataclasses._set_qualname`
# Ensure that the functions returned from _create_fn uses the proper
# __qualname__ (the class they belong to).
if isinstance(value, FunctionType):
value.__qualname__ = f"{cls.__qualname__}.{value.__name__}"
return value


def _set_new_attribute(cls, name, value):
# Removed in Python 3.13
# Original: `dataclasses._set_new_attribute`
# Never overwrites an existing attribute. Returns True if the
# attribute already exists.
if name in cls.__dict__:
return True
_set_qualname(cls, value)
setattr(cls, name, value)
return False


def _create_fn(name, args, body, *, globals=None, locals=None,
return_type=MISSING):
# Removed in Python 3.13
# Original: `dataclasses._create_fn`
# Note that we may mutate locals. Callers beware!
# The only callers are internal to this module, so no
# worries about external callers.
if locals is None:
locals = {}
return_annotation = ''
if return_type is not MISSING:
locals['__dataclass_return_type__'] = return_type
return_annotation = '->__dataclass_return_type__'
args = ','.join(args)
body = '\n'.join(f' {b}' for b in body)

# Compute the text of the entire function.
txt = f' def {name}({args}){return_annotation}:\n{body}'

# Free variables in exec are resolved in the global namespace.
# The global namespace we have is user-provided, so we can't modify it for
# our purposes. So we put the things we need into locals and introduce a
# scope to allow the function we're creating to close over them.
local_vars = ', '.join(locals.keys())
txt = f"def __create_fn__({local_vars}):\n{txt}\n return {name}"
ns = {}
exec(txt, globals, ns)
return ns['__create_fn__'](**locals)
18 changes: 7 additions & 11 deletions dataclass_wizard/utils/typing_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
from collections.abc import Callable

from .string_conv import repl_or_with_union
from ..constants import PY36, PY38, PY310_OR_ABOVE, PY39
from ..constants import PY36, PY38, PY310_OR_ABOVE, PY313_OR_ABOVE, PY39
from ..type_def import FREF, PyLiteral, PyTypedDicts, PyForwardRef


Expand Down Expand Up @@ -85,11 +85,9 @@ def get_keys_for_typed_dict(cls):
except ImportError:
from typing import _AnnotatedAlias


def _is_annotated(cls):
return isinstance(cls, _AnnotatedAlias)


def _is_base_generic(cls):
if isinstance(cls, typing._GenericAlias):
if cls.__origin__ in {typing.Generic, typing._Protocol}:
Expand Down Expand Up @@ -142,7 +140,6 @@ def is_literal(cls) -> bool:
def _process_forward_annotation(base_type):
return PyForwardRef(base_type, is_argument=False)


def _get_origin(cls, raise_=False):
if isinstance(cls, types.UnionType):
return typing.Union
Expand Down Expand Up @@ -180,7 +177,6 @@ def _get_origin(cls, raise_=False):
raise
return cls


def _get_named_tuple_field_types(cls, raise_=True):
"""
Note: The latest Python versions only support the `__annotations__`
Expand All @@ -204,12 +200,10 @@ def _get_named_tuple_field_types(cls, raise_=True):

from typing_extensions import AnnotatedMeta


def _process_forward_annotation(base_type):
return PyForwardRef(
repl_or_with_union(base_type), is_argument=False)


def _is_base_generic(cls):
if isinstance(cls, (typing.GenericMeta, typing._Union)):
return cls.__args__ in {None, ()}
Expand Down Expand Up @@ -248,7 +242,6 @@ def _get_origin(cls, raise_=False):
raise
return cls


def _get_args(cls):
if is_literal(cls):
return cls.__values__
Expand All @@ -266,7 +259,6 @@ def _get_args(cls):
# my_union: Union
return ()


def _get_named_tuple_field_types(cls, raise_=True):
"""
Note: Prior to PEP 526, only `_field_types` attribute was assigned.
Expand Down Expand Up @@ -372,8 +364,12 @@ def eval_forward_ref(base_type: FREF,
# Evaluate the ForwardRef here
base_globals = sys.modules[cls.__module__].__dict__

# noinspection PyProtectedMember
return typing._eval_type(base_type, base_globals, _TYPING_LOCALS)
if PY313_OR_ABOVE:
# noinspection PyProtectedMember
return typing._eval_type(base_type, base_globals, _TYPING_LOCALS, type_params=())
Copy link
Owner

@rnag rnag Nov 4, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@benjjs Minor thing, can you pull this out to the top level? That way we won't need to do a python version check each time eval_forward_ref() is called. Thanks!

if sys.version_info >= (3, 13):
  _eval_type = functools.partial(typing._eval_type, type_params=())
else:
  _eval_type = typing._eval_type

Copy link
Owner

@rnag rnag Nov 4, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up, we can add this to utils/typing_compat.py and import/use it instead:

# Check if currently running Python 3.13 or higher
PY313_OR_ABOVE = _PY_VERSION >= (3, 13)

Thanks!

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@benjjs Minor thing, can you pull this out to the top level? That way we won't need to do a python version check each time eval_forward_ref() is called. Thanks!

if sys.version_info >= (3, 13):
  _eval_type = functools.partial(typing._eval_type, type_params=())
else:
  _eval_type = typing._eval_type

This was not resolved, but its OK, I can do that. Simple fix.

else:
# noinspection PyProtectedMember
return typing._eval_type(base_type, base_globals, _TYPING_LOCALS)


def eval_forward_ref_if_needed(base_type: typing.Union[typing.Type, FREF],
Expand Down
Loading