-
Notifications
You must be signed in to change notification settings - Fork 1
/
Copy pathaddonmanager_macro_parser.py
257 lines (223 loc) · 10.3 KB
/
addonmanager_macro_parser.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
# SPDX-License-Identifier: LGPL-2.1-or-later
# ***************************************************************************
# * *
# * Copyright (c) 2023 FreeCAD Project Association *
# * *
# * This file is part of FreeCAD. *
# * *
# * FreeCAD is free software: you can redistribute it and/or modify it *
# * under the terms of the GNU Lesser General Public License as *
# * published by the Free Software Foundation, either version 2.1 of the *
# * License, or (at your option) any later version. *
# * *
# * FreeCAD is distributed in the hope that it will be useful, but *
# * WITHOUT ANY WARRANTY; without even the implied warranty of *
# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
# * Lesser General Public License for more details. *
# * *
# * You should have received a copy of the GNU Lesser General Public *
# * License along with FreeCAD. If not, see *
# * <https://www.gnu.org/licenses/>. *
# * *
# ***************************************************************************
"""Contains the parser class for extracting metadata from a FreeCAD macro"""
import datetime
# pylint: disable=too-few-public-methods
import io
import re
from typing import Any, Tuple, Optional
try:
from PySide import QtCore
except ImportError:
QtCore = None
try:
import FreeCAD
from addonmanager_licenses import get_license_manager
except ImportError:
FreeCAD = None
get_license_manager = None
class DummyThread:
@classmethod
def isInterruptionRequested(cls):
return False
class MacroParser:
"""Extracts metadata information from a FreeCAD macro"""
MAX_LINES_TO_SEARCH = 200 # To speed up parsing: some files are VERY large
def __init__(self, name: str, code: str = ""):
"""Create a parser for the macro named "name". Note that the name is only
used as the context for error messages, it is not otherwise important."""
self.name = name
self.parse_results = {
"comment": "",
"url": "",
"wiki": "",
"version": "",
"other_files": [""],
"author": "",
"date": "",
"license": "",
"icon": "",
"xpm": "",
}
self.remaining_item_map = {}
self.console = None if FreeCAD is None else FreeCAD.Console
self.current_thread = DummyThread() if QtCore is None else QtCore.QThread.currentThread()
if code:
self.fill_details_from_code(code)
def _reset_map(self):
"""This map tracks which items we've already read. If the same parser is used
twice, it has to be reset."""
self.remaining_item_map = {
"__comment__": "comment",
"__web__": "url",
"__wiki__": "wiki",
"__version__": "version",
"__files__": "other_files",
"__author__": "author",
"__date__": "date",
"__license__": "license",
"__licence__": "license", # accept either spelling
"__icon__": "icon",
"__xpm__": "xpm",
}
def fill_details_from_code(self, code: str) -> None:
"""Reads in the macro code from the given string and parses it for its
metadata."""
self._reset_map()
line_counter = 0
content_lines = io.StringIO(code)
while content_lines and line_counter < self.MAX_LINES_TO_SEARCH:
line = content_lines.readline()
if not line:
break
if self.current_thread.isInterruptionRequested():
return
line_counter += 1
if not line.startswith("__"):
# Speed things up a bit... this comparison is very cheap
continue
try:
self._process_line(line, content_lines)
except SyntaxError as e:
err_string = f"Syntax error when parsing macro {self.name}:\n{str(e)}"
if self.console:
self.console.PrintWarning(err_string)
else:
print(err_string)
def _process_line(self, line: str, content_lines: io.StringIO):
"""Given a single line of the macro file, see if it matches one of our items,
and if so, extract the data."""
lowercase_line = line.lower()
for key in self.remaining_item_map:
if lowercase_line.startswith(key):
self._process_key(key, line, content_lines)
break
def _process_key(self, key: str, line: str, content_lines: io.StringIO):
"""Given a line that starts with a known key, extract the data for that key,
possibly reading in additional lines (if it contains a line continuation
character, or is a triple-quoted string)."""
line = self._handle_backslash_continuation(line, content_lines)
line, was_triple_quoted = self._handle_triple_quoted_string(line, content_lines)
_, _, line = line.partition("=")
if not was_triple_quoted:
line, _, _ = line.partition("#")
self._detect_illegal_content(line)
final_content_line = line.strip()
stripped_of_quotes = self._strip_quotes(final_content_line)
if stripped_of_quotes is not None:
self._standard_extraction(self.remaining_item_map[key], stripped_of_quotes)
self.remaining_item_map.pop(key)
else:
self._apply_special_handling(key, line)
@staticmethod
def _handle_backslash_continuation(line, content_lines) -> str:
while line.strip().endswith("\\"):
line = line.strip()[:-1]
concat_line = content_lines.readline()
line += concat_line.strip()
return line
@staticmethod
def _handle_triple_quoted_string(line, content_lines) -> Tuple[str, bool]:
result = line
was_triple_quoted = False
if '"""' in result:
was_triple_quoted = True
while True:
new_line = content_lines.readline()
if not new_line:
raise SyntaxError("Syntax error while reading macro")
if '"""' in new_line:
last_line, _, _ = new_line.partition('"""')
result += last_line + '"""'
break
result += new_line
return result, was_triple_quoted
@staticmethod
def _strip_quotes(line) -> str:
line = line.strip()
stripped_of_quotes = None
if line.startswith('"""') and line.endswith('"""'):
stripped_of_quotes = line[3:-3]
elif (line[0] == '"' and line[-1] == '"') or (line[0] == "'" and line[-1] == "'"):
stripped_of_quotes = line[1:-1]
return stripped_of_quotes
def _standard_extraction(self, value: str, match_group: str):
"""For most macro metadata values, this extracts the required data"""
if isinstance(self.parse_results[value], str):
self.parse_results[value] = match_group
if value == "comment":
self._cleanup_comment()
elif value == "license":
self._cleanup_license()
elif isinstance(self.parse_results[value], list):
self.parse_results[value] = [of.strip() for of in match_group.split(",")]
else:
raise SyntaxError(f"Conflicting data type for {value}")
def _cleanup_comment(self):
"""Remove HTML from the comment line, and truncate it at 512 characters."""
self.parse_results["comment"] = re.sub(r"<.*?>", "", self.parse_results["comment"])
if len(self.parse_results["comment"]) > 512:
self.parse_results["comment"] = self.parse_results["comment"][:511] + "…"
def _cleanup_license(self):
if get_license_manager is not None:
lm = get_license_manager()
self.parse_results["license"] = lm.normalize(self.parse_results["license"])
def _apply_special_handling(self, key: str, line: str):
# Macro authors are supposed to be providing strings here, but in some
# cases they are not doing so. If this is the "__version__" tag, try
# to apply some special handling to accept numbers, and "__date__"
if key == "__version__":
self._process_noncompliant_version(line)
self.remaining_item_map.pop(key)
return
raise SyntaxError(f"Failed to process {key} from {line}")
def _process_noncompliant_version(self, after_equals):
if is_float(after_equals):
self.parse_results["version"] = str(after_equals).strip()
elif "__date__" in after_equals.lower() and self.parse_results["date"]:
self.parse_results["version"] = self.parse_results["date"]
else:
self.parse_results["version"] = "(Unknown)"
raise SyntaxError(f"Unrecognized version string {after_equals}")
@staticmethod
def _detect_illegal_content(line: str):
"""Raise a syntax error if this line contains something we can't handle"""
lower_line = line.strip().lower()
if lower_line.startswith("'") and lower_line.endswith("'"):
return
if lower_line.startswith('"') and lower_line.endswith('"'):
return
if is_float(lower_line):
return
if lower_line == "__date__":
return
raise SyntaxError(f"Metadata is expected to be a static string, but got {line}")
# Borrowed from Stack Overflow:
# https://stackoverflow.com/questions/736043/checking-if-a-string-can-be-converted-to-float
def is_float(element: Any) -> bool:
"""Determine whether a given item can be converted to a floating-point number"""
try:
float(element)
return True
except ValueError:
return False