Files
create/src/Mod/AddonManager/addonmanager_macro.py
Chris Hennes e9d6a2c4a4 Addon Manager: Create NetworkManager class
To enable single-login authenticated proxy use, and simplified multi-threaded
network accesses, this commit adds a new wrapper around a QNetworkAccessManager
and includes a global instantiation of the class intended to exist for the
lifetime of the program. This instance can be used to enqueue any number of
network requests, which the manager will send out to the networking subsystem
in an appropriate manner.
2022-01-25 13:30:58 -06:00

333 lines
12 KiB
Python

# -*- coding: utf-8 -*-
# ***************************************************************************
# * *
# * Copyright (c) 2018 Gaël Écorchard <galou_breizh@yahoo.fr> *
# * *
# * This program is free software; you can redistribute it and/or modify *
# * it under the terms of the GNU Lesser General Public License (LGPL) *
# * as published by the Free Software Foundation; either version 2 of *
# * the License, or (at your option) any later version. *
# * for detail see the LICENCE text file. *
# * *
# * This program 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 Library General Public License for more details. *
# * *
# * You should have received a copy of the GNU Library General Public *
# * License along with this program; if not, write to the Free Software *
# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 *
# * USA *
# * *
# ***************************************************************************
import os
import re
import io
import codecs
import shutil
import time
from typing import Dict, Tuple, List, Union
import FreeCAD
from NetworkManager import AM_NETWORK_MANAGER
translate = FreeCAD.Qt.translate
from addonmanager_utilities import remove_directory_if_empty
try:
from HTMLParser import HTMLParser
unescape = HTMLParser().unescape
except ImportError:
from html import unescape
# @package AddonManager_macro
# \ingroup ADDONMANAGER
# \brief Unified handler for FreeCAD macros that can be obtained from
# different sources
# @{
class Macro(object):
"""This class provides a unified way to handle macros coming from different sources"""
def __init__(self, name):
self.name = name
self.on_wiki = False
self.on_git = False
self.desc = ""
self.comment = ""
self.code = ""
self.url = ""
self.version = ""
self.date = ""
self.src_filename = ""
self.author = ""
self.other_files = []
self.parsed = False
def __eq__(self, other):
return self.filename == other.filename
@classmethod
def from_cache(self, cache_dict: Dict):
instance = Macro(cache_dict["name"])
for key, value in cache_dict.items():
instance.__dict__[key] = value
return instance
def to_cache(self) -> Dict:
"""For cache purposes this entire class is dumped directly"""
return self.__dict__
@property
def filename(self):
if self.on_git:
return os.path.basename(self.src_filename)
return (self.name + ".FCMacro").replace(" ", "_")
def is_installed(self):
if self.on_git and not self.src_filename:
return False
return os.path.exists(
os.path.join(FreeCAD.getUserMacroDir(True), self.filename)
) or os.path.exists(
os.path.join(FreeCAD.getUserMacroDir(True), "Macro_" + self.filename)
)
def fill_details_from_file(self, filename: str) -> None:
with open(filename, errors="replace") as f:
self.code = f.read()
self.fill_details_from_code(self.code)
def fill_details_from_code(self, code: str) -> None:
# Number of parsed fields of metadata. Overrides anything set previously (the code is considered authoritative).
# For now:
# __Comment__
# __Web__
# __Version__
# __Files__
# __Author__
# __Date__
max_lines_to_search = 50
line_counter = 0
number_of_fields = 5
ic = re.IGNORECASE # Shorten the line for Black
re_comment = re.compile(r"^__Comment__\s*=\s*(['\"])(.*)\1", flags=ic)
re_url = re.compile(r"^__Web__\s*=\s*(['\"])(.*)\1", flags=ic)
re_version = re.compile(r"^__Version__\s*=\s*(['\"])(.*)\1", flags=ic)
re_files = re.compile(r"^__Files__\s*=\s*(['\"])(.*)\1", flags=ic)
re_author = re.compile(r"^__Author__\s*=\s*(['\"])(.*)\1", flags=ic)
re_date = re.compile(r"^__Date__\s*=\s*(['\"])(.*)\1", flags=ic)
f = io.StringIO(code)
while f and line_counter < max_lines_to_search:
line = f.readline()
line_counter += 1
if not line.startswith(
"__"
): # Speed things up a bit... this comparison is very cheap
continue
match = re.match(re_comment, line)
if match:
self.comment = match.group(2)
self.comment = re.sub("<.*?>", "", self.comment) # Strip any HTML tags
number_of_fields -= 1
match = re.match(re_author, line)
if match:
self.author = match.group(2)
number_of_fields -= 1
match = re.match(re_url, line)
if match:
self.url = match.group(2)
number_of_fields -= 1
match = re.match(re_version, line)
if match:
self.version = match.group(2)
number_of_fields -= 1
match = re.match(re_date, line)
if match:
self.date = match.group(2)
number_of_fields -= 1
match = re.match(re_files, line)
if match:
self.other_files = [of.strip() for of in match.group(2).split(",")]
number_of_fields -= 1
if number_of_fields <= 0:
break
# Truncate long comments to speed up searches, and clean up display
if len(self.comment) > 512:
self.comment = self.comment[:511] + ""
self.parsed = True
def fill_details_from_wiki(self, url):
code = ""
p = AM_NETWORK_MANAGER.blocking_get(url)
if not p:
FreeCAD.Console.PrintWarning(
translate(
"AddonsInstaller",
f"Unable to open macro wiki page at {url}",
)
+ "\n"
)
return
p = p.data().decode("utf8")
# check if the macro page has its code hosted elsewhere, download if
# needed
if "rawcodeurl" in p:
rawcodeurl = re.findall('rawcodeurl.*?href="(http.*?)">', p)
if rawcodeurl:
rawcodeurl = rawcodeurl[0]
u2 = AM_NETWORK_MANAGER.blocking_get(rawcodeurl)
if not u2:
FreeCAD.Console.PrintWarning(
translate(
"AddonsInstaller",
f"Unable to open macro code URL {rawcodeurl}",
)
+ "\n"
)
return
code = u2.data().decode("utf8")
if not code:
code = re.findall(r"<pre>(.*?)</pre>", p.replace("\n", "--endl--"))
if code:
# take the biggest code block
code = sorted(code, key=len)[-1]
code = code.replace("--endl--", "\n")
# Clean HTML escape codes.
code = unescape(code)
code = code.replace(b"\xc2\xa0".decode("utf-8"), " ")
else:
FreeCAD.Console.PrintWarning(
translate(
"AddonsInstaller", "Unable to fetch the code of this macro."
)
+ "\n"
)
desc = re.findall(
r"<td class=\"ctEven left macro-description\">(.*?)</td>",
p.replace("\n", " "),
)
if desc:
desc = desc[0]
else:
FreeCAD.Console.PrintWarning(
translate(
"AddonsInstaller",
f"Unable to retrieve a description from the wiki for macro {self.name}",
)
+ "\n"
)
desc = "No description available"
self.desc = desc
self.comment, _, _ = desc.partition("<br") # Up to the first line break
self.comment = re.sub("<.*?>", "", self.comment) # Strip any tags
self.url = url
if isinstance(code, list):
flat_code = ""
for chunk in code:
flat_code += chunk
code = flat_code
self.code = code
self.fill_details_from_code(self.code)
if not self.author:
self.author = self.parse_desc("Author: ")
if not self.date:
self.date = self.parse_desc("Last modified: ")
def parse_desc(self, line_start: str) -> Union[str, None]:
components = self.desc.split(">")
for component in components:
if component.startswith(line_start):
end = component.find("<")
return component[len(line_start) : end]
def install(self, macro_dir: str) -> Tuple[bool, List[str]]:
"""Install a macro and all its related files
Returns True if the macro was installed correctly.
Parameters
----------
- macro_dir: the directory to install into
"""
if not self.code:
return False, ["No code"]
if not os.path.isdir(macro_dir):
try:
os.makedirs(macro_dir)
except OSError:
return False, [f"Failed to create {macro_dir}"]
macro_path = os.path.join(macro_dir, self.filename)
try:
with codecs.open(macro_path, "w", "utf-8") as macrofile:
macrofile.write(self.code)
except IOError:
return False, [f"Failed to write {macro_path}"]
# Copy related files, which are supposed to be given relative to
# self.src_filename.
base_dir = os.path.dirname(self.src_filename)
warnings = []
for other_file in self.other_files:
dst_dir = os.path.join(macro_dir, os.path.dirname(other_file))
if not os.path.isdir(dst_dir):
try:
os.makedirs(dst_dir)
except OSError:
return False, [f"Failed to create {dst_dir}"]
src_file = os.path.join(base_dir, other_file)
dst_file = os.path.join(macro_dir, other_file)
try:
shutil.copy(src_file, dst_file)
except IOError:
warnings.append(f"Failed to copy {src_file} to {dst_file}")
if len(warnings) > 0:
return False, warnings
FreeCAD.Console.PrintLog(f"Macro {self.name} was installed successfully.\n")
return True, []
def remove(self) -> bool:
"""Remove a macro and all its related files
Returns True if the macro was removed correctly.
"""
if not self.is_installed():
# Macro not installed, nothing to do.
return True
macro_dir = FreeCAD.getUserMacroDir(True)
macro_path = os.path.join(macro_dir, self.filename)
macro_path_with_macro_prefix = os.path.join(macro_dir, "Macro_" + self.filename)
if os.path.exists(macro_path):
os.remove(macro_path)
elif os.path.exists(macro_path_with_macro_prefix):
os.remove(macro_path_with_macro_prefix)
# Remove related files, which are supposed to be given relative to
# self.src_filename.
for other_file in self.other_files:
dst_file = os.path.join(macro_dir, other_file)
try:
os.remove(dst_file)
remove_directory_if_empty(os.path.dirname(dst_file))
except Exception:
FreeCAD.Console.PrintWarning(
translate(
"AddonsInstaller",
f"Failed to remove macro file '{dst_file}': it might not exist, or its permissions changed",
)
+ "\n"
)
return True
# @}