Completed
Push — develop ( b517c8...2e9112 )
by Jace
02:23
created

yorm.wrapped()   A

Complexity

Conditions 2

Size

Total Lines 8

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 7
CRAP Score 2
Metric Value
cc 2
dl 0
loc 8
ccs 7
cts 7
cp 1
crap 2
rs 9.4285
1
"""Core YAML mapping functionality."""
2
3 1
import os
4 1
import abc
5 1
import functools
6 1
from pprint import pformat
7
8 1
from . import common, exceptions, settings
9 1
from .bases import Container
10
11 1
MAPPER = '__mapper__'
12
13 1
log = common.logger(__name__)
14
15
16 1
def get_mapper(obj):
17
    """Get `Mapper` instance attached to an object."""
18 1
    return getattr(obj, MAPPER, None)
19
20
21 1
def set_mapper(obj, path, attrs, auto=True, root=None):
22
    """Create and attach a `Mapper` instance to an object."""
23 1
    mapper = Mapper(obj, path, attrs, auto=auto, root=root)
24 1
    setattr(obj, MAPPER, mapper)
25 1
    return mapper
26
27
28 1
def file_required(create=False):
29
    """Decorator for methods that require the file to exist.
30
31
    :param create: boolean or the method to decorate
32
33
    """
34 1
    def decorator(method):
35
36 1
        @functools.wraps(method)
37
        def wrapped(self, *args, **kwargs):
38 1
            if not self.path:
39 1
                return None
40 1
            if not self.exists and self.auto:
41 1
                if create is True and not self.deleted:
42 1
                    self.create()
43
                else:
44 1
                    msg = "Cannot access deleted: {}".format(self.path)
45 1
                    raise exceptions.FileDeletedError(msg)
46
47 1
            return method(self, *args, **kwargs)
48
49 1
        return wrapped
50
51 1
    if callable(create):
52 1
        return decorator(create)
53
    else:
54 1
        return decorator
55
56
57 1
def prevent_recursion(method):
58
    """Decorator to prevent indirect recursive calls."""
59
60
    # pylint: disable=protected-access
61
62 1
    @functools.wraps(method)
63
    def wrapped(self, *args, **kwargs):
64 1
        if self._activity:
65 1
            return
66 1
        self._activity = True
67 1
        result = method(self, *args, **kwargs)
68 1
        self._activity = False
69 1
        return result
70
71 1
    return wrapped
72
73
74 1
def prefix(obj):
75
    """Prefix a string with a fake designator if enabled."""
76 1
    fake = "(fake) " if settings.fake else ""
77 1
    name = obj if isinstance(obj, str) else "'{}'".format(obj)
78 1
    return fake + name
79
80
81 1
class BaseHelper(metaclass=abc.ABCMeta):
82
    """Utility class to map attributes to text files.
83
84
    To start mapping attributes to a file:
85
86
        create -> [empty] -> FILE
87
88
    When getting an attribute:
89
90
        FILE -> read -> [text] -> load -> [dict] -> fetch -> ATTRIBUTES
91
92
    When setting an attribute:
93
94
        ATTRIBUTES -> store -> [dict] -> dump -> [text] -> write -> FILE
95
96
    After the mapped file is no longer needed:
97
98
        delete -> [null] -> FILE
99
100
    """
101
102 1
    def __init__(self, path, auto=True):
103 1
        self.path = path
104 1
        self.auto = auto
105 1
        self.auto_store = False
106 1
        self.exists = self.path and os.path.isfile(self.path)
107 1
        self.deleted = False
108 1
        self._activity = False
109 1
        self._timestamp = 0
110 1
        self._fake = ""
111
112 1
    def __str__(self):
113 1
        return str(self.path)
114
115 1
    @property
116
    def text(self):
117
        """Get file contents."""
118 1
        log.info("Getting contents of %s...", prefix(self))
119 1
        if settings.fake:
120 1
            text = self._fake
121
        else:
122 1
            text = self._read()
123 1
        log.trace("Text read: \n%s", text[:-1])
124 1
        return text
125
126 1
    @text.setter
127
    def text(self, text):
128
        """Set file contents."""
