Completed
Push — develop ( c55321...0353d4 )
by Jace
05:33
created

doorstop/core/base.py (4 issues)

1
"""Base classes and decorators for the doorstop.core package."""
2
3 1
import os
4 1
import abc
5 1
import functools
6
7 1
import yaml
8
9 1
from doorstop import common
10 1
from doorstop.common import DoorstopError, DoorstopWarning, DoorstopInfo
11 1
from doorstop import settings
12
13 1
log = common.logger(__name__)
14
15
16 1 View Code Duplication
def add_item(func):
0 ignored issues
show
This code seems to be duplicated in your project.
Loading history...
17
    """Add and cache the returned item."""
18 1
    @functools.wraps(func)
19
    def wrapped(self, *args, **kwargs):
20
        item = func(self, *args, **kwargs) or self
21 1
        if settings.ADDREMOVE_FILES and item.tree:
22 1
            item.tree.vcs.add(item.path)
23 1
        # pylint: disable=W0212
24
        if item.document and item not in item.document._items:
25 1
            item.document._items.append(item)
26 1
        if settings.CACHE_ITEMS and item.tree:
27 1
            item.tree._item_cache[item.uid] = item
28 1
            log.trace("cached item: {}".format(item))
29 1
        return item
30 1
    return wrapped
31 1
32
33
def edit_item(func):
34 1
    """Mark the returned item as modified."""
35
    @functools.wraps(func)
36 1
    def wrapped(self, *args, **kwargs):
37
        item = func(self, *args, **kwargs) or self
38
        if settings.ADDREMOVE_FILES and item.tree:
39 1
            item.tree.vcs.edit(item.path)
40 1
        return item
41 1
    return wrapped
42 1
43 1
44 View Code Duplication
def delete_item(func):
0 ignored issues
show
This code seems to be duplicated in your project.
Loading history...
45
    """Remove and expunge the returned item."""
46 1
    @functools.wraps(func)
47
    def wrapped(self, *args, **kwargs):
48 1
        item = func(self, *args, **kwargs) or self
49
        if settings.ADDREMOVE_FILES and item.tree:
50
            item.tree.vcs.delete(item.path)
51 1
        # pylint: disable=W0212
52 1
        if item.document and item in item.document._items:
53 1
            item.document._items.remove(item)
54
        if settings.CACHE_ITEMS and item.tree:
55 1
            item.tree._item_cache[item.uid] = None
56 1
            log.trace("expunged item: {}".format(item))
57 1
        BaseFileObject.delete(item, item.path)
58 1
        return item
59 1
    return wrapped
60 1
61 1
62 1 View Code Duplication
def add_document(func):
0 ignored issues
show
This code seems to be duplicated in your project.
Loading history...
63
    """Add and cache the returned document."""
64
    @functools.wraps(func)
65 1
    def wrapped(self, *args, **kwargs):
66
        document = func(self, *args, **kwargs) or self
67 1
        if settings.ADDREMOVE_FILES and document.tree:
68
            document.tree.vcs.add(document.config)
69
        # pylint: disable=W0212
70 1
        if settings.CACHE_DOCUMENTS and document.tree:
71 1
            document.tree._document_cache[document.prefix] = document
72 1
            log.trace("cached document: {}".format(document))
73
        return document
74 1
    return wrapped
75 1
76 1
77 1
def edit_document(func):
78 1
    """Mark the returned document as modified."""
79
    @functools.wraps(func)
80
    def wrapped(self, *args, **kwargs):
81 1
        document = func(self, *args, **kwargs) or self
82
        if settings.ADDREMOVE_FILES and document.tree:
83 1
            document.tree.vcs.edit(document.config)
84
        return document
85
    return wrapped
86 1
87 1
88 1
def delete_document(func):
89 1
    """Remove and expunge the returned document."""
90 1 View Code Duplication
    @functools.wraps(func)
0 ignored issues
show
This code seems to be duplicated in your project.
Loading history...
91
    def wrapped(self, *args, **kwargs):
92
        document = func(self, *args, **kwargs) or self
93 1
        if settings.ADDREMOVE_FILES and document.tree:
