Skip to content

Commit d5d7778

Browse files
committed
Backport dataclasses.asdict(…) for Python ≤ 3.11
Work around python/cpython#79721 on these versions.
1 parent b8d9622 commit d5d7778

File tree

6 files changed

+142
-6
lines changed

6 files changed

+142
-6
lines changed

message_ix_models/tests/util/test_context.py

+8-3
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from message_ix import Scenario
1414

1515
from message_ix_models import Context
16+
from message_ix_models.util.scenarioinfo import ScenarioInfo
1617

1718

1819
class TestContext:
@@ -155,12 +156,16 @@ def test_set_scenario(self, test_context):
155156
dict(model="foo", scenario="bar", version=0) == test_context.scenario_info
156157
)
157158

158-
def test_asdict(self, session_context):
159+
def test_asdict(self, test_context):
160+
# Add a ScenarioInfo object. This fails on Python <= 3.11 due to
161+
# https://github.com/python/cpython/issues/79721
162+
test_context.core.scenarios.append(ScenarioInfo())
163+
159164
# asdict() method runs
160-
session_context.asdict()
165+
test_context.asdict()
161166

162167
# Context can be serialized to json using the genno caching Encoder
163-
json.dumps(session_context, cls=genno.caching.Encoder)
168+
json.dumps(test_context, cls=genno.caching.Encoder)
164169

165170
def test_write_debug_archive(self, mix_models_cli):
166171
""":meth:`.write_debug_archive` works."""

message_ix_models/tests/util/test_scenarioinfo.py

+28
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import logging
22
import re
3+
import sys
4+
from copy import deepcopy
5+
from dataclasses import asdict as asdict_stdlib
36

47
import pandas as pd
58
import pytest
@@ -12,9 +15,34 @@
1215
from message_ix_models import ScenarioInfo, Spec
1316
from message_ix_models.model.structure import get_codes, process_technology_codes
1417
from message_ix_models.util import as_codes
18+
from message_ix_models.util._dataclasses import asdict as asdict_backport
1519

1620

1721
class TestScenarioInfo:
22+
@pytest.fixture(scope="class")
23+
def info(self) -> ScenarioInfo:
24+
return ScenarioInfo()
25+
26+
@pytest.mark.parametrize(
27+
"func",
28+
(
29+
pytest.param(
30+
asdict_stdlib,
31+
marks=pytest.mark.xfail(
32+
condition=sys.version_info.minor <= 11,
33+
reason="https://github.com/python/cpython/issues/79721",
34+
),
35+
),
36+
asdict_backport,
37+
),
38+
)
39+
def test_asdict(self, func, info) -> None:
40+
"""Test backported :func:`.asdict` works for ScenarioInfo."""
41+
func(info)
42+
43+
def test_deepcopy(self, info) -> None:
44+
deepcopy(info)
45+
1846
def test_empty(self):
1947
"""ScenarioInfo created from scratch."""
2048
info = ScenarioInfo()
+95
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
"""Utilities for :mod:`.dataclasses`.
2+
3+
This module currently backports one function, :func:`asdict`, from Python 3.13.0, in
4+
order to avoid https://github.com/python/cpython/issues/79721. This issue specifically
5+
occurs with :attr:`.ScenarioInfo.set`, which is of class :class:`defaultdict`. The
6+
backported function **should** be used when (a) Python 3.11 or earlier is in use and (b)
7+
ScenarioInfo is handled directly or indirectly.
8+
"""
9+
# NB Comments are deleted
10+
11+
import copy
12+
import types
13+
from dataclasses import fields
14+
15+
__all__ = [
16+
"asdict",
17+
]
18+
19+
_ATOMIC_TYPES = frozenset(
20+
{
21+
types.NoneType,
22+
bool,
23+
int,
24+
float,
25+
str,
26+
complex,
27+
bytes,
28+
types.EllipsisType,
29+
types.NotImplementedType,
30+
types.CodeType,
31+
types.BuiltinFunctionType,
32+
types.FunctionType,
33+
type,
34+
range,
35+
property,
36+
}
37+
)
38+
39+
_FIELDS = "__dataclass_fields__"
40+
41+
42+
def _is_dataclass_instance(obj):
43+
return hasattr(type(obj), _FIELDS)
44+
45+
46+
def asdict(obj, *, dict_factory=dict):
47+
if not _is_dataclass_instance(obj):
48+
raise TypeError("asdict() should be called on dataclass instances")
49+
return _asdict_inner(obj, dict_factory)
50+
51+
52+
def _asdict_inner(obj, dict_factory): # noqa: C901
53+
obj_type = type(obj)
54+
if obj_type in _ATOMIC_TYPES:
55+
return obj
56+
elif hasattr(obj_type, _FIELDS):
57+
if dict_factory is dict:
58+
return {
59+
f.name: _asdict_inner(getattr(obj, f.name), dict) for f in fields(obj)
60+
}
61+
else:
62+
return dict_factory(
63+
[
64+
(f.name, _asdict_inner(getattr(obj, f.name), dict_factory))
65+
for f in fields(obj)
66+
]
67+
)
68+
elif obj_type is list:
69+
return [_asdict_inner(v, dict_factory) for v in obj]
70+
elif obj_type is dict:
71+
return {
72+
_asdict_inner(k, dict_factory): _asdict_inner(v, dict_factory)
73+
for k, v in obj.items()
74+
}
75+
elif obj_type is tuple:
76+
return tuple([_asdict_inner(v, dict_factory) for v in obj])
77+
elif issubclass(obj_type, tuple):
78+
if hasattr(obj, "_fields"):
79+
return obj_type(*[_asdict_inner(v, dict_factory) for v in obj])
80+
else:
81+
return obj_type(_asdict_inner(v, dict_factory) for v in obj)
82+
elif issubclass(obj_type, dict):
83+
if hasattr(obj_type, "default_factory"):
84+
result = obj_type(obj.default_factory)
85+
for k, v in obj.items():
86+
result[_asdict_inner(k, dict_factory)] = _asdict_inner(v, dict_factory)
87+
return result
88+
return obj_type(
89+
(_asdict_inner(k, dict_factory), _asdict_inner(v, dict_factory))
90+
for k, v in obj.items()
91+
)
92+
elif issubclass(obj_type, list):
93+
return obj_type(_asdict_inner(v, dict_factory) for v in obj)
94+
else:
95+
return copy.deepcopy(obj)

