Completed
Push — develop ( 6ce2f8...c255a9 )
by Jace
01:42
created

yorm.get_mapper()   A

Complexity

Conditions 1

Size

Total Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1
Metric Value
cc 1
dl 0
loc 3
ccs 2
cts 2
cp 1
crap 1
rs 10
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):  # pylint: disable=W0621
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 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=W0212
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 BaseHelper(metaclass=abc.ABCMeta):
80
    """Utility class to map attributes to text files.
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, path, auto=True):
101 1
        self.path = path
102 1
        self.auto = auto
103 1
        self.auto_store = False
104 1
        self.exists = self.path and os.path.isfile(self.path)
105 1
        self.deleted = False
106 1
        self._activity = False
107 1
        self._timestamp = 0
108 1
        self._fake = ""
109
110 1
    def __str__(self):
111 1
        return str(self.path)
112
113 1
    @property
114
    def text(self):
115
        """Get file contents."""
116 1
        log.info("Getting contents of %s...", prefix(self))
117 1
        if settings.fake:
118 1
            text = self._fake
119
        else:
120 1
            text = self._read()
121 1
        log.trace("Text read: \n%s", text[:-1])
122 1
        return text
123
124 1
    @text.setter
125
    def text(self, text):
126
        """Set file contents."""
127 1
        log.info("Setting contents of %s...", prefix(self))
128 1
        if settings.fake:
129 1
            self._fake = text
130
        else:
131 1
            self._write(text)
132 1
        log.trace("Text wrote: \n%s", text[:-1])
133 1
        self.modified = True
134
135 1
    @property
136
    def modified(self):
137
        """Determine if the file has been modified."""
138 1
        if settings.fake:
139 1
            changes = self._timestamp is not None
140 1
            return changes
141 1
        elif not self.exists:
142 1
            return True
143
        else:
144 1
            was = self._timestamp
145 1
            now = common.stamp(self.path)
146 1
            return was != now
147
148 1
    @modified.setter
149
    def modified(self, changes):
150
        """Mark the file as modified if there are changes."""
151 1
        if changes:
152 1
            log.debug("Marked %s as modified", prefix(self))
153 1
            self._timestamp = 0
154
        else:
155 1
            if settings.fake or self.path is None:
156 1
                self._timestamp = None
157
            else:
158 1
                self._timestamp = common.stamp(self.path)
159 1
            log.debug("Marked %s as unmodified", prefix(self))
160
161 1
    @property
162
    def ext(self):
163 1
        if '.' in self.path:
164 1
            return self.path.split('.')[-1]
165
        else:
166 1
            return 'yml'
167
168 1
    def create(self, obj):
169
        """Create a new file for the object."""
170 1
        log.info("Creating %s for %r...", prefix(self), obj)
171 1
        if self.exists:
172 1
            log.warning("Already created: %s", self)
173 1
            return
174 1
        if not settings.fake:
175 1
            common.create_dirname(self.path)
176 1
            common.touch(self.path)
177 1
        self.exists = True
178 1
        self.deleted = False
179
180 1
    @file_required
181 1
    @prevent_recursion
182
    def fetch(self, obj, attrs):
183
        """Update the object's mapped attributes from its file."""
184 1
        log.info("Fetching %r from %s...", obj, prefix(self))
185
186
        # Parse data from file
187 1
        text = self._read()
188 1
        data = self._load(text=text, path=self.path, ext=self.ext)
189 1
        log.trace("Loaded data: \n%s", pformat(data))
190
191
        # Update all attributes
192 1
        attrs2 = attrs.copy()
193 1
        for name, data in data.items():
194 1
            attrs2.pop(name, None)
195
196
            # Find a matching converter
197 1
            try:
198 1
                converter = attrs[name]
199 1
            except KeyError:
200
                # TODO: determine if runtime import is the best way to avoid
201
                # cyclic import
202 1
                from .converters import match
203 1
                converter = match(name, data)
204 1
                attrs[name] = converter
205
206
            # Convert the loaded attribute
207 1
            attr = getattr(obj, name, None)
208 1
            if all((isinstance(attr, converter),
209
                    issubclass(converter, Container))):
210 1
                attr.update_value(data)
211
            else:
212 1
                attr = converter.to_value(data)
213 1
                setattr(obj, name, attr)
214 1
            self._remap(attr)
215 1
            log.trace("Value fetched: %s = %r", name, attr)
216
217
        # Add missing attributes
218 1
        for name, converter in attrs2.items():
219 1
            if not hasattr(obj, name):
220 1
                value = converter.to_value(None)
221 1
                msg = "Fetched default value for missing attribute: %s = %r"
222 1
                log.warning(msg, name, value)
223 1
                setattr(obj, name, value)
224
225
        # Set meta attributes
226 1
        self.modified = False
