yorm.mapper.Mapper.__init__()   A
last analyzed

Complexity

Conditions 1

Size

Total Lines 18
Code Lines 16

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 14
CRAP Score 1

Importance

Changes 0
Metric Value
cc 1
eloc 16
nop 9
dl 0
loc 18
ccs 14
cts 14
cp 1
crap 1
rs 9.6
c 0
b 0
f 0

How to fix   Many Parameters   

Many Parameters

Methods with many parameters are not only hard to understand, but their parameters also often become inconsistent when you need more, or different data.

There are several approaches to avoid long parameter lists:

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