Files
create/src/Mod/AddonManager/addonmanager_utilities.py
2022-08-21 14:32:15 -05:00

391 lines
14 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 platform
import shutil
import re
import ctypes
from typing import Union, Optional, Any
from urllib.parse import urlparse
from PySide2 import QtCore, QtWidgets
import FreeCAD
import FreeCADGui
# @package AddonManager_utilities
# \ingroup ADDONMANAGER
# \brief Utilities to work across different platforms, providers and python versions
# @{
translate = FreeCAD.Qt.translate
def symlink(source, link_name):
"""Creates a symlink of a file, if possible. Note that it fails on most modern Windows installations"""
if os.path.exists(link_name) or os.path.lexists(link_name):
pass
else:
os_symlink = getattr(os, "symlink", None)
if callable(os_symlink):
os_symlink(source, link_name)
else:
# NOTE: This does not work on most normal Windows 10 and later installations, unless developer
# mode is turned on. Make sure to catch any exception thrown and have a fallback plan.
csl = ctypes.windll.kernel32.CreateSymbolicLinkW
csl.argtypes = (ctypes.c_wchar_p, ctypes.c_wchar_p, ctypes.c_uint32)
csl.restype = ctypes.c_ubyte
flags = 1 if os.path.isdir(source) else 0
# set the SYMBOLIC_LINK_FLAG_ALLOW_UNPRIVILEGED_CREATE flag
# (see https://blogs.windows.com/buildingapps/2016/12/02/symlinks-windows-10/#joC5tFKhdXs2gGml.97)
flags += 2
if csl(link_name, source, flags) == 0:
raise ctypes.WinError()
def update_macro_details(old_macro, new_macro):
"""Update a macro with information from another one
Update a macro with information from another one, supposedly the same but
from a different source. The first source is supposed to be git, the second
one the wiki.
"""
if old_macro.on_git and new_macro.on_git:
FreeCAD.Console.PrintLog(
'The macro "{}" is present twice in github, please report'.format(
old_macro.name
)
)
# We don't report macros present twice on the wiki because a link to a
# macro is considered as a macro. For example, 'Perpendicular To Wire'
# appears twice, as of 2018-05-05).
old_macro.on_wiki = new_macro.on_wiki
for attr in ["desc", "url", "code"]:
if not hasattr(old_macro, attr):
setattr(old_macro, attr, getattr(new_macro, attr))
def remove_directory_if_empty(dir):
"""Remove the directory if it is empty
Directory FreeCAD.getUserMacroDir(True) will not be removed even if empty.
"""
if dir == FreeCAD.getUserMacroDir(True):
return
if not os.listdir(dir):
os.rmdir(dir)
def restart_freecad():
"""Shuts down and restarts FreeCAD"""
args = QtWidgets.QApplication.arguments()[1:]
if FreeCADGui.getMainWindow().close():
QtCore.QProcess.startDetached(
QtWidgets.QApplication.applicationFilePath(), args
)
def get_zip_url(repo):
"""Returns the location of a zip file from a repo, if available"""
parsed_url = urlparse(repo.url)
if parsed_url.netloc == "github.com":
return f"{repo.url}/archive/{repo.branch}.zip"
elif parsed_url.netloc in ["gitlab.com", "framagit.org", "salsa.debian.org"]:
return f"{repo.url}/-/archive/{repo.branch}/{repo.name}-{repo.branch}.zip"
else:
FreeCAD.Console.PrintLog(
"Debug: addonmanager_utilities.get_zip_url: Unknown git host fetching zip URL:",
parsed_url.netloc,
"\n",
)
return f"{repo.url}/-/archive/{repo.branch}/{repo.name}-{repo.branch}.zip"
def recognized_git_location(repo) -> bool:
"""Returns whether this repo is based at a known git repo location: works with github, gitlab, framagit, and salsa.debian.org"""
parsed_url = urlparse(repo.url)
if parsed_url.netloc in [
"github.com",
"gitlab.com",
"framagit.org",
"salsa.debian.org",
]:
return True
else:
return False
def construct_git_url(repo, filename):
"""Returns a direct download link to a file in an online Git repo"""
parsed_url = urlparse(repo.url)
if parsed_url.netloc == "github.com":
return f"{repo.url}/raw/{repo.branch}/{filename}"
elif parsed_url.netloc in ["gitlab.com", "framagit.org", "salsa.debian.org"]:
return f"{repo.url}/-/raw/{repo.branch}/{filename}"
else:
FreeCAD.Console.PrintLog(
"Debug: addonmanager_utilities.construct_git_url: Unknown git host:"
+ parsed_url.netloc
+ f" for file {filename}\n"
)
# Assume it's some kind of local GitLab instance...
return f"{repo.url}/-/raw/{repo.branch}/{filename}"
def get_readme_url(repo):
"""Returns the location of a readme file"""
return construct_git_url(repo, "README.md")
def get_metadata_url(url):
"""Returns the location of a package.xml metadata file"""
return construct_git_url(url, "package.xml")
def get_desc_regex(repo):
"""Returns a regex string that extracts a WB description to be displayed in the description
panel of the Addon manager, if the README could not be found"""
parsedUrl = urlparse(repo.url)
if parsedUrl.netloc == "github.com":
return r'<meta property="og:description" content="(.*?)"'
elif parsedUrl.netloc in ["gitlab.com", "salsa.debian.org", "framagit.org"]:
return r'<meta.*?content="(.*?)".*?og:description.*?>'
else:
FreeCAD.Console.PrintLog(
"Debug: addonmanager_utilities.get_desc_regex: Unknown git host:",
repo.url,
"\n",
)
return r'<meta.*?content="(.*?)".*?og:description.*?>'
def get_readme_html_url(repo):
"""Returns the location of a html file containing readme"""
parsedUrl = urlparse(repo.url)
if parsedUrl.netloc == "github.com":
return f"{repo.url}/blob/{repo.branch}/README.md"
elif parsedUrl.netloc in ["gitlab.com", "salsa.debian.org", "framagit.org"]:
return f"{repo.url}/-/blob/{repo.branch}/README.md"
else:
FreeCAD.Console.PrintLog(
"Unrecognized git repo location '' -- guessing it is a GitLab instance..."
)
return f"{repo.url}/-/blob/{repo.branch}/README.md"
def repair_git_repo(repo_url: str, clone_dir: str) -> None:
# Repair addon installed with raw download by adding the .git
# directory to it
try:
import git
# If GitPython is not installed, but the user has a directory named "git" in their Python path, they
# may have the import succeed, but it will not be a real GitPython installation
have_git = hasattr(git, "Repo")
if not have_git:
return
except ImportError:
return
try:
bare_repo = git.Repo.clone_from(
repo_url, clone_dir + os.sep + ".git", bare=True
)
with bare_repo.config_writer() as cw:
cw.set("core", "bare", False)
except AttributeError:
FreeCAD.Console.PrintLog(
translate(
"AddonsInstaller",
"Outdated GitPython detected, consider upgrading with pip.",
)
+ "\n"
)
cw = bare_repo.config_writer()
cw.set("core", "bare", False)
del cw
except Exception as e:
FreeCAD.Console.PrintWarning(
translate("AddonsInstaller", "Failed to repair missing .git directory")
+ "\n"
)
FreeCAD.Console.PrintWarning(
translate("AddonsInstaller", "Repository URL") + f": {repo_url}\n"
)
FreeCAD.Console.PrintWarning(
translate("AddonsInstaller", "Clone directory") + f": {clone_dir}\n"
)
FreeCAD.Console.PrintWarning(e)
return
repo = git.Repo(clone_dir)
repo.head.reset("--hard")
def is_darkmode() -> bool:
"""Heuristics to determine if we are in a darkmode stylesheet"""
pl = FreeCADGui.getMainWindow().palette()
return pl.color(pl.Background).lightness() < 128
def warning_color_string() -> str:
"""A shade of red, adapted to darkmode if possible. Targets a minimum 7:1 contrast ratio."""
return "rgb(255,105,97)" if is_darkmode() else "rgb(215,0,21)"
def bright_color_string() -> str:
"""A shade of green, adapted to darkmode if possible. Targets a minimum 7:1 contrast ratio."""
return "rgb(48,219,91)" if is_darkmode() else "rgb(36,138,61)"
def attention_color_string() -> str:
"""A shade of orange, adapted to darkmode if possible. Targets a minimum 7:1 contrast ratio."""
return "rgb(255,179,64)" if is_darkmode() else "rgb(255,149,0)"
def get_assigned_string_literal(line: str) -> Optional[str]:
"""Look for a line of the form my_var = "A string literal" and return the string literal.
If the assignment is of a floating point value, that value is converted to a string
and returned. If neither is true, returns None."""
string_search_regex = re.compile(r"\s*(['\"])(.*)\1")
_, _, after_equals = line.partition("=")
match = re.match(string_search_regex, after_equals)
if match:
return str(match.group(2))
if is_float(after_equals):
return str(after_equals).strip()
return None
def get_macro_version_from_file(filename: str) -> str:
"""Get the version of the macro from a local macro file. Supports strings, ints, and floats, as
well as a reference to __date__"""
date = ""
with open(filename, "r", errors="ignore") as f:
line_counter = 0
max_lines_to_scan = 200
while line_counter < max_lines_to_scan:
line_counter += 1
line = f.readline()
if not line: # EOF
break
if line.lower().startswith("__version__"):
match = get_assigned_string_literal(line)
if match:
return match
elif "__date__" in line.lower():
# Don't do any real syntax checking, just assume the line is something like __version__ = __date__
return date
elif line.lower().startswith("__date__"):
match = get_assigned_string_literal(line)
if match:
date = match
return ""
def update_macro_installation_details(repo) -> None:
if repo is None or not hasattr(repo, "macro") or repo.macro is None:
FreeCAD.Console.PrintLog(f"Requested macro details for non-macro object\n")
return
test_file_one = os.path.join(FreeCAD.getUserMacroDir(True), repo.macro.filename)
test_file_two = os.path.join(
FreeCAD.getUserMacroDir(True), "Macro_" + repo.macro.filename
)
if os.path.exists(test_file_one):
repo.updated_timestamp = os.path.getmtime(test_file_one)
repo.installed_version = get_macro_version_from_file(test_file_one)
elif os.path.exists(test_file_two):
repo.updated_timestamp = os.path.getmtime(test_file_two)
repo.installed_version = get_macro_version_from_file(test_file_two)
else:
return
# Borrowed from Stack Overflow: https://stackoverflow.com/questions/736043/checking-if-a-string-can-be-converted-to-float-in-python
def is_float(element: Any) -> bool:
try:
float(element)
return True
except ValueError:
return False
# @}
def get_python_exe() -> str:
# Find Python. In preference order
# A) The value of the PythonExecutableForPip user preference
# B) The executable located in the same bin directory as FreeCAD and called "python3"
# C) The executable located in the same bin directory as FreeCAD and called "python"
# D) The result of an shutil search for your system's "python3" executable
# E) The result of an shutil search for your system's "python" executable
prefs = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Addons")
python_exe = prefs.GetString("PythonExecutableForPip", "Not set")
if not python_exe or python_exe == "Not set" or not os.path.exists(python_exe):
fc_dir = FreeCAD.getHomePath()
python_exe = os.path.join(fc_dir, "bin", "python3")
if "Windows" in platform.system():
python_exe += ".exe"
if not python_exe or not os.path.exists(python_exe):
python_exe = os.path.join(fc_dir, "bin", "python")
if "Windows" in platform.system():
python_exe += ".exe"
if not python_exe or not os.path.exists(python_exe):
python_exe = shutil.which("python3")
if not python_exe or not os.path.exists(python_exe):
python_exe = shutil.which("python")
if not python_exe or not os.path.exists(python_exe):
return ""
prefs.SetString("PythonExecutableForPip", python_exe)
return python_exe
def get_cache_file_name(file: str) -> str:
cache_path = FreeCAD.getUserCachePath()
am_path = os.path.join(cache_path, "AddonManager")
os.makedirs(am_path, exist_ok=True)
return os.path.join(am_path, file)