Skip to content

Commit

Permalink
Merge branch '23.2'
Browse files Browse the repository at this point in the history
  • Loading branch information
Tinche committed Nov 30, 2023
2 parents 82c4059 + 5dc43b3 commit 177055a
Show file tree
Hide file tree
Showing 10 changed files with 105 additions and 36 deletions.
13 changes: 12 additions & 1 deletion HISTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,21 @@
- Tests are run with the pytest-xdist plugin by default.
- The docs now use the Inter font.

## 23.2.3 (2023-11-30)

- Fix a regression when unstructuring dictionary values typed as `Any`.
([#453](https://github.com/python-attrs/cattrs/issues/453) [#462](https://github.com/python-attrs/cattrs/pull/462))
- Fix a regression when unstructuring unspecialized generic classes.
([#465](https://github.com/python-attrs/cattrs/issues/465) [#466](https://github.com/python-attrs/cattrs/pull/466))
- Optimize function source code caching.
([#445](https://github.com/python-attrs/cattrs/issues/445) [#464](https://github.com/python-attrs/cattrs/pull/464))
- Generate unique files only in case of linecache enabled.
([#445](https://github.com/python-attrs/cattrs/issues/445) [#441](https://github.com/python-attrs/cattrs/pull/461))

## 23.2.2 (2023-11-21)

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

## 23.2.1 (2023-11-18)

Expand Down
2 changes: 1 addition & 1 deletion src/cattrs/converters.py
Original file line number Diff line number Diff line change
Expand Up @@ -975,7 +975,7 @@ def gen_unstructure_optional(self, cl: Type[T]) -> Callable[[T], Any]:
other = union_params[0] if union_params[1] is NoneType else union_params[1]

# TODO: Remove this special case when we make unstructuring Any consistent.
if other is Any:
if other is Any or isinstance(other, TypeVar):
handler = self.unstructure
else:
handler = self._unstructure_func.dispatch(other)
Expand Down
25 changes: 11 additions & 14 deletions src/cattrs/gen/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
from __future__ import annotations

import linecache
import re
from typing import TYPE_CHECKING, Any, Callable, Iterable, Mapping, Tuple, TypeVar

Expand Down Expand Up @@ -212,22 +211,17 @@ def make_dict_unstructure_fn(
+ [" return res"]
)
script = "\n".join(total_lines)

fname = generate_unique_filename(
cl, "unstructure", reserve=_cattrs_use_linecache
cl, "unstructure", lines=total_lines if _cattrs_use_linecache else []
)

eval(compile(script, fname, "exec"), globs)

fn = globs[fn_name]
if _cattrs_use_linecache:
linecache.cache[fname] = len(script), None, total_lines, fname
finally:
working_set.remove(cl)
if not working_set:
del already_generating.working_set

return fn
return globs[fn_name]


DictStructureFn = Callable[[Mapping[str, Any], Any], T]
Expand Down Expand Up @@ -628,11 +622,12 @@ def make_dict_structure_fn(
*pi_lines,
]

fname = generate_unique_filename(cl, "structure", reserve=_cattrs_use_linecache)
script = "\n".join(total_lines)
fname = generate_unique_filename(
cl, "structure", lines=total_lines if _cattrs_use_linecache else []
)

eval(compile(script, fname, "exec"), globs)
if _cattrs_use_linecache:
linecache.cache[fname] = len(script), None, total_lines, fname

return globs[fn_name]

Expand Down Expand Up @@ -743,9 +738,11 @@ def make_mapping_unstructure_fn(
if kh == identity:
kh = None

val_handler = converter._unstructure_func.dispatch(val_arg)
if val_handler == identity:
val_handler = None
if val_arg is not Any:
# TODO: Remove this once we have more consistent Any handling in place.
val_handler = converter._unstructure_func.dispatch(val_arg)
if val_handler == identity:
val_handler = None

globs = {
"__cattr_mapping_cl": unstructure_to or cl,
Expand Down
16 changes: 7 additions & 9 deletions src/cattrs/gen/_lc.py
Original file line number Diff line number Diff line change
@@ -1,27 +1,25 @@
"""Line-cache functionality."""
import linecache
import uuid
from typing import Any
from typing import Any, List


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

while True:
unique_filename = "<cattrs generated {} {}.{}{}>".format(
func_name, cls.__module__, getattr(cls, "__qualname__", cls.__name__), extra
)
if not reserve:
if not lines:
return unique_filename
# To handle concurrency we essentially "reserve" our spot in
# the linecache with a dummy line. The caller can then
# set this value correctly.
cache_line = (1, None, (str(unique_id),), unique_filename)
cache_line = (len("\n".join(lines)), None, lines, unique_filename)
if linecache.cache.setdefault(unique_filename, cache_line) == cache_line:
return unique_filename

Expand Down
17 changes: 6 additions & 11 deletions src/cattrs/gen/typeddicts.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
from __future__ import annotations

import linecache
import re
import sys
from typing import TYPE_CHECKING, Any, Callable, TypeVar
Expand Down Expand Up @@ -227,20 +226,16 @@ def make_dict_unstructure_fn(
script = "\n".join(total_lines)

fname = generate_unique_filename(
cl, "unstructure", reserve=_cattrs_use_linecache
cl, "unstructure", lines=total_lines if _cattrs_use_linecache else []
)

eval(compile(script, fname, "exec"), globs)

fn = globs[fn_name]
if _cattrs_use_linecache:
linecache.cache[fname] = len(script), None, total_lines, fname
finally:
working_set.remove(cl)
if not working_set:
del already_generating.working_set

return fn
return globs[fn_name]


def make_dict_structure_fn(
Expand Down Expand Up @@ -515,12 +510,12 @@ def make_dict_structure_fn(
" return res",
]

fname = generate_unique_filename(cl, "structure", reserve=_cattrs_use_linecache)
script = "\n".join(total_lines)
eval(compile(script, fname, "exec"), globs)
if _cattrs_use_linecache:
linecache.cache[fname] = len(script), None, total_lines, fname
fname = generate_unique_filename(
cl, "structure", lines=total_lines if _cattrs_use_linecache else []
)

eval(compile(script, fname, "exec"), globs)
return globs[fn_name]


Expand Down
4 changes: 4 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,7 @@ def converter_cls(request):

if sys.version_info < (3, 12):
collect_ignore_glob = ["*_695.py"]

collect_ignore = []
if sys.version_info < (3, 10):
collect_ignore.append("test_generics_604.py")
14 changes: 14 additions & 0 deletions tests/test_any.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
"""Tests for handling `typing.Any`."""
from typing import Any, Dict

from attrs import define


@define
class A:
pass


def test_unstructuring_dict_of_any(converter):
"""Dicts with Any values should use runtime dispatch for their values."""
assert converter.unstructure({"a": A()}, Dict[str, Any]) == {"a": {}}
21 changes: 21 additions & 0 deletions tests/test_gen.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,24 @@ class B:
c.structure(c.unstructure(B(1)), B)

assert len(linecache.cache) == before


def test_linecache_dedup():
"""Linecaching avoids duplicates."""

@define
class LinecacheA:
a: int

c = Converter()
before = len(linecache.cache)
c.structure(c.unstructure(LinecacheA(1)), LinecacheA)
after = len(linecache.cache)

assert after == before + 2

c = Converter()

c.structure(c.unstructure(LinecacheA(1)), LinecacheA)

assert len(linecache.cache) == after
13 changes: 13 additions & 0 deletions tests/test_generics.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,9 @@ def test_unstructure_generic_attrs(genconverter):
class Inner(Generic[T]):
a: T

inner = Inner(Inner(1))
assert genconverter.unstructure(inner) == {"a": {"a": 1}}

@define
class Outer:
inner: Inner[int]
Expand All @@ -203,6 +206,16 @@ class OuterStr:
assert genconverter.structure(raw, OuterStr) == OuterStr(Inner("1"))


def test_unstructure_optional(genconverter):
"""Generics with optional fields work."""

@define
class C(Generic[T]):
a: Union[T, None]

assert genconverter.unstructure(C(C(1))) == {"a": {"a": 1}}


def test_unstructure_deeply_nested_generics(genconverter):
@define
class Inner:
Expand Down
16 changes: 16 additions & 0 deletions tests/test_generics_604.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
"""Tests for generics under PEP 604 (unions as pipes)."""
from typing import Generic, TypeVar

from attrs import define

T = TypeVar("T")


def test_unstructure_optional(genconverter):
"""Generics with optional fields work."""

@define
class C(Generic[T]):
a: T | None

assert genconverter.unstructure(C(C(1))) == {"a": {"a": 1}}

0 comments on commit 177055a

Please sign in to comment.