Completed
Pull Request — develop (#214)
by
unknown
13:36
created

Level.__str__()   A

Complexity

Conditions 2

Size

Total Lines 2

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 6

Importance

Changes 0
Metric Value
cc 2
dl 0
loc 2
ccs 0
cts 0
cp 0
crap 6
rs 10
c 0
b 0
f 0
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
        text_value = re.sub('^\n+', '', value)
270
        text_value = re.sub('\n+$', '', text_value)
271 1
        return text_value.rstrip(' ')
272 1
273
    @staticmethod
274 1
    def save_text(text, end='\n'):
275 1
        """Break a string at sentences and dump as wrapped literal YAML."""
276 1
        if text:
277
            return _Literal(text + end)
278
        else:
279 1
            return ''
280
281
282
class Level(object):
283
    """Variable-length numerical outline level values.
284
285
    Level values cannot contain zeros. Zeros are reserved for
286
    identifying "heading" levels when written to file.
287
    """
288
289
    def __init__(self, value=None, heading=None):
290
        """Initialize an item level from a sequence of numbers.
291
292
        :param value: sequence of int, float, or period-delimited string
293
        :param heading: force a heading value (or inferred from trailing zero)
294 1
295 1
        """
296
        if isinstance(value, Level):
297
            self._parts = list(value)
298
            self.heading = value.heading
299
        else:
300
            parts = self.load_level(value)
301
            if parts and parts[-1] == 0:
302
                self._parts = parts[:-1]
303
                self.heading = True
304
            else:
305
                self._parts = parts
306
                self.heading = False
307
        self.heading = self.heading if heading is None else heading
308 1
        if not value:
309 1
            self._adjust()
310 1
311
    def __repr__(self):
312 1
        if self.heading:
313
            level = '.'.join(str(n) for n in self._parts)
314 1
            return "Level('{}', heading=True)".format(level)
315 1
        else:
316
            return "Level('{}')".format(str(self))
317
318
    def __str__(self):
319
        return '.'.join(str(n) for n in self.value)
320
321
    def __iter__(self):
322
        return iter(self._parts)
323
324
    def __len__(self):
325 1
        return len(self._parts)
326 1
327 1
    def __eq__(self, other):
328
        if other:
329 1
            parts = list(other)
330
            if parts and not parts[-1]:
331 1
                parts.pop(-1)
332 1
            return self._parts == parts
333 1
        else:
334
            return False
335 1
336
    def __ne__(self, other):
337
        return not self == other
338
339
    def __lt__(self, other):
340
        return self._parts < list(other)
341
342
    def __gt__(self, other):
343
        return self._parts > list(other)
344
345
    def __le__(self, other):
346
        return self._parts <= list(other)
347
348
    def __ge__(self, other):
349
        return self._parts >= list(other)
350
351
    def __hash__(self):
352
        return hash(self.value)
353
354 1
    def __add__(self, value):
355
        parts = list(self._parts)
356
        parts[-1] += value
357
        return Level(parts, heading=self.heading)
358
359
    def __iadd__(self, value):
360
        self._parts[-1] += value
361
        self._adjust()
362
        return self
363
364
    def __sub__(self, value):
365
        parts = list(self._parts)
366
        parts[-1] -= value
367
        return Level(parts, heading=self.heading)
368 1
369
    def __isub__(self, value):
370
        self._parts[-1] -= value
371 1
        self._adjust()
372
        return self
373
374
    def __rshift__(self, value):
375
        if value > 0:
376
            parts = list(self._parts) + [1] * value
377
            return Level(parts, heading=self.heading)
378 1
        else:
379
            return self.__lshift__(abs(value))
380
381
    def __irshift__(self, value):
382
        if value > 0:
383
            self._parts += [1] * value
384
            self._adjust()
385 1
            return self
386 1
        else:
387 1
            return self.__ilshift__(abs(value))
388
389 1
    def __lshift__(self, value):
390 1
        if value >= 0:
391 1
            parts = list(self._parts)
392 1
            if value:
393
                parts = parts[:-value]
394 1
            return Level(parts, heading=self.heading)
395 1
        else:
396 1
            return self.__rshift__(abs(value))
397 1
398 1
    def __ilshift__(self, value):
399
        if value >= 0:
400 1
            if value:
401 1
                self._parts = self._parts[:-value]
402 1
            self._adjust()
403 1
            return self
404
        else:
405 1
            return self.__irshift__(abs(value))
406
407 1
    @property
408 1
    def value(self):
409
        """Get a tuple for the level's value with heading indications."""
410 1
        parts = self._parts + ([0] if self.heading else [])
411 1
        return tuple(parts)
412
413 1
    @property
414 1
    def yaml(self):
415
        """Get the value to be used in YAML dumping."""
416 1
        return self.save_level(self.value)
417 1
418 1
    def _adjust(self):
419 1
        """Force all non-zero values."""
420 1
        old = self
421 1
        new = None
422
        if not self._parts:
423 1
            new = Level(1)
424
        elif 0 in self._parts:
425 1
            new = Level(1 if not n else n for n in self._parts)
426 1
        if new:
427
            msg = "minimum level reached, reseting: {} -> {}".format(old, new)
428 1
            log.warning(msg)
429 1
            self._parts = list(new.value)
430
431 1
    @staticmethod
432 1
    def load_level(value):
433
        """Convert an iterable, number, or level string to a tuple.
434 1
435 1
        >>> Level.load_level("1.2.3")
436
        [1, 2, 3]
437 1
438 1
        >>> Level.load_level(['4', '5'])
439
        [4, 5]
440 1
441 1
        >>> Level.load_level(4.2)
442
        [4, 2]
443 1
444 1
        >>> Level.load_level([7, 0, 0])
445 1
        [7, 0]
446 1
447
        >>> Level.load_level(1)
448 1
        [1]
449 1
450 1
        """
451 1
        # Correct for default values
452
        if not value:
453 1
            value = 1
454 1
        # Correct for integers (e.g. 42) and floats (e.g. 4.2) in YAML
455 1
        if isinstance(value, (int, float)):
456 1
            value = str(value)
457
458 1
        # Split strings by periods
459 1
        if isinstance(value, str):
460 1
            nums = value.split('.')
461 1
        else:  # assume an iterable
462
            nums = value
463 1
464 1
        # Clean up multiple trailing zeros
465 1
        parts = [int(n) for n in nums]
466 1
        if parts and parts[-1] == 0:
467
            while parts and parts[-1] == 0:
468 1
                del parts[-1]
469
            parts.append(0)
470 1
471 1
        return parts
472 1
473 1
    @staticmethod
474 1
    def save_level(parts):
475
        """Convert a level's part into non-quoted YAML value.
476 1
477
        >>> Level.save_level((1,))
478 1
        1
479 1
480 1
        >>> Level.save_level((1,0))
481 1
        1.0
482 1
483 1
        >>> Level.save_level((1,0,0))
484
        '1.0.0'
485 1
486
        """
487 1
        # Join the level's parts
488 1
        level = '.'.join(str(n) for n in parts)
489 1
490 1
        # Convert formats to cleaner YAML formats
491 1
        if len(parts) == 1:
492 1
            level = int(level)
493
        elif len(parts) == 2 and not (level.endswith('0') and parts[-1]):
494 1
            level = float(level)
495
496 1
        return level
497
498
    def copy(self):
499 1
        """Return a copy of the level."""
500 1
        return Level(self.value)
501
502 1
503
class Stamp(object):
504
    """Hashed content for change tracking.
505 1
506
    :param values: one of the following:
507 1
508
        - objects to hash as strings
509 1
        - existing string stamp
510 1
        - `True` - manually-confirmed matching hash, to be replaced later
511 1
        - `False` | `None` | (nothing) - manually-confirmed mismatching hash
512 1
513 1
    """
514 1
515 1
    def __init__(self, *values):
516 1
        if not values:
517 1
            self.value = None
518 1
            return
519
        if len(values) == 1:
520 1
            value = values[0]
521
            if to_bool(value):
522
                self.value = True
523
                return
524
            if not value:
525
                self.value = None
526
                return
527
            if isinstance(value, str):
528
                self.value = value
529
                return
530
        self.value = self.digest(*values)
531
532
    def __repr__(self):
533
        return "Stamp({})".format(repr(self.value))
534
535
    def __str__(self):
536
        if isinstance(self.value, str):
537
            return self.value
538
        else:
539
            return ''
540
541 1
    def __bool__(self):
542 1
        return bool(self.value)
543
544 1
    def __eq__(self, other):
545 1
        return self.value == other
546
547
    def __ne__(self, other):
548 1
        return not self == other
549 1
550
    @property
551 1
    def yaml(self):
552
        """Get the value to be used in YAML dumping."""
553
        return self.value
554 1
555 1
    @staticmethod
556 1
    def digest(*values):
557 1
        """Hash the values for later comparison."""
558 1
        md5 = hashlib.md5()
559
        for value in values:
560 1
            md5.update(str(value).encode())
561
        return md5.hexdigest()
562 1
563
564
class Reference(object):
565
    """External reference to a file or lines in a file."""
566
567
568
def to_bool(obj):
569
    """Convert a boolean-like object.
570
571
    >>> to_bool(1)
572
    True
573
574
    >>> to_bool(0)
575
    False
576
577 1
    >>> to_bool(' True ')
578
    True
579
580 1
    >>> to_bool('F')
581 1
    False
582 1
583 1
    """
584
    if isinstance(obj, str):
585 1
        return obj.lower().strip() in ('yes', 'true', 'enabled', '1')
586
    else:
587 1
        return bool(obj)
588
589 1
590
def is_tree(obj):
591
    """Determine if the object is a tree-like."""
592 1
    return hasattr(obj, 'documents')
593
594
595
def is_document(obj):
596
    """Determine if the object is a document-like."""
597
    return hasattr(obj, 'items')
598
599
600
def is_item(obj):
601
    """Determine if the object is item-like."""
602
    return hasattr(obj, 'text')
603
604 1
605 1
def iter_documents(obj, path, ext):
606 1
    """Get an iterator if documents from a tree or document-like object."""
607 1
    if is_tree(obj):
608 1
        # a tree
609 1
        log.debug("iterating over tree...")
610 1
        for document in obj.documents:
611 1
            path2 = os.path.join(path, document.prefix + ext)
612 1
            yield document, path2
613 1
    else:
614 1
        # assume a document-like object
615 1
        log.debug("iterating over document-like object...")
616 1
        yield obj, path
617 1
618 1
619 1
def iter_items(obj):
620
    """Get an iterator of items from from an item, list, or document."""
621 1
    if is_document(obj):
622 1
        # a document
623
        log.debug("iterating over document...")
624 1
        return (i for i in obj.items)
625 1
    try:
626 1
        # an iterable (of items)
627
        log.debug("iterating over document-like object...")
628 1
        return iter(obj)
629
    except TypeError:
630 1
        # assume an item
631 1
        log.debug("iterating over an item (in a container)...")
632
        return [obj]
633