1 | """Converter classes for extensions to builtin types.""" |
||
0 ignored issues
–
show
|
|||
2 | |||
3 | 1 | import re |
|
4 | |||
5 | 1 | from .standard import String, Integer, Float, Boolean |
|
6 | 1 | from .containers import Dictionary, List |
|
7 | 1 | from ._representers import LiteralString |
|
8 | |||
9 | |||
10 | # NULLABLE BUILTINS ############################################################ |
||
11 | |||
12 | |||
13 | 1 | class NullableString(String): |
|
14 | """Converter for the `str` type with `None` as default.""" |
||
15 | |||
16 | 1 | DEFAULT = None |
|
17 | |||
18 | |||
19 | 1 | class NullableInteger(Integer): |
|
20 | """Converter for the `int` type with `None` as default.""" |
||
21 | |||
22 | 1 | DEFAULT = None |
|
23 | |||
24 | |||
25 | 1 | class NullableFloat(Float): |
|
26 | """Converter for the `float` type with `None` as default.""" |
||
27 | |||
28 | 1 | DEFAULT = None |
|
29 | |||
30 | |||
31 | 1 | class NullableBoolean(Boolean): |
|
32 | """Converter for the `bool` type with `None` as default.""" |
||
33 | |||
34 | 1 | DEFAULT = None |
|
35 | |||
36 | |||
37 | # CUSTOM TYPES ################################################################# |
||
38 | |||
39 | |||
40 | 1 | class Markdown(String): |
|
41 | """Converter for a `str` type that contains Markdown.""" |
||
42 | |||
43 | 1 | REGEX_MARKDOWN_SPACES = re.compile(r""" |
|
44 | |||
45 | ([^\n ]) # any character but a newline or space |
||
46 | |||
47 | (\ ?\n) # optional space + single newline |
||
48 | |||
49 | (?! # none of the following: |
||
50 | |||
51 | (?:\s) # whitespace |
||
52 | | |
||
53 | (?:[-+*]\s) # unordered list separator + whitespace |
||
54 | | |
||
55 | (?:\d+\.\s) # number + period + whitespace |
||
56 | |||
57 | ) |
||
58 | |||
59 | ([^\n]) # any character but a newline |
||
60 | |||
61 | """, re.VERBOSE | re.IGNORECASE) |
||
62 | |||
63 | # based on: http://en.wikipedia.org/wiki/Sentence_boundary_disambiguation |
||
64 | 1 | REGEX_SENTENCE_BOUNDARIES = re.compile(r""" |
|
65 | |||
66 | ( # one of the following: |
||
67 | |||
68 | (?<=[a-z)][.?!]) # lowercase letter + punctuation |
||
69 | | |
||
70 | (?<=[a-z0-9][.?!]\") # lowercase letter/number + punctuation + quote |
||
71 | |||
72 | ) |
||
73 | |||
74 | (\s) # any whitespace |
||
75 | |||
76 | (?=\"?[A-Z]) # optional quote + an upppercase letter |
||
77 | |||
78 | """, re.VERBOSE) |
||
79 | |||
80 | 1 | @classmethod |
|
81 | def to_value(cls, obj): |
||
82 | """Join non-meaningful line breaks.""" |
||
83 | 1 | value = String.to_value(obj) |
|
84 | 1 | return cls._join(value) |
|
85 | |||
86 | 1 | @classmethod |
|
87 | def to_data(cls, obj): |
||
88 | """Break a string at sentences and dump as a literal string.""" |
||
89 | 1 | value = String.to_value(obj) |
|
90 | 1 | data = String.to_data(value) |
|
91 | 1 | split = cls._split(data) |
|
92 | 1 | return LiteralString(split) |
|
93 | |||
94 | 1 | @classmethod |
|
95 | def _join(cls, text): |
||
96 | r"""Convert single newlines (ignored by Markdown) to spaces. |
||
97 | |||
98 | >>> Markdown._join("abc\n123") |
||
99 | 'abc 123' |
||
100 | |||
101 | >>> Markdown._join("abc\n\n123") |
||
102 | 'abc\n\n123' |
||
103 | |||
104 | >>> Markdown._join("abc \n123") |
||
105 | 'abc 123' |
||
106 | |||
107 | """ |
||
108 | 1 | return cls.REGEX_MARKDOWN_SPACES.sub(r'\1 \3', text).strip() |
|
109 | |||
110 | 1 | @classmethod |
|
111 | 1 | def _split(cls, text, end='\n'): |
|
112 | r"""Replace sentence boundaries with newlines and append a newline. |
||
113 | |||
114 | :param text: string to line break at sentences |
||
115 | :param end: appended to the end of the update text |
||
116 | |||
117 | >>> Markdown._split("Hello, world!", end='') |
||
118 | 'Hello, world!' |
||
119 | |||
120 | >>> Markdown._split("Hello, world! How are you? I'm fine. Good.") |
||
121 | "Hello, world!\nHow are you?\nI'm fine.\nGood.\n" |
||
122 | |||
123 | """ |
||
124 | 1 | stripped = text.strip() |
|
125 | 1 | if stripped: |
|
126 | 1 | return cls.REGEX_SENTENCE_BOUNDARIES.sub('\n', stripped) + end |
|
127 | else: |
||
128 | 1 | return '' |
|
129 | |||
130 | |||
131 | # CUSTOM CONTAINERS ############################################################ |
||
132 | |||
133 | |||
134 | 1 | class AttributeDictionary(Dictionary): |
|
135 | """Dictionary converter with keys available as attributes.""" |
||
136 | |||
137 | 1 | def __init__(self, *args, **kwargs): |
|
138 | 1 | super().__init__(*args, **kwargs) |
|
139 | 1 | self.__dict__ = self |
|
140 | |||
141 | 1 | @classmethod |
|
142 | def create_default(cls): |
||
143 | """Create an uninitialized object with keys as attributes.""" |
||
144 | 1 | if cls is AttributeDictionary: |
|
145 | 1 | msg = "AttributeDictionary class must be subclassed to use" |
|
146 | 1 | raise NotImplementedError(msg) |
|
147 | |||
148 | 1 | return cls() |
|
149 | |||
150 | |||
151 | 1 | class SortedList(List): |
|
152 | """List converter that is sorted on disk.""" |
||
153 | |||
154 | 1 | @classmethod |
|
155 | def create_default(cls): |
||
156 | """Create an uninitialized object.""" |
||
157 | 1 | if cls is SortedList: |
|
158 | 1 | msg = "SortedList class must be subclassed to use" |
|
159 | 1 | raise NotImplementedError(msg) |
|
160 | 1 | if not cls.item_type: |
|
161 | 1 | msg = "SortedList subclass must specify item type" |
|
162 | 1 | raise NotImplementedError(msg) |
|
163 | |||
164 | 1 | return cls.__new__(cls) |
|
165 | |||
166 | 1 | @classmethod |
|
167 | def to_data(cls, obj): |
||
168 | """Convert all attribute values for optimal dumping to YAML.""" |
||
169 | 1 | value = cls.to_value(obj) |
|
170 | |||
171 | 1 | data = [] |
|
172 | |||
173 | 1 | for item in sorted(value): |
|
174 | 1 | data.append(cls.item_type.to_data(item)) # pylint: disable=no-member |
|
175 | |||
176 | return data |
||
177 |
Cyclic imports may cause partly loaded modules to be returned. This might lead to unexpected runtime behavior which is hard to debug.