129 1
        log.info("Setting contents of %s...", prefix(self))
130 1
        if settings.fake:
131 1
            self._fake = text
132
        else:
133 1
            self._write(text)
134 1
        log.trace("Text wrote: \n%s", text[:-1])
135 1
        self.modified = True
136
137 1
    @property
138
    def modified(self):
139
        """Determine if the file has been modified."""
140 1
        if settings.fake:
141 1
            changes = self._timestamp is not None
142 1
            return changes
143 1
        elif not self.exists:
144 1
            return True
145
        else:
146 1
            was = self._timestamp
147 1
            now = common.stamp(self.path)
148 1
            return was != now
149
150 1
    @modified.setter
151
    def modified(self, changes):
152
        """Mark the file as modified if there are changes."""
153 1
        if changes:
154 1
            log.debug("Marked %s as modified", prefix(self))
155 1
            self._timestamp = 0
156
        else:
157 1
            if settings.fake or self.path is None:
158 1
                self._timestamp = None
159
            else:
160 1
                self._timestamp = common.stamp(self.path)
161 1
            log.debug("Marked %s as unmodified", prefix(self))
162
163 1
    @property
164
    def ext(self):
165 1
        if '.' in self.path:
166 1
            return self.path.split('.')[-1]
167
        else:
168 1
            return 'yml'
169
170 1
    def create(self, obj):
171
        """Create a new file for the object."""
172 1
        log.info("Creating %s for %r...", prefix(self), obj)
173 1
        if self.exists:
174 1
            log.warning("Already created: %s", self)
175 1
            return
176 1
        if not settings.fake:
177 1
            common.create_dirname(self.path)
178 1
            common.touch(self.path)
179 1
        self.exists = True
180 1
        self.deleted = False
181
182 1
    @file_required
183 1
    @prevent_recursion
184
    def fetch(self, obj, attrs):
185
        """Update the object's mapped attributes from its file."""
186 1
        log.info("Fetching %r from %s...", obj, prefix(self))
187
188
        # Parse data from file
189 1
        text = self._read()
190 1
        data = self._load(text=text, path=self.path, ext=self.ext)
191 1
        log.trace("Loaded data: \n%s", pformat(data))
192
193
        # Update all attributes
194 1
        attrs2 = attrs.copy()
195 1
        for name, data in data.items():
196 1
            attrs2.pop(name, None)
197
198
            # Find a matching converter
199 1
            try:
200 1
                converter = attrs[name]
201 1
            except KeyError:
202
                # TODO: determine if runtime import is the best way to avoid
203
                # cyclic import
204 1
                from .types import match
205 1
                converter = match(name, data)
206 1
                attrs[name] = converter
207
208
            # Convert the loaded attribute
209 1
            attr = getattr(obj, name, None)
210 1
            if all((isinstance(attr, converter),
211
                    issubclass(converter, Container))):
212 1
                attr.update_value(data)
213
            else:
214 1
                attr = converter.to_value(data)
215 1
                setattr(obj, name, attr)
216 1
            self._remap(attr)
217 1
            log.trace("Value fetched: %s = %r", name, attr)
218
219
        # Add missing attributes
220 1
        for name, converter in attrs2.items():
221 1
            if not hasattr(obj, name):
222 1
                value = converter.to_value(None)
223 1
                msg = "Fetched default value for missing attribute: %s = %r"
224 1
                log.warning(msg, name, value)
225 1
                setattr(obj, name, value)
226
227
        # Set meta attributes
228 1
        self.modified = False
229
230 1
    @file_required(create=True)
231 1
    @prevent_recursion
232
    def store(self, obj, attrs):
233
        """Format and save the object's mapped attributes to its file."""
234 1
        log.info("Storing %r to %s...", obj, prefix(self))
235
236
        # Format the data items
237 1
        data = {}
238 1
        for name, converter in attrs.items():
239 1
            try:
240 1
                value = getattr(obj, name)
241 1
            except AttributeError:
242 1
                value = None
243 1
                msg = "Storing default data for missing attribute '%s'..."
244 1
                log.warning(msg, name)
245
246 1
            data2 = converter.to_data(value)
