FreeCAD

Форк
0
/
addonmanager_macro.py 
439 строк · 17.8 Кб
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
""" Unified handler for FreeCAD macros that can be obtained from different sources. """
26

27
import os
28
import re
29
import io
30
import codecs
31
import shutil
32
from html import unescape
33
from typing import Dict, Tuple, List, Union, Optional
34
import urllib.parse
35

36
from addonmanager_macro_parser import MacroParser
37
import addonmanager_utilities as utils
38

39
import addonmanager_freecad_interface as fci
40

41
translate = fci.translate
42

43

44
#  @package AddonManager_macro
45
#  \ingroup ADDONMANAGER
46
#  \brief Unified handler for FreeCAD macros that can be obtained from
47
#  different sources
48
#  @{
49

50

51
class Macro:
52
    """This class provides a unified way to handle macros coming from different
53
    sources"""
54

55
    # Use a stored class variable for this so that we can override it during testing
56
    blocking_get = None
57

58
    # pylint: disable=too-many-instance-attributes
59
    def __init__(self, name):
60
        self.name = name
61
        self.on_wiki = False
62
        self.on_git = False
63
        self.desc = ""
64
        self.comment = ""
65
        self.code = ""
66
        self.url = ""
67
        self.raw_code_url = ""
68
        self.wiki = ""
69
        self.version = ""
70
        self.license = ""
71
        self.date = ""
72
        self.src_filename = ""
73
        self.filename_from_url = ""
74
        self.author = ""
75
        self.icon = ""
76
        self.icon_source = None
77
        self.xpm = ""  # Possible alternate icon data
78
        self.other_files = []
79
        self.parsed = False
80
        self._console = fci.Console
81
        if Macro.blocking_get is None:
82
            Macro.blocking_get = utils.blocking_get
83

84
    def __eq__(self, other):
85
        return self.filename == other.filename
86

87
    @classmethod
88
    def from_cache(cls, cache_dict: Dict):
89
        """Use data from the cache dictionary to create a new macro, returning a
90
        reference to it."""
91
        instance = Macro(cache_dict["name"])
92
        for key, value in cache_dict.items():
93
            instance.__dict__[key] = value
94
        return instance
95

96
    def to_cache(self) -> Dict:
97
        """For cache purposes all public members of the class are returned"""
98
        cache_dict = {}
99
        for key, value in self.__dict__.items():
100
            if key[0] != "_":
101
                cache_dict[key] = value
102
        return cache_dict
103

104
    @property
105
    def filename(self):
106
        """The filename of this macro"""
107
        if self.on_git:
108
            return os.path.basename(self.src_filename)
109
        elif self.filename_from_url:
110
            return self.filename_from_url
111
        return (self.name + ".FCMacro").replace(" ", "_")
112

113
    def is_installed(self):
114
        """Returns True if this macro is currently installed (that is, if it exists
115
        in the user macro directory), or False if it is not. Both the exact filename
116
        and the filename prefixed with "Macro", are considered an installation
117
        of this macro.
118
        """
119
        if self.on_git and not self.src_filename:
120
            return False
121
        return os.path.exists(
122
            os.path.join(fci.DataPaths().macro_dir, self.filename)
123
        ) or os.path.exists(os.path.join(fci.DataPaths().macro_dir, "Macro_" + self.filename))
124

125
    def fill_details_from_file(self, filename: str) -> None:
126
        """Opens the given Macro file and parses it for its metadata"""
127
        with open(filename, errors="replace", encoding="utf-8") as f:
128
            self.code = f.read()
129
            self.fill_details_from_code(self.code)
130

131
    def fill_details_from_code(self, code: str) -> None:
132
        """Read the passed-in code and parse it for known metadata elements"""
133
        parser = MacroParser(self.name, code)
134
        for key, value in parser.parse_results.items():
135
            if value:
136
                self.__dict__[key] = value
137
        self.clean_icon()
138
        self.parsed = True
139

140
    def fill_details_from_wiki(self, url):
141
        """For a given URL, download its data and attempt to get the macro's metadata
142
        out of it. If the macro's code is hosted elsewhere, as specified by a
143
        "rawcodeurl" found on the wiki page, that code is downloaded and used as the
144
        source."""
