Completed
Push — develop ( 1caf91...40f9fa )
by Jace
9s
created

Text.sbd()   A

Complexity

Conditions 2

Size

Total Lines 19

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 6
CRAP Score 2

Importance

Changes 0
Metric Value
cc 2
dl 0
loc 19
rs 9.4285
c 0
b 0
f 0
ccs 6
cts 6
cp 1
crap 2
1
"""Common classes and functions for the `doorstop.core` package."""
2
3 1
import os
4 1
import re
5 1
import textwrap
0 ignored issues
show
Unused Code introduced by
The import textwrap seems to be unused.
Loading history...
6 1
import hashlib
7
8 1
import yaml
9
10 1
from doorstop import common
11 1
from doorstop.common import DoorstopError
12 1
from doorstop import settings
13
14 1
log = common.logger(__name__)
15
16
17 1
class Prefix(str):
18
    """Unique document prefixes."""
19
20 1
    UNKNOWN_MESSGE = "no document with prefix: {}"
21
22 1
    def __new__(cls, value=""):
23 1
        if isinstance(value, Prefix):
24 1
            return value
25
        else:
26 1
            if str(value).lower() in settings.RESERVED_WORDS:
27 1
                raise DoorstopError("cannot use reserved word: %s" % value)
28 1
            obj = super().__new__(cls, Prefix.load_prefix(value))
29 1
            return obj
30
31 1
    def __repr__(self):
32 1
        return "Prefix('{}')".format(self)
33
34 1
    def __hash__(self):
35 1
        return super().__hash__()
36
37 1
    def __eq__(self, other):
38 1
        if other in settings.RESERVED_WORDS:
39 1
            return False
40 1
        if not isinstance(other, Prefix):
41 1
            other = Prefix(other)
42 1
        return self.lower() == other.lower()
43
44 1
    def __ne__(self, other):
45 1
        return not self == other
46
47 1
    def __lt__(self, other):
48 1
        return self.lower() < other.lower()
49
50 1
    @property
51
    def short(self):
52
        """Get a shortened version of the prefix."""
53 1
        return self.lower()
54
55 1
    @staticmethod
56
    def load_prefix(value):
57
        """Convert a value to a prefix.
58
59
        >>> Prefix.load_prefix("abc 123")
60
        'abc'
61
        """
62 1
        return str(value).split(' ')[0] if value else ''
63
64
65 1
class UID(object):
66
    """Unique item ID built from document prefix and number."""
67
68 1
    UNKNOWN_MESSAGE = "no{k} item with UID: {u}"  # k='parent'|'child', u=UID
69
70 1
    def __new__(cls, *args, **kwargs):  # pylint: disable=W0613
71 1
        if args and isinstance(args[0], UID):
72 1
            return args[0]
73
        else:
74 1
            return super().__new__(cls)
75
76 1
    def __init__(self, *values, stamp=None):
77
        """Initialize an UID using a string, dictionary, or set of parts.
78
79
        Option 1:
80
81
        :param *values: UID + optional stamp ("UID:stamp")
82
        :param stamp: stamp of :class:`~doorstop.core.item.Item` (if known)
83
84
        Option 2:
85
86
        :param *values: {UID: stamp}
87
        :param stamp: stamp of :class:`~doorstop.core.item.Item` (if known)
88
89
        Option 3:
90
91
        :param *values: prefix, separator, number, digit count
92
        param stamp: stamp of :class:`~doorstop.core.item.Item` (if known)
93
94
        """
95 1
        if values and isinstance(values[0], UID):
96 1
            self.stamp = stamp or values[0].stamp
97 1
            return
98 1
        self.stamp = stamp or Stamp()
99
        # Join values
100 1
        if len(values) == 0:
101 1
            self.value = ''
102 1
        elif len(values) == 1:
103 1
            value = values[0]
104 1
            if isinstance(value, str) and ':' in value:
105
                # split UID:stamp into a dictionary
106 1
                pair = value.rsplit(':', 1)
107 1
                value = {pair[0]: pair[1]}
108 1
            if isinstance(value, dict):
