FreeCAD

Форк
0
886 строк · 34.1 Кб
1
# SPDX-License-Identifier: LGPL-2.1-or-later
2
# ***************************************************************************
3
# *                                                                         *
4
# *   Copyright (c) 2022-2023 FreeCAD Project Association                   *
5
# *                                                                         *
6
# *   This file is part of FreeCAD.                                         *
7
# *                                                                         *
8
# *   FreeCAD is free software: you can redistribute it and/or modify it    *
9
# *   under the terms of the GNU Lesser General Public License as           *
10
# *   published by the Free Software Foundation, either version 2.1 of the  *
11
# *   License, or (at your option) any later version.                       *
12
# *                                                                         *
13
# *   FreeCAD is distributed in the hope that it will be useful, but        *
14
# *   WITHOUT ANY WARRANTY; without even the implied warranty of            *
15
# *   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU      *
16
# *   Lesser General Public License for more details.                       *
17
# *                                                                         *
18
# *   You should have received a copy of the GNU Lesser General Public      *
19
# *   License along with FreeCAD. If not, see                               *
20
# *   <https://www.gnu.org/licenses/>.                                      *
21
# *                                                                         *
22
# ***************************************************************************
23

24
""" Defines the Addon class to encapsulate information about FreeCAD Addons """
25

26
import os
27
import re
28
from datetime import datetime
29
from urllib.parse import urlparse
30
from typing import Dict, Set, List, Optional
31
from threading import Lock
32
from enum import IntEnum, auto
33

34
import addonmanager_freecad_interface as fci
35
from addonmanager_macro import Macro
36
import addonmanager_utilities as utils
37
from addonmanager_utilities import construct_git_url
38
from addonmanager_metadata import (
39
    Metadata,
40
    MetadataReader,
41
    UrlType,
42
    Version,
43
    DependencyType,
44
)
45
from AddonStats import AddonStats
46

47
translate = fci.translate
48

49
#  A list of internal workbenches that can be used as a dependency of an Addon
50
INTERNAL_WORKBENCHES = {
51
    "arch": "Arch",
52
    "assembly": "Assembly",
53
    "draft": "Draft",
54
    "fem": "FEM",
55
    "mesh": "Mesh",
56
    "openscad": "OpenSCAD",
57
    "part": "Part",
58
    "partdesign": "PartDesign",
59
    "cam": "CAM",
60
    "plot": "Plot",
61
    "points": "Points",
62
    "robot": "Robot",
63
    "sketcher": "Sketcher",
64
    "spreadsheet": "Spreadsheet",
65
    "techdraw": "TechDraw",
66
}
67

68

69
class Addon:
70
    """Encapsulates information about a FreeCAD addon"""
71

72
    class Kind(IntEnum):
73
        """The type of Addon: Workbench, macro, or package"""
74

75
        WORKBENCH = 1
76
        MACRO = 2
77
        PACKAGE = 3
78

79
        def __str__(self) -> str:
80
            if self.value == 1:
81
                return "Workbench"
82
            if self.value == 2:
83
                return "Macro"
84
            if self.value == 3:
85
                return "Package"
86
            return "ERROR_TYPE"
87

88
    class Status(IntEnum):
89
        """The installation status of an Addon"""
90

91
        NOT_INSTALLED = 0
92
        UNCHECKED = 1
93
        NO_UPDATE_AVAILABLE = 2
94
        UPDATE_AVAILABLE = 3
95
        PENDING_RESTART = 4
96
        CANNOT_CHECK = 5  # If we don't have git, etc.
97
        UNKNOWN = 100
98

99
        def __lt__(self, other):
100
            if self.__class__ is other.__class__:
101
                return self.value < other.value
102
            return NotImplemented
103

104
        def __str__(self) -> str:
105
            if self.value == 0:
106
                result = "Not installed"
107
            elif self.value == 1:
108
                result = "Unchecked"
109
            elif self.value == 2:
110
                result = "No update available"
111
            elif self.value == 3:
112
                result = "Update available"
113
            elif self.value == 4:
114
                result = "Restart required"
115
            elif self.value == 5:
116
                result = "Can't check"
117
            else:
118
                result = "ERROR_STATUS"
119
            return result
120

121
    class Dependencies:
122
        """Addon dependency information"""
