Completed
Push — develop ( dec8f0...e5f224 )
by Jace
01:58
created

yorm.file_required()   D

Complexity

Conditions 8

Size

Total Lines 25

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 13
CRAP Score 8
Metric Value
cc 8
dl 0
loc 25
rs 4
ccs 13
cts 13
cp 1
crap 8

1 Method

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