391 lines
14 KiB
Python
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)
|