145
        code = ""
146
        p = Macro.blocking_get(url)
147
        if not p:
148
            self._console.PrintWarning(
149
                translate(
150
                    "AddonsInstaller",
151
                    "Unable to open macro wiki page at {}",
152
                ).format(url)
153
                + "\n"
154
            )
155
            return
156
        p = p.decode("utf8")
157
        # check if the macro page has its code hosted elsewhere, download if
158
        # needed
159
        if "rawcodeurl" in p:
160
            code = self._fetch_raw_code(p)
161
        if not code:
162
            code = self._read_code_from_wiki(p)
163
        if not code:
164
            self._console.PrintWarning(
165
                translate("AddonsInstaller", "Unable to fetch the code of this macro.") + "\n"
166
            )
167
            return
168

169
        desc = re.findall(
170
            r"<td class=\"ctEven left macro-description\">(.*?)</td>",
171
            p.replace("\n", " "),
172
        )
173
        if desc:
174
            desc = desc[0]
175
        else:
176
            self._console.PrintWarning(
177
                translate(
178
                    "AddonsInstaller",
179
                    "Unable to retrieve a description from the wiki for macro {}",
180
                ).format(self.name)
181
                + "\n"
182
            )
183
            desc = "No description available"
184
        self.desc = desc
185
        self.comment, _, _ = desc.partition("<br")  # Up to the first line break
186
        self.comment = re.sub("<.*?>", "", self.comment)  # Strip any tags
187
        self.url = url
188
        if isinstance(code, list):
189
            code = "".join(code)
190
        self.code = code
191
        self.fill_details_from_code(self.code)
192
        if not self.icon and not self.xpm:
193
            self.parse_wiki_page_for_icon(p)
194
        self.clean_icon()
195

196
        if not self.author:
197
            self.author = self.parse_desc("Author: ")
198
        if not self.date:
199
            self.date = self.parse_desc("Last modified: ")
200

201
    def _fetch_raw_code(self, page_data) -> Optional[str]:
202
        """Fetch code from the raw code URL specified on the wiki page."""
203
        code = None
204
        self.raw_code_url = re.findall('rawcodeurl.*?href="(http.*?)">', page_data)
205
        if self.raw_code_url:
206
            self.raw_code_url = self.raw_code_url[0]
207
            u2 = Macro.blocking_get(self.raw_code_url)
208
            if not u2:
209
                self._console.PrintWarning(
210
                    translate(
211
                        "AddonsInstaller",
212
                        "Unable to open macro code URL {}",
213
                    ).format(self.raw_code_url)
214
                    + "\n"
215
                )
216
                return None
217
            code = u2.decode("utf8")
218
            self._set_filename_from_url(self.raw_code_url)
219
        return code
220

221
    def _set_filename_from_url(self, url: str):
222
        lhs, slash, rhs = url.rpartition("/")
223
        if rhs.endswith(".py") or rhs.lower().endswith(".fcmacro"):
224
            self.filename_from_url = rhs
225

226
    @staticmethod
227
    def _read_code_from_wiki(p: str) -> Optional[str]:
228
        code = re.findall(r"<pre>(.*?)</pre>", p.replace("\n", "--endl--"))
229
        if code:
230
            # take the biggest code block
231
            code = str(sorted(code, key=len)[-1])
232
            code = code.replace("--endl--", "\n")
233
            # Clean HTML escape codes.
234
            code = unescape(code)
235
            code = code.replace(b"\xc2\xa0".decode("utf-8"), " ")
236
        return code
237

238
    def clean_icon(self):
239
        """Downloads the macro's icon from whatever source is specified and stores a local
240
        copy, potentially updating the internal icon location to that local storage."""
241
        if self.icon.startswith("http://") or self.icon.startswith("https://"):
242
            self._console.PrintLog(f"Attempting to fetch macro icon from {self.icon}\n")
243
            parsed_url = urllib.parse.urlparse(self.icon)
244
            p = Macro.blocking_get(self.icon)
245
            if p:
246
                cache_path = fci.DataPaths().cache_dir
247
                am_path = os.path.join(cache_path, "AddonManager", "MacroIcons")
248
                os.makedirs(am_path, exist_ok=True)
249
                _, _, filename = parsed_url.path.rpartition("/")
250
                base, _, extension = filename.rpartition(".")
251
                if base.lower().startswith("file:"):
252
                    self._console.PrintMessage(
253
                        f"Cannot use specified icon for {self.name}, {self.icon} "
254
                        "is not a direct download link\n"
255
                    )
256
                    self.icon = ""
257
                else:
258
                    constructed_name = os.path.join(am_path, base + "." + extension)
259
                    with open(constructed_name, "wb") as f:
260
                        f.write(p)
261
                    self.icon_source = self.icon
262
                    self.icon = constructed_name
263
            else:
264
                self._console.PrintLog(
265
                    f"MACRO DEVELOPER WARNING: failed to download icon from {self.icon}"
266
                    f" for macro {self.name}\n"
267
                )
268
                self.icon = ""
269

270
    def parse_desc(self, line_start: str) -> Union[str, None]:
271
        """Get data from the wiki for the value specified by line_start."""
272
        components = self.desc.split(">")
273
        for component in components:
274
            if component.startswith(line_start):
275
                end = component.find("<")
276
                return component[len(line_start) : end]
277
        return None
278

279
    def install(self, macro_dir: str) -> Tuple[bool, List[str]]:
280
        """Install a macro and all its related files
281
        Returns True if the macro was installed correctly.
282
        Parameters
283
        ----------
284
        - macro_dir: the directory to install into
285
        """
286

287
        if not self.code:
288
            return False, ["No code"]
289
        if not os.path.isdir(macro_dir):
290
            try:
291
                os.makedirs(macro_dir)
292
            except OSError:
293
                return False, [f"Failed to create {macro_dir}"]
294
        macro_path = os.path.join(macro_dir, self.filename)
295
        try:
296
            with codecs.open(macro_path, "w", "utf-8") as macrofile:
297
                macrofile.write(self.code)
298
        except OSError:
299
            return False, [f"Failed to write {macro_path}"]
300
        # Copy related files, which are supposed to be given relative to
301
        # self.src_filename.
302
        warnings = []
303

304
        self._copy_icon_data(macro_dir, warnings)
305
        success = self._copy_other_files(macro_dir, warnings)
306

307
        if warnings or not success > 0:
308
            return False, warnings
309

310
        self._console.PrintLog(f"Macro {self.name} was installed successfully.\n")
311
        return True, []
312

313
    def _copy_icon_data(self, macro_dir, warnings):
314
        """Copy any available icon data into the install directory"""
315
        base_dir = os.path.dirname(self.src_filename)
316
        if self.xpm:
317
            xpm_file = os.path.join(base_dir, self.name + "_icon.xpm")
318
            with open(xpm_file, "w", encoding="utf-8") as f:
319
                f.write(self.xpm)
320
        if self.icon:
321
            if os.path.isabs(self.icon):
322
                dst_file = os.path.normpath(os.path.join(macro_dir, os.path.basename(self.icon)))
323
                try:
324
                    shutil.copy(self.icon, dst_file)
325
                except OSError:
326
                    warnings.append(f"Failed to copy icon to {dst_file}")
327
            elif self.icon not in self.other_files:
328
                self.other_files.append(self.icon)
329

330
    def _copy_other_files(self, macro_dir, warnings) -> bool:
331
        """Copy any specified "other files" into the installation directory"""
332
        base_dir = os.path.dirname(self.src_filename)
333
        for other_file in self.other_files:
334
            if not other_file:
335
                continue
336
            if os.path.isabs(other_file):
337
                dst_dir = macro_dir
338
            else:
339
                dst_dir = os.path.join(macro_dir, os.path.dirname(other_file))
340
            if not os.path.isdir(dst_dir):
341
                try:
342
                    os.makedirs(dst_dir)
343
                except OSError:
344
                    warnings.append(f"Failed to create {dst_dir}")
345
                    return False
346
            if os.path.isabs(other_file):