109 1
                pair = list(value.items())[0]
110 1
                self.value = str(pair[0])
111 1
                self.stamp = self.stamp or Stamp(pair[1])
112
            else:
113 1
                self.value = str(value) if values[0] else ''
114 1
        elif len(values) == 4:
115 1
            self.value = UID.join_uid(*values)
116
        else:
117 1
            raise TypeError("__init__() takes 1 or 4 positional arguments")
118
        # Split values
119 1
        try:
120 1
            parts = UID.split_uid(self.value)
121 1
            self._prefix = Prefix(parts[0])
122 1
            self._number = parts[1]
123 1
        except ValueError:
124 1
            self._prefix = self._number = None
125 1
            self._exc = DoorstopError("invalid UID: {}".format(self.value))
126
        else:
127 1
            self._exc = None
128
129 1
    def __repr__(self):
130 1
        if self.stamp:
131 1
            return "UID('{}', stamp='{}')".format(self.value, self.stamp)
132
        else:
133 1
            return "UID('{}')".format(self.value)
134
135 1
    def __str__(self):
136 1
        return self.value
137
138 1
    def __hash__(self):
139 1
        return hash((self._prefix, self._number))
140
141 1
    def __eq__(self, other):
142 1
        if not other:
143 1
            return False
144 1
        if not isinstance(other, UID):
145 1
            other = UID(other)
146 1
        try:
147 1
            return all((self.prefix == other.prefix,
148
                        self.number == other.number))
149 1
        except DoorstopError:
150 1
            return self.value.lower() == other.value.lower()
151
152 1
    def __ne__(self, other):
153 1
        return not self == other
154
155 1
    def __lt__(self, other):
156 1
        try:
157 1
            if self.prefix == other.prefix:
158 1
                return self.number < other.number
159
            else:
160 1
                return self.prefix < other.prefix
161 1
        except DoorstopError:
162 1
            return self.value < other.value
163
164 1
    @property
165
    def prefix(self):
166
        """Get the UID's prefix."""
167 1
        self.check()
168 1
        return self._prefix
169
170 1
    @property
171
    def number(self):
172
        """Get the UID's number."""
173 1
        self.check()
174 1
        return self._number
175
176 1
    @property
177
    def short(self):
178
        """Get a shortened version of the UID."""
179 1
        self.check()
180 1
        return self.prefix.lower() + str(self.number)
181
182 1
    @property
183
    def string(self):
184
        """Convert the UID and stamp to a single string."""
185 1
        if self.stamp:
186 1
            return "{}:{}".format(self.value, self.stamp)
187
        else:
188 1
            return "{}".format(self.value)
189
190 1
    def check(self):
191
        """Verify an UID is valid."""
192 1
        if self._exc:
193 1
            raise self._exc
194
195 1
    @staticmethod
196
    def split_uid(text):
197
        """Split an item's UID string into a prefix and number.
198
199
        >>> UID.split_uid('ABC00123')
200
        ('ABC', 123)
201
202
        >>> UID.split_uid('ABC.HLR_01-00123')
203
        ('ABC.HLR_01', 123)
204
205
        >>> UID.split_uid('REQ2-001')
206
        ('REQ2', 1)
207
208
        """
209 1
        match = re.match(r"([\w.-]*\D)(\d+)", text)
210 1
        if not match:
211 1
            raise ValueError("unable to parse UID: {}".format(text))
212 1
        prefix = match.group(1).rstrip(settings.SEP_CHARS)
213 1
        number = int(match.group(2))
214 1
        return prefix, number
215
216 1
    @staticmethod
217
    def join_uid(prefix, sep, number, digits):
218
        """Join the parts of an item's UID into a string.
219
220
        >>> UID.join_uid('ABC', '', 123, 5)
221
        'ABC00123'
222
223
        >>> UID.join_uid('REQ.H', '-', 42, 4)
224
        'REQ.H-0042'
225
226
        >>> UID.join_uid('ABC', '-', 123, 0)
227
        'ABC-123'
228
229
        """
230 1
        return "{}{}{}".format(prefix, sep, str(number).zfill(digits))