123

124
        def __init__(self):
125
            self.required_external_addons = []  # A list of Addons
126
            self.blockers = []  # A list of Addons
127
            self.replaces = []  # A list of Addons
128
            self.internal_workbenches: Set[str] = set()  # Required internal workbenches
129
            self.python_requires: Set[str] = set()
130
            self.python_optional: Set[str] = set()
131
            self.python_min_version = {"major": 3, "minor": 0}
132

133
    class DependencyType(IntEnum):
134
        """Several types of dependency information is stored"""
135

136
        INTERNAL_WORKBENCH = auto()
137
        REQUIRED_ADDON = auto()
138
        BLOCKED_ADDON = auto()
139
        REPLACED_ADDON = auto()
140
        REQUIRED_PYTHON = auto()
141
        OPTIONAL_PYTHON = auto()
142

143
    class ResolutionFailed(RuntimeError):
144
        """An exception type for dependency resolution failure."""
145

146
    # The location of Addon Manager cache files: overridden by testing code
147
    cache_directory = os.path.join(fci.DataPaths().cache_dir, "AddonManager")
148

149
    # The location of the Mod directory: overridden by testing code
150
    mod_directory = fci.DataPaths().mod_dir
151

152
    def __init__(
153
        self,
154
        name: str,
155
        url: str = "",
156
        status: Status = Status.UNKNOWN,
157
        branch: str = "",
158
    ):
159
        self.name = name.strip()
160
        self.display_name = self.name
161
        self.url = url.strip()
162
        self.branch = branch.strip()
163
        self.python2 = False
164
        self.obsolete = False
165
        self.rejected = False
166
        self.repo_type = Addon.Kind.WORKBENCH
167
        self.description = None
168
        self.tags = set()  # Just a cache, loaded from Metadata
169
        self.last_updated = None
170
        self.stats = AddonStats()
171
        self.score = 0
172

173
        # To prevent multiple threads from running git actions on this repo at the
174
        # same time
175
        self.git_lock = Lock()
176

177
        # To prevent multiple threads from accessing the status at the same time
178
        self.status_lock = Lock()
179
        self.update_status = status
180

181
        self._clean_url()
182

183
        if utils.recognized_git_location(self):
184
            self.metadata_url = construct_git_url(self, "package.xml")
185
        else:
186
            self.metadata_url = None
187
        self.metadata: Optional[Metadata] = None
188
        self.icon = None  # A QIcon version of this Addon's icon
189
        self.icon_file: str = ""  # Absolute local path to cached icon file
190
        self.best_icon_relative_path = ""
191
        self.macro = None  # Bridge to Gaël Écorchard's macro management class
192
        self.updated_timestamp = None
193
        self.installed_version = None
194
        self.installed_metadata = None
195

196
        # Each repo is also a node in a directed dependency graph (referenced by name so
197
        # they can be serialized):
198
        self.requires: Set[str] = set()
199
        self.blocks: Set[str] = set()
200

201
        # And maintains a list of required and optional Python dependencies
202
        self.python_requires: Set[str] = set()
203
        self.python_optional: Set[str] = set()
204
        self.python_min_version = {"major": 3, "minor": 0}
205

206
        self._icon_file = None
207
        self._cached_license: str = ""
208
        self._cached_update_date = None
209

210
    def _clean_url(self):
211
        # The url should never end in ".git", so strip it if it's there
212
        parsed_url = urlparse(self.url)
213
        if parsed_url.path.endswith(".git"):
214
            self.url = parsed_url.scheme + "://" + parsed_url.netloc + parsed_url.path[:-4]
215
            if parsed_url.query:
216
                self.url += "?" + parsed_url.query
217
            if parsed_url.fragment:
218
                self.url += "#" + parsed_url.fragment
219

220
    def __str__(self) -> str:
221
        result = f"FreeCAD {self.repo_type}\n"
222
        result += f"Name: {self.name}\n"
223
        result += f"URL: {self.url}\n"
224
        result += "Has metadata\n" if self.metadata is not None else "No metadata found\n"
225
        if self.macro is not None:
226
            result += "Has linked Macro object\n"
227
        return result
228

229
    @property
230
    def license(self):
231
        if not self._cached_license:
232
            self._cached_license = "UNLICENSED"
