Completed
Pull Request — develop (#115)
by Jace
04:51 queued 03:41
created

wrapped()   A

Complexity

Conditions 2

Size

Total Lines 10

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 8
CRAP Score 2
Metric Value
cc 2
dl 0
loc 10
ccs 8
cts 8
cp 1
crap 2
rs 9.4285
1
"""Core object-file mapping functionality."""
2
3 1
import functools
4 1
from pprint import pformat
5 1
import logging
6
7 1
from . import common, diskutils, exceptions, types, settings
8 1
from .bases import Container
9
10 1
log = logging.getLogger(__name__)
11
12
13 1
def file_required(method):
14
    """Decorator for methods that require the file to exist."""
15
16 1
    @functools.wraps(method)
17
    def wrapped(self, *args, **kwargs):
18 1
        if self.deleted:
19 1
            msg = "File deleted: {}".format(self.path)
20 1
            raise exceptions.DeletedFileError(msg)
21 1
        elif self.missing and not settings.fake:
22 1
            msg = "File missing: {}".format(self.path)
23 1
            raise exceptions.MissingFileError(msg)
24
        else:
25 1
            return method(self, *args, **kwargs)
26
27 1
    return wrapped
28
29
30 1
def prevent_recursion(method):
31
    """Decorator to prevent indirect recursive calls."""
32
33 1
    @functools.wraps(method)
34
    def wrapped(self, *args, **kwargs):
35
        # pylint: disable=protected-access
36 1
        if self._activity:
37 1
            return
38 1
        self._activity = True
39 1
        result = method(self, *args, **kwargs)
40 1
        self._activity = False
41 1
        return result
42
43 1
    return wrapped
44
45
46 1
def prefix(obj):
47
    """Prefix a string with a fake designator if enabled."""
48 1
    fake = "(fake) " if settings.fake else ""
49 1
    name = obj if isinstance(obj, str) else "'{}'".format(obj)
50 1
    return fake + name
51
52
53 1
class Mapper:
54
    """Utility class to map an object's attributes to a file.
55
56
    To start mapping attributes to a file:
57
58
        create -> [empty] -> FILE
59
60
    When getting an attribute:
61
62
        FILE -> read -> [text] -> parse -> [dict] -> load -> ATTRIBUTES
63
64
    When setting an attribute:
65
66
        ATTRIBUTES -> save -> [dict] -> dump -> [text] -> write -> FILE
67
68
    After the mapped file is no longer needed:
69
70
        delete -> [null] -> FILE
71
72
    """
73
74 1
    def __init__(self, obj, path, attrs, *,
75
                 auto_create=True, auto_save=True, auto_track=False):
76 1
        self._obj = obj
77 1
        self.path = path
78 1
        self.attrs = attrs
79 1
        self.auto_create = auto_create
80 1
        self.auto_save = auto_save
81 1
        self.auto_track = auto_track
82
83 1
        self.exists = diskutils.exists(self.path)
84 1
        self.deleted = False
85 1
        self.auto_save_after_load = False
86
87 1
        self._activity = False
88 1
        self._timestamp = 0
89 1
        self._fake = ""
90
91 1
    def __str__(self):
92 1
        return str(self.path)
93
94 1
    @property
95
    def missing(self):
96 1
        return not self.exists
97
98 1
    @property
99
    def modified(self):
100
        """Determine if the file has been modified."""
101 1
        if settings.fake:
102 1
            changes = self._timestamp is not None
103 1
            return changes
104 1
        elif not self.exists:
105 1
            return True
106
        else:
107
            # TODO: this raises an exception is the file is missing
108 1
            was = self._timestamp
109 1
            now = diskutils.stamp(self.path)
110 1
            return was != now
111
112 1
    @modified.setter
113 1
    @file_required
114
    def modified(self, changes):
115
        """Mark the file as modified if there are changes."""
116 1
        if changes:
117 1
            log.debug("Marked %s as modified", prefix(self))
118 1
            self._timestamp = 0
119
        else:
120 1
            if settings.fake or self.path is None:
121 1
                self._timestamp = None
122
            else:
123 1
                self._timestamp = diskutils.stamp(self.path)
124 1
            log.debug("Marked %s as unmodified", prefix(self))
125
126 1
    @property
127
    def text(self):
128
        """Get file contents."""
129 1
        log.info("Getting contents of %s...", prefix(self))
130 1
        if settings.fake:
131 1
            text = self._fake
132
        else:
133 1
            text = self._read()
134 1
        log.trace("Text read: \n%s", text[:-1])
135 1
        return text
136
137 1
    @text.setter
138
    def text(self, text):
139
        """Set file contents."""
140 1
        log.info("Setting contents of %s...", prefix(self))
141 1
        if settings.fake:
142 1
            self._fake = text
143
        else:
144 1
            self._write(text)
145 1
        log.trace("Text wrote: \n%s", text.rstrip())
146 1
        self.modified = True
147
148 1
    def create(self):
149
        """Create a new file for the object."""
150 1
        log.info("Creating %s for %r...", prefix(self), self._obj)
151 1
        if self.exists:
152 1
            log.warning("Already created: %s", self)
153 1
            return
154 1
        if not settings.fake:
155 1
            diskutils.touch(self.path)
156 1
        self.exists = True
157 1
        self.deleted = False
158
159 1
    @file_required
160 1
    @prevent_recursion
161
    def load(self):
162
        """Update the object's mapped attributes from its file."""
163 1
        log.info("Loading %r from %s...", self._obj, prefix(self))
164
165
        # Parse data from file
166 1
        text = self._read()
167 1
        data = diskutils.parse(text=text, path=self.path)
168 1
        log.trace("Parsed data: \n%s", pformat(data))
169
170
        # Update all attributes
171 1
        attrs2 = self.attrs.copy()
172 1
        for name, data in data.items():
173 1
            attrs2.pop(name, None)
174
175
            # Find a matching converter
176 1
            try:
177 1
                converter = self.attrs[name]
178 1
            except KeyError:
179 1
                if self.auto_track:
180 1
                    converter = types.match(name, data)
181 1
                    self.attrs[name] = converter
182
                else:
183 1
                    msg = "Ignored unknown file attribute: %s = %r"
184 1
                    log.warning(msg, name, data)
185 1
                    continue
186
187
            # Convert the parsed value to the attribute's final type
188 1
            attr = getattr(self._obj, name, None)
189 1
            if all((isinstance(attr, converter),
190
                    issubclass(converter, Container))):
191 1
                attr.update_value(data, auto_track=self.auto_track)
192
            else:
193 1
                attr = converter.to_value(data)
194 1
                setattr(self._obj, name, attr)
195 1
            self._remap(attr, self)
196 1
            log.trace("Value loaded: %s = %r", name, attr)
197
198
        # Add missing attributes
199 1
        for name, converter in attrs2.items():
200 1
            if not hasattr(self._obj, name):
201 1
                value = converter.to_value(None)
202 1
                msg = "Default value for missing object attribute: %s = %r"
203 1
                log.warning(msg, name, value)
204 1
                setattr(self._obj, name, value)
205
206
        # Set meta attributes
207 1
        self.modified = False
208
209 1
    def _remap(self, obj, root):
210
        """Resave mapping on nested attributes."""
211 1
        if isinstance(obj, Container):
212 1
            common.set_mapper(obj, root)
213
214 1
            if isinstance(obj, dict):
215 1
                for obj2 in obj.values():
216 1
                    self._remap(obj2, root)
217
            else:
218 1
                assert isinstance(obj, list)
219 1
                for obj2 in obj:
220 1
                    self._remap(obj2, root)
221
222 1
    @file_required
223 1
    @prevent_recursion
224
    def save(self):
225
        """Format and save the object's mapped attributes to its file."""
226 1
        log.info("Saving %r to %s...", self._obj, prefix(self))
227
228
        # Format the data items
229 1
        data = self.attrs.__class__()
230 1
        for name, converter in self.attrs.items():
231 1
            try:
232 1
                value = getattr(self._obj, name)
233 1
            except AttributeError:
234 1
                data2 = converter.to_data(None)
235 1
                msg = "Default data for missing object attribute: %s = %r"
236 1
                log.warning(msg, name, data2)
237
            else:
238 1
                data2 = converter.to_data(value)
239
240 1
            log.trace("Data to save: %s = %r", name, data2)
241 1
            data[name] = data2
242
243
        # Dump data to file
244 1
        text = diskutils.dump(data=data, path=self.path)
245 1
        self._write(text)
246
247
        # Set meta attributes
248 1
        self.modified = True
249 1
        self.auto_save_after_load = self.auto_save
250
251 1
    def delete(self):
252
        """Delete the object's file from the file system."""
253 1
        if self.exists:
254 1
            log.info("Deleting %s...", prefix(self))
255 1
            diskutils.delete(self.path)
256
        else:
257 1
            log.warning("Already deleted: %s", self)
258 1
        self.exists = False
259 1
        self.deleted = True
260
261 1
    @file_required
262
    def _read(self):
263
        """Read text from the object's file."""
264 1
        if settings.fake:
265 1
            return self._fake
266 1
        elif not self.exists:
267
            return ""
268
        else:
269 1
            return diskutils.read(self.path)
270
271 1
    @file_required
272
    def _write(self, text):
273
        """Write text to the object's file."""
274 1
        if settings.fake:
275 1
            self._fake = text
276
        else:
277
            diskutils.write(text, self.path)
278