Skip to content

Commit 177055a

Browse files
committed
Merge branch '23.2'
2 parents 82c4059 + 5dc43b3 commit 177055a

10 files changed

Lines changed: 105 additions & 36 deletions

File tree

HISTORY.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,21 @@
1414
- Tests are run with the pytest-xdist plugin by default.
1515
- The docs now use the Inter font.
1616

17+
## 23.2.3 (2023-11-30)
18+
19+
- Fix a regression when unstructuring dictionary values typed as `Any`.
20+
([#453](https://github.com/python-attrs/cattrs/issues/453) [#462](https://github.com/python-attrs/cattrs/pull/462))
21+
- Fix a regression when unstructuring unspecialized generic classes.
22+
([#465](https://github.com/python-attrs/cattrs/issues/465) [#466](https://github.com/python-attrs/cattrs/pull/466))
23+
- Optimize function source code caching.
24+
([#445](https://github.com/python-attrs/cattrs/issues/445) [#464](https://github.com/python-attrs/cattrs/pull/464))
25+
- Generate unique files only in case of linecache enabled.
26+
([#445](https://github.com/python-attrs/cattrs/issues/445) [#441](https://github.com/python-attrs/cattrs/pull/461))
27+
1728
## 23.2.2 (2023-11-21)
1829

1930
- Fix a regression when unstructuring `Any | None`.
20-
([#453](https://github.com/python-attrs/cattrs/issues/453))
31+
([#453](https://github.com/python-attrs/cattrs/issues/453) [#454](https://github.com/python-attrs/cattrs/pull/454))
2132

2233
## 23.2.1 (2023-11-18)
2334

src/cattrs/converters.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -975,7 +975,7 @@ def gen_unstructure_optional(self, cl: Type[T]) -> Callable[[T], Any]:
975975
other = union_params[0] if union_params[1] is NoneType else union_params[1]
976976

977977
# TODO: Remove this special case when we make unstructuring Any consistent.
978-
if other is Any:
978+
if other is Any or isinstance(other, TypeVar):
979979
handler = self.unstructure
980980
else:
981981
handler = self._unstructure_func.dispatch(other)

src/cattrs/gen/__init__.py

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
from __future__ import annotations
22

3-
import linecache
43
import re
54
from typing import TYPE_CHECKING, Any, Callable, Iterable, Mapping, Tuple, TypeVar
65

@@ -212,22 +211,17 @@ def make_dict_unstructure_fn(
212211
+ [" return res"]
213212
)
214213
script = "\n".join(total_lines)
215-
216214
fname = generate_unique_filename(
217-
cl, "unstructure", reserve=_cattrs_use_linecache
215+
cl, "unstructure", lines=total_lines if _cattrs_use_linecache else []
218216
)
219217

220218
eval(compile(script, fname, "exec"), globs)
221-
222-
fn = globs[fn_name]
223-
if _cattrs_use_linecache:
224-
linecache.cache[fname] = len(script), None, total_lines, fname
225219
finally:
226220
working_set.remove(cl)
227221
if not working_set:
228222
del already_generating.working_set
229223

230-
return fn
224+
return globs[fn_name]
231225

232226

233227
DictStructureFn = Callable[[Mapping[str, Any], Any], T]
@@ -628,11 +622,12 @@ def make_dict_structure_fn(
628622
*pi_lines,
629623
]
630624

631-
fname = generate_unique_filename(cl, "structure", reserve=_cattrs_use_linecache)
632625
script = "\n".join(total_lines)
626+
fname = generate_unique_filename(
627+
cl, "structure", lines=total_lines if _cattrs_use_linecache else []
628+
)
629+
633630
eval(compile(script, fname, "exec"), globs)
634-
if _cattrs_use_linecache:
635-
linecache.cache[fname] = len(script), None, total_lines, fname
636631

637632
return globs[fn_name]
638633

@@ -743,9 +738,11 @@ def make_mapping_unstructure_fn(
743738
if kh == identity:
744739
kh = None
745740

746-
val_handler = converter._unstructure_func.dispatch(val_arg)
747-
if val_handler == identity:
748-
val_handler = None
741+
if val_arg is not Any:
742+
# TODO: Remove this once we have more consistent Any handling in place.
743+
val_handler = converter._unstructure_func.dispatch(val_arg)
744+
if val_handler == identity:
745+
val_handler = None
749746

750747
globs = {
751748
"__cattr_mapping_cl": unstructure_to or cl,

src/cattrs/gen/_lc.py

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,25 @@
11
"""Line-cache functionality."""
22
import linecache
3-
import uuid
4-
from typing import Any
3+
from typing import Any, List
54

65

7-
def generate_unique_filename(cls: Any, func_name: str, reserve: bool = True) -> str:
6+
def generate_unique_filename(cls: Any, func_name: str, lines: List[str] = []) -> str:
87
"""
98
Create a "filename" suitable for a function being generated.
9+
10+
If *lines* are provided, insert them in the first free spot or stop
11+
if a duplicate is found.
1012
"""
11-
unique_id = uuid.uuid4()
1213
extra = ""
1314
count = 1
1415

1516
while True:
1617
unique_filename = "<cattrs generated {} {}.{}{}>".format(
1718
func_name, cls.__module__, getattr(cls, "__qualname__", cls.__name__), extra
1819
)
19-
if not reserve:
20+
if not lines:
2021
return unique_filename
21-
# To handle concurrency we essentially "reserve" our spot in
22-
# the linecache with a dummy line. The caller can then
23-
# set this value correctly.
24-
cache_line = (1, None, (str(unique_id),), unique_filename)
22+
cache_line = (len("\n".join(lines)), None, lines, unique_filename)
2523
if linecache.cache.setdefault(unique_filename, cache_line) == cache_line:
2624
return unique_filename
2725

src/cattrs/gen/typeddicts.py

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
from __future__ import annotations
22

3-
import linecache
43
import re
54
import sys
65
from typing import TYPE_CHECKING, Any, Callable, TypeVar
@@ -227,20 +226,16 @@ def make_dict_unstructure_fn(
227226
script = "\n".join(total_lines)
228227

229228
fname = generate_unique_filename(
230-
cl, "unstructure", reserve=_cattrs_use_linecache
229+
cl, "unstructure", lines=total_lines if _cattrs_use_linecache else []
231230
)
232231

233232
eval(compile(script, fname, "exec"), globs)
234-
235-
fn = globs[fn_name]
236-
if _cattrs_use_linecache:
237-
linecache.cache[fname] = len(script), None, total_lines, fname
238233
finally:
239234
working_set.remove(cl)
240235
if not working_set:
241236
del already_generating.working_set
242237

243-
return fn
238+
return globs[fn_name]
244239

245240

246241
def make_dict_structure_fn(
@@ -515,12 +510,12 @@ def make_dict_structure_fn(
515510
" return res",
516511
]
517512

518-
fname = generate_unique_filename(cl, "structure", reserve=_cattrs_use_linecache)
519513
script = "\n".join(total_lines)
520-
eval(compile(script, fname, "exec"), globs)
521-
if _cattrs_use_linecache:
522-
linecache.cache[fname] = len(script), None, total_lines, fname
514+
fname = generate_unique_filename(
515+
cl, "structure", lines=total_lines if _cattrs_use_linecache else []
516+
)
523517

518+
eval(compile(script, fname, "exec"), globs)
524519
return globs[fn_name]
525520

526521

tests/conftest.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,7 @@ def converter_cls(request):
3131

3232
if sys.version_info < (3, 12):
3333
collect_ignore_glob = ["*_695.py"]
34+
35+
collect_ignore = []
36+
if sys.version_info < (3, 10):
37+
collect_ignore.append("test_generics_604.py")

tests/test_any.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
"""Tests for handling `typing.Any`."""
2+
from typing import Any, Dict
3+
4+
from attrs import define
5+
6+
7+
@define
8+
class A:
9+
pass
10+
11+
12+
def test_unstructuring_dict_of_any(converter):
13+
"""Dicts with Any values should use runtime dispatch for their values."""
14+
assert converter.unstructure({"a": A()}, Dict[str, Any]) == {"a": {}}

tests/test_gen.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,3 +70,24 @@ class B:
7070
c.structure(c.unstructure(B(1)), B)
7171

7272
assert len(linecache.cache) == before
73+
74+
75+
def test_linecache_dedup():
76+
"""Linecaching avoids duplicates."""
77+
78+
@define
79+
class LinecacheA:
80+
a: int
81+
82+
c = Converter()
83+
before = len(linecache.cache)
84+
c.structure(c.unstructure(LinecacheA(1)), LinecacheA)
85+
after = len(linecache.cache)
86+
87+
assert after == before + 2
88+
89+
c = Converter()
90+
91+
c.structure(c.unstructure(LinecacheA(1)), LinecacheA)
92+
93+
assert len(linecache.cache) == after

tests/test_generics.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,9 @@ def test_unstructure_generic_attrs(genconverter):
184184
class Inner(Generic[T]):
185185
a: T
186186

187+
inner = Inner(Inner(1))
188+
assert genconverter.unstructure(inner) == {"a": {"a": 1}}
189+
187190
@define
188191
class Outer:
189192
inner: Inner[int]
@@ -203,6 +206,16 @@ class OuterStr:
203206
assert genconverter.structure(raw, OuterStr) == OuterStr(Inner("1"))
204207

205208

209+
def test_unstructure_optional(genconverter):
210+
"""Generics with optional fields work."""
211+
212+
@define
213+
class C(Generic[T]):
214+
a: Union[T, None]
215+
216+
assert genconverter.unstructure(C(C(1))) == {"a": {"a": 1}}
217+
218+
206219
def test_unstructure_deeply_nested_generics(genconverter):
207220
@define
208221
class Inner:

tests/test_generics_604.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
"""Tests for generics under PEP 604 (unions as pipes)."""
2+
from typing import Generic, TypeVar
3+
4+
from attrs import define
5+
6+
T = TypeVar("T")
7+
8+
9+
def test_unstructure_optional(genconverter):
10+
"""Generics with optional fields work."""
11+
12+
@define
13+
class C(Generic[T]):
14+
a: T | None
15+
16+
assert genconverter.unstructure(C(C(1))) == {"a": {"a": 1}}

0 commit comments

Comments
 (0)