233
            if self.metadata and self.metadata.license:
234
                self._cached_license = self.metadata.license
235
            elif self.stats and self.stats.license:
236
                self._cached_license = self.stats.license
237
            elif self.macro:
238
                if self.macro.license:
239
                    self._cached_license = self.macro.license
240
                elif self.macro.on_wiki:
241
                    self._cached_license = "CC-BY-3.0"
242
        return self._cached_license
243

244
    @property
245
    def update_date(self):
246
        if self._cached_update_date is None:
247
            self._cached_update_date = 0
248
            if self.stats and self.stats.last_update_time:
249
                self._cached_update_date = self.stats.last_update_time
250
            elif self.macro and self.macro.date:
251
                # Try to parse the date:
252
                try:
253
                    self._cached_update_date = self._process_date_string_to_python_datetime(
254
                        self.macro.date
255
                    )
256
                except SyntaxError as e:
257
                    fci.Console.PrintWarning(str(e) + "\n")
258
            else:
259
                fci.Console.PrintWarning(f"No update date info for {self.name}\n")
260
        return self._cached_update_date
261

262
    def _process_date_string_to_python_datetime(self, date_string: str) -> datetime:
263
        split_result = re.split(r"[ ./-]+", date_string.strip())
264
        if len(split_result) != 3:
265
            raise SyntaxError(
266
                f"In macro {self.name}, unrecognized date string '{date_string}' (expected YYYY-MM-DD)"
267
            )
268

269
        if int(split_result[0]) > 2000:  # Assume YYYY-MM-DD
270
            try:
271
                year = int(split_result[0])
272
                month = int(split_result[1])
273
                day = int(split_result[2])
274
                return datetime(year, month, day)
275
            except (OverflowError, OSError, ValueError):
276
                raise SyntaxError(
277
                    f"In macro {self.name}, unrecognized date string {date_string} (expected YYYY-MM-DD)"
278
                )
279
        elif int(split_result[2]) > 2000:
280
            # Two possibilities, impossible to distinguish in the general case: DD-MM-YYYY and
281
            # MM-DD-YYYY. See if the first one makes sense, and if not, try the second
282
            if int(split_result[1]) <= 12:
283
                year = int(split_result[2])
284
                month = int(split_result[1])
285
                day = int(split_result[0])
286
            else:
287
                year = int(split_result[2])
288
                month = int(split_result[0])
289
                day = int(split_result[1])
290
            return datetime(year, month, day)
291
        else:
292
            raise SyntaxError(
293
                f"In macro {self.name}, unrecognized date string '{date_string}' (expected YYYY-MM-DD)"
294
            )
295

296
    @classmethod
297
    def from_macro(cls, macro: Macro):
298
        """Create an Addon object from a Macro wrapper object"""
299

300
        if macro.is_installed():
301
            status = Addon.Status.UNCHECKED
302
        else:
303
            status = Addon.Status.NOT_INSTALLED
304
        instance = Addon(macro.name, macro.url, status, "master")
305
        instance.macro = macro
306
        instance.repo_type = Addon.Kind.MACRO
307
        instance.description = macro.desc
308
        return instance
309

310
    @classmethod
311
    def from_cache(cls, cache_dict: Dict):
312
        """Load basic data from cached dict data. Does not include Macro or Metadata
313
        information, which must be populated separately."""
314

315
        mod_dir = os.path.join(cls.mod_directory, cache_dict["name"])
316
        if os.path.isdir(mod_dir):
317
            status = Addon.Status.UNCHECKED
318
        else:
319
            status = Addon.Status.NOT_INSTALLED
320
        instance = Addon(cache_dict["name"], cache_dict["url"], status, cache_dict["branch"])
321

322
        for key, value in cache_dict.items():
323
            if not str(key).startswith("_"):
324
                instance.__dict__[key] = value
325

326
        instance.repo_type = Addon.Kind(cache_dict["repo_type"])
327
        if instance.repo_type == Addon.Kind.PACKAGE:
328
            # There must be a cached metadata file, too
329
            cached_package_xml_file = os.path.join(
330
                instance.cache_directory,
331
                "PackageMetadata",
332
                instance.name,
333
            )
334
            if os.path.isfile(cached_package_xml_file):
335
                instance.load_metadata_file(cached_package_xml_file)
