FreeCAD
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
26import os27import re28from datetime import datetime29from urllib.parse import urlparse30from typing import Dict, Set, List, Optional31from threading import Lock32from enum import IntEnum, auto33
34import addonmanager_freecad_interface as fci35from addonmanager_macro import Macro36import addonmanager_utilities as utils37from addonmanager_utilities import construct_git_url38from addonmanager_metadata import (39Metadata,40MetadataReader,41UrlType,42Version,43DependencyType,44)
45from AddonStats import AddonStats46
47translate = fci.translate48
49# A list of internal workbenches that can be used as a dependency of an Addon
50INTERNAL_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
69class Addon:70"""Encapsulates information about a FreeCAD addon"""71
72class Kind(IntEnum):73"""The type of Addon: Workbench, macro, or package"""74
75WORKBENCH = 176MACRO = 277PACKAGE = 378
79def __str__(self) -> str:80if self.value == 1:81return "Workbench"82if self.value == 2:83return "Macro"84if self.value == 3:85return "Package"86return "ERROR_TYPE"87
88class Status(IntEnum):89"""The installation status of an Addon"""90
91NOT_INSTALLED = 092UNCHECKED = 193NO_UPDATE_AVAILABLE = 294UPDATE_AVAILABLE = 395PENDING_RESTART = 496CANNOT_CHECK = 5 # If we don't have git, etc.97UNKNOWN = 10098
99def __lt__(self, other):100if self.__class__ is other.__class__:101return self.value < other.value102return NotImplemented103
104def __str__(self) -> str:105if self.value == 0:106result = "Not installed"107elif self.value == 1:108result = "Unchecked"109elif self.value == 2:110result = "No update available"111elif self.value == 3:112result = "Update available"113elif self.value == 4:114result = "Restart required"115elif self.value == 5:116result = "Can't check"117else:118result = "ERROR_STATUS"119return result120
121class Dependencies:122"""Addon dependency information"""123
124def __init__(self):125self.required_external_addons = [] # A list of Addons126self.blockers = [] # A list of Addons127self.replaces = [] # A list of Addons128self.internal_workbenches: Set[str] = set() # Required internal workbenches129self.python_requires: Set[str] = set()130self.python_optional: Set[str] = set()131self.python_min_version = {"major": 3, "minor": 0}132
133class DependencyType(IntEnum):134"""Several types of dependency information is stored"""135
136INTERNAL_WORKBENCH = auto()137REQUIRED_ADDON = auto()138BLOCKED_ADDON = auto()139REPLACED_ADDON = auto()140REQUIRED_PYTHON = auto()141OPTIONAL_PYTHON = auto()142
143class ResolutionFailed(RuntimeError):144"""An exception type for dependency resolution failure."""145
146# The location of Addon Manager cache files: overridden by testing code147cache_directory = os.path.join(fci.DataPaths().cache_dir, "AddonManager")148
149# The location of the Mod directory: overridden by testing code150mod_directory = fci.DataPaths().mod_dir151
152def __init__(153self,154name: str,155url: str = "",156status: Status = Status.UNKNOWN,157branch: str = "",158):159self.name = name.strip()160self.display_name = self.name161self.url = url.strip()162self.branch = branch.strip()163self.python2 = False164self.obsolete = False165self.rejected = False166self.repo_type = Addon.Kind.WORKBENCH167self.description = None168self.tags = set() # Just a cache, loaded from Metadata169self.last_updated = None170self.stats = AddonStats()171self.score = 0172
173# To prevent multiple threads from running git actions on this repo at the174# same time175self.git_lock = Lock()176
177# To prevent multiple threads from accessing the status at the same time178self.status_lock = Lock()179self.update_status = status180
181self._clean_url()182
183if utils.recognized_git_location(self):184self.metadata_url = construct_git_url(self, "package.xml")185else:186self.metadata_url = None187self.metadata: Optional[Metadata] = None188self.icon = None # A QIcon version of this Addon's icon189self.icon_file: str = "" # Absolute local path to cached icon file190self.best_icon_relative_path = ""191self.macro = None # Bridge to Gaël Écorchard's macro management class192self.updated_timestamp = None193self.installed_version = None194self.installed_metadata = None195
196# Each repo is also a node in a directed dependency graph (referenced by name so197# they can be serialized):198self.requires: Set[str] = set()199self.blocks: Set[str] = set()200
201# And maintains a list of required and optional Python dependencies202self.python_requires: Set[str] = set()203self.python_optional: Set[str] = set()204self.python_min_version = {"major": 3, "minor": 0}205
206self._icon_file = None207self._cached_license: str = ""208self._cached_update_date = None209
210def _clean_url(self):211# The url should never end in ".git", so strip it if it's there212parsed_url = urlparse(self.url)213if parsed_url.path.endswith(".git"):214self.url = parsed_url.scheme + "://" + parsed_url.netloc + parsed_url.path[:-4]215if parsed_url.query:216self.url += "?" + parsed_url.query217if parsed_url.fragment:218self.url += "#" + parsed_url.fragment219
220def __str__(self) -> str:221result = f"FreeCAD {self.repo_type}\n"222result += f"Name: {self.name}\n"223result += f"URL: {self.url}\n"224result += "Has metadata\n" if self.metadata is not None else "No metadata found\n"225if self.macro is not None:226result += "Has linked Macro object\n"227return result228
229@property230def license(self):231if not self._cached_license:232self._cached_license = "UNLICENSED"233if self.metadata and self.metadata.license:234self._cached_license = self.metadata.license235elif self.stats and self.stats.license:236self._cached_license = self.stats.license237elif self.macro:238if self.macro.license:239self._cached_license = self.macro.license240elif self.macro.on_wiki:241self._cached_license = "CC-BY-3.0"242return self._cached_license243
244@property245def update_date(self):246if self._cached_update_date is None:247self._cached_update_date = 0248if self.stats and self.stats.last_update_time:249self._cached_update_date = self.stats.last_update_time250elif self.macro and self.macro.date:251# Try to parse the date:252try:253self._cached_update_date = self._process_date_string_to_python_datetime(254self.macro.date255)256except SyntaxError as e:257fci.Console.PrintWarning(str(e) + "\n")258else:259fci.Console.PrintWarning(f"No update date info for {self.name}\n")260return self._cached_update_date261
262def _process_date_string_to_python_datetime(self, date_string: str) -> datetime:263split_result = re.split(r"[ ./-]+", date_string.strip())264if len(split_result) != 3:265raise SyntaxError(266f"In macro {self.name}, unrecognized date string '{date_string}' (expected YYYY-MM-DD)"267)268
269if int(split_result[0]) > 2000: # Assume YYYY-MM-DD270try:271year = int(split_result[0])272month = int(split_result[1])273day = int(split_result[2])274return datetime(year, month, day)275except (OverflowError, OSError, ValueError):276raise SyntaxError(277f"In macro {self.name}, unrecognized date string {date_string} (expected YYYY-MM-DD)"278)279elif int(split_result[2]) > 2000:280# Two possibilities, impossible to distinguish in the general case: DD-MM-YYYY and281# MM-DD-YYYY. See if the first one makes sense, and if not, try the second282if int(split_result[1]) <= 12:283year = int(split_result[2])284month = int(split_result[1])285day = int(split_result[0])286else:287year = int(split_result[2])288month = int(split_result[0])289day = int(split_result[1])290return datetime(year, month, day)291else:292raise SyntaxError(293f"In macro {self.name}, unrecognized date string '{date_string}' (expected YYYY-MM-DD)"294)295
296@classmethod297def from_macro(cls, macro: Macro):298"""Create an Addon object from a Macro wrapper object"""299
300if macro.is_installed():301status = Addon.Status.UNCHECKED302else:303status = Addon.Status.NOT_INSTALLED304instance = Addon(macro.name, macro.url, status, "master")305instance.macro = macro306instance.repo_type = Addon.Kind.MACRO307instance.description = macro.desc308return instance309
310@classmethod311def from_cache(cls, cache_dict: Dict):312"""Load basic data from cached dict data. Does not include Macro or Metadata313information, which must be populated separately."""
314
315mod_dir = os.path.join(cls.mod_directory, cache_dict["name"])316if os.path.isdir(mod_dir):317status = Addon.Status.UNCHECKED318else:319status = Addon.Status.NOT_INSTALLED320instance = Addon(cache_dict["name"], cache_dict["url"], status, cache_dict["branch"])321
322for key, value in cache_dict.items():323if not str(key).startswith("_"):324instance.__dict__[key] = value325
326instance.repo_type = Addon.Kind(cache_dict["repo_type"])327if instance.repo_type == Addon.Kind.PACKAGE:328# There must be a cached metadata file, too329cached_package_xml_file = os.path.join(330instance.cache_directory,331"PackageMetadata",332instance.name,333)334if os.path.isfile(cached_package_xml_file):335instance.load_metadata_file(cached_package_xml_file)336
337instance._load_installed_metadata()338
339if "requires" in cache_dict:340instance.requires = set(cache_dict["requires"])341instance.blocks = set(cache_dict["blocks"])342instance.python_requires = set(cache_dict["python_requires"])343instance.python_optional = set(cache_dict["python_optional"])344
345instance._clean_url()346
347return instance348
349def to_cache(self) -> Dict:350"""Returns a dictionary with cache information that can be used later with351from_cache to recreate this object."""
352
353return {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
371def load_metadata_file(self, file: str) -> None:372"""Read a given metadata file and set it as this object's metadata"""373
374if os.path.exists(file):375metadata = MetadataReader.from_file(file)376self.set_metadata(metadata)377self._clean_url()378else:379fci.Console.PrintLog(f"Internal error: {file} does not exist")380
381def _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.384mod_dir = os.path.join(self.mod_directory, self.name)385installed_metadata_path = os.path.join(mod_dir, "package.xml")386if os.path.isfile(installed_metadata_path):387self.installed_metadata = MetadataReader.from_file(installed_metadata_path)388
389def set_metadata(self, metadata: Metadata) -> None:390"""Set the given metadata object as this object's metadata, updating the391object's display name and package type information to match, as well as
392updating any dependency information, etc.
393"""
394
395self.metadata = metadata396self.display_name = metadata.name397self.repo_type = Addon.Kind.PACKAGE398self.description = metadata.description399for url in metadata.url:400if url.type == UrlType.repository:401self.url = url.location402self.branch = url.branch if url.branch else "master"403self._clean_url()404self.extract_tags(self.metadata)405self.extract_metadata_dependencies(self.metadata)406
407@staticmethod408def version_is_ok(metadata: Metadata) -> bool:409"""Checks to see if the current running version of FreeCAD meets the410requirements set by the passed-in metadata parameter."""
411
412from_fci = list(fci.Version())413fc_version = Version(from_list=from_fci)414
415dep_fc_min = metadata.freecadmin if metadata.freecadmin else fc_version416dep_fc_max = metadata.freecadmax if metadata.freecadmax else fc_version417
418return dep_fc_min <= fc_version <= dep_fc_max419
420def extract_metadata_dependencies(self, metadata: Metadata):421"""Read dependency information from a metadata object and store it in this422Addon"""
423
424# Version check: if this piece of metadata doesn't apply to this version of425# FreeCAD, just skip it.426if not Addon.version_is_ok(metadata):427return428
429if metadata.pythonmin:430self.python_min_version["major"] = metadata.pythonmin.version_as_list[0]431self.python_min_version["minor"] = metadata.pythonmin.version_as_list[1]432
433for dep in metadata.depend:434if dep.dependency_type == DependencyType.internal:435if dep.package in INTERNAL_WORKBENCHES:436self.requires.add(dep.package)437else:438fci.Console.PrintWarning(439translate(440"AddonsInstaller",441"{}: Unrecognized internal workbench '{}'",442).format(self.name, dep.package)443)444elif dep.dependency_type == DependencyType.addon:445self.requires.add(dep.package)446elif dep.dependency_type == DependencyType.python:447if dep.optional:448self.python_optional.add(dep.package)449else:450self.python_requires.add(dep.package)451else:452# Automatic resolution happens later, once we have a complete list of453# Addons454self.requires.add(dep.package)455
456for dep in metadata.conflict:457self.blocks.add(dep.package)458
459# Recurse460content = metadata.content461for _, value in content.items():462for item in value:463self.extract_metadata_dependencies(item)464
465def verify_url_and_branch(self, url: str, branch: str) -> None:466"""Print diagnostic information for Addon Developers if their metadata is467inconsistent with the actual fetch location. Most often this is due to using
468the wrong branch name."""
469
470if self.url != url:471fci.Console.PrintWarning(472translate(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)478if self.branch != branch:479fci.Console.PrintWarning(480translate(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
487def 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 of491# FreeCAD, just skip it.492if not Addon.version_is_ok(metadata):493return494
495for new_tag in metadata.tag:496self.tags.add(new_tag)497
498content = metadata.content499for _, value in content.items():500for item in value:501self.extract_tags(item)502
503def contains_workbench(self) -> bool:504"""Determine if this package contains (or is) a workbench"""505
506if self.repo_type == Addon.Kind.WORKBENCH:507return True508if self.repo_type == Addon.Kind.PACKAGE:509if self.metadata is None:510fci.Console.PrintLog(511f"Addon Manager internal error: lost metadata for package {self.name}\n"512)513return False514content = self.metadata.content515if not content:516return False517return "workbench" in content518return False519
520def contains_macro(self) -> bool:521"""Determine if this package contains (or is) a macro"""522
523if self.repo_type == Addon.Kind.MACRO:524return True525if self.repo_type == Addon.Kind.PACKAGE:526if self.metadata is None:527fci.Console.PrintLog(528f"Addon Manager internal error: lost metadata for package {self.name}\n"529)530return False531content = self.metadata.content532return "macro" in content533return False534
535def contains_preference_pack(self) -> bool:536"""Determine if this package contains a preference pack"""537
538if self.repo_type == Addon.Kind.PACKAGE:539if self.metadata is None:540fci.Console.PrintLog(541f"Addon Manager internal error: lost metadata for package {self.name}\n"542)543return False544content = self.metadata.content545return "preferencepack" in content546return False547
548def get_best_icon_relative_path(self) -> str:549"""Get the path within the repo the addon's icon. Usually specified by550top-level metadata, but some authors omit it and specify only icons for the
551contents. Find the first one of those, in such cases."""
552
553if self.best_icon_relative_path:554return self.best_icon_relative_path555
556if not self.metadata:557return ""558
559real_icon = self.metadata.icon560if not real_icon:561# If there is no icon set for the entire package, see if there are any562# workbenches, which are required to have icons, and grab the first one563# we find:564content = self.metadata.content565if "workbench" in content:566wb = content["workbench"][0]567if wb.icon:568if wb.subdirectory:569subdir = wb.subdirectory570else:571subdir = wb.name572real_icon = subdir + wb.icon573
574self.best_icon_relative_path = real_icon575return self.best_icon_relative_path576
577def get_cached_icon_filename(self) -> str:578"""NOTE: This function is deprecated and will be removed in a coming update."""579
580if hasattr(self, "cached_icon_filename") and self.cached_icon_filename:581return self.cached_icon_filename582
583if not self.metadata:584return ""585
586real_icon = self.metadata.icon587if not real_icon:588# If there is no icon set for the entire package, see if there are any589# workbenches, which are required to have icons, and grab the first one590# we find:591content = self.metadata.content592if "workbench" in content:593wb = content["workbench"][0]594if wb.icon:595if wb.subdirectory:596subdir = wb.subdirectory597else:598subdir = wb.name599real_icon = subdir + wb.icon600
601real_icon = real_icon.replace(602"/", os.path.sep603) # Required path separator in the metadata.xml file to local separator604
605_, file_extension = os.path.splitext(real_icon)606store = os.path.join(self.cache_directory, "PackageMetadata")607self.cached_icon_filename = os.path.join(store, self.name, "cached_icon" + file_extension)608
609return self.cached_icon_filename610
611def 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
615information that may be needed.
616"""
617
618deps.python_requires |= self.python_requires619deps.python_optional |= self.python_optional620
621deps.python_min_version["major"] = max(622deps.python_min_version["major"], self.python_min_version["major"]623)624if deps.python_min_version["major"] == 3:625deps.python_min_version["minor"] = max(626deps.python_min_version["minor"], self.python_min_version["minor"]627)628else:629fci.Console.PrintWarning("Unrecognized Python version information")630
631for dep in self.requires:632if dep in all_repos:633if dep not in deps.required_external_addons:634deps.required_external_addons.append(all_repos[dep])635all_repos[dep].walk_dependency_tree(all_repos, deps)636else:637# See if this is an internal workbench:638if dep.upper().endswith("WB"):639real_name = dep[:-2].strip().lower()640elif dep.upper().endswith("WORKBENCH"):641real_name = dep[:-9].strip().lower()642else:643real_name = dep.strip().lower()644
645if real_name in INTERNAL_WORKBENCHES:646deps.internal_workbenches.add(INTERNAL_WORKBENCHES[real_name])647else:648# Assume it's a Python requirement of some kind:649deps.python_requires.add(dep)650
651for dep in self.blocks:652if dep in all_repos:653deps.blockers[dep] = all_repos[dep]654
655def status(self):656"""Threadsafe access to the current update status"""657with self.status_lock:658return self.update_status659
660def set_status(self, status):661"""Threadsafe setting of the update status"""662with self.status_lock:663self.update_status = status664
665def is_disabled(self):666"""Check to see if the disabling stopfile exists"""667
668stopfile = os.path.join(self.mod_directory, self.name, "ADDON_DISABLED")669return os.path.exists(stopfile)670
671def disable(self):672"""Disable this addon from loading when FreeCAD starts up by creating a673stopfile"""
674
675stopfile = os.path.join(self.mod_directory, self.name, "ADDON_DISABLED")676with open(stopfile, "w", encoding="utf-8") as f:677f.write(678"The existence of this file prevents FreeCAD from loading this Addon. To re-enable, delete the file."679)680
681if self.contains_workbench():682self.disable_workbench()683
684def enable(self):685"""Re-enable loading this addon by deleting the stopfile"""686
687stopfile = os.path.join(self.mod_directory, self.name, "ADDON_DISABLED")688try:689os.unlink(stopfile)690except FileNotFoundError:691pass692
693if self.contains_workbench():694self.enable_workbench()695
696def enable_workbench(self):697wbName = self.get_workbench_name()698
699# Remove from the list of disabled.700self.remove_from_disabled_wbs(wbName)701
702def disable_workbench(self):703pref = fci.ParamGet("User parameter:BaseApp/Preferences/Workbenches")704wbName = self.get_workbench_name()705
706# Add the wb to the list of disabled if it was not already707disabled_wbs = pref.GetString("Disabled", "NoneWorkbench,TestWorkbench")708# print(f"start disabling {disabled_wbs}")709disabled_wbs_list = disabled_wbs.split(",")710if not (wbName in disabled_wbs_list):711disabled_wbs += "," + wbName712pref.SetString("Disabled", disabled_wbs)713# print(f"done disabling : {disabled_wbs} \n")714
715def desinstall_workbench(self):716pref = fci.ParamGet("User parameter:BaseApp/Preferences/Workbenches")717wbName = self.get_workbench_name()718
719# Remove from the list of ordered.720ordered_wbs = pref.GetString("Ordered", "")721# print(f"start remove from ordering {ordered_wbs}")722ordered_wbs_list = ordered_wbs.split(",")723ordered_wbs = ""724for wb in ordered_wbs_list:725if wb != wbName:726if ordered_wbs != "":727ordered_wbs += ","728ordered_wbs += wb729pref.SetString("Ordered", ordered_wbs)730# print(f"end remove from ordering {ordered_wbs}")731
732# Remove from the list of disabled.733self.remove_from_disabled_wbs(wbName)734
735def remove_from_disabled_wbs(self, wbName: str):736pref = fci.ParamGet("User parameter:BaseApp/Preferences/Workbenches")737
738disabled_wbs = pref.GetString("Disabled", "NoneWorkbench,TestWorkbench")739# print(f"start enabling : {disabled_wbs}")740disabled_wbs_list = disabled_wbs.split(",")741disabled_wbs = ""742for wb in disabled_wbs_list:743if wb != wbName:744if disabled_wbs != "":745disabled_wbs += ","746disabled_wbs += wb747pref.SetString("Disabled", disabled_wbs)748# print(f"Done enabling {disabled_wbs} \n")749
750def get_workbench_name(self) -> str:751"""Find the name of the workbench class (ie the name under which it's752registered in freecad core)'"""
753wb_name = ""754
755if self.repo_type == Addon.Kind.PACKAGE:756for wb in self.metadata.content["workbench"]: # we may have more than one wb.757if wb_name != "":758wb_name += ","759wb_name += wb.classname760if self.repo_type == Addon.Kind.WORKBENCH or wb_name == "":761wb_name = self.try_find_wbname_in_files()762if wb_name == "":763wb_name = self.name764return wb_name765
766def try_find_wbname_in_files(self) -> str:767"""Attempt to locate a line with an addWorkbench command in the workbench's768Python files. If it is directly instantiating a workbench, then we can use
769the line to determine classname for this workbench. If it uses a variable,
770or if the line doesn't exist at all, an empty string is returned."""
771mod_dir = os.path.join(self.mod_directory, self.name)772
773for root, _, files in os.walk(mod_dir):774for f in files:775current_file = os.path.join(root, f)776if not os.path.isdir(current_file):777filename, extension = os.path.splitext(current_file)778if extension == ".py":779wb_classname = self._find_classname_in_file(current_file)780if wb_classname:781return wb_classname782return ""783
784@staticmethod785def _find_classname_in_file(current_file) -> str:786try:787with open(current_file, "r", encoding="utf-8") as python_file:788content = python_file.read()789search_result = re.search(r"Gui.addWorkbench\s*\(\s*(\w+)\s*\(\s*\)\s*\)", content)790if search_result:791return search_result.group(1)792except OSError:793pass794return ""795
796
797# @dataclass(frozen)
798class 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
806def __init__(self, repo: Addon, all_repos: List[Addon]):807deps = Addon.Dependencies()808repo_name_dict = {}809for r in all_repos:810repo_name_dict[r.name] = r811if hasattr(r, "display_name"):812# Test harness might not provide a display name813repo_name_dict[r.display_name] = r814
815if hasattr(repo, "walk_dependency_tree"):816# Sometimes the test harness doesn't provide this function, to override817# any dependency checking818repo.walk_dependency_tree(repo_name_dict, deps)819
820self.external_addons = []821for dep in deps.required_external_addons:822if dep.status() == Addon.Status.NOT_INSTALLED:823self.external_addons.append(dep.name)824
825# Now check the loaded addons to see if we are missing an internal workbench:826if fci.FreeCADGui:827wbs = [wb.lower() for wb in fci.FreeCADGui.listWorkbenches()]828else:829wbs = []830
831self.wbs = []832for dep in deps.internal_workbenches:833if dep.lower() + "workbench" not in wbs:834if dep.lower() == "plot":835# Special case for plot, which is no longer a full workbench:836try:837__import__("Plot")838except ImportError:839# Plot might fail for a number of reasons840self.wbs.append(dep)841fci.Console.PrintLog("Failed to import Plot module")842else:843self.wbs.append(dep)844
845# Check the Python dependencies:846self.python_min_version = deps.python_min_version847self.python_requires = []848for py_dep in deps.python_requires:849if py_dep not in self.python_requires:850try:851__import__(py_dep)852except ImportError:853self.python_requires.append(py_dep)854except (OSError, NameError, TypeError, RuntimeError) as e:855fci.Console.PrintWarning(856translate(857"AddonsInstaller",858"Got an error when trying to import {}",859).format(py_dep)860+ ":\n"861+ str(e)862)863
864self.python_optional = []865for py_dep in deps.python_optional:866try:867__import__(py_dep)868except ImportError:869self.python_optional.append(py_dep)870except (OSError, NameError, TypeError, RuntimeError) as e:871fci.Console.PrintWarning(872translate(873"AddonsInstaller",874"Got an error when trying to import {}",875).format(py_dep)876+ ":\n"877+ str(e)878)879
880self.wbs.sort()881self.external_addons.sort()882self.python_requires.sort()883self.python_optional.sort()884self.python_optional = [885option for option in self.python_optional if option not in self.python_requires886]887