227
228 1
    @file_required(create=True)
229 1
    @prevent_recursion
230
    def store(self, obj, attrs):
231
        """Format and save the object's mapped attributes to its file."""
232 1
        log.info("Storing %r to %s...", obj, prefix(self))
233
234
        # Format the data items
235 1
        data = {}
236 1
        for name, converter in attrs.items():
237 1
            try:
238 1
                value = getattr(obj, name)
239 1
            except AttributeError:
240 1
                value = None
241 1
                msg = "Storing default data for missing attribute '%s'..."
242 1
                log.warning(msg, name)
243
244 1
            data2 = converter.to_data(value)
245
246 1
            log.trace("Data to store: %s = %r", name, data2)
247 1
            data[name] = data2
248
249
        # Dump data to file
250 1
        text = self._dump(data=data, ext=self.ext)
251 1
        self._write(text)
252
253
        # Set meta attributes
254 1
        self.modified = True
255 1
        self.auto_store = self.auto
256
257 1
    def delete(self):
258
        """Delete the object's file from the file system."""
259 1
        if self.exists:
260 1
            log.info("Deleting %s...", prefix(self))
261 1
            if not settings.fake:
262 1
                common.delete(self.path)
263
        else:
264 1
            log.warning("Already deleted: %s", self)
265 1
        self.exists = False
266 1
        self.deleted = True
267
268 1
    @file_required
269
    def _read(self):
270
        """Read text from the object's file.
271
272
        :param path: path to a text file
273
274
        :return: contexts of text file
275
276
        """
277 1
        if settings.fake:
278 1
            return self._fake
279 1
        elif not self.exists:
280 1
            return ""
281
        else:
282 1
            return common.read_text(self.path)
283
284 1
    @file_required
285
    def _write(self, text):
286
        """Write text to the object's file.
287
288
        :param text: text to write to a file
289
        :param path: path to the file
290
291
        """
292 1
        if settings.fake:
293 1
            self._fake = text
294
        else:
295 1
            common.write_text(text, self.path)
296
297 1
    @abc.abstractstaticmethod
298
    def _load(text, path, ext):  # pragma: no cover (abstract method)
299
        """Parsed data from text.
300
301
        :param text: text read from a file
302
        :param path: path to the file (for displaying errors)
303
304
        :return: dictionary of parsed data
305
306
        """
307
        raise NotImplementedError
308
309 1
    @abc.abstractstaticmethod
310
    def _dump(data, ext):  # pragma: no cover (abstract method)
311
        """Dump data to text.
312
313
        :param data: dictionary of data
314
315
        :return: text to write to a file
316
317
        """
318
        raise NotImplementedError
319
320 1
    def _remap(self, obj):
321
        """Restore mapping on nested attributes."""
322 1
        if isinstance(obj, Container):
323 1
            set_mapper(obj, None, common.attrs[obj.__class__], root=self)
324 1
            if isinstance(obj, dict):
325 1
                for obj2 in obj.values():
326 1
                    self._remap(obj2)
327
            else:
328 1
                assert isinstance(obj, list)
329 1
                for obj2 in obj:
330 1
                    self._remap(obj2)
331
332
333 1
class Helper(BaseHelper):
334
    """Utility class to map attributes to YAML files."""
335
336 1
    @staticmethod
337
    def _load(text, path, ext):
338
        """Load YAML data from text.
339
340
        :param text: text read from a file
341
        :param path: path to the file (for displaying errors)
342
343
        :return: dictionary of YAML data
344
345
        """
346 1
        return common.load_file(text, path, ext)
347
348 1
    @staticmethod
349
    def _dump(data, ext):
350
        """Dump YAML data to text.
351
352
        :param data: dictionary of YAML data
353
354
        :return: text to write to a file
355
356
        """
357 1
        return common.dump_file(data, ext)
358
359
360 1
class Mapper(Helper):
361
    """Maps an object's attribute to YAML files."""
362
363 1
    def __init__(self, obj, path, attrs, auto=True, root=None):
364 1
        super().__init__(path, auto=auto)
365 1
        self.obj = obj
366 1
        self.attrs = attrs
367 1
        self.root = root
368
369 1
    def create(self):  # pylint: disable=W0221
370 1
        super().create(self.obj)
371
372 1
    def fetch(self):  # pylint: disable=W0221
373 1
        if self.root and self.root.auto and not self._activity:
374 1
            self._activity = True
375 1
            self.root.fetch()
376 1
            self._activity = False
377 1
        super().fetch(self.obj, self.attrs)
378
379 1
    def store(self):  # pylint: disable=W0221
380 1
        if self.root and self.root.auto and not self._activity:
381 1
            self._activity = True
382 1
            self.root.store()
383 1
            self._activity = False
384
        super().store(self.obj, self.attrs)
385