Completed
Pull Request — develop (#208)
by
unknown
19:01 queued 03:54
created

Text.join()   A

Complexity

Conditions 1

Size

Total Lines 15

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

Changes 0
Metric Value
cc 1
dl 0
loc 15
rs 9.4285
c 0
b 0
f 0
ccs 2
cts 2
cp 1
crap 1
1
"""Common classes and functions for the `doorstop.core` package."""
2
3 1
import os
4 1
import re
5 1
import textwrap
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\n'
267
268
        """
269 1
        return value
270
271 1
    @staticmethod
272 1
    def save_text(text, end='\n'):
273
        """Break a string at sentences and dump as wrapped literal YAML."""
274 1
        split = Text.sbd(str(text), end=end)
275 1
        wrapped = Text.wrap(split)
276 1
        return _Literal(wrapped)
277
278
    # Based on: http://en.wikipedia.org/wiki/Sentence_boundary_disambiguation
279 1
    RE_SENTENCE_BOUNDARIES = re.compile(r"""
280
281
    (            # one of the following:
282
283
      (?<=[a-z)][.?!])      # lowercase letter + punctuation
284
      |
285
      (?<=[a-z0-9][.?!]\")  # lowercase letter/number + punctuation + quote
286
287
    )
288
289
    (\s)          # any whitespace
290
291
    (?=\"?[A-Z])  # optional quote + an upppercase letter
292
    """, re.VERBOSE)
293
294 1
    @staticmethod
295 1
    def sbd(text, end='\n'):
296
        r"""Replace sentence boundaries with newlines and append a newline.
297
298
        :param text: string to line break at sentences
299
        :param end: appended to the end of the update text
300
301
        >>> Text.sbd("Hello, world!", end='')
302
        'Hello, world!'
303
304
        >>> Text.sbd("Hello, world! How are you? I'm fine. Good.")
305
        "Hello, world!\nHow are you?\nI'm fine.\nGood.\n"
306
307
        """
308 1
        stripped = text.strip()
309 1
        if stripped:
310 1
            return Text.RE_SENTENCE_BOUNDARIES.sub('\n', stripped) + end
311
        else:
312 1
            return ''
313
314 1
    @staticmethod
315 1
    def wrap(text, width=settings.MAX_LINE_LENGTH):
316
        r"""Wrap lines of text to the maximum line length.
317
318
        >>> Text.wrap("Hello, world!", 9)
319
        'Hello,\nworld!'
320
321
        >>> Text.wrap("How are you?\nI'm fine.\n", 14)
322
        "How are you?\nI'm fine.\n"
323
324
        """
325 1
        end = '\n' if '\n' in text else ''
326 1
        lines = []
327 1
        for line in text.splitlines():
328
            # wrap longs lines of text compensating for the 2-space indent
329 1
            lines.extend(textwrap.wrap(line, width=width - 2,
330
                                       replace_whitespace=True))
331 1
            if not line.strip():
332 1
                lines.append('')
333 1
        return '\n'.join(lines) + end
334
335 1
    RE_MARKDOWN_SPACES = re.compile(r"""
336
337
    ([^\n ])  # any character but a newline or space
338
339
    (\ ?\n)     # optional space + single newline
340
341
    (?!      # none of the following:
342
343
      (?:\s)       # whitespace
344
      |
345
      (?:[-+*]\s)  # unordered list separator + whitespace
346
      |
347
      (?:\d+\.\s)  # number + period + whitespace
348
349
    )
350
351
    ([^\n])  # any character but a newline
352
    """, re.VERBOSE | re.IGNORECASE)
353
354 1
355
356
class Level(object):
357
    """Variable-length numerical outline level values.
358
359
    Level values cannot contain zeros. Zeros are reserved for
360
    identifying "heading" levels when written to file.
361
    """
362
363
    def __init__(self, value=None, heading=None):
364
        """Initialize an item level from a sequence of numbers.
365
366
        :param value: sequence of int, float, or period-delimited string
367
        :param heading: force a heading value (or inferred from trailing zero)
368 1
369
        """
370
        if isinstance(value, Level):
371 1
            self._parts = list(value)
372
            self.heading = value.heading
373
        else:
374
            parts = self.load_level(value)
375
            if parts and parts[-1] == 0:
376
                self._parts = parts[:-1]
377
                self.heading = True
378 1
            else:
379
                self._parts = parts
380
                self.heading = False
381
        self.heading = self.heading if heading is None else heading
382
        if not value:
383
            self._adjust()
384
385 1
    def __repr__(self):
386 1
        if self.heading:
387 1
            level = '.'.join(str(n) for n in self._parts)
388
            return "Level('{}', heading=True)".format(level)
389 1
        else:
390 1
            return "Level('{}')".format(str(self))
391 1
392 1
    def __str__(self):
393
        return '.'.join(str(n) for n in self.value)
394 1
395 1
    def __iter__(self):
396 1
        return iter(self._parts)
397 1
398 1
    def __len__(self):
399
        return len(self._parts)
400 1
401 1
    def __eq__(self, other):
402 1
        if other:
403 1
            parts = list(other)
404
            if parts and not parts[-1]:
405 1
                parts.pop(-1)
406
            return self._parts == parts
407 1
        else:
408 1
            return False
409
410 1
    def __ne__(self, other):
411 1
        return not self == other
412
413 1
    def __lt__(self, other):
414 1
        return self._parts < list(other)
415
416 1
    def __gt__(self, other):
417 1
        return self._parts > list(other)
418 1
419 1
    def __le__(self, other):
420 1
        return self._parts <= list(other)
421 1
422
    def __ge__(self, other):
423 1
        return self._parts >= list(other)
424
425 1
    def __hash__(self):
426 1
        return hash(self.value)
427
428 1
    def __add__(self, value):
429 1
        parts = list(self._parts)
430
        parts[-1] += value
431 1
        return Level(parts, heading=self.heading)
432 1
433
    def __iadd__(self, value):
434 1
        self._parts[-1] += value
435 1
        self._adjust()
436
        return self
437 1
438 1
    def __sub__(self, value):
439
        parts = list(self._parts)
440 1
        parts[-1] -= value
441 1
        return Level(parts, heading=self.heading)
442
443 1
    def __isub__(self, value):
444 1
        self._parts[-1] -= value
445 1
        self._adjust()
446 1
        return self
447
448 1
    def __rshift__(self, value):
449 1
        if value > 0:
450 1
            parts = list(self._parts) + [1] * value
451 1
            return Level(parts, heading=self.heading)
452
        else:
453 1
            return self.__lshift__(abs(value))
454 1
455 1
    def __irshift__(self, value):
456 1
        if value > 0:
457
            self._parts += [1] * value
458 1
            self._adjust()
459 1
            return self
460 1
        else:
461 1
            return self.__ilshift__(abs(value))
462
463 1
    def __lshift__(self, value):
464 1
        if value >= 0:
465 1
            parts = list(self._parts)
466 1
            if value:
467
                parts = parts[:-value]
468 1
            return Level(parts, heading=self.heading)
469
        else:
470 1
            return self.__rshift__(abs(value))
471 1
472 1
    def __ilshift__(self, value):
473 1
        if value >= 0:
474 1
            if value:
475
                self._parts = self._parts[:-value]
476 1
            self._adjust()
477
            return self
478 1
        else:
479 1
            return self.__irshift__(abs(value))
480 1
481 1
    @property
482 1
    def value(self):
483 1
        """Get a tuple for the level's value with heading indications."""
484
        parts = self._parts + ([0] if self.heading else [])
485 1
        return tuple(parts)
486
487 1
    @property
488 1
    def yaml(self):
489 1
        """Get the value to be used in YAML dumping."""
490 1
        return self.save_level(self.value)
491 1
492 1
    def _adjust(self):
493
        """Force all non-zero values."""
494 1
        old = self
495
        new = None
496 1
        if not self._parts:
497
            new = Level(1)
498
        elif 0 in self._parts:
499 1
            new = Level(1 if not n else n for n in self._parts)
500 1
        if new:
501
            msg = "minimum level reached, reseting: {} -> {}".format(old, new)
502 1
            log.warning(msg)
503
            self._parts = list(new.value)
504
505 1
    @staticmethod
506
    def load_level(value):
507 1
        """Convert an iterable, number, or level string to a tuple.
508
509 1
        >>> Level.load_level("1.2.3")
510 1
        [1, 2, 3]
511 1
512 1
        >>> Level.load_level(['4', '5'])
513 1
        [4, 5]
514 1
515 1
        >>> Level.load_level(4.2)
516 1
        [4, 2]
517 1
518 1
        >>> Level.load_level([7, 0, 0])
519
        [7, 0]
520 1
521
        >>> Level.load_level(1)
522
        [1]
523
524
        """
525
        # Correct for default values
526
        if not value:
527
            value = 1
528
        # Correct for integers (e.g. 42) and floats (e.g. 4.2) in YAML
529
        if isinstance(value, (int, float)):
530
            value = str(value)
531
532
        # Split strings by periods
533
        if isinstance(value, str):
534
            nums = value.split('.')
535
        else:  # assume an iterable
536
            nums = value
537
538
        # Clean up multiple trailing zeros
539
        parts = [int(n) for n in nums]
540
        if parts and parts[-1] == 0:
541 1
            while parts and parts[-1] == 0:
542 1
                del parts[-1]
543
            parts.append(0)
544 1
545 1
        return parts
546
547
    @staticmethod
548 1
    def save_level(parts):
549 1
        """Convert a level's part into non-quoted YAML value.
550
551 1
        >>> Level.save_level((1,))
552
        1
553
554 1
        >>> Level.save_level((1,0))
555 1
        1.0
556 1
557 1
        >>> Level.save_level((1,0,0))
558 1
        '1.0.0'
559
560 1
        """
561
        # Join the level's parts
562 1
        level = '.'.join(str(n) for n in parts)
563
564
        # Convert formats to cleaner YAML formats
565
        if len(parts) == 1:
566
            level = int(level)
567
        elif len(parts) == 2 and not (level.endswith('0') and parts[-1]):
568
            level = float(level)
569
570
        return level
571
572
    def copy(self):
573
        """Return a copy of the level."""
574
        return Level(self.value)
575
576
577 1
class Stamp(object):
578
    """Hashed content for change tracking.
579
580 1
    :param values: one of the following:
581 1
582 1
        - objects to hash as strings
583 1
        - existing string stamp
584
        - `True` - manually-confirmed matching hash, to be replaced later
585 1
        - `False` | `None` | (nothing) - manually-confirmed mismatching hash
586
587 1
    """
588
589 1
    def __init__(self, *values):
590
        if not values:
591
            self.value = None
592 1
            return
593
        if len(values) == 1:
594
            value = values[0]
595
            if to_bool(value):
596
                self.value = True
597
                return
598
            if not value:
599
                self.value = None
600
                return
601
            if isinstance(value, str):
602
                self.value = value
603
                return
604 1
        self.value = self.digest(*values)
605 1
606 1
    def __repr__(self):
607 1
        return "Stamp({})".format(repr(self.value))
608 1
609 1
    def __str__(self):
610 1
        if isinstance(self.value, str):
611 1
            return self.value
612 1
        else:
613 1
            return ''
614 1
615 1
    def __bool__(self):
616 1
        return bool(self.value)
617 1
618 1
    def __eq__(self, other):
619 1
        return self.value == other
620
621 1
    def __ne__(self, other):
622 1
        return not self == other
623
624 1
    @property
625 1
    def yaml(self):
626 1
        """Get the value to be used in YAML dumping."""
627
        return self.value
628 1
629
    @staticmethod
630 1
    def digest(*values):
631 1
        """Hash the values for later comparison."""
632
        md5 = hashlib.md5()
633 1
        for value in values:
634 1
            md5.update(str(value).encode())
635
        return md5.hexdigest()
636 1
637 1
638
class Reference(object):
639 1
    """External reference to a file or lines in a file."""
640
641
642 1
def to_bool(obj):
643
    """Convert a boolean-like object.
644 1
645
    >>> to_bool(1)
646
    True
647 1
648 1
    >>> to_bool(0)
649 1
    False
650 1
651
    >>> to_bool(' True ')
652
    True
653 1
654
    >>> to_bool('F')
655
    False
656
657 1
    """
658
    if isinstance(obj, str):
659
        return obj.lower().strip() in ('yes', 'true', 'enabled', '1')
660
    else:
661
        return bool(obj)
662
663
664
def is_tree(obj):
665
    """Determine if the object is a tree-like."""
666
    return hasattr(obj, 'documents')
667
668
669
def is_document(obj):
670
    """Determine if the object is a document-like."""
671
    return hasattr(obj, 'items')
672
673 1
674 1
def is_item(obj):
675
    """Determine if the object is item-like."""
676 1
    return hasattr(obj, 'text')
677
678
679 1
def iter_documents(obj, path, ext):
680
    """Get an iterator if documents from a tree or document-like object."""
681 1
    if is_tree(obj):
682
        # a tree
683
        log.debug("iterating over tree...")
684 1
        for document in obj.documents:
685
            path2 = os.path.join(path, document.prefix + ext)
686 1
            yield document, path2
687
    else:
688
        # assume a document-like object
689 1
        log.debug("iterating over document-like object...")
690
        yield obj, path
691 1
692
693
def iter_items(obj):
694 1
    """Get an iterator of items from from an item, list, or document."""
695
    if is_document(obj):
696 1
        # a document
697
        log.debug("iterating over document...")
698 1
        return (i for i in obj.items)
699 1
    try:
700 1
        # an iterable (of items)
701 1
        log.debug("iterating over document-like object...")
702
        return iter(obj)
703
    except TypeError:
704 1
        # assume an item
705 1
        log.debug("iterating over an item (in a container)...")
706
        return [obj]
707