freecad-cam/Mod/AddonManager/addonmanager_macro_parser.py
2026-02-01 01:59:24 +01:00

258 lines
10 KiB
Python

# 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