336

337
        instance._load_installed_metadata()
338

339
        if "requires" in cache_dict:
340
            instance.requires = set(cache_dict["requires"])
341
            instance.blocks = set(cache_dict["blocks"])
342
            instance.python_requires = set(cache_dict["python_requires"])
343
            instance.python_optional = set(cache_dict["python_optional"])
344

345
        instance._clean_url()
346

347
        return instance
348

349
    def to_cache(self) -> Dict:
350
        """Returns a dictionary with cache information that can be used later with
351
        from_cache to recreate this object."""
352

353
        return {
354
            "name": self.name,
355
            "display_name": self.display_name,
356
            "url": self.url,
357
            "branch": self.branch,
358
            "repo_type": int(self.repo_type),
359
            "description": self.description,
360
            "cached_icon_filename": self.get_cached_icon_filename(),
361
            "best_icon_relative_path": self.get_best_icon_relative_path(),
362
            "python2": self.python2,
363
            "obsolete": self.obsolete,
364
            "rejected": self.rejected,
365
            "requires": list(self.requires),
366
            "blocks": list(self.blocks),
367
            "python_requires": list(self.python_requires),
368
            "python_optional": list(self.python_optional),
369
        }
370

371
    def load_metadata_file(self, file: str) -> None:
372
        """Read a given metadata file and set it as this object's metadata"""
373

374
        if os.path.exists(file):
375
            metadata = MetadataReader.from_file(file)
376
            self.set_metadata(metadata)
377
            self._clean_url()
378
        else:
379
            fci.Console.PrintLog(f"Internal error: {file} does not exist")
380

381
    def _load_installed_metadata(self) -> None:
382
        # If it is actually installed, there is a SECOND metadata file, in the actual installation,
383
        # that may not match the cached one if the Addon has not been updated but the cache has.
384
        mod_dir = os.path.join(self.mod_directory, self.name)
385
        installed_metadata_path = os.path.join(mod_dir, "package.xml")
386
        if os.path.isfile(installed_metadata_path):
387
            self.installed_metadata = MetadataReader.from_file(installed_metadata_path)
388

389
    def set_metadata(self, metadata: Metadata) -> None:
390
        """Set the given metadata object as this object's metadata, updating the
391
        object's display name and package type information to match, as well as
392
        updating any dependency information, etc.
393
        """
394

395
        self.metadata = metadata
396
        self.display_name = metadata.name
397
        self.repo_type = Addon.Kind.PACKAGE
398
        self.description = metadata.description
399
        for url in metadata.url:
400
            if url.type == UrlType.repository:
401
                self.url = url.location
402
                self.branch = url.branch if url.branch else "master"
403
        self._clean_url()
404
        self.extract_tags(self.metadata)
405
        self.extract_metadata_dependencies(self.metadata)
406

407
    @staticmethod
408
    def version_is_ok(metadata: Metadata) -> bool:
409
        """Checks to see if the current running version of FreeCAD meets the
410
        requirements set by the passed-in metadata parameter."""
411

412
        from_fci = list(fci.Version())
413
        fc_version = Version(from_list=from_fci)
414

415
        dep_fc_min = metadata.freecadmin if metadata.freecadmin else fc_version
416
        dep_fc_max = metadata.freecadmax if metadata.freecadmax else fc_version
417

418
        return dep_fc_min <= fc_version <= dep_fc_max
419

420
    def extract_metadata_dependencies(self, metadata: Metadata):
421
        """Read dependency information from a metadata object and store it in this
422
        Addon"""
423

424
        # Version check: if this piece of metadata doesn't apply to this version of
425
        # FreeCAD, just skip it.
426
        if not Addon.version_is_ok(metadata):
427
            return
428

429
        if metadata.pythonmin:
430
            self.python_min_version["major"] = metadata.pythonmin.version_as_list[0]
431
            self.python_min_version["minor"] = metadata.pythonmin.version_as_list[1]
432

433
        for dep in metadata.depend:
434
            if dep.dependency_type == DependencyType.internal:
435
                if dep.package in INTERNAL_WORKBENCHES:
436
                    self.requires.add(dep.package)
437
                else:
438
                    fci.Console.PrintWarning(
439
                        translate(
440
                            "AddonsInstaller",
441
                            "{}: Unrecognized internal workbench '{}'",
442
                        ).format(self.name, dep.package)
443
                    )