347
                src_file = other_file
348
                dst_file = os.path.normpath(os.path.join(macro_dir, os.path.basename(other_file)))
349
            else:
350
                src_file = os.path.normpath(os.path.join(base_dir, other_file))
351
                dst_file = os.path.normpath(os.path.join(macro_dir, other_file))
352
            self._fetch_single_file(other_file, src_file, dst_file, warnings)
353
            try:
354
                shutil.copy(src_file, dst_file)
355
            except OSError:
356
                warnings.append(f"Failed to copy {src_file} to {dst_file}")
357
        return True  # No fatal errors, but some files may have failed to copy
358

359
    def _fetch_single_file(self, other_file, src_file, dst_file, warnings):
360
        if not os.path.isfile(src_file):
361
            # If the file does not exist, see if we have a raw code URL to fetch from
362
            if self.raw_code_url:
363
                fetch_url = self.raw_code_url.rsplit("/", 1)[0] + "/" + other_file
364
                self._console.PrintLog(f"Attempting to fetch {fetch_url}...\n")
365
                p = Macro.blocking_get(fetch_url)
366
                if p:
367
                    with open(dst_file, "wb") as f:
368
                        f.write(p)
369
                else:
370
                    self._console.PrintWarning(
371
                        translate(
372
                            "AddonsInstaller",
373
                            "Unable to fetch macro-specified file {} from {}",
374
                        ).format(other_file, fetch_url)
375
                        + "\n"
376
                    )
377
            else:
378
                warnings.append(
379
                    translate(
380
                        "AddonsInstaller",
381
                        "Could not locate macro-specified file {} (expected at {})",
382
                    ).format(other_file, src_file)
383
                )
384

385
    def parse_wiki_page_for_icon(self, page_data: str) -> None:
386
        """Attempt to find the url for the icon in the wiki page. Sets 'self.icon' if
387
        found."""
388

389
        # Method 1: the text "toolbar icon" appears on the page, and provides a direct
390
        # link to an icon
391

392
        # pylint: disable=line-too-long
393
        # Try to get an icon from the wiki page itself:
394
        # <a rel="nofollow" class="external text"
395
        # href="https://wiki.freecad.org/images/f/f5/blah.png">ToolBar Icon</a>
396
        icon_regex = re.compile(r'.*href="(.*?)">ToolBar Icon', re.IGNORECASE)
397
        wiki_icon = ""
398
        if "ToolBar Icon" in page_data:
399
            f = io.StringIO(page_data)
400
            lines = f.readlines()
401
            for line in lines:
402
                if ">ToolBar Icon<" in line:
403
                    match = icon_regex.match(line)
404
                    if match:
405
                        wiki_icon = match.group(1)
406
                        if "file:" not in wiki_icon.lower():
407
                            self.icon = wiki_icon
408
                            return
409
                        break
410

411
        # See if we found an icon, but it wasn't a direct link:
412
        icon_regex = re.compile(r'.*img.*?src="(.*?)"', re.IGNORECASE)
413
        if wiki_icon.startswith("http"):
414
            # It's a File: wiki link. We can load THAT page and get the image from it...
415
            self._console.PrintLog(f"Found a File: link for macro {self.name} -- {wiki_icon}\n")
416
            p = Macro.blocking_get(wiki_icon)
417
            if p:
418
                p = p.decode("utf8")
419
                f = io.StringIO(p)
420
                lines = f.readlines()
421
                trigger = False
422
                for line in lines:
423
                    if trigger:
424
                        match = icon_regex.match(line)
425
                        if match:
426
                            wiki_icon = match.group(1)
427
                            self.icon = "https://wiki.freecad.org/" + wiki_icon
428
                            return
429
                    elif "fullImageLink" in line:
430
                        trigger = True
431

432
            #    <div class="fullImageLink" id="file">
433
            #        <a href="/images/a/a2/Bevel.svg">
434
            #            <img alt="File:Bevel.svg" src="/images/a/a2/Bevel.svg"
435
            #            width="64" height="64"/>
436
            #        </a>
437

438

439
#  @}
440

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

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

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

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