Tue, 25 Aug 2020 22:08:30 +0300
made colors toggleable
47 | 1 | import re |
2 | import linetypes | |
48 | 3 | import datetime |
47 | 4 | |
5 | class Header: | |
97 | 6 | ''' |
7 | Result type of header processing, this contains all the header | |
8 | information. | |
9 | ''' | |
47 | 10 | def __init__(self): |
11 | self.description = None | |
12 | self.name = None | |
13 | self.author = None | |
14 | self.username = None | |
15 | self.filetype = None | |
16 | self.qualifiers = None | |
17 | self.license = None | |
48 | 18 | self.help = '' |
47 | 19 | self.bfc = None |
20 | self.category = None | |
48 | 21 | self.keywords = '' |
47 | 22 | self.cmdline = None |
23 | self.history = [] | |
48 | 24 | self.first_occurrence = dict() |
25 | @property | |
26 | def valid(self): | |
27 | return True | |
69
a24c4490d9f2
added a check for keywords in non-parts
Teemu Piippo <teemu@hecknology.net>
parents:
67
diff
changeset
|
28 | @property |
a24c4490d9f2
added a check for keywords in non-parts
Teemu Piippo <teemu@hecknology.net>
parents:
67
diff
changeset
|
29 | def effective_filetype(self): |
97 | 30 | ''' |
31 | What's the effective file type? The "Unofficial_" prefix is | |
32 | left out. | |
33 | ''' | |
69
a24c4490d9f2
added a check for keywords in non-parts
Teemu Piippo <teemu@hecknology.net>
parents:
67
diff
changeset
|
34 | if self.filetype.startswith('Unofficial_'): |
a24c4490d9f2
added a check for keywords in non-parts
Teemu Piippo <teemu@hecknology.net>
parents:
67
diff
changeset
|
35 | return self.filetype.rsplit('Unofficial_')[1] |
a24c4490d9f2
added a check for keywords in non-parts
Teemu Piippo <teemu@hecknology.net>
parents:
67
diff
changeset
|
36 | else: |
a24c4490d9f2
added a check for keywords in non-parts
Teemu Piippo <teemu@hecknology.net>
parents:
67
diff
changeset
|
37 | return self.filetype |
79
eb93feb6d3a3
added a test for valid categories
Teemu Piippo <teemu@hecknology.net>
parents:
77
diff
changeset
|
38 | @property |
eb93feb6d3a3
added a test for valid categories
Teemu Piippo <teemu@hecknology.net>
parents:
77
diff
changeset
|
39 | def effective_category(self): |
97 | 40 | ''' |
41 | Returns the category of the part. Leading punctuation marks | |
42 | are ignored. | |
43 | ''' | |
79
eb93feb6d3a3
added a test for valid categories
Teemu Piippo <teemu@hecknology.net>
parents:
77
diff
changeset
|
44 | if self.category: |
eb93feb6d3a3
added a test for valid categories
Teemu Piippo <teemu@hecknology.net>
parents:
77
diff
changeset
|
45 | return self.category |
eb93feb6d3a3
added a test for valid categories
Teemu Piippo <teemu@hecknology.net>
parents:
77
diff
changeset
|
46 | else: |
84
55d52e25267f
fixed prefixed punctuations winding up in the effective categories of subparts
Teemu Piippo <teemu@hecknology.net>
parents:
79
diff
changeset
|
47 | import string |
55d52e25267f
fixed prefixed punctuations winding up in the effective categories of subparts
Teemu Piippo <teemu@hecknology.net>
parents:
79
diff
changeset
|
48 | category = self.description.split(' ', 1)[0] |
55d52e25267f
fixed prefixed punctuations winding up in the effective categories of subparts
Teemu Piippo <teemu@hecknology.net>
parents:
79
diff
changeset
|
49 | while category and category[0] in string.punctuation: |
55d52e25267f
fixed prefixed punctuations winding up in the effective categories of subparts
Teemu Piippo <teemu@hecknology.net>
parents:
79
diff
changeset
|
50 | category = category[1:] |
55d52e25267f
fixed prefixed punctuations winding up in the effective categories of subparts
Teemu Piippo <teemu@hecknology.net>
parents:
79
diff
changeset
|
51 | return category |
47 | 52 | |
53 | class BadHeader: | |
97 | 54 | ''' |
55 | If header processing fails this object is returned as the resulting | |
56 | header instead. It contains the details of where the header could not | |
57 | be understood and why. | |
58 | ''' | |
47 | 59 | def __init__(self, index, reason): |
60 | self.index = index | |
61 | self.reason = reason | |
62 | def __repr__(self): | |
63 | return str.format( | |
64 | 'header.BadHeader(index = {index!r}, reason = {reason!r})', | |
65 | index = self.index, | |
66 | reason = self.reason, | |
67 | ) | |
48 | 68 | @property |
69 | def valid(self): | |
70 | return False | |
47 | 71 | |
56
ed6d39c59e56
fixed BFC INVERTNEXT being interpreted as a header command
Teemu Piippo <teemu@hecknology.net>
parents:
54
diff
changeset
|
72 | def is_invertnext(entry): |
ed6d39c59e56
fixed BFC INVERTNEXT being interpreted as a header command
Teemu Piippo <teemu@hecknology.net>
parents:
54
diff
changeset
|
73 | return isinstance(entry, linetypes.MetaCommand) \ |
ed6d39c59e56
fixed BFC INVERTNEXT being interpreted as a header command
Teemu Piippo <teemu@hecknology.net>
parents:
54
diff
changeset
|
74 | and entry.text == "BFC INVERTNEXT" |
ed6d39c59e56
fixed BFC INVERTNEXT being interpreted as a header command
Teemu Piippo <teemu@hecknology.net>
parents:
54
diff
changeset
|
75 | |
47 | 76 | def is_suitable_header_object(entry): |
97 | 77 | ''' |
78 | Is the given object something that we can consider to be | |
79 | part of the header? | |
80 | ''' | |
56
ed6d39c59e56
fixed BFC INVERTNEXT being interpreted as a header command
Teemu Piippo <teemu@hecknology.net>
parents:
54
diff
changeset
|
81 | if is_invertnext(entry): |
97 | 82 | # It's BFC INVERTNEXT, that's not a header command. |
56
ed6d39c59e56
fixed BFC INVERTNEXT being interpreted as a header command
Teemu Piippo <teemu@hecknology.net>
parents:
54
diff
changeset
|
83 | return False |
97 | 84 | # Check if it's one of the functional linetypes |
47 | 85 | return not any( |
86 | isinstance(entry, linetype) | |
87 | for linetype in [ | |
77
d98502ae1f33
improved header extent scanning
Teemu Piippo <teemu@hecknology.net>
parents:
76
diff
changeset
|
88 | linetypes.SubfileReference, |
d98502ae1f33
improved header extent scanning
Teemu Piippo <teemu@hecknology.net>
parents:
76
diff
changeset
|
89 | linetypes.LineSegment, |
d98502ae1f33
improved header extent scanning
Teemu Piippo <teemu@hecknology.net>
parents:
76
diff
changeset
|
90 | linetypes.Triangle, |
d98502ae1f33
improved header extent scanning
Teemu Piippo <teemu@hecknology.net>
parents:
76
diff
changeset
|
91 | linetypes.Quadrilateral, |
d98502ae1f33
improved header extent scanning
Teemu Piippo <teemu@hecknology.net>
parents:
76
diff
changeset
|
92 | linetypes.ConditionalLine, |
47 | 93 | linetypes.Comment, |
94 | linetypes.Error, | |
95 | ] | |
96 | ) | |
97 | ||
98 | class HeaderError(Exception): | |
97 | 99 | ''' |
100 | An error raised during header parsing | |
101 | ''' | |
47 | 102 | def __init__(self, index, reason): |
103 | self.index, self.reason = index, reason | |
104 | def __repr__(self): | |
105 | return str.format( | |
106 | 'HeaderError({index!r}, {reason!r})', | |
107 | index = self.index, | |
108 | reason = self.reason, | |
109 | ) | |
110 | def __str__(self): | |
111 | return reason | |
112 | ||
48 | 113 | class HistoryEntry: |
97 | 114 | ''' |
115 | Represents a single !HISTORY entry | |
116 | ''' | |
48 | 117 | def __init__(self, date, user, text): |
118 | self.date, self.user, self.text = date, user, text | |
119 | def __repr__(self): | |
120 | return str.format( | |
121 | 'HistoryEntry({date!r}, {user!r}, {text!r})', | |
122 | date = self.date, | |
123 | user = self.user, | |
124 | text = self.text) | |
125 | ||
47 | 126 | class HeaderParser: |
127 | def __init__(self): | |
128 | self.model_body = None | |
129 | self.cursor = 0 | |
130 | self.problems = [] | |
131 | def parse(self, model_body): | |
132 | result = Header() | |
48 | 133 | self.result = result |
47 | 134 | self.order = [] |
135 | self.cursor = -1 | |
136 | self.model_body = model_body | |
137 | self.skip_to_next() | |
138 | result.description = self.current() | |
139 | self.skip_to_next() | |
52
cd2b4f3c1189
fix author parsing getting extra spaces in the name
Teemu Piippo <teemu@hecknology.net>
parents:
49
diff
changeset
|
140 | result.name = self.parse_pattern(r'^Name: (.+)$', 'name')[0] |
47 | 141 | self.skip_to_next() |
97 | 142 | # Parse author line |
143 | result.author, result.username = self.parse_pattern(r'^Author: (?:([^\[]+))?(?:\[([^\]]+)\])?', 'author') | |
144 | if isinstance(result.author, str): | |
145 | # clean leading spaces | |
146 | result.author = str.strip(result.author) | |
76
c73432653fd9
fixed choking on 'Author: [PTAdmin]'-lines
Teemu Piippo <teemu@hecknology.net>
parents:
69
diff
changeset
|
147 | if not result.author and not result.username: |
c73432653fd9
fixed choking on 'Author: [PTAdmin]'-lines
Teemu Piippo <teemu@hecknology.net>
parents:
69
diff
changeset
|
148 | self.parse_error('author line does not contain a name nor username') |
97 | 149 | # use more patterns to parse the rest of the header |
47 | 150 | for header_entry in self.get_more_header_stuff(): |
151 | if self.try_to_match( | |
52
cd2b4f3c1189
fix author parsing getting extra spaces in the name
Teemu Piippo <teemu@hecknology.net>
parents:
49
diff
changeset
|
152 | r'^!LDRAW_ORG ' \ |
49 | 153 | r'((?:Unofficial_)?(?:' \ |
52
cd2b4f3c1189
fix author parsing getting extra spaces in the name
Teemu Piippo <teemu@hecknology.net>
parents:
49
diff
changeset
|
154 | r'Part|' \ |
cd2b4f3c1189
fix author parsing getting extra spaces in the name
Teemu Piippo <teemu@hecknology.net>
parents:
49
diff
changeset
|
155 | r'Subpart|' \ |
cd2b4f3c1189
fix author parsing getting extra spaces in the name
Teemu Piippo <teemu@hecknology.net>
parents:
49
diff
changeset
|
156 | r'Primitive|' \ |
cd2b4f3c1189
fix author parsing getting extra spaces in the name
Teemu Piippo <teemu@hecknology.net>
parents:
49
diff
changeset
|
157 | r'8_Primitive|' \ |
cd2b4f3c1189
fix author parsing getting extra spaces in the name
Teemu Piippo <teemu@hecknology.net>
parents:
49
diff
changeset
|
158 | r'48_Primitive|' \ |
cd2b4f3c1189
fix author parsing getting extra spaces in the name
Teemu Piippo <teemu@hecknology.net>
parents:
49
diff
changeset
|
159 | r'Shortcut' \ |
cd2b4f3c1189
fix author parsing getting extra spaces in the name
Teemu Piippo <teemu@hecknology.net>
parents:
49
diff
changeset
|
160 | r'))\s?' \ |
cd2b4f3c1189
fix author parsing getting extra spaces in the name
Teemu Piippo <teemu@hecknology.net>
parents:
49
diff
changeset
|
161 | r'(.*)$', |
47 | 162 | 'part type'): |
48 | 163 | result.filetype = self.groups[0] |
164 | result.qualifiers = re.findall(r'(?:Physical_Colour|Alias|ORIGINAL|UPDATE \d\d\d\d-\d\d)', self.groups[1]) | |
47 | 165 | elif self.try_to_match( |
52
cd2b4f3c1189
fix author parsing getting extra spaces in the name
Teemu Piippo <teemu@hecknology.net>
parents:
49
diff
changeset
|
166 | r'^!LICENSE (.+)$', |
47 | 167 | 'license'): |
48 | 168 | result.license = self.groups[0] |
169 | elif self.try_to_match( | |
52
cd2b4f3c1189
fix author parsing getting extra spaces in the name
Teemu Piippo <teemu@hecknology.net>
parents:
49
diff
changeset
|
170 | r'BFC (CERTIFY CW|CERTIFY CCW|NOCERTIFY)', |
48 | 171 | 'bfc'): |
172 | result.bfc = self.groups[0] | |
173 | elif self.try_to_match( | |
174 | r'!HISTORY (\d{4}-\d{2}-\d{2}) ([\[{][^\]}]+[\]}]) (.+)$', | |
175 | 'history'): | |
59
0f3e70a2bb4b
report invalid ISO dates instead of crashing
Teemu Piippo <teemu@hecknology.net>
parents:
56
diff
changeset
|
176 | try: |
0f3e70a2bb4b
report invalid ISO dates instead of crashing
Teemu Piippo <teemu@hecknology.net>
parents:
56
diff
changeset
|
177 | time_object = datetime.datetime.strptime( |
0f3e70a2bb4b
report invalid ISO dates instead of crashing
Teemu Piippo <teemu@hecknology.net>
parents:
56
diff
changeset
|
178 | self.groups[0], |
0f3e70a2bb4b
report invalid ISO dates instead of crashing
Teemu Piippo <teemu@hecknology.net>
parents:
56
diff
changeset
|
179 | '%Y-%m-%d', |
0f3e70a2bb4b
report invalid ISO dates instead of crashing
Teemu Piippo <teemu@hecknology.net>
parents:
56
diff
changeset
|
180 | ) |
0f3e70a2bb4b
report invalid ISO dates instead of crashing
Teemu Piippo <teemu@hecknology.net>
parents:
56
diff
changeset
|
181 | except ValueError: |
0f3e70a2bb4b
report invalid ISO dates instead of crashing
Teemu Piippo <teemu@hecknology.net>
parents:
56
diff
changeset
|
182 | self.parse_error("invalid ISO date in history") |
48 | 183 | result.history.append(HistoryEntry( |
59
0f3e70a2bb4b
report invalid ISO dates instead of crashing
Teemu Piippo <teemu@hecknology.net>
parents:
56
diff
changeset
|
184 | date = time_object.date(), |
48 | 185 | user = self.groups[1], |
186 | text = self.groups[2], | |
187 | )) | |
188 | elif self.try_to_match( | |
189 | r'!HELP (.+)', | |
190 | 'help'): | |
191 | if result.help: | |
192 | result.help += '\n' | |
193 | result.help += self.groups[0] | |
194 | elif self.try_to_match( | |
195 | r'!CATEGORY (.+)', | |
196 | 'category'): | |
197 | result.category = self.groups[0] | |
198 | elif self.try_to_match( | |
199 | r'!KEYWORDS (.+)', | |
200 | 'keywords'): | |
201 | if result.keywords: | |
202 | result.keywords += '\n' | |
203 | result.keywords += self.groups[0] | |
204 | elif self.try_to_match( | |
205 | r'!CMDLINE (.+)', | |
206 | 'cmdline'): | |
207 | result.cmdline = self.groups[0] | |
47 | 208 | else: |
77
d98502ae1f33
improved header extent scanning
Teemu Piippo <teemu@hecknology.net>
parents:
76
diff
changeset
|
209 | self.cursor -= 1 |
d98502ae1f33
improved header extent scanning
Teemu Piippo <teemu@hecknology.net>
parents:
76
diff
changeset
|
210 | break |
67
afaa4d3bc3e5
complain if LDRAW_ORG line is missing
Teemu Piippo <teemu@hecknology.net>
parents:
60
diff
changeset
|
211 | if not result.filetype: |
afaa4d3bc3e5
complain if LDRAW_ORG line is missing
Teemu Piippo <teemu@hecknology.net>
parents:
60
diff
changeset
|
212 | self.parse_error('LDRAW_ORG line is missing') |
47 | 213 | return { |
214 | 'header': result, | |
97 | 215 | 'end-index': self.cursor + 1, # record where the header ended |
47 | 216 | } |
217 | def parse_error(self, message): | |
218 | raise HeaderError(index = self.cursor, reason = message) | |
219 | def get_more_header_stuff(self): | |
97 | 220 | ''' |
221 | Iterates through the header and yields metacommand entries | |
222 | one after the other. | |
223 | ''' | |
77
d98502ae1f33
improved header extent scanning
Teemu Piippo <teemu@hecknology.net>
parents:
76
diff
changeset
|
224 | self.cursor += 1 |
d98502ae1f33
improved header extent scanning
Teemu Piippo <teemu@hecknology.net>
parents:
76
diff
changeset
|
225 | new_cursor = self.cursor |
d98502ae1f33
improved header extent scanning
Teemu Piippo <teemu@hecknology.net>
parents:
76
diff
changeset
|
226 | while new_cursor < len(self.model_body): |
d98502ae1f33
improved header extent scanning
Teemu Piippo <teemu@hecknology.net>
parents:
76
diff
changeset
|
227 | entry = self.model_body[new_cursor] |
47 | 228 | if not is_suitable_header_object(entry): |
97 | 229 | # looks like the header ended |
47 | 230 | break |
231 | if isinstance(entry, linetypes.MetaCommand): | |
77
d98502ae1f33
improved header extent scanning
Teemu Piippo <teemu@hecknology.net>
parents:
76
diff
changeset
|
232 | self.cursor = new_cursor |
47 | 233 | yield entry |
77
d98502ae1f33
improved header extent scanning
Teemu Piippo <teemu@hecknology.net>
parents:
76
diff
changeset
|
234 | new_cursor += 1 |
47 | 235 | def skip_to_next(self, *, spaces_expected = 0): |
97 | 236 | ''' |
237 | Skip to the next header line. | |
238 | ''' | |
47 | 239 | while True: |
240 | if self.cursor + 1 >= len(self.model_body): | |
97 | 241 | # wound up past the end of model |
54
0c686d10eb49
added tests for moved-to files and scaling in flat dimensions
Teemu Piippo <teemu@hecknology.net>
parents:
52
diff
changeset
|
242 | self.parse_error('file does not have a proper header') |
47 | 243 | self.cursor += 1 |
244 | entry = self.model_body[self.cursor] | |
245 | if not is_suitable_header_object(entry): | |
246 | self.parse_error('header is incomplete') | |
247 | if isinstance(entry, linetypes.MetaCommand): | |
248 | return | |
249 | def try_to_match(self, pattern, patterntype): | |
97 | 250 | ''' |
251 | Tries to parse the specified pattern and to store the groups in | |
252 | self.groups. Returns whether or not this succeeded. | |
253 | ''' | |
47 | 254 | try: |
255 | self.groups = self.parse_pattern(pattern, patterntype) | |
48 | 256 | return True |
47 | 257 | except: |
258 | return False | |
259 | def current(self): | |
97 | 260 | ''' |
261 | Returns the text of the header line we're currently processing. | |
262 | ''' | |
47 | 263 | entry = self.model_body[self.cursor] |
264 | assert isinstance(entry, linetypes.MetaCommand) | |
265 | return entry.text | |
266 | def parse_pattern(self, pattern, description): | |
97 | 267 | ''' |
268 | Matches the current header line against the specified pattern. | |
269 | If not, raises an exception. See try_to_match for a softer wrapper | |
270 | that does not raise exceptions. | |
271 | ''' | |
47 | 272 | match = re.search(pattern, self.current()) |
273 | if match: | |
274 | self.order.append(description) | |
48 | 275 | if description not in self.result.first_occurrence: |
276 | self.result.first_occurrence[description] = self.cursor | |
47 | 277 | return match.groups() |
278 | else: | |
279 | self.parse_error(str.format("couldn't parse {}", description)) |