444
            elif dep.dependency_type == DependencyType.addon:
445
                self.requires.add(dep.package)
446
            elif dep.dependency_type == DependencyType.python:
447
                if dep.optional:
448
                    self.python_optional.add(dep.package)
449
                else:
450
                    self.python_requires.add(dep.package)
451
            else:
452
                # Automatic resolution happens later, once we have a complete list of
453
                # Addons
454
                self.requires.add(dep.package)
455

456
        for dep in metadata.conflict:
457
            self.blocks.add(dep.package)
458

459
        # Recurse
460
        content = metadata.content
461
        for _, value in content.items():
462
            for item in value:
463
                self.extract_metadata_dependencies(item)
464

465
    def verify_url_and_branch(self, url: str, branch: str) -> None:
466
        """Print diagnostic information for Addon Developers if their metadata is
467
        inconsistent with the actual fetch location. Most often this is due to using
468
        the wrong branch name."""
469

470
        if self.url != url:
471
            fci.Console.PrintWarning(
472
                translate(
473
                    "AddonsInstaller",
474
                    "Addon Developer Warning: Repository URL set in package.xml file for addon {} ({}) does not match the URL it was fetched from ({})",
475
                ).format(self.display_name, self.url, url)
476
                + "\n"
477
            )
478
        if self.branch != branch:
479
            fci.Console.PrintWarning(
480
                translate(
481
                    "AddonsInstaller",
482
                    "Addon Developer Warning: Repository branch set in package.xml file for addon {} ({}) does not match the branch it was fetched from ({})",
483
                ).format(self.display_name, self.branch, branch)
484
                + "\n"
485
            )
486

487
    def extract_tags(self, metadata: Metadata) -> None:
488
        """Read the tags from the metadata object"""
489

490
        # Version check: if this piece of metadata doesn't apply to this version of
491
        # FreeCAD, just skip it.
492
        if not Addon.version_is_ok(metadata):
493
            return
494

495
        for new_tag in metadata.tag:
496
            self.tags.add(new_tag)
497

498
        content = metadata.content
499
        for _, value in content.items():
500
            for item in value:
501
                self.extract_tags(item)
502

503
    def contains_workbench(self) -> bool:
504
        """Determine if this package contains (or is) a workbench"""
505

506
        if self.repo_type == Addon.Kind.WORKBENCH:
507
            return True
508
        if self.repo_type == Addon.Kind.PACKAGE:
509
            if self.metadata is None:
510
                fci.Console.PrintLog(
511
                    f"Addon Manager internal error: lost metadata for package {self.name}\n"
512
                )
513
                return False
514
            content = self.metadata.content
515
            if not content:
516
                return False
517
            return "workbench" in content
518
        return False
519

520
    def contains_macro(self) -> bool:
521
        """Determine if this package contains (or is) a macro"""
522

523
        if self.repo_type == Addon.Kind.MACRO:
524
            return True
525
        if self.repo_type == Addon.Kind.PACKAGE:
526
            if self.metadata is None:
527
                fci.Console.PrintLog(
528
                    f"Addon Manager internal error: lost metadata for package {self.name}\n"
529
                )
530
                return False
531
            content = self.metadata.content
532
            return "macro" in content
533
        return False
534

535
    def contains_preference_pack(self) -> bool:
536
        """Determine if this package contains a preference pack"""
537

538
        if self.repo_type == Addon.Kind.PACKAGE:
539
            if self.metadata is None:
540
                fci.Console.PrintLog(
541
                    f"Addon Manager internal error: lost metadata for package {self.name}\n"
542
                )
543
                return False
544
            content = self.metadata.content
545
            return "preferencepack" in content
546
        return False
547

548
    def get_best_icon_relative_path(self) -> str:
549
        """Get the path within the repo the addon's icon. Usually specified by
550
        top-level metadata, but some authors omit it and specify only icons for the
551
        contents. Find the first one of those, in such cases."""
552

553
        if self.best_icon_relative_path:
554
            return self.best_icon_relative_path
555

556
        if not self.metadata:
557
            return ""
558

559
        real_icon = self.metadata.icon
560
        if not real_icon:
561
            # If there is no icon set for the entire package, see if there are any
562
            # workbenches, which are required to have icons, and grab the first one
563
            # we find:
564
            content = self.metadata.content
565
            if "workbench" in content:
566
                wb = content["workbench"][0]
567
                if wb.icon:
568
                    if wb.subdirectory:
569
                        subdir = wb.subdirectory
570
                    else:
571
                        subdir = wb.name
572
                    real_icon = subdir + wb.icon
573

574
        self.best_icon_relative_path = real_icon
575
        return self.best_icon_relative_path
576

577
    def get_cached_icon_filename(self) -> str:
578
        """NOTE: This function is deprecated and will be removed in a coming update."""
579

580
        if hasattr(self, "cached_icon_filename") and self.cached_icon_filename:
581
            return self.cached_icon_filename
582

583
        if not self.metadata:
584
            return ""
585

586
        real_icon = self.metadata.icon
587
        if not real_icon:
588
            # If there is no icon set for the entire package, see if there are any
589
            # workbenches, which are required to have icons, and grab the first one
590
            # we find:
591
            content = self.metadata.content
592
            if "workbench" in content:
593
                wb = content["workbench"][0]
594
                if wb.icon:
595
                    if wb.subdirectory:
596
                        subdir = wb.subdirectory
597
                    else:
598
                        subdir = wb.name
599
                    real_icon = subdir + wb.icon
600

601
        real_icon = real_icon.replace(
602
            "/", os.path.sep
603
        )  # Required path separator in the metadata.xml file to local separator
604

605
        _, file_extension = os.path.splitext(real_icon)
606
        store = os.path.join(self.cache_directory, "PackageMetadata")
607
        self.cached_icon_filename = os.path.join(store, self.name, "cached_icon" + file_extension)
608

609
        return self.cached_icon_filename
610

611
    def walk_dependency_tree(self, all_repos, deps):
612
        """Compute the total dependency tree for this repo (recursive)
613
        - all_repos is a dictionary of repos, keyed on the name of the repo
614
        - deps is an Addon.Dependency object encapsulating all the types of dependency
615
        information that may be needed.
616
        """
617

618
        deps.python_requires |= self.python_requires
619
        deps.python_optional |= self.python_optional
620

621
        deps.python_min_version["major"] = max(
622
            deps.python_min_version["major"], self.python_min_version["major"]
623
        )
624
        if deps.python_min_version["major"] == 3:
625
            deps.python_min_version["minor"] = max(
626
                deps.python_min_version["minor"], self.python_min_version["minor"]
627
            )
628
        else:
629
            fci.Console.PrintWarning("Unrecognized Python version information")
630

631
        for dep in self.requires:
632
            if dep in all_repos:
633
                if dep not in deps.required_external_addons:
634
                    deps.required_external_addons.append(all_repos[dep])
635
                    all_repos[dep].walk_dependency_tree(all_repos, deps)
636
            else:
637
                # See if this is an internal workbench:
638
                if dep.upper().endswith("WB"):
639
                    real_name = dep[:-2].strip().lower()
640
                elif dep.upper().endswith("WORKBENCH"):
641
                    real_name = dep[:-9].strip().lower()
642
                else:
643
                    real_name = dep.strip().lower()
644

645
                if real_name in INTERNAL_WORKBENCHES:
646
                    deps.internal_workbenches.add(INTERNAL_WORKBENCHES[real_name])
647
                else:
648
                    # Assume it's a Python requirement of some kind:
649
                    deps.python_requires.add(dep)
650

651
        for dep in self.blocks:
652
            if dep in all_repos:
653
                deps.blockers[dep] = all_repos[dep]
654

655
    def status(self):
656
        """Threadsafe access to the current update status"""
657
        with self.status_lock:
658
            return self.update_status
659

660
    def set_status(self, status):
661
        """Threadsafe setting of the update status"""
662
        with self.status_lock:
663
            self.update_status = status
664

665
    def is_disabled(self):
666
        """Check to see if the disabling stopfile exists"""
667

668
        stopfile = os.path.join(self.mod_directory, self.name, "ADDON_DISABLED")
669
        return os.path.exists(stopfile)
670

671
    def disable(self):
672
        """Disable this addon from loading when FreeCAD starts up by creating a
673
        stopfile"""
674

