Completed
Push — develop ( 177319...6ce2f8 )
by Jace
01:47
created

yorm.get_mapper()   B

Complexity

Conditions 5

Size

Total Lines 12

Duplication

Lines 0
Ratio 0 %

Code Coverage

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