Completed
Push — develop ( e4f288...dec8f0 )
by Jace
02:13
created

yorm.Mapper   B

Complexity

Total Complexity 40

Size/Duplication

Total Lines 221
Duplicated Lines 0 %

Test Coverage

Coverage 100%
Metric Value
dl 0
loc 221
ccs 132
cts 132
cp 1
rs 8.2608
wmc 40

12 Methods

Rating   Name   Duplication   Size   Complexity  
A __str__() 0 2 1
A __init__() 0 12 1
A text() 0 10 2
A modified() 0 12 4
A _read() 0 9 3
B _remap() 0 12 6
A create() 0 10 3
B fetch() 0 47 6
A delete() 0 9 2
B store() 0 28 3
A ext() 0 6 2
A _write() 0 7 2

How to fix   Complexity   

Complex Class

Complex classes like yorm.Mapper often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

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