675
        stopfile = os.path.join(self.mod_directory, self.name, "ADDON_DISABLED")
676
        with open(stopfile, "w", encoding="utf-8") as f:
677
            f.write(
678
                "The existence of this file prevents FreeCAD from loading this Addon. To re-enable, delete the file."
679
            )
680

681
        if self.contains_workbench():
682
            self.disable_workbench()
683

684
    def enable(self):
685
        """Re-enable loading this addon by deleting the stopfile"""
686

687
        stopfile = os.path.join(self.mod_directory, self.name, "ADDON_DISABLED")
688
        try:
689
            os.unlink(stopfile)
690
        except FileNotFoundError:
691
            pass
692

693
        if self.contains_workbench():
694
            self.enable_workbench()
695

696
    def enable_workbench(self):
697
        wbName = self.get_workbench_name()
698

699
        # Remove from the list of disabled.
700
        self.remove_from_disabled_wbs(wbName)
701

702
    def disable_workbench(self):
703
        pref = fci.ParamGet("User parameter:BaseApp/Preferences/Workbenches")
704
        wbName = self.get_workbench_name()
705

706
        # Add the wb to the list of disabled if it was not already
707
        disabled_wbs = pref.GetString("Disabled", "NoneWorkbench,TestWorkbench")
708
        # print(f"start disabling {disabled_wbs}")
709
        disabled_wbs_list = disabled_wbs.split(",")
710
        if not (wbName in disabled_wbs_list):
711
            disabled_wbs += "," + wbName
712
        pref.SetString("Disabled", disabled_wbs)
713
        # print(f"done disabling :  {disabled_wbs} \n")
714

715
    def desinstall_workbench(self):
716
        pref = fci.ParamGet("User parameter:BaseApp/Preferences/Workbenches")
717
        wbName = self.get_workbench_name()
718

719
        # Remove from the list of ordered.
720
        ordered_wbs = pref.GetString("Ordered", "")
721
        # print(f"start remove from ordering {ordered_wbs}")
722
        ordered_wbs_list = ordered_wbs.split(",")
723
        ordered_wbs = ""
724
        for wb in ordered_wbs_list:
725
            if wb != wbName:
726
                if ordered_wbs != "":
727
                    ordered_wbs += ","
728
                ordered_wbs += wb
729
        pref.SetString("Ordered", ordered_wbs)
730
        # print(f"end remove from ordering {ordered_wbs}")
731

732
        # Remove from the list of disabled.
733
        self.remove_from_disabled_wbs(wbName)
734

735
    def remove_from_disabled_wbs(self, wbName: str):
736
        pref = fci.ParamGet("User parameter:BaseApp/Preferences/Workbenches")
737

738
        disabled_wbs = pref.GetString("Disabled", "NoneWorkbench,TestWorkbench")
739
        # print(f"start enabling : {disabled_wbs}")
740
        disabled_wbs_list = disabled_wbs.split(",")
741
        disabled_wbs = ""
742
        for wb in disabled_wbs_list:
743
            if wb != wbName:
744
                if disabled_wbs != "":
745
                    disabled_wbs += ","
746
                disabled_wbs += wb
747
        pref.SetString("Disabled", disabled_wbs)
748
        # print(f"Done enabling {disabled_wbs} \n")
749

750
    def get_workbench_name(self) -> str:
751
        """Find the name of the workbench class (ie the name under which it's
752
        registered in freecad core)'"""
753
        wb_name = ""
754

755
        if self.repo_type == Addon.Kind.PACKAGE:
756
            for wb in self.metadata.content["workbench"]:  # we may have more than one wb.
757
                if wb_name != "":
758
                    wb_name += ","
759
                wb_name += wb.classname
760
        if self.repo_type == Addon.Kind.WORKBENCH or wb_name == "":
761
            wb_name = self.try_find_wbname_in_files()
762
        if wb_name == "":
763
            wb_name = self.name
764
        return wb_name
765

766
    def try_find_wbname_in_files(self) -> str:
767
        """Attempt to locate a line with an addWorkbench command in the workbench's
768
        Python files. If it is directly instantiating a workbench, then we can use
769
        the line to determine classname for this workbench. If it uses a variable,
770
        or if the line doesn't exist at all, an empty string is returned."""
