FreeCAD

Форк
0
/
addonmanager_utilities.py 
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

27
import os
28
import platform
29
import shutil
30
import stat
31
import subprocess
32
import re
33
import ctypes
34
from typing import Optional, Any
35

36
from urllib.parse import urlparse
37

38
try:
39
    from PySide import QtCore, QtGui, QtWidgets
40
except ImportError:
41
    QtCore = None
42
    QtWidgets = None
43
    QtGui = None
44

45
import addonmanager_freecad_interface as fci
46

47
if 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).
52
    import NetworkManager  # Requires an event loop, so is only available with the GUI
53
else:
54
    try:
55
        import requests
56
    except ImportError:
57
        requests = None
58
        import urllib.request
59
        import ssl
60

61
#  @package AddonManager_utilities
62
#  \ingroup ADDONMANAGER
63
#  \brief Utilities to work across different platforms, providers and python versions
64
#  @{
65

66

67
translate = fci.translate
68

69

70
class ProcessInterrupted(RuntimeError):
71
    """An interruption request was received and the process killed because of it."""
72

73

74
def symlink(source, link_name):
75
    """Creates a symlink of a file, if possible. Note that it fails on most modern Windows
76
    installations"""
77

78
    if os.path.exists(link_name) or os.path.lexists(link_name):
79
        pass
80
    else:
81
        os_symlink = getattr(os, "symlink", None)
82
        if callable(os_symlink):
83
            os_symlink(source, link_name)
84
        else:
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.
88
            csl = ctypes.windll.kernel32.CreateSymbolicLinkW
89
            csl.argtypes = (ctypes.c_wchar_p, ctypes.c_wchar_p, ctypes.c_uint32)
90
            csl.restype = ctypes.c_ubyte
91
            flags = 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)
94
            flags += 2
95
            if csl(link_name, source, flags) == 0:
96
                raise ctypes.WinError()
97

98

99
def rmdir(path: str) -> bool:
100
    try:
101
        if os.path.islink(path):
102
            os.unlink(path)  # Remove symlink
103
        else:
104
            shutil.rmtree(path, onerror=remove_readonly)
105
    except (WindowsError, PermissionError, OSError):
106
        return False
107
    return True
108

109

110
def remove_readonly(func, path, _) -> None:
111
    """Remove a read-only file."""
112

113
    os.chmod(path, stat.S_IWRITE)
114
    func(path)
115

116

117
def update_macro_details(old_macro, new_macro):
118
    """Update a macro with information from another one
119

120
    Update a macro with information from another one, supposedly the same but
121
    from a different source. The first source is supposed to be git, the second
122
    one the wiki.
123
    """
124

125
    if old_macro.on_git and new_macro.on_git:
126
        fci.Console.PrintLog(
127
            f'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).
132
    old_macro.on_wiki = new_macro.on_wiki
133
    for attr in ["desc", "url", "code"]:
134
        if not hasattr(old_macro, attr):
135
            setattr(old_macro, attr, getattr(new_macro, attr))
136

137

138
def remove_directory_if_empty(dir_to_remove):
139
    """Remove the directory if it is empty, with one exception: the directory returned by
140
    FreeCAD.getUserMacroDir(True) will not be removed even if it is empty."""
141

142
    if dir_to_remove == fci.DataPaths().macro_dir:
143
        return
144
    if not os.listdir(dir_to_remove):
145
        os.rmdir(dir_to_remove)
146

147

148
def restart_freecad():
149
    """Shuts down and restarts FreeCAD"""
150

151
    if not QtCore or not QtWidgets:
152
        return
153

154
    args = QtWidgets.QApplication.arguments()[1:]
155
    if fci.FreeCADGui.getMainWindow().close():
156
        QtCore.QProcess.startDetached(QtWidgets.QApplication.applicationFilePath(), args)
157

158

159
def get_zip_url(repo):
160
    """Returns the location of a zip file from a repo, if available"""
161

162
    parsed_url = urlparse(repo.url)
163
    if parsed_url.netloc == "github.com":
164
        return f"{repo.url}/archive/{repo.branch}.zip"
165
    if parsed_url.netloc in ["gitlab.com", "framagit.org", "salsa.debian.org"]:
166
        return f"{repo.url}/-/archive/{repo.branch}/{repo.name}-{repo.branch}.zip"
167
    if parsed_url.netloc in ["codeberg.org"]:
168
        return f"{repo.url}/archive/{repo.branch}.zip"
169
    fci.Console.PrintLog(
170
        "Debug: addonmanager_utilities.get_zip_url: Unknown git host fetching zip URL:"
171
        + parsed_url.netloc
172
        + "\n"
173
    )
174
    return f"{repo.url}/-/archive/{repo.branch}/{repo.name}-{repo.branch}.zip"
175

176

177
def recognized_git_location(repo) -> bool:
178
    """Returns whether this repo is based at a known git repo location: works with github, gitlab,
179
    framagit, and salsa.debian.org"""
180

181
    parsed_url = urlparse(repo.url)
182
    return parsed_url.netloc in [
183
        "github.com",
184
        "gitlab.com",
185
        "framagit.org",
186
        "salsa.debian.org",
187
        "codeberg.org",
188
    ]
189

190

191
def construct_git_url(repo, filename):
192
    """Returns a direct download link to a file in an online Git repo"""
193

194
    parsed_url = urlparse(repo.url)
195
    if parsed_url.netloc == "github.com":
196
        return f"{repo.url}/raw/{repo.branch}/{filename}"
197
    if parsed_url.netloc in ["gitlab.com", "framagit.org", "salsa.debian.org"]:
198
        return f"{repo.url}/-/raw/{repo.branch}/{filename}"
199
    if parsed_url.netloc in ["codeberg.org"]:
200
        return f"{repo.url}/raw/branch/{repo.branch}/{filename}"
201
    fci.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...
207
    return f"{repo.url}/-/raw/{repo.branch}/{filename}"
208

209

210
def get_readme_url(repo):
211
    """Returns the location of a readme file"""
212

213
    return construct_git_url(repo, "README.md")
214

215

216
def get_metadata_url(url):
217
    """Returns the location of a package.xml metadata file"""
218

219
    return construct_git_url(url, "package.xml")
220

221

222
def get_desc_regex(repo):
223
    """Returns a regex string that extracts a WB description to be displayed in the description
224
    panel of the Addon manager, if the README could not be found"""
225

226
    parsed_url = urlparse(repo.url)
227
    if parsed_url.netloc == "github.com":
228
        return r'<meta property="og:description" content="(.*?)"'
229
    if parsed_url.netloc in ["gitlab.com", "salsa.debian.org", "framagit.org"]:
230
        return r'<meta.*?content="(.*?)".*?og:description.*?>'
231
    if parsed_url.netloc in ["codeberg.org"]:
232
        return r'<meta property="og:description" content="(.*?)"'
233
    fci.Console.PrintLog(
234
        "Debug: addonmanager_utilities.get_desc_regex: Unknown git host:",
235
        repo.url,
236
        "\n",
237
    )
238
    return r'<meta.*?content="(.*?)".*?og:description.*?>'
239

240

241
def get_readme_html_url(repo):
242
    """Returns the location of a html file containing readme"""
243

244
    parsed_url = urlparse(repo.url)
245
    if parsed_url.netloc == "github.com":
246
        return f"{repo.url}/blob/{repo.branch}/README.md"
247
    if parsed_url.netloc in ["gitlab.com", "salsa.debian.org", "framagit.org"]:
248
        return f"{repo.url}/-/blob/{repo.branch}/README.md"
249
    if parsed_url.netloc in ["gitlab.com", "salsa.debian.org", "framagit.org"]:
250
        return f"{repo.url}/raw/branch/{repo.branch}/README.md"
251
    fci.Console.PrintLog("Unrecognized git repo location '' -- guessing it is a GitLab instance...")
252
    return f"{repo.url}/-/blob/{repo.branch}/README.md"
253

254

255
def is_darkmode() -> bool:
256
    """Heuristics to determine if we are in a darkmode stylesheet"""
257
    pl = fci.FreeCADGui.getMainWindow().palette()
258
    return pl.color(QtGui.QPalette.Window).lightness() < 128
259

260

261
def warning_color_string() -> str:
262
    """A shade of red, adapted to darkmode if possible. Targets a minimum 7:1 contrast ratio."""
263
    return "rgb(255,105,97)" if is_darkmode() else "rgb(215,0,21)"
264

265

266
def bright_color_string() -> str:
267
    """A shade of green, adapted to darkmode if possible. Targets a minimum 7:1 contrast ratio."""
268
    return "rgb(48,219,91)" if is_darkmode() else "rgb(36,138,61)"
269

270

271
def attention_color_string() -> str:
272
    """A shade of orange, adapted to darkmode if possible. Targets a minimum 7:1 contrast ratio."""
273
    return "rgb(255,179,64)" if is_darkmode() else "rgb(255,149,0)"
274

275

276
def 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.
278
    If the assignment is of a floating point value, that value is converted to a string
279
    and returned. If neither is true, returns None."""
280

281
    string_search_regex = re.compile(r"\s*(['\"])(.*)\1")
282
    _, _, after_equals = line.partition("=")
283
    match = re.match(string_search_regex, after_equals)
284
    if match:
285
        return str(match.group(2))
286
    if is_float(after_equals):
287
        return str(after_equals).strip()
288
    return None
289

290

291
def 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,
293
    as well as a reference to __date__"""
294

295
    date = ""
296
    with open(filename, errors="ignore", encoding="utf-8") as f:
297
        line_counter = 0
298
        max_lines_to_scan = 200
299
        while line_counter < max_lines_to_scan:
300
            line_counter += 1
301
            line = f.readline()
302
            if not line:  # EOF
303
                break
304
            if line.lower().startswith("__version__"):
305
                match = get_assigned_string_literal(line)
306
                if match:
307
                    return match
308
                if "__date__" in line.lower():
309
                    # Don't do any real syntax checking, just assume the line is something
310
                    # like __version__ = __date__
311
                    if date:
312
                        return date
313
                    # pylint: disable=line-too-long,consider-using-f-string
314
                    fci.Console.PrintWarning(
315
                        translate(
316
                            "AddonsInstaller",
317
                            "Macro {} specified '__version__ = __date__' prior to setting a value for __date__".format(
318
                                filename
319
                            ),
320
                        )
321
                    )
322
            elif line.lower().startswith("__date__"):
323
                match = get_assigned_string_literal(line)
324
                if match:
325
                    date = match
326
    return ""
327

328

329
def update_macro_installation_details(repo) -> None:
330
    """Determine if a given macro is installed, either in its plain name,
331
    or prefixed with "Macro_" """
332
    if repo is None or not hasattr(repo, "macro") or repo.macro is None:
333
        fci.Console.PrintLog("Requested macro details for non-macro object\n")
334
        return
335
    test_file_one = os.path.join(fci.DataPaths().macro_dir, repo.macro.filename)
336
    test_file_two = os.path.join(fci.DataPaths().macro_dir, "Macro_" + repo.macro.filename)
337
    if os.path.exists(test_file_one):
338
        repo.updated_timestamp = os.path.getmtime(test_file_one)
339
        repo.installed_version = get_macro_version_from_file(test_file_one)
340
    elif os.path.exists(test_file_two):
341
        repo.updated_timestamp = os.path.getmtime(test_file_two)
342
        repo.installed_version = get_macro_version_from_file(test_file_two)
343
    else:
344
        return
345

346

347
# Borrowed from Stack Overflow:
348
# https://stackoverflow.com/questions/736043/checking-if-a-string-can-be-converted-to-float
349
def is_float(element: Any) -> bool:
350
    """Determine whether a given item can be converted to a floating-point number"""
351
    try:
352
        float(element)
353
        return True
354
    except ValueError:
355
        return False
356

357

358
def get_pip_target_directory():
359
    # Get the default location to install new pip packages
360
    major, minor, _ = platform.python_version_tuple()
361
    vendor_path = os.path.join(
362
        fci.DataPaths().mod_dir, "..", "AdditionalPythonPackages", f"py{major}{minor}"
363
    )
364
    return vendor_path
365

366

367
def get_cache_file_name(file: str) -> str:
368
    """Get the full path to a cache file with a given name."""
369
    cache_path = fci.DataPaths().cache_dir
370
    am_path = os.path.join(cache_path, "AddonManager")
371
    os.makedirs(am_path, exist_ok=True)
372
    return os.path.join(am_path, file)
373

374

375
def blocking_get(url: str, method=None) -> bytes:
376
    """Wrapper around three possible ways of accessing data, depending on the current run mode and
377
    Python installation. Blocks until complete, and returns the text results of the call if it
378
    succeeded, or an empty string if it failed, or returned no data. The method argument is
379
    provided mainly for testing purposes."""
380
    p = b""
381
    if fci.FreeCADGui and method is None or method == "networkmanager":
382
        NetworkManager.InitializeNetworkManager()
383
        p = NetworkManager.AM_NETWORK_MANAGER.blocking_get(url, 10000)  # 10 second timeout
384
        if p:
385
            try:
386
                p = p.data()
387
            except AttributeError:
388
                pass
389
    elif requests and method is None or method == "requests":
390
        response = requests.get(url)
391
        if response.status_code == 200:
392
            p = response.content
393
    else:
394
        ctx = ssl.create_default_context()
395
        with urllib.request.urlopen(url, context=ctx) as f:
396
            p = f.read()
397
    return p
398

399

400
def run_interruptable_subprocess(args) -> subprocess.CompletedProcess:
401
    """Wrap subprocess call so it can be interrupted gracefully."""
402
    creation_flags = 0
403
    if hasattr(subprocess, "CREATE_NO_WINDOW"):
404
        # Added in Python 3.7 -- only used on Windows
405
        creation_flags = subprocess.CREATE_NO_WINDOW
406
    try:
407
        p = subprocess.Popen(
408
            args,
409
            stdout=subprocess.PIPE,
410
            stderr=subprocess.PIPE,
411
            creationflags=creation_flags,
412
            text=True,
413
            encoding="utf-8",
414
        )
415
    except OSError as e:
416
        raise subprocess.CalledProcessError(-1, args, "", e.strerror)
417
    stdout = ""
418
    stderr = ""
419
    return_code = None
420
    while return_code is None:
421
        try:
422
            stdout, stderr = p.communicate(timeout=10)
423
            return_code = p.returncode
424
        except subprocess.TimeoutExpired:
425
            if QtCore.QThread.currentThread().isInterruptionRequested():
426
                p.kill()
427
                raise ProcessInterrupted()
428
    if return_code is None or return_code != 0:
429
        raise subprocess.CalledProcessError(
430
            return_code if return_code is not None else -1, args, stdout, stderr
431
        )
432
    return subprocess.CompletedProcess(args, return_code, stdout, stderr)
433

434

435
def get_main_am_window():
436
    windows = QtWidgets.QApplication.topLevelWidgets()
437
    for widget in windows:
438
        if widget.objectName() == "AddonManager_Main_Window":
439
            return widget
440
    # If there is no main AM window, we may be running unit tests: see if the Test Runner window
441
    # exists:
442
    for widget in windows:
443
        if widget.objectName() == "TestGui__UnitTest":
444
            return widget
445
    # If we still didn't find it, try to locate the main FreeCAD window:
446
    for widget in windows:
447
        if hasattr(widget, "centralWidget"):
448
            return widget.centralWidget()
449
    # Why is this code even getting called?
450
    return None
451

Использование cookies

Мы используем файлы cookie в соответствии с Политикой конфиденциальности и Политикой использования cookies.

Нажимая кнопку «Принимаю», Вы даете АО «СберТех» согласие на обработку Ваших персональных данных в целях совершенствования нашего веб-сайта и Сервиса GitVerse, а также повышения удобства их использования.

Запретить использование cookies Вы можете самостоятельно в настройках Вашего браузера.