231
232
233 1
class _Literal(str):
234
    """Custom type for text which should be dumped in the literal style."""
235
236 1
    @staticmethod
237
    def representer(dumper, data):
238
        """Return a custom dumper that formats str in the literal style."""
239 1
        return dumper.represent_scalar('tag:yaml.org,2002:str', data,
240
                                       style='|' if data else '')
241
242 1
yaml.add_representer(_Literal, _Literal.representer)
243
244
245 1
class Text(str):
246
    """Markdown text paragraph."""
247
248 1
    def __new__(cls, value=""):
249 1
        assert not isinstance(value, Text)
250 1
        obj = super(Text, cls).__new__(cls, Text.load_text(value))
251 1
        return obj
252
253 1
    @property
254
    def yaml(self):
255
        """Get the value to be used in YAML dumping."""
256 1
        return Text.save_text(self)
257
258 1
    @staticmethod
259
    def load_text(value):
260
        r"""Convert dumped text to the original string.
261
262
        >>> Text.load_text("abc\ndef")
263
        'abc\ndef'
264
265
        >>> Text.load_text("list:\n\n- a\n- b\n")
266
        'list:\n\n- a\n- b'
267
268
        """
269 1
        if not value:
270 1
            return ""
271 1
        text_value = re.sub('^\n+', '', value)
272 1
        text_value = re.sub('\n+$', '', text_value)
273 1
        return text_value.rstrip(' ')
274
275 1
    @staticmethod
276 1
    def save_text(text, end='\n'):
277
        """Break a string at sentences and dump as wrapped literal YAML."""
278 1
        if text:
279 1
            return _Literal(text + end)
280
        else:
281 1
            return ''
282
283
284 1
class Level(object):
285
    """Variable-length numerical outline level values.
286
287
    Level values cannot contain zeros. Zeros are reserved for
288
    identifying "heading" levels when written to file.
289
    """
290
291 1
    def __init__(self, value=None, heading=None):
292
        """Initialize an item level from a sequence of numbers.
293
294
        :param value: sequence of int, float, or period-delimited string
295
        :param heading: force a heading value (or inferred from trailing zero)
296
297
        """
298 1
        if isinstance(value, Level):
299 1
            self._parts = list(value)
300 1
            self.heading = value.heading
301
        else:
302 1
            parts = self.load_level(value)
303 1
            if parts and parts[-1] == 0:
304 1
                self._parts = parts[:-1]
305 1
                self.heading = True
306
            else:
307 1
                self._parts = parts
308 1
                self.heading = False
309 1
        self.heading = self.heading if heading is None else heading
310 1
        if not value:
311 1
            self._adjust()
312
313 1
    def __repr__(self):
314 1
        if self.heading:
315 1
            level = '.'.join(str(n) for n in self._parts)
316 1
            return "Level('{}', heading=True)".format(level)
317
        else:
318 1
            return "Level('{}')".format(str(self))
319
320 1
    def __str__(self):
321 1
        return '.'.join(str(n) for n in self.value)
322
323 1
    def __iter__(self):
324 1
        return iter(self._parts)
325
326 1
    def __len__(self):
327 1
        return len(self._parts)
328
329 1
    def __eq__(self, other):
330 1
        if other:
331 1
            parts = list(other)
332 1
            if parts and not parts[-1]:
333 1
                parts.pop(-1)
334 1
            return self._parts == parts
335
        else:
336 1
            return False
337
338 1
    def __ne__(self, other):
339 1
        return not self == other
340
341 1
    def __lt__(self, other):
342 1
        return self._parts < list(other)
343
344 1
    def __gt__(self, other):
345 1
        return self._parts > list(other)
346
347 1
    def __le__(self, other):
348 1
        return self._parts <= list(other)
349
350 1
    def __ge__(self, other):
351 1
        return self._parts >= list(other)
352
353 1
    def __hash__(self):
354 1
        return hash(self.value)
355
356 1
    def __add__(self, value):
357 1
        parts = list(self._parts)
358 1
        parts[-1] += value
359 1
        return Level(parts, heading=self.heading)
