Completed
Push — develop ( b4a21c...b1df34 )
by Jace
02:37
created

Mapper   B

Complexity

Total Complexity 40

Size/Duplication

Total Lines 217
Duplicated Lines 0 %

Test Coverage

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

11 Methods

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

How to fix   Complexity   

Complex Class

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