Completed
Push — develop ( 43cfbd...dbd5e6 )
by Jace
01:46
created

yorm.wrapped()   A

Complexity

Conditions 2

Size

Total Lines 9

Duplication

Lines 0
Ratio 0 %

Code Coverage

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