FreeCAD
450 строк · 17.0 Кб
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""" Utilities to work across different platforms, providers and python versions """
26
27import os
28import platform
29import shutil
30import stat
31import subprocess
32import re
33import ctypes
34from typing import Optional, Any
35
36from urllib.parse import urlparse
37
38try:
39from PySide import QtCore, QtGui, QtWidgets
40except ImportError:
41QtCore = None
42QtWidgets = None
43QtGui = None
44
45import addonmanager_freecad_interface as fci
46
47if fci.FreeCADGui:
48
49# If the GUI is up, we can use the NetworkManager to handle our downloads. If there is no event
50# loop running this is not possible, so fall back to requests (if available), or the native
51# Python urllib.request (if requests is not available).
52import NetworkManager # Requires an event loop, so is only available with the GUI
53else:
54try:
55import requests
56except ImportError:
57requests = None
58import urllib.request
59import ssl
60
61# @package AddonManager_utilities
62# \ingroup ADDONMANAGER
63# \brief Utilities to work across different platforms, providers and python versions
64# @{
65
66
67translate = fci.translate
68
69
70class ProcessInterrupted(RuntimeError):
71"""An interruption request was received and the process killed because of it."""
72
73
74def symlink(source, link_name):
75"""Creates a symlink of a file, if possible. Note that it fails on most modern Windows
76installations"""
77
78if os.path.exists(link_name) or os.path.lexists(link_name):
79pass
80else:
81os_symlink = getattr(os, "symlink", None)
82if callable(os_symlink):
83os_symlink(source, link_name)
84else:
85# NOTE: This does not work on most normal Windows 10 and later installations, unless
86# developer mode is turned on. Make sure to catch any exception thrown and have a
87# fallback plan.
88csl = ctypes.windll.kernel32.CreateSymbolicLinkW
89csl.argtypes = (ctypes.c_wchar_p, ctypes.c_wchar_p, ctypes.c_uint32)
90csl.restype = ctypes.c_ubyte
91flags = 1 if os.path.isdir(source) else 0
92# set the SYMBOLIC_LINK_FLAG_ALLOW_UNPRIVILEGED_CREATE flag
93# (see https://blogs.windows.com/buildingapps/2016/12/02/symlinks-windows-10)
94flags += 2
95if csl(link_name, source, flags) == 0:
96raise ctypes.WinError()
97
98
99def rmdir(path: str) -> bool:
100try:
101if os.path.islink(path):
102os.unlink(path) # Remove symlink
103else:
104shutil.rmtree(path, onerror=remove_readonly)
105except (WindowsError, PermissionError, OSError):
106return False
107return True
108
109
110def remove_readonly(func, path, _) -> None:
111"""Remove a read-only file."""
112
113os.chmod(path, stat.S_IWRITE)
114func(path)
115
116
117def update_macro_details(old_macro, new_macro):
118"""Update a macro with information from another one
119
120Update a macro with information from another one, supposedly the same but
121from a different source. The first source is supposed to be git, the second
122one the wiki.
123"""
124
125if old_macro.on_git and new_macro.on_git:
126fci.Console.PrintLog(
127f'The macro "{old_macro.name}" is present twice in github, please report'
128)
129# We don't report macros present twice on the wiki because a link to a
130# macro is considered as a macro. For example, 'Perpendicular To Wire'
131# appears twice, as of 2018-05-05).
132old_macro.on_wiki = new_macro.on_wiki
133for attr in ["desc", "url", "code"]:
134if not hasattr(old_macro, attr):
135setattr(old_macro, attr, getattr(new_macro, attr))
136
137
138def remove_directory_if_empty(dir_to_remove):
139"""Remove the directory if it is empty, with one exception: the directory returned by
140FreeCAD.getUserMacroDir(True) will not be removed even if it is empty."""
141
142if dir_to_remove == fci.DataPaths().macro_dir:
143return
144if not os.listdir(dir_to_remove):
145os.rmdir(dir_to_remove)
146
147
148def restart_freecad():
149"""Shuts down and restarts FreeCAD"""
150
151if not QtCore or not QtWidgets:
152return
153
154args = QtWidgets.QApplication.arguments()[1:]
155if fci.FreeCADGui.getMainWindow().close():
156QtCore.QProcess.startDetached(QtWidgets.QApplication.applicationFilePath(), args)
157
158
159def get_zip_url(repo):
160"""Returns the location of a zip file from a repo, if available"""
161
162parsed_url = urlparse(repo.url)
163if parsed_url.netloc == "github.com":
164return f"{repo.url}/archive/{repo.branch}.zip"
165if parsed_url.netloc in ["gitlab.com", "framagit.org", "salsa.debian.org"]:
166return f"{repo.url}/-/archive/{repo.branch}/{repo.name}-{repo.branch}.zip"
167if parsed_url.netloc in ["codeberg.org"]:
168return f"{repo.url}/archive/{repo.branch}.zip"
169fci.Console.PrintLog(
170"Debug: addonmanager_utilities.get_zip_url: Unknown git host fetching zip URL:"
171+ parsed_url.netloc
172+ "\n"
173)
174return f"{repo.url}/-/archive/{repo.branch}/{repo.name}-{repo.branch}.zip"
175
176
177def recognized_git_location(repo) -> bool:
178"""Returns whether this repo is based at a known git repo location: works with github, gitlab,
179framagit, and salsa.debian.org"""
180
181parsed_url = urlparse(repo.url)
182return parsed_url.netloc in [
183"github.com",
184"gitlab.com",
185"framagit.org",
186"salsa.debian.org",
187"codeberg.org",
188]
189
190
191def construct_git_url(repo, filename):
192"""Returns a direct download link to a file in an online Git repo"""
193
194parsed_url = urlparse(repo.url)
195if parsed_url.netloc == "github.com":
196return f"{repo.url}/raw/{repo.branch}/{filename}"
197if parsed_url.netloc in ["gitlab.com", "framagit.org", "salsa.debian.org"]:
198return f"{repo.url}/-/raw/{repo.branch}/{filename}"
199if parsed_url.netloc in ["codeberg.org"]:
200return f"{repo.url}/raw/branch/{repo.branch}/{filename}"
201fci.Console.PrintLog(
202"Debug: addonmanager_utilities.construct_git_url: Unknown git host:"
203+ parsed_url.netloc
204+ f" for file {filename}\n"
205)
206# Assume it's some kind of local GitLab instance...
207return f"{repo.url}/-/raw/{repo.branch}/{filename}"
208
209
210def get_readme_url(repo):
211"""Returns the location of a readme file"""
212
213return construct_git_url(repo, "README.md")
214
215
216def get_metadata_url(url):
217"""Returns the location of a package.xml metadata file"""
218
219return construct_git_url(url, "package.xml")
220
221
222def get_desc_regex(repo):
223"""Returns a regex string that extracts a WB description to be displayed in the description
224panel of the Addon manager, if the README could not be found"""
225
226parsed_url = urlparse(repo.url)
227if parsed_url.netloc == "github.com":
228return r'<meta property="og:description" content="(.*?)"'
229if parsed_url.netloc in ["gitlab.com", "salsa.debian.org", "framagit.org"]:
230return r'<meta.*?content="(.*?)".*?og:description.*?>'
231if parsed_url.netloc in ["codeberg.org"]:
232return r'<meta property="og:description" content="(.*?)"'
233fci.Console.PrintLog(
234"Debug: addonmanager_utilities.get_desc_regex: Unknown git host:",
235repo.url,
236"\n",
237)
238return r'<meta.*?content="(.*?)".*?og:description.*?>'
239
240
241def get_readme_html_url(repo):
242"""Returns the location of a html file containing readme"""
243
244parsed_url = urlparse(repo.url)
245if parsed_url.netloc == "github.com":
246return f"{repo.url}/blob/{repo.branch}/README.md"
247if parsed_url.netloc in ["gitlab.com", "salsa.debian.org", "framagit.org"]:
248return f"{repo.url}/-/blob/{repo.branch}/README.md"
249if parsed_url.netloc in ["gitlab.com", "salsa.debian.org", "framagit.org"]:
250return f"{repo.url}/raw/branch/{repo.branch}/README.md"
251fci.Console.PrintLog("Unrecognized git repo location '' -- guessing it is a GitLab instance...")
252return f"{repo.url}/-/blob/{repo.branch}/README.md"
253
254
255def is_darkmode() -> bool:
256"""Heuristics to determine if we are in a darkmode stylesheet"""
257pl = fci.FreeCADGui.getMainWindow().palette()
258return pl.color(QtGui.QPalette.Window).lightness() < 128
259
260
261def warning_color_string() -> str:
262"""A shade of red, adapted to darkmode if possible. Targets a minimum 7:1 contrast ratio."""
263return "rgb(255,105,97)" if is_darkmode() else "rgb(215,0,21)"
264
265
266def bright_color_string() -> str:
267"""A shade of green, adapted to darkmode if possible. Targets a minimum 7:1 contrast ratio."""
268return "rgb(48,219,91)" if is_darkmode() else "rgb(36,138,61)"
269
270
271def attention_color_string() -> str:
272"""A shade of orange, adapted to darkmode if possible. Targets a minimum 7:1 contrast ratio."""
273return "rgb(255,179,64)" if is_darkmode() else "rgb(255,149,0)"
274
275
276def get_assigned_string_literal(line: str) -> Optional[str]:
277"""Look for a line of the form my_var = "A string literal" and return the string literal.
278If the assignment is of a floating point value, that value is converted to a string
279and returned. If neither is true, returns None."""
280
281string_search_regex = re.compile(r"\s*(['\"])(.*)\1")
282_, _, after_equals = line.partition("=")
283match = re.match(string_search_regex, after_equals)
284if match:
285return str(match.group(2))
286if is_float(after_equals):
287return str(after_equals).strip()
288return None
289
290
291def get_macro_version_from_file(filename: str) -> str:
292"""Get the version of the macro from a local macro file. Supports strings, ints, and floats,
293as well as a reference to __date__"""
294
295date = ""
296with open(filename, errors="ignore", encoding="utf-8") as f:
297line_counter = 0
298max_lines_to_scan = 200
299while line_counter < max_lines_to_scan:
300line_counter += 1
301line = f.readline()
302if not line: # EOF
303break
304if line.lower().startswith("__version__"):
305match = get_assigned_string_literal(line)
306if match:
307return match
308if "__date__" in line.lower():
309# Don't do any real syntax checking, just assume the line is something
310# like __version__ = __date__
311if date:
312return date
313# pylint: disable=line-too-long,consider-using-f-string
314fci.Console.PrintWarning(
315translate(
316"AddonsInstaller",
317"Macro {} specified '__version__ = __date__' prior to setting a value for __date__".format(
318filename
319),
320)
321)
322elif line.lower().startswith("__date__"):
323match = get_assigned_string_literal(line)
324if match:
325date = match
326return ""
327
328
329def update_macro_installation_details(repo) -> None:
330"""Determine if a given macro is installed, either in its plain name,
331or prefixed with "Macro_" """
332if repo is None or not hasattr(repo, "macro") or repo.macro is None:
333fci.Console.PrintLog("Requested macro details for non-macro object\n")
334return
335test_file_one = os.path.join(fci.DataPaths().macro_dir, repo.macro.filename)
336test_file_two = os.path.join(fci.DataPaths().macro_dir, "Macro_" + repo.macro.filename)
337if os.path.exists(test_file_one):
338repo.updated_timestamp = os.path.getmtime(test_file_one)
339repo.installed_version = get_macro_version_from_file(test_file_one)
340elif os.path.exists(test_file_two):
341repo.updated_timestamp = os.path.getmtime(test_file_two)
342repo.installed_version = get_macro_version_from_file(test_file_two)
343else:
344return
345
346
347# Borrowed from Stack Overflow:
348# https://stackoverflow.com/questions/736043/checking-if-a-string-can-be-converted-to-float
349def is_float(element: Any) -> bool:
350"""Determine whether a given item can be converted to a floating-point number"""
351try:
352float(element)
353return True
354except ValueError:
355return False
356
357
358def get_pip_target_directory():
359# Get the default location to install new pip packages
360major, minor, _ = platform.python_version_tuple()
361vendor_path = os.path.join(
362fci.DataPaths().mod_dir, "..", "AdditionalPythonPackages", f"py{major}{minor}"
363)
364return vendor_path
365
366
367def get_cache_file_name(file: str) -> str:
368"""Get the full path to a cache file with a given name."""
369cache_path = fci.DataPaths().cache_dir
370am_path = os.path.join(cache_path, "AddonManager")
371os.makedirs(am_path, exist_ok=True)
372return os.path.join(am_path, file)
373
374
375def blocking_get(url: str, method=None) -> bytes:
376"""Wrapper around three possible ways of accessing data, depending on the current run mode and
377Python installation. Blocks until complete, and returns the text results of the call if it
378succeeded, or an empty string if it failed, or returned no data. The method argument is
379provided mainly for testing purposes."""
380p = b""
381if fci.FreeCADGui and method is None or method == "networkmanager":
382NetworkManager.InitializeNetworkManager()
383p = NetworkManager.AM_NETWORK_MANAGER.blocking_get(url, 10000) # 10 second timeout
384if p:
385try:
386p = p.data()
387except AttributeError:
388pass
389elif requests and method is None or method == "requests":
390response = requests.get(url)
391if response.status_code == 200:
392p = response.content
393else:
394ctx = ssl.create_default_context()
395with urllib.request.urlopen(url, context=ctx) as f:
396p = f.read()
397return p
398
399
400def run_interruptable_subprocess(args) -> subprocess.CompletedProcess:
401"""Wrap subprocess call so it can be interrupted gracefully."""
402creation_flags = 0
403if hasattr(subprocess, "CREATE_NO_WINDOW"):
404# Added in Python 3.7 -- only used on Windows
405creation_flags = subprocess.CREATE_NO_WINDOW
406try:
407p = subprocess.Popen(
408args,
409stdout=subprocess.PIPE,
410stderr=subprocess.PIPE,
411creationflags=creation_flags,
412text=True,
413encoding="utf-8",
414)
415except OSError as e:
416raise subprocess.CalledProcessError(-1, args, "", e.strerror)
417stdout = ""
418stderr = ""
419return_code = None
420while return_code is None:
421try:
422stdout, stderr = p.communicate(timeout=10)
423return_code = p.returncode
424except subprocess.TimeoutExpired:
425if QtCore.QThread.currentThread().isInterruptionRequested():
426p.kill()
427raise ProcessInterrupted()
428if return_code is None or return_code != 0:
429raise subprocess.CalledProcessError(
430return_code if return_code is not None else -1, args, stdout, stderr
431)
432return subprocess.CompletedProcess(args, return_code, stdout, stderr)
433
434
435def get_main_am_window():
436windows = QtWidgets.QApplication.topLevelWidgets()
437for widget in windows:
438if widget.objectName() == "AddonManager_Main_Window":
439return widget
440# If there is no main AM window, we may be running unit tests: see if the Test Runner window
441# exists:
442for widget in windows:
443if widget.objectName() == "TestGui__UnitTest":
444return widget
445# If we still didn't find it, try to locate the main FreeCAD window:
446for widget in windows:
447if hasattr(widget, "centralWidget"):
448return widget.centralWidget()
449# Why is this code even getting called?
450return None
451