Skip to content

Commit 2477c96

Browse files
authored
Merge pull request #169 from jacebrowning/nested-comments
Improve preservation of nested comments
2 parents 0971ac9 + 567df63 commit 2477c96

File tree

8 files changed

+242
-106
lines changed

8 files changed

+242
-106
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# 0.9 (beta)
22

33
- Fixed serialization of optional nested dataclasses with a value of `None`.
4+
- Fixed preservation of comments on nested dataclass attributes.
45

56
# 0.8.1 (2020-03-30)
67

datafiles/hooks.py

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from functools import wraps
44

55
import log
6+
from ruamel.yaml.comments import CommentedMap, CommentedSeq
67

78
from . import settings
89
from .mapper import create_mapper
@@ -27,14 +28,6 @@
2728
FLAG = '_patched'
2829

2930

30-
class List(list):
31-
"""Patchable `list` type."""
32-
33-
34-
class Dict(dict):
35-
"""Patchable `dict` type."""
36-
37-
3831
def apply(instance, mapper):
3932
"""Path methods that get or set attributes."""
4033
cls = instance.__class__
@@ -56,10 +49,10 @@ def apply(instance, mapper):
5649
for attr_name in instance.datafile.attrs:
5750
attr = getattr(instance, attr_name)
5851
if isinstance(attr, list):
59-
attr = List(attr)
52+
attr = CommentedSeq(attr)
6053
setattr(instance, attr_name, attr)
6154
elif isinstance(attr, dict):
62-
attr = Dict(attr)
55+
attr = CommentedMap(attr)
6356
setattr(instance, attr_name, attr)
6457
elif not is_dataclass(attr):
6558
continue

datafiles/mapper.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -138,12 +138,13 @@ def _get_data(self, include_default_values: Trilean = None) -> Dict:
138138

