# 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 * # * . * # * * # *************************************************************************** """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