247
248 1
            log.trace("Data to store: %s = %r", name, data2)
249 1
            data[name] = data2
250
251
        # Dump data to file
252 1
        text = self._dump(data=data, ext=self.ext)
253 1
        self._write(text)
254
255
        # Set meta attributes
256 1
        self.modified = True
257 1
        self.auto_store = self.auto
258
259 1
    def delete(self):
260
        """Delete the object's file from the file system."""
261 1
        if self.exists:
262 1
            log.info("Deleting %s...", prefix(self))
263 1
            if not settings.fake:
264 1
                common.delete(self.path)
265
        else:
266 1
            log.warning("Already deleted: %s", self)
267 1
        self.exists = False
268 1
        self.deleted = True
269
270 1
    @file_required
271
    def _read(self):
272
        """Read text from the object's file.
273
274
        :param path: path to a text file
275
276
        :return: contexts of text file
277
278
        """
279 1
        if settings.fake:
280 1
            return self._fake
281 1
        elif not self.exists:
282 1
            return ""
283
        else:
284 1
            return common.read_text(self.path)
285
286 1
    @file_required
287
    def _write(self, text):
288
        """Write text to the object's file.
289
290
        :param text: text to write to a file
291
        :param path: path to the file
292
293
        """
294 1
        if settings.fake:
295 1
            self._fake = text
296
        else:
297 1
            common.write_text(text, self.path)
298
299 1
    @abc.abstractstaticmethod
300
    def _load(text, path, ext):  # pragma: no cover (abstract method)
301
        """Parsed data from text.
302
303
        :param text: text read from a file
304
        :param path: path to the file (for displaying errors)
305
306
        :return: dictionary of parsed data
307
308
        """
309
        raise NotImplementedError
310
311 1
    @abc.abstractstaticmethod
312
    def _dump(data, ext):  # pragma: no cover (abstract method)
313
        """Dump data to text.
314
315
        :param data: dictionary of data
316
317
        :return: text to write to a file
318
319
        """
320
        raise NotImplementedError
321
322 1
    def _remap(self, obj):
323
        """Restore mapping on nested attributes."""
324 1
        if isinstance(obj, Container):
325 1
            set_mapper(obj, None, common.attrs[obj.__class__], root=self)
326 1
            if isinstance(obj, dict):
327 1
                for obj2 in obj.values():
328 1
                    self._remap(obj2)
329
            else:
330 1
                assert isinstance(obj, list)
331 1
                for obj2 in obj:
332 1
                    self._remap(obj2)
333
334
335 1
class Helper(BaseHelper):
336
    """Utility class to map attributes to YAML files."""
337
338 1
    @staticmethod
339
    def _load(text, path, ext):
340
        """Load YAML data from text.
341
342
        :param text: text read from a file
343
        :param path: path to the file (for displaying errors)
344
345
        :return: dictionary of YAML data
346
347
        """
348 1
        return common.load_file(text, path, ext)
349
350 1
    @staticmethod
351
    def _dump(data, ext):
352
        """Dump YAML data to text.
353
354
        :param data: dictionary of YAML data
355
356
        :return: text to write to a file
357
358
        """
359 1
        return common.dump_file(data, ext)
360
361
362 1
class Mapper(Helper):
363
    """Maps an object's attribute to YAML files."""
364
365
    # TODO: rewrite this class to use composition
366
    # pylint: disable=arguments-differ
367
368 1
    def __init__(self, obj, path, attrs, auto=True, root=None):
369 1
        super().__init__(path, auto=auto)
370 1
        self.obj = obj
371 1
        self.attrs = attrs
372 1
        self.root = root
373
374 1
    def create(self):
375 1
        super().create(self.obj)
376
377 1
    def fetch(self):
378 1
        if self.root and self.root.auto and not self._activity:
379 1
            self._activity = True
380 1
            self.root.fetch()
381 1
            self._activity = False
382 1
        super().fetch(self.obj, self.attrs)
383
384 1
    def store(self):
385 1
        if self.root and self.root.auto and not self._activity:
386 1
            self._activity = True
387 1
            self.root.store()
388 1
            self._activity = False
389
        super().store(self.obj, self.attrs)
390