771
        mod_dir = os.path.join(self.mod_directory, self.name)
772

773
        for root, _, files in os.walk(mod_dir):
774
            for f in files:
775
                current_file = os.path.join(root, f)
776
                if not os.path.isdir(current_file):
777
                    filename, extension = os.path.splitext(current_file)
778
                    if extension == ".py":
779
                        wb_classname = self._find_classname_in_file(current_file)
780
                        if wb_classname:
781
                            return wb_classname
782
        return ""
783

784
    @staticmethod
785
    def _find_classname_in_file(current_file) -> str:
786
        try:
787
            with open(current_file, "r", encoding="utf-8") as python_file:
788
                content = python_file.read()
789
                search_result = re.search(r"Gui.addWorkbench\s*\(\s*(\w+)\s*\(\s*\)\s*\)", content)
790
                if search_result:
791
                    return search_result.group(1)
792
        except OSError:
793
            pass
794
        return ""
795

796

797
# @dataclass(frozen)
798
class MissingDependencies:
799
    """Encapsulates a group of four types of dependencies:
800
    * Internal workbenches -> wbs
801
    * External addons -> external_addons
802
    * Required Python packages -> python_requires
803
    * Optional Python packages -> python_optional
804
    """
805

806
    def __init__(self, repo: Addon, all_repos: List[Addon]):
807
        deps = Addon.Dependencies()
808
        repo_name_dict = {}
809
        for r in all_repos:
810
            repo_name_dict[r.name] = r
811
            if hasattr(r, "display_name"):
812
                # Test harness might not provide a display name
813
                repo_name_dict[r.display_name] = r
814

815
        if hasattr(repo, "walk_dependency_tree"):
816
            # Sometimes the test harness doesn't provide this function, to override
817
            # any dependency checking
818
            repo.walk_dependency_tree(repo_name_dict, deps)
819

820
        self.external_addons = []
821
        for dep in deps.required_external_addons:
822
            if dep.status() == Addon.Status.NOT_INSTALLED:
823
                self.external_addons.append(dep.name)
824

825
        # Now check the loaded addons to see if we are missing an internal workbench:
826
        if fci.FreeCADGui:
827
            wbs = [wb.lower() for wb in fci.FreeCADGui.listWorkbenches()]
828
        else:
829
            wbs = []
830

831
        self.wbs = []
832
        for dep in deps.internal_workbenches:
833
            if dep.lower() + "workbench" not in wbs:
834
                if dep.lower() == "plot":
835
                    # Special case for plot, which is no longer a full workbench:
836
                    try:
837
                        __import__("Plot")
838
                    except ImportError:
839
                        # Plot might fail for a number of reasons
840
                        self.wbs.append(dep)
841
                        fci.Console.PrintLog("Failed to import Plot module")
842
                else:
843
                    self.wbs.append(dep)
844

845
        # Check the Python dependencies:
846
        self.python_min_version = deps.python_min_version
847
        self.python_requires = []
848
        for py_dep in deps.python_requires:
849
            if py_dep not in self.python_requires:
850
                try:
851
                    __import__(py_dep)
852
                except ImportError:
853
                    self.python_requires.append(py_dep)
854
                except (OSError, NameError, TypeError, RuntimeError) as e:
855
                    fci.Console.PrintWarning(
856
                        translate(
857
                            "AddonsInstaller",
858
                            "Got an error when trying to import {}",
859
                        ).format(py_dep)
860
                        + ":\n"
861
                        + str(e)
862
                    )
863

864
        self.python_optional = []
865
        for py_dep in deps.python_optional:
866
            try:
867
                __import__(py_dep)
868
            except ImportError:
869
                self.python_optional.append(py_dep)
870
            except (OSError, NameError, TypeError, RuntimeError) as e:
871
                fci.Console.PrintWarning(
872
                    translate(
873
                        "AddonsInstaller",
874
                        "Got an error when trying to import {}",
875
                    ).format(py_dep)
876
                    + ":\n"
877
                    + str(e)
878
                )
879

880
        self.wbs.sort()
881
        self.external_addons.sort()
882
        self.python_requires.sort()
883
        self.python_optional.sort()
884
        self.python_optional = [
885
            option for option in self.python_optional if option not in self.python_requires
886
        ]
887

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

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

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

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