94
            document.tree.vcs.delete(document.config)
95 1
        # pylint: disable=W0212
96
        if settings.CACHE_DOCUMENTS and document.tree:
97
            document.tree._document_cache[document.prefix] = None
98 1
            log.trace("expunged document: {}".format(document))
99 1
        try:
100 1
            os.rmdir(document.path)
101
        except OSError:
102 1
            # Directory wasn't empty
103 1
            pass
104 1
        return document
105 1
    return wrapped
106 1
107 1
108
class BaseValidatable(object, metaclass=abc.ABCMeta):
109 1
    """Abstract Base Class for objects that can be validated."""
110 1
111 1
    def validate(self, skip=None, document_hook=None, item_hook=None):
112
        """Check the object for validity.
113
114 1
        :param skip: list of document prefixes to skip
115
        :param document_hook: function to call for custom document
116
            validation
117 1
        :param item_hook: function to call for custom item validation
118
119
        :return: indication that the object is valid
120
121
        """
122
        valid = True
123
        # Display all issues
124
        for issue in self.get_issues(skip=skip, document_hook=document_hook,
125
                                     item_hook=item_hook):
126
            if isinstance(issue, DoorstopInfo) and not settings.WARN_ALL:
127
                log.info(issue)
128 1
            elif isinstance(issue, DoorstopWarning) and not settings.ERROR_ALL:
129
                log.warning(issue)
130 1
            else:
131
                assert isinstance(issue, DoorstopError)
132 1
                log.error(issue)
133 1
                valid = False
134 1
        # Return the result
135 1
        return valid
136
137 1
    @abc.abstractmethod
138 1
    def get_issues(self, skip=None, document_hook=None, item_hook=None):
139 1
        """Yield all the objects's issues.
140
141 1
        :param skip: list of document prefixes to skip
142
        :param document_hook: function to call for custom document
143 1
            validation
144 1
        :param item_hook: function to call for custom item validation
145
146
        :return: generator of :class:`~doorstop.common.DoorstopError`,
147
                              :class:`~doorstop.common.DoorstopWarning`,
148
                              :class:`~doorstop.common.DoorstopInfo`
149
150
        """
151
152
    @property
153
    def issues(self):
154
        """Get a list of the item's issues."""
155
        return list(self.get_issues())
156
157
158 1
def auto_load(func):
159
    """Call self.load() before execution."""
160
    @functools.wraps(func)
161 1
    def wrapped(self, *args, **kwargs):
162
        self.load()
163
        return func(self, *args, **kwargs)
164 1
    return wrapped
165
166 1
167
def auto_save(func):
168
    """Call self.save() after execution."""
169 1
    @functools.wraps(func)
170 1
    def wrapped(self, *args, **kwargs):
171 1
        result = func(self, *args, **kwargs)
172
        if self.auto:
173
            self.save()
174 1
        return result
175
    return wrapped
176 1
177
178
class BaseFileObject(object, metaclass=abc.ABCMeta):
179 1
    """Abstract Base Class for objects whose attributes save to a file.
180 1
181 1
    For properties that are saved to a file, decorate their getters
182 1
    with :func:`auto_load` and their setters with :func:`auto_save`.
183 1
184
    """
185
186 1
    auto = True  # set to False to delay automatic save until explicit save
187
188
    def __init__(self):
189
        self.path = None
190
        self.root = None
191
        self._data = {}
192
        self._exists = True
193
        self._loaded = False
194 1
195
    def __hash__(self):
196 1
        return hash(self.path)
197 1
198 1
    def __eq__(self, other):
199 1
        return isinstance(other, self.__class__) and self.path == other.path
200 1
201 1
    def __ne__(self, other):
202
        return not self == other
203 1
204 1
    @staticmethod
205
    def _create(path, name):  # pragma: no cover (integration test)
206 1
        """Create a new file for the object.
207 1
208
        :param path: path to new file
209 1
        :param name: humanized name for this file
210 1
211
        :raises: :class:`~doorstop.common.DoorstopError` if the file
212 1
            already exists
213
214
        """
215
        if os.path.exists(path):
216
            raise DoorstopError("{} already exists: {}".format(name, path))