360
361 1
    def __iadd__(self, value):
362 1
        self._parts[-1] += value
363 1
        self._adjust()
364 1
        return self
365
366 1
    def __sub__(self, value):
367 1
        parts = list(self._parts)
368 1
        parts[-1] -= value
369 1
        return Level(parts, heading=self.heading)
370
371 1
    def __isub__(self, value):
372 1
        self._parts[-1] -= value
373 1
        self._adjust()
374 1
        return self
375
376 1
    def __rshift__(self, value):
377 1
        if value > 0:
378 1
            parts = list(self._parts) + [1] * value
379 1
            return Level(parts, heading=self.heading)
380
        else:
381 1
            return self.__lshift__(abs(value))
382
383 1
    def __irshift__(self, value):
384 1
        if value > 0:
385 1
            self._parts += [1] * value
386 1
            self._adjust()
387 1
            return self
388
        else:
389 1
            return self.__ilshift__(abs(value))
390
391 1
    def __lshift__(self, value):
392 1
        if value >= 0:
393 1
            parts = list(self._parts)
394 1
            if value:
395 1
                parts = parts[:-value]
396 1
            return Level(parts, heading=self.heading)
397
        else:
398 1
            return self.__rshift__(abs(value))
399
400 1
    def __ilshift__(self, value):
401 1
        if value >= 0:
402 1
            if value:
403 1
                self._parts = self._parts[:-value]
404 1
            self._adjust()
405 1
            return self
406
        else:
407 1
            return self.__irshift__(abs(value))
408
409 1
    @property
410
    def value(self):
411
        """Get a tuple for the level's value with heading indications."""
412 1
        parts = self._parts + ([0] if self.heading else [])
413 1
        return tuple(parts)
414
415 1
    @property
416
    def yaml(self):
417
        """Get the value to be used in YAML dumping."""
418 1
        return self.save_level(self.value)
419
420 1
    def _adjust(self):
421
        """Force all non-zero values."""
422 1
        old = self
423 1
        new = None
424 1
        if not self._parts:
425 1
            new = Level(1)
426 1
        elif 0 in self._parts:
427 1
            new = Level(1 if not n else n for n in self._parts)
428 1
        if new:
429 1
            msg = "minimum level reached, reseting: {} -> {}".format(old, new)
430 1
            log.warning(msg)
431 1
            self._parts = list(new.value)
432
433 1
    @staticmethod
434
    def load_level(value):
435
        """Convert an iterable, number, or level string to a tuple.
436
437
        >>> Level.load_level("1.2.3")
438
        [1, 2, 3]
439
440
        >>> Level.load_level(['4', '5'])
441
        [4, 5]
442
443
        >>> Level.load_level(4.2)
444
        [4, 2]
445
446
        >>> Level.load_level([7, 0, 0])
447
        [7, 0]
448
449
        >>> Level.load_level(1)
450
        [1]
451
452
        """
453
        # Correct for default values
454 1
        if not value:
455 1
            value = 1
456
        # Correct for integers (e.g. 42) and floats (e.g. 4.2) in YAML
457 1
        if isinstance(value, (int, float)):
458 1
            value = str(value)
459
460
        # Split strings by periods
461 1
        if isinstance(value, str):
462 1
            nums = value.split('.')
463
        else:  # assume an iterable
464 1
            nums = value
465
466
        # Clean up multiple trailing zeros
467 1
        parts = [int(n) for n in nums]
468 1
        if parts and parts[-1] == 0:
469 1
            while parts and parts[-1] == 0:
470 1
                del parts[-1]
471 1
            parts.append(0)
472
473 1
        return parts
474
475 1
    @staticmethod
476
    def save_level(parts):
477
        """Convert a level's part into non-quoted YAML value.
478
479
        >>> Level.save_level((1,))
480
        1
481
482
        >>> Level.save_level((1,0))
483
        1.0
484
485
        >>> Level.save_level((1,0,0))
486
        '1.0.0'
487
488
        """
489
        # Join the level's parts
490 1
        level = '.'.join(str(n) for n in parts)