message_ix_models/util/cache.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
import json
1414
import logging
1515
from collections.abc import Callable
16-
from dataclasses import asdict, is_dataclass
16+
from dataclasses import is_dataclass
1717
from types import FunctionType
1818
from typing import TYPE_CHECKING, Union
1919

@@ -22,6 +22,7 @@
2222
import sdmx.model
2323
import xarray as xr
2424

25+
from ._dataclasses import asdict
2526
from .context import Context
2627
from .scenarioinfo import ScenarioInfo
2728

message_ix_models/util/config.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,14 @@
22
import os
33
import pickle
44
from collections.abc import Mapping, MutableMapping, Sequence
5-
from dataclasses import asdict, dataclass, field, fields, is_dataclass, replace
5+
from dataclasses import dataclass, field, fields, is_dataclass, replace
66
from hashlib import blake2s
77
from pathlib import Path
88
from typing import TYPE_CHECKING, Any, Hashable, Optional
99

1010
import ixmp
1111

12+
from ._dataclasses import asdict
1213
from .scenarioinfo import ScenarioInfo
1314

1415
if TYPE_CHECKING:

message_ix_models/util/context.py

+7-1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import logging
44
from copy import deepcopy
5+
from dataclasses import is_dataclass
56
from functools import lru_cache
67
from importlib import import_module
78
from pathlib import Path
@@ -253,7 +254,12 @@ def __setattr__(self, name, value):
253254
# Particular methods of Context
254255
def asdict(self) -> dict:
255256
"""Return a :func:`.deepcopy` of the Context's values as a :class:`dict`."""
256-
return {k: deepcopy(v) for k, v in self._values.items()}
257+
from ._dataclasses import asdict
258+
259+
return {
260+
k: asdict(v) if is_dataclass(v) else deepcopy(v)
261+
for k, v in self._values.items()
262+
}
257263

258264
def clone_to_dest(self, create=True) -> "message_ix.Scenario":
259265
"""Return a scenario based on the ``--dest`` command-line option.

0 commit comments

Comments
 (0)