217
        common.create_dirname(path)
218
        common.touch(path)
219
220
    @abc.abstractmethod
221
    def load(self, reload=False):  # pragma: no cover (abstract method)
222
        """Load the object's properties from its file."""
223
        # 1. Start implementations of this method with:
224
        if self._loaded and not reload:
225
            return
226
        # 2. Call self._read() and update properties here
227
        # 3. End implementations of this method with:
228 1
        self._loaded = True
229
230
    def _read(self, path):  # pragma: no cover (integration test)
231
        """Read text from the object's file.
232
233
        :param path: path to a text file
234
235
        :return: contexts of text file
236
237
        """
238
        if not self._exists:
239
            msg = "cannot read from deleted: {}".format(self.path)
240
            raise DoorstopError(msg)
241
        return common.read_text(path)
242
243
    @staticmethod
244
    def _load(text, path):
245
        """Load YAML data from text.
246
247
        :param text: text read from a file
248
        :param path: path to the file (for displaying errors)
249
250
        :return: dictionary of YAML data
251 1
252
        """
253
        return common.load_yaml(text, path)
254
255
    @abc.abstractmethod
256
    def save(self):  # pragma: no cover (abstract method)
257
        """Format and save the object's properties to its file."""
258
        # 1. Call self._write() with the current properties here
259
        # 2. End implementations of this method with:
260
        self._loaded = False
261 1
        self.auto = True
262
263 1
    def _write(self, text, path):  # pragma: no cover (integration test)
264
        """Write text to the object's file.
265
266
        :param text: text to write to a file
267
        :param path: path to the file
268
269
        """
270
        if not self._exists:
271
            raise DoorstopError("cannot save to deleted: {}".format(self))
272
        common.write_text(text, path)
273
274
    @staticmethod
275
    def _dump(data):
276
        """Dump YAML data to text.
277
278
        :param data: dictionary of YAML data
279
280
        :return: text to write to a file
281
282 1
        """
283
        return yaml.dump(data, default_flow_style=False, allow_unicode=True)
284
285
    # properties #############################################################
286
287
    @property
288
    def relpath(self):
289
        """Get the item's relative path string."""
290
        relpath = os.path.relpath(self.path, self.root)
291 1
        return "@{}{}".format(os.sep, relpath)
292
293
    # extended attributes ####################################################
294
295 1
    @property
296
    @auto_load
297
    def extended(self):
298 1
        """Get a list of all extended attribute names."""
299 1
        names = []
300
        for name in self._data:
301
            if not hasattr(self, name):
302
                names.append(name)
303 1
        return sorted(names)
304 1
305
    @auto_load
306
    def get(self, name, default=None):
307 1
        """Get an extended attribute.
308 1
309 1
        :param name: name of extended attribute
310 1
        :param default: value to return for missing attributes
311 1
312
        :return: value of extended attribute
313 1
314 1
        """
315
        if hasattr(self, name):
316
            cname = self.__class__.__name__
317
            msg = "'{n}' can be accessed from {c}.{n}".format(n=name, c=cname)
318
            log.trace(msg)
319
            return getattr(self, name)
320
        else:
321
            return self._data.get(name, default)
322
323 1
    @auto_load
324 1
    @auto_save
325 1
    def set(self, name, value):
326 1
        """Set an extended attribute.
327 1
328
        :param name: name of extended attribute
329 1
        :param value: value to set
330
331 1
        """
332 1
        if hasattr(self, name):
333
            cname = self.__class__.__name__
334
            msg = "'{n}' can be set from {c}.{n}".format(n=name, c=cname)
335
            log.trace(msg)
336
            setattr(self, name, value)
337
        else:
338
            self._data[name] = value
339
340 1
    # actions ################################################################
341 1
342 1
    def delete(self, path):
343 1
        """Delete the object's file from the file system."""
344 1
        if self._exists:
345
            log.info("deleting {}...".format(path))
346 1
            common.delete(path)
347
            self._loaded = False  # force the object to reload
348
            self._exists = False  # but, prevent future access
349
        else:
350
            log.warning("already deleted: {}".format(self))
351