491
492
        # Convert formats to cleaner YAML formats
493 1
        if len(parts) == 1:
494 1
            level = int(level)
495 1
        elif len(parts) == 2 and not (level.endswith('0') and parts[-1]):
496 1
            level = float(level)
497
498 1
        return level
499
500 1
    def copy(self):
501
        """Return a copy of the level."""
502 1
        return Level(self.value)
503
504
505 1
class Stamp(object):
506
    """Hashed content for change tracking.
507
508
    :param values: one of the following:
509
510
        - objects to hash as strings
511
        - existing string stamp
512
        - `True` - manually-confirmed matching hash, to be replaced later
513
        - `False` | `None` | (nothing) - manually-confirmed mismatching hash
514
515
    """
516
517 1
    def __init__(self, *values):
518 1
        if not values:
519 1
            self.value = None
520 1
            return
521 1
        if len(values) == 1:
522 1
            value = values[0]
523 1
            if to_bool(value):
524 1
                self.value = True
525 1
                return
526 1
            if not value:
527 1
                self.value = None
528 1
                return
529 1
            if isinstance(value, str):
530 1
                self.value = value
531 1
                return
532 1
        self.value = self.digest(*values)
533
534 1
    def __repr__(self):
535 1
        return "Stamp({})".format(repr(self.value))
536
537 1
    def __str__(self):
538 1
        if isinstance(self.value, str):
539 1
            return self.value
540
        else:
541 1
            return ''
542
543 1
    def __bool__(self):
544 1
        return bool(self.value)
545
546 1
    def __eq__(self, other):
547 1
        return self.value == other
548
549 1
    def __ne__(self, other):
550 1
        return not self == other
551
552 1
    @property
553
    def yaml(self):
554
        """Get the value to be used in YAML dumping."""
555 1
        return self.value
556
557 1
    @staticmethod
558
    def digest(*values):
559
        """Hash the values for later comparison."""
560 1
        md5 = hashlib.md5()
561 1
        for value in values:
562 1
            md5.update(str(value).encode())
563 1
        return md5.hexdigest()
564
565
566 1
class Reference(object):
567
    """External reference to a file or lines in a file."""
568
569
570 1
def to_bool(obj):
571
    """Convert a boolean-like object.
572
573
    >>> to_bool(1)
574
    True
575
576
    >>> to_bool(0)
577
    False
578
579
    >>> to_bool(' True ')
580
    True
581
582
    >>> to_bool('F')
583
    False
584
585
    """
586 1
    if isinstance(obj, str):
587 1
        return obj.lower().strip() in ('yes', 'true', 'enabled', '1')
588
    else:
589 1
        return bool(obj)
590
591
592 1
def is_tree(obj):
593
    """Determine if the object is a tree-like."""
594 1
    return hasattr(obj, 'documents')
595
596
597 1
def is_document(obj):
598
    """Determine if the object is a document-like."""
599 1
    return hasattr(obj, 'items')
600
601
602 1
def is_item(obj):
603
    """Determine if the object is item-like."""
604 1
    return hasattr(obj, 'text')
605
606
607 1
def iter_documents(obj, path, ext):
608
    """Get an iterator if documents from a tree or document-like object."""
609 1
    if is_tree(obj):
610
        # a tree
611 1
        log.debug("iterating over tree...")
612 1
        for document in obj.documents:
613 1
            path2 = os.path.join(path, document.prefix + ext)
614 1
            yield document, path2
615
    else:
616
        # assume a document-like object
617 1
        log.debug("iterating over document-like object...")
618 1
        yield obj, path
619
620
621 1
def iter_items(obj):
622
    """Get an iterator of items from from an item, list, or document."""
623 1
    if is_document(obj):
624
        # a document
625 1
        log.debug("iterating over document...")
626 1
        return (i for i in obj.items)
627 1
    try:
628
        # an iterable (of items)
629 1
        log.debug("iterating over document-like object...")
630 1
        return iter(obj)
631 1
    except TypeError:
632
        # assume an item
633 1
        log.debug("iterating over an item (in a container)...")
634
        return [obj]
635