FreeCAD
439 строк · 17.8 Кб
1# SPDX-License-Identifier: LGPL-2.1-or-later
2# ***************************************************************************
3# * *
4# * Copyright (c) 2022-2023 FreeCAD Project Association *
5# * Copyright (c) 2018 Gaël Écorchard <galou_breizh@yahoo.fr> *
6# * *
7# * This file is part of FreeCAD. *
8# * *
9# * FreeCAD is free software: you can redistribute it and/or modify it *
10# * under the terms of the GNU Lesser General Public License as *
11# * published by the Free Software Foundation, either version 2.1 of the *
12# * License, or (at your option) any later version. *
13# * *
14# * FreeCAD is distributed in the hope that it will be useful, but *
15# * WITHOUT ANY WARRANTY; without even the implied warranty of *
16# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
17# * Lesser General Public License for more details. *
18# * *
19# * You should have received a copy of the GNU Lesser General Public *
20# * License along with FreeCAD. If not, see *
21# * <https://www.gnu.org/licenses/>. *
22# * *
23# ***************************************************************************
24
25""" Unified handler for FreeCAD macros that can be obtained from different sources. """
26
27import os28import re29import io30import codecs31import shutil32from html import unescape33from typing import Dict, Tuple, List, Union, Optional34import urllib.parse35
36from addonmanager_macro_parser import MacroParser37import addonmanager_utilities as utils38
39import addonmanager_freecad_interface as fci40
41translate = fci.translate42
43
44# @package AddonManager_macro
45# \ingroup ADDONMANAGER
46# \brief Unified handler for FreeCAD macros that can be obtained from
47# different sources
48# @{
49
50
51class Macro:52"""This class provides a unified way to handle macros coming from different53sources"""
54
55# Use a stored class variable for this so that we can override it during testing56blocking_get = None57
58# pylint: disable=too-many-instance-attributes59def __init__(self, name):60self.name = name61self.on_wiki = False62self.on_git = False63self.desc = ""64self.comment = ""65self.code = ""66self.url = ""67self.raw_code_url = ""68self.wiki = ""69self.version = ""70self.license = ""71self.date = ""72self.src_filename = ""73self.filename_from_url = ""74self.author = ""75self.icon = ""76self.icon_source = None77self.xpm = "" # Possible alternate icon data78self.other_files = []79self.parsed = False80self._console = fci.Console81if Macro.blocking_get is None:82Macro.blocking_get = utils.blocking_get83
84def __eq__(self, other):85return self.filename == other.filename86
87@classmethod88def from_cache(cls, cache_dict: Dict):89"""Use data from the cache dictionary to create a new macro, returning a90reference to it."""
91instance = Macro(cache_dict["name"])92for key, value in cache_dict.items():93instance.__dict__[key] = value94return instance95
96def to_cache(self) -> Dict:97"""For cache purposes all public members of the class are returned"""98cache_dict = {}99for key, value in self.__dict__.items():100if key[0] != "_":101cache_dict[key] = value102return cache_dict103
104@property105def filename(self):106"""The filename of this macro"""107if self.on_git:108return os.path.basename(self.src_filename)109elif self.filename_from_url:110return self.filename_from_url111return (self.name + ".FCMacro").replace(" ", "_")112
113def is_installed(self):114"""Returns True if this macro is currently installed (that is, if it exists115in the user macro directory), or False if it is not. Both the exact filename
116and the filename prefixed with "Macro", are considered an installation
117of this macro.
118"""
119if self.on_git and not self.src_filename:120return False121return os.path.exists(122os.path.join(fci.DataPaths().macro_dir, self.filename)123) or os.path.exists(os.path.join(fci.DataPaths().macro_dir, "Macro_" + self.filename))124
125def fill_details_from_file(self, filename: str) -> None:126"""Opens the given Macro file and parses it for its metadata"""127with open(filename, errors="replace", encoding="utf-8") as f:128self.code = f.read()129self.fill_details_from_code(self.code)130
131def fill_details_from_code(self, code: str) -> None:132"""Read the passed-in code and parse it for known metadata elements"""133parser = MacroParser(self.name, code)134for key, value in parser.parse_results.items():135if value:136self.__dict__[key] = value137self.clean_icon()138self.parsed = True139
140def fill_details_from_wiki(self, url):141"""For a given URL, download its data and attempt to get the macro's metadata142out of it. If the macro's code is hosted elsewhere, as specified by a
143"rawcodeurl" found on the wiki page, that code is downloaded and used as the
144source."""
145code = ""146p = Macro.blocking_get(url)147if not p:148self._console.PrintWarning(149translate(150"AddonsInstaller",151"Unable to open macro wiki page at {}",152).format(url)153+ "\n"154)155return156p = p.decode("utf8")157# check if the macro page has its code hosted elsewhere, download if158# needed159if "rawcodeurl" in p:160code = self._fetch_raw_code(p)161if not code:162code = self._read_code_from_wiki(p)163if not code:164self._console.PrintWarning(165translate("AddonsInstaller", "Unable to fetch the code of this macro.") + "\n"166)167return168
169desc = re.findall(170r"<td class=\"ctEven left macro-description\">(.*?)</td>",171p.replace("\n", " "),172)173if desc:174desc = desc[0]175else:176self._console.PrintWarning(177translate(178"AddonsInstaller",179"Unable to retrieve a description from the wiki for macro {}",180).format(self.name)181+ "\n"182)183desc = "No description available"184self.desc = desc185self.comment, _, _ = desc.partition("<br") # Up to the first line break186self.comment = re.sub("<.*?>", "", self.comment) # Strip any tags187self.url = url188if isinstance(code, list):189code = "".join(code)190self.code = code191self.fill_details_from_code(self.code)192if not self.icon and not self.xpm:193self.parse_wiki_page_for_icon(p)194self.clean_icon()195
196if not self.author:197self.author = self.parse_desc("Author: ")198if not self.date:199self.date = self.parse_desc("Last modified: ")200
201def _fetch_raw_code(self, page_data) -> Optional[str]:202"""Fetch code from the raw code URL specified on the wiki page."""203code = None204self.raw_code_url = re.findall('rawcodeurl.*?href="(http.*?)">', page_data)205if self.raw_code_url:206self.raw_code_url = self.raw_code_url[0]207u2 = Macro.blocking_get(self.raw_code_url)208if not u2:209self._console.PrintWarning(210translate(211"AddonsInstaller",212"Unable to open macro code URL {}",213).format(self.raw_code_url)214+ "\n"215)216return None217code = u2.decode("utf8")218self._set_filename_from_url(self.raw_code_url)219return code220
221def _set_filename_from_url(self, url: str):222lhs, slash, rhs = url.rpartition("/")223if rhs.endswith(".py") or rhs.lower().endswith(".fcmacro"):224self.filename_from_url = rhs225
226@staticmethod227def _read_code_from_wiki(p: str) -> Optional[str]:228code = re.findall(r"<pre>(.*?)</pre>", p.replace("\n", "--endl--"))229if code:230# take the biggest code block231code = str(sorted(code, key=len)[-1])232code = code.replace("--endl--", "\n")233# Clean HTML escape codes.234code = unescape(code)235code = code.replace(b"\xc2\xa0".decode("utf-8"), " ")236return code237
238def clean_icon(self):239"""Downloads the macro's icon from whatever source is specified and stores a local240copy, potentially updating the internal icon location to that local storage."""
241if self.icon.startswith("http://") or self.icon.startswith("https://"):242self._console.PrintLog(f"Attempting to fetch macro icon from {self.icon}\n")243parsed_url = urllib.parse.urlparse(self.icon)244p = Macro.blocking_get(self.icon)245if p:246cache_path = fci.DataPaths().cache_dir247am_path = os.path.join(cache_path, "AddonManager", "MacroIcons")248os.makedirs(am_path, exist_ok=True)249_, _, filename = parsed_url.path.rpartition("/")250base, _, extension = filename.rpartition(".")251if base.lower().startswith("file:"):252self._console.PrintMessage(253f"Cannot use specified icon for {self.name}, {self.icon} "254"is not a direct download link\n"255)256self.icon = ""257else:258constructed_name = os.path.join(am_path, base + "." + extension)259with open(constructed_name, "wb") as f:260f.write(p)261self.icon_source = self.icon262self.icon = constructed_name263else:264self._console.PrintLog(265f"MACRO DEVELOPER WARNING: failed to download icon from {self.icon}"266f" for macro {self.name}\n"267)268self.icon = ""269
270def parse_desc(self, line_start: str) -> Union[str, None]:271"""Get data from the wiki for the value specified by line_start."""272components = self.desc.split(">")273for component in components:274if component.startswith(line_start):275end = component.find("<")276return component[len(line_start) : end]277return None278
279def install(self, macro_dir: str) -> Tuple[bool, List[str]]:280"""Install a macro and all its related files281Returns True if the macro was installed correctly.
282Parameters
283----------
284- macro_dir: the directory to install into
285"""
286
287if not self.code:288return False, ["No code"]289if not os.path.isdir(macro_dir):290try:291os.makedirs(macro_dir)292except OSError:293return False, [f"Failed to create {macro_dir}"]294macro_path = os.path.join(macro_dir, self.filename)295try:296with codecs.open(macro_path, "w", "utf-8") as macrofile:297macrofile.write(self.code)298except OSError:299return False, [f"Failed to write {macro_path}"]300# Copy related files, which are supposed to be given relative to301# self.src_filename.302warnings = []303
304self._copy_icon_data(macro_dir, warnings)305success = self._copy_other_files(macro_dir, warnings)306
307if warnings or not success > 0:308return False, warnings309
310self._console.PrintLog(f"Macro {self.name} was installed successfully.\n")311return True, []312
313def _copy_icon_data(self, macro_dir, warnings):314"""Copy any available icon data into the install directory"""315base_dir = os.path.dirname(self.src_filename)316if self.xpm:317xpm_file = os.path.join(base_dir, self.name + "_icon.xpm")318with open(xpm_file, "w", encoding="utf-8") as f:319f.write(self.xpm)320if self.icon:321if os.path.isabs(self.icon):322dst_file = os.path.normpath(os.path.join(macro_dir, os.path.basename(self.icon)))323try:324shutil.copy(self.icon, dst_file)325except OSError:326warnings.append(f"Failed to copy icon to {dst_file}")327elif self.icon not in self.other_files:328self.other_files.append(self.icon)329
330def _copy_other_files(self, macro_dir, warnings) -> bool:331"""Copy any specified "other files" into the installation directory"""332base_dir = os.path.dirname(self.src_filename)333for other_file in self.other_files:334if not other_file:335continue336if os.path.isabs(other_file):337dst_dir = macro_dir338else:339dst_dir = os.path.join(macro_dir, os.path.dirname(other_file))340if not os.path.isdir(dst_dir):341try:342os.makedirs(dst_dir)343except OSError:344warnings.append(f"Failed to create {dst_dir}")345return False346if os.path.isabs(other_file):347src_file = other_file348dst_file = os.path.normpath(os.path.join(macro_dir, os.path.basename(other_file)))349else:350src_file = os.path.normpath(os.path.join(base_dir, other_file))351dst_file = os.path.normpath(os.path.join(macro_dir, other_file))352self._fetch_single_file(other_file, src_file, dst_file, warnings)353try:354shutil.copy(src_file, dst_file)355except OSError:356warnings.append(f"Failed to copy {src_file} to {dst_file}")357return True # No fatal errors, but some files may have failed to copy358
359def _fetch_single_file(self, other_file, src_file, dst_file, warnings):360if not os.path.isfile(src_file):361# If the file does not exist, see if we have a raw code URL to fetch from362if self.raw_code_url:363fetch_url = self.raw_code_url.rsplit("/", 1)[0] + "/" + other_file364self._console.PrintLog(f"Attempting to fetch {fetch_url}...\n")365p = Macro.blocking_get(fetch_url)366if p:367with open(dst_file, "wb") as f:368f.write(p)369else:370self._console.PrintWarning(371translate(372"AddonsInstaller",373"Unable to fetch macro-specified file {} from {}",374).format(other_file, fetch_url)375+ "\n"376)377else:378warnings.append(379translate(380"AddonsInstaller",381"Could not locate macro-specified file {} (expected at {})",382).format(other_file, src_file)383)384
385def parse_wiki_page_for_icon(self, page_data: str) -> None:386"""Attempt to find the url for the icon in the wiki page. Sets 'self.icon' if387found."""
388
389# Method 1: the text "toolbar icon" appears on the page, and provides a direct390# link to an icon391
392# pylint: disable=line-too-long393# Try to get an icon from the wiki page itself:394# <a rel="nofollow" class="external text"395# href="https://wiki.freecad.org/images/f/f5/blah.png">ToolBar Icon</a>396icon_regex = re.compile(r'.*href="(.*?)">ToolBar Icon', re.IGNORECASE)397wiki_icon = ""398if "ToolBar Icon" in page_data:399f = io.StringIO(page_data)400lines = f.readlines()401for line in lines:402if ">ToolBar Icon<" in line:403match = icon_regex.match(line)404if match:405wiki_icon = match.group(1)406if "file:" not in wiki_icon.lower():407self.icon = wiki_icon408return409break410
411# See if we found an icon, but it wasn't a direct link:412icon_regex = re.compile(r'.*img.*?src="(.*?)"', re.IGNORECASE)413if wiki_icon.startswith("http"):414# It's a File: wiki link. We can load THAT page and get the image from it...415self._console.PrintLog(f"Found a File: link for macro {self.name} -- {wiki_icon}\n")416p = Macro.blocking_get(wiki_icon)417if p:418p = p.decode("utf8")419f = io.StringIO(p)420lines = f.readlines()421trigger = False422for line in lines:423if trigger:424match = icon_regex.match(line)425if match:426wiki_icon = match.group(1)427self.icon = "https://wiki.freecad.org/" + wiki_icon428return429elif "fullImageLink" in line:430trigger = True431
432# <div class="fullImageLink" id="file">433# <a href="/images/a/a2/Bevel.svg">434# <img alt="File:Bevel.svg" src="/images/a/a2/Bevel.svg"435# width="64" height="64"/>436# </a>437
438
439# @}
440