139139
if getattr(converter, 'DATACLASS', None):
140140
log.debug(f"Converting '{name}' dataclass with {converter}")
141-
data[name] = converter.to_preserialization_data(
141+
new_value = converter.to_preserialization_data(
142142
value,
143143
default_to_skip=Missing
144144
if include_default_values
145145
else get_default_field_value(self._instance, name),
146146
)
147+
data[name] = recursive_update(value, new_value)
147148

148149
elif (
149150
value == get_default_field_value(self._instance, name)
@@ -171,7 +172,7 @@ def _get_text(self, **kwargs) -> str:
171172

172173
@text.setter # type: ignore
173174
def text(self, value: str):
174-
write(self.path, value.strip() + '\n')
175+
write(self.path, value.strip() + '\n', display=True)
175176

176177
def load(self, *, _log=True, _first_load=False) -> None:
177178
if self._root:
@@ -274,7 +275,7 @@ def save(self, *, include_default_values: Trilean = None, _log=True) -> None:
274275
with hooks.disabled():
275276
text = self._get_text(include_default_values=include_default_values)
276277

277-
write(self.path, text)
278+
write(self.path, text, display=True)
278279

279280
self.modified = False
280281

datafiles/tests/test_utils.py

Lines changed: 102 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -6,48 +6,119 @@
66

77

88
def describe_recursive_update():
9-
def it_preserves_root_id(expect):
10-
old: Dict = {}
11-
new = {'a': 1}
12-
id_ = id(old)
9+
def describe_id_preservation():
10+
def with_dict(expect):
11+
old = {'my_dict': {'a': 1}}
12+
new = {'my_dict': {'a': 2}}
13+
previous_id = id(old['my_dict'])
1314

14-
old = recursive_update(old, new)
15+
recursive_update(old, new)
1516

16-
expect(old) == new
17-
expect(id(old)) == id_
17+
expect(old) == new
18+
expect(id(old['my_dict'])) == previous_id
1819

19-
def it_preserves_nested_dict_id(expect):
20-
old = {'a': {'b': 1}}
21-
new = {'a': {'b': 2}}
22-
id_ = id(old['a'])
20+
def with_dict_value(expect):
21+
old = {'my_dict': {'my_nested_dict': {'a': 1}}}
22+
new = {'my_dict': {'my_nested_dict': {'a': 2}}}
23+
previous_id = id(old['my_dict']['my_nested_dict'])
2324

24-
old = recursive_update(old, new)
25+
recursive_update(old, new)
2526

26-
expect(old) == new
27-
expect(id(old['a'])) == id_
27+
expect(old) == new
28+
expect(id(old['my_dict']['my_nested_dict'])) == previous_id
2829

29-
def it_preserves_nested_list_id(expect):
30-
old = {'a': [1]}
31-
new = {'a': [2]}
32-
id_ = id(old['a'])
30+
def with_list(expect):
31+
old = {'my_list': [1]}
32+
new = {'my_list': [2]}
33+
previous_id = id(old['my_list'])
3334

34-
old = recursive_update(old, new)
35+
recursive_update(old, new)
3536

36-
expect(old) == new
37-
expect(id(old['a'])) == id_
37+
expect(old) == new
38+
expect(id(old['my_list'])) == previous_id
3839

39-
def it_adds_missing_dict(expect):
40-
old: Dict = {}
41-
new = {'a': {'b': 2}}
40+
def with_list_item(expect):
41+
old = {'my_list': [{'name': "John"}]}
42+
new = {'my_list': [{'name': "Jane"}]}
43+
previous_id = id(old['my_list'][0])
4244

43-
old = recursive_update(old, new)
45+
recursive_update(old, new)
4446

45-
expect(old) == new
47+
expect(old) == new
48+
expect(id(old['my_list'][0])) == previous_id
4649

47-
def it_adds_missing_list(expect):
48-
old: Dict = {}
49-
new = {'a': [1]}
50+
def with_nested_list(expect):
51+
old = {'my_dict': {'my_list': [{'name': "John"}]}}
52+
new = {'my_dict': {'my_list': [{'name': "Jane"}]}}
53+
previous_id = id(old['my_dict']['my_list'][0])
5054

51-
old = recursive_update(old, new)
55+
recursive_update(old, new)
5256

53-
expect(old) == new
57+
expect(old) == new
58+
expect(id(old['my_dict']['my_list'][0])) == previous_id
59+
60+
def with_unchanged_immutables(expect):
61+
hello = "hello"
62+
world = "world"
63+
64+
old = {'my_dict': {'my_str': hello + world}}
65+
new = {'my_dict': {'my_str': "helloworld"}}
66+
previous_id = id(old['my_dict']['my_str'])
67+
68+
recursive_update(old, new)
69+
70+
expect(old) == new
71+
expect(id(old['my_dict']['my_str'])) == previous_id
72+
73+
def with_updated_types(expect):
74+
old = {'x': 1}
75+
new = {'x': 1.0}
76+
previous_id = id(old['x'])
77+
78+
recursive_update(old, new)
79+
80+
expect(old) == new
81+
expect(id(old['x'])) != previous_id
82+
83+
def describe_merge():
84+
def with_shoreter_list_into_longer(expect):
85+
old = {'my_list': [1, 2, 3]}
86+
new = {'my_list': [5, 6]}
87+
88+
recursive_update(old, new)
89+
90+
expect(old) == new
91+
92+
def with_longer_list_into_shorter(expect):
93+
old = {'my_list': [1, 2]}
94+
new = {'my_list': [3, 4, 5]}
95+
96+
recursive_update(old, new)
97+
98+
expect(old) == new
99+
100+
def describe_missing():
101+
def with_dict(expect):
102+
old: Dict = {}
103+
new = {'my_dict': {'a': 1}}
104+
105+
recursive_update(old, new)
106+
107+
expect(old) == new
108+
109+
def with_list(expect):
110+
old: Dict = {}
111+
new = {'my_list': [1]}
112+
113+
recursive_update(old, new)
114+
115+
expect(old) == new
116+
117+
def describe_extra():
118+
def with_dict(expect):
119+
old = {'my_dict': {'a': 1, 'b': 2}}
120+
new = {'my_dict': {'a': 1}}
121+
122+
recursive_update(old, new)
123+
124+
expect(old) == new

datafiles/utils.py

Lines changed: 48 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from pathlib import Path
77
from pprint import pformat
88
from shutil import get_terminal_size
9-
from typing import Dict, Optional, Union
9+
from typing import Any, Dict, Optional, Union
1010

1111
import log
1212

@@ -52,24 +52,43 @@ def dictify(value):
5252
return value
5353

5454

55-
def recursive_update(old: Dict, new: Dict):
56-
"""Recursively update a dictionary."""
55+
def recursive_update(old: Dict, new: Dict) -> Dict:
56+
"""Recursively update a dictionary, keeping equivalent objects."""
57+
return _merge(old, new)
5758

58-
for key, value in new.items():
59-
if isinstance(value, dict):
60-
if key in old:
61-
recursive_update(old[key], value)
62-
else:
63-
old[key] = value
64-
elif isinstance(value, list):
65-
if key in old:
66-
old[key][:] = value
67-
else:
68-
old[key] = value
69-
else:
70-
old[key] = value
7159

72-
return old
60+
def _merge(old: Any, new: Any) -> Any:
61+
if old is None:
62+
return new
63+
64+
if isinstance(new, dict):
65+
for key, value in new.items():
66+
old[key] = _merge(old.get(key), value)
67+
68+
for key in list(old.keys()):
69+
if key not in new:
70+
old.pop(key)
71+
72+
return old
73+
74+
if isinstance(new, list):
75+
for index, new_item in enumerate(new):
76+
try:
77+
old_item = old[index]
78+
except IndexError:
79+
old_item = None
80+
old.append(old_item)
81+
old[index] = _merge(old_item, new_item)
82+
83+
while len(old) > len(new):
84+
old.pop()
85+
86+
return old
87+
88+
if new == old and isinstance(new, type(old)):
89+
return old
90+
91+
return new
7392

7493

7594
def dedent(text: str) -> str:
@@ -79,8 +98,8 @@ def dedent(text: str) -> str:
7998
return text.replace(' ' * indent, '')
8099

81100

82-
def write(filename_or_path: Union[str, Path], text: str) -> None:
83-
"""Write text to a given file with logging."""
101+
def write(filename_or_path: Union[str, Path], text: str, *, display=False) -> None:
102+
"""Write text to a given file and optionally log it."""
84103
if isinstance(filename_or_path, Path):
85104
path = filename_or_path
86105
else:
@@ -93,14 +112,17 @@ def write(filename_or_path: Union[str, Path], text: str) -> None:
93112
content = text.replace(' \n', '␠\n')
94113
else:
95114
content = '∅\n'
96-
log.debug(message + '\n' + line + '\n' + content + line)
115+
if display:
116+
log.debug(message + '\n' + line + '\n' + content + line)
117+
else:
118+
log.critical(message)
97119

98120
path.parent.mkdir(parents=True, exist_ok=True)
99121
path.write_text(text)
100122

101123

102-
def read(filename: str) -> str:
103-
"""Read text from a file with logging."""
124+
def read(filename: str, *, display=False) -> str:
125+
"""Read text from a file and optionally log it."""
104126
path = Path(filename).resolve()
105127
message = f'Reading file: {path}'
106128
line = '=' * (31 + len(message))
@@ -109,7 +131,10 @@ def read(filename: str) -> str:
109131
content = text.replace(' \n', '␠\n')
110132
else:
111133
content = '∅\n'
112-
log.debug(message + '\n' + line + '\n' + content + line)
134+
if display:
135+
log.debug(message + '\n' + line + '\n' + content + line)
136+
else:
137+
log.critical(message)
113138
return text
114139

115140

poetry.lock

Lines changed: 7 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)