def get_cached_icon_filename(self) -> str: """Get the filename for the locally-cached copy of the icon""" if self.cached_icon_filename: return self.cached_icon_filename if not self.metadata: return "" real_icon = self.metadata.Icon if not real_icon: # If there is no icon set for the entire package, see if there are any workbenches, which # are required to have icons, and grab the first one we find: content = self.metadata.Content if "workbench" in content: wb = content["workbench"][0] if wb.Icon: if wb.Subdirectory: subdir = wb.Subdirectory else: subdir = wb.Name real_icon = subdir + wb.Icon real_icon = real_icon.replace( "/", os.path.sep ) # Required path separator in the metadata.xml file to local separator _, file_extension = os.path.splitext(real_icon) store = os.path.join(FreeCAD.getUserCachePath(), "AddonManager", "PackageMetadata") self.cached_icon_filename = os.path.join( store, self.name, "cached_icon" + file_extension) return self.cached_icon_filename
def clean_icon(self): if self.icon.startswith("http://") or self.icon.startswith("https://"): FreeCAD.Console.PrintLog( f"Attempting to fetch macro icon from {self.icon}\n") p = NetworkManager.AM_NETWORK_MANAGER.blocking_get(self.icon) if p: cache_path = FreeCAD.getUserCachePath() am_path = os.path.join(cache_path, "AddonManager", "MacroIcons") os.makedirs(am_path, exist_ok=True) _, _, filename = self.icon.rpartition("/") base, _, extension = filename.rpartition(".") if base.lower().startswith("file:"): FreeCAD.Console.PrintMessage( f"Cannot use specified icon for {self.name}, {self.icon} is not a direct download link\n" ) self.icon = "" else: constructed_name = os.path.join(am_path, base + "." + extension) with open(constructed_name, "wb") as f: f.write(p.data()) self.icon_source = self.icon self.icon = constructed_name else: FreeCAD.Console.PrintLog( f"MACRO DEVELOPER WARNING: failed to download icon from {self.icon} for macro {self.name}\n" ) self.icon = ""
def from_cache(self, cache_dict: Dict): """Load basic data from cached dict data. Does not include Macro or Metadata information, which must be populated separately.""" mod_dir = os.path.join(FreeCAD.getUserAppDataDir(), "Mod", cache_dict["name"]) if os.path.isdir(mod_dir): status = AddonManagerRepo.UpdateStatus.UNCHECKED else: status = AddonManagerRepo.UpdateStatus.NOT_INSTALLED instance = AddonManagerRepo(cache_dict["name"], cache_dict["url"], status, cache_dict["branch"]) for key, value in cache_dict.items(): instance.__dict__[key] = value instance.repo_type = AddonManagerRepo.RepoType(cache_dict["repo_type"]) if instance.repo_type == AddonManagerRepo.RepoType.PACKAGE: # There must be a cached metadata file, too cached_package_xml_file = os.path.join( FreeCAD.getUserCachePath(), "AddonManager", "PackageMetadata", instance.name, ) if os.path.isfile(cached_package_xml_file): instance.load_metadata_file(cached_package_xml_file) if "requires" in cache_dict: instance.requires = set(cache_dict["requires"]) instance.blocks = set(cache_dict["blocks"]) instance.python_requires = set(cache_dict["python_requires"]) instance.python_optional = set(cache_dict["python_optional"]) return instance
def __init__(self, parent, repo: AddonManagerRepo, index: Dict[str, str]): """repo is an AddonManagerRepo object, and index is a dictionary of SHA1 hashes of the package.xml files in the cache""" super().__init__(parent, repo.metadata_url) self.repo = repo self.index = index self.store = os.path.join(FreeCAD.getUserCachePath(), "AddonManager", "PackageMetadata") self.last_sha1 = ""
def __init__(self, repos): QtCore.QThread.__init__(self) self.repos = repos self.requests: Dict[int, (Addon, UpdateMetadataCacheWorker.RequestType)] = {} NetworkManager.AM_NETWORK_MANAGER.completed.connect(self.download_completed) self.requests_completed = 0 self.total_requests = 0 self.store = os.path.join( FreeCAD.getUserCachePath(), "AddonManager", "PackageMetadata" ) self.updated_repos = set()
def update_macro(self, repo: Addon): """Updating a macro happens in this function, in the current thread""" cache_path = os.path.join( FreeCAD.getUserCachePath(), "AddonManager", "MacroCache" ) os.makedirs(cache_path, exist_ok=True) install_succeeded, _ = repo.macro.install(cache_path) if install_succeeded: install_succeeded, _ = repo.macro.install(FreeCAD.getUserMacroDir(True)) utils.update_macro_installation_details(repo) if install_succeeded: self.success.emit(repo) else: self.failure.emit(repo)
def from_cache(self, data: Dict): """Load basic data from cached dict data. Does not include Macro or Metadata information, which must be populated separately.""" mod_dir = os.path.join(FreeCAD.getUserAppDataDir(), "Mod", data["name"]) if os.path.isdir(mod_dir): status = AddonManagerRepo.UpdateStatus.UNCHECKED else: status = AddonManagerRepo.UpdateStatus.NOT_INSTALLED instance = AddonManagerRepo(data["name"], data["url"], status, data["branch"]) instance.display_name = data["display_name"] instance.repo_type = AddonManagerRepo.RepoType(data["repo_type"]) instance.description = data["description"] instance.cached_icon_filename = data["cached_icon_filename"] if instance.repo_type == AddonManagerRepo.RepoType.PACKAGE: # There must be a cached metadata file, too cached_package_xml_file = os.path.join(FreeCAD.getUserCachePath(), "AddonManager", "PackageMetadata", instance.name) if os.path.isfile(cached_package_xml_file): instance.load_metadata_file(cached_package_xml_file) return instance
def fill_details_from_wiki(self, url): code = "" p = NetworkManager.AM_NETWORK_MANAGER.blocking_get(url) if not p: FreeCAD.Console.PrintWarning( translate( "AddonsInstaller", "Unable to open macro wiki page at {}", ).format(url) + "\n") return p = p.data().decode("utf8") # check if the macro page has its code hosted elsewhere, download if # needed if "rawcodeurl" in p: rawcodeurl = re.findall('rawcodeurl.*?href="(http.*?)">', p) if rawcodeurl: rawcodeurl = rawcodeurl[0] u2 = NetworkManager.AM_NETWORK_MANAGER.blocking_get(rawcodeurl) if not u2: FreeCAD.Console.PrintWarning( translate( "AddonsInstaller", "Unable to open macro code URL {rawcodeurl}", ).format(rawcodeurl) + "\n") return code = u2.data().decode("utf8") if not code: code = re.findall(r"<pre>(.*?)</pre>", p.replace("\n", "--endl--")) if code: # take the biggest code block code = sorted(code, key=len)[-1] code = code.replace("--endl--", "\n") # Clean HTML escape codes. code = unescape(code) code = code.replace(b"\xc2\xa0".decode("utf-8"), " ") else: FreeCAD.Console.PrintWarning( translate("AddonsInstaller", "Unable to fetch the code of this macro.") + "\n") desc = re.findall( r"<td class=\"ctEven left macro-description\">(.*?)</td>", p.replace("\n", " "), ) if desc: desc = desc[0] else: FreeCAD.Console.PrintWarning( translate( "AddonsInstaller", "Unable to retrieve a description from the wiki for macro {}", ).format(self.name) + "\n") desc = "No description available" self.desc = desc self.comment, _, _ = desc.partition( "<br") # Up to the first line break self.comment = re.sub("<.*?>", "", self.comment) # Strip any tags self.url = url if isinstance(code, list): flat_code = "" for chunk in code: flat_code += chunk code = flat_code self.code = code self.fill_details_from_code(self.code) if not self.icon and not self.xpm: self.parse_wiki_page_for_icon(p) if not self.author: self.author = self.parse_desc("Author: ") if not self.date: self.date = self.parse_desc("Last modified: ") if self.icon.startswith("http://") or self.icon.startswith("https://"): FreeCAD.Console.PrintLog( f"Attempting to fetch macro icon from {self.icon}\n") p = NetworkManager.AM_NETWORK_MANAGER.blocking_get(self.icon) if p: cache_path = FreeCAD.getUserCachePath() am_path = os.path.join(cache_path, "AddonManager", "MacroIcons") os.makedirs(am_path, exist_ok=True) _, _, filename = self.icon.rpartition("/") base, _, extension = filename.rpartition(".") if base.lower().startswith("file:"): FreeCAD.Console.PrintMessage( f"Cannot use specified icon for {self.name}, {self.icon} is not a direct download link\n" ) self.icon = "" else: constructed_name = os.path.join(am_path, base + "." + extension) with open(constructed_name, "wb") as f: f.write(p.data()) self.icon_source = self.icon self.icon = constructed_name
class Addon: """Encapsulates information about a FreeCAD addon""" class Kind(IntEnum): """The type of Addon: Workbench, macro, or package""" WORKBENCH = 1 MACRO = 2 PACKAGE = 3 def __str__(self) -> str: if self.value == 1: return "Workbench" if self.value == 2: return "Macro" if self.value == 3: return "Package" return "ERROR_TYPE" class Status(IntEnum): """The installation status of an Addon""" NOT_INSTALLED = 0 UNCHECKED = 1 NO_UPDATE_AVAILABLE = 2 UPDATE_AVAILABLE = 3 PENDING_RESTART = 4 CANNOT_CHECK = 5 # If we don't have git, etc. def __lt__(self, other): if self.__class__ is other.__class__: return self.value < other.value return NotImplemented def __str__(self) -> str: result = "" if self.value == 0: result = "Not installed" elif self.value == 1: result = "Unchecked" elif self.value == 2: result = "No update available" elif self.value == 3: result = "Update available" elif self.value == 4: result = "Restart required" elif self.value == 5: result = "Can't check" else: result = "ERROR_STATUS" return result class Dependencies: """Addon dependency information""" def __init__(self): self.required_external_addons = [] # A list of Addons self.blockers = [] # A list of Addons self.replaces = [] # A list of Addons self.internal_workbenches: Set[str] = set() # Required internal workbenches self.python_required: Set[str] = set() self.python_optional: Set[str] = set() class ResolutionFailed(RuntimeError): """An exception type for dependency resolution failure.""" # The location of Addon Manager cache files: overridden by testing code cache_directory = os.path.join(FreeCAD.getUserCachePath(), "AddonManager") # The location of the Mod directory: overridden by testing code mod_directory = os.path.join(FreeCAD.getUserAppDataDir(), "Mod") def __init__(self, name: str, url: str, status: Status, branch: str): self.name = name.strip() self.display_name = self.name self.url = url.strip() self.branch = branch.strip() self.python2 = False self.obsolete = False self.rejected = False self.repo_type = Addon.Kind.WORKBENCH self.description = None self.tags = set() # Just a cache, loaded from Metadata # To prevent multiple threads from running git actions on this repo at the same time self.git_lock = Lock() # To prevent multiple threads from accessing the status at the same time self.status_lock = Lock() self.set_status(status) # The url should never end in ".git", so strip it if it's there parsed_url = urlparse(self.url) if parsed_url.path.endswith(".git"): self.url = ( parsed_url.scheme + "://" + parsed_url.netloc + parsed_url.path[:-4] ) if parsed_url.query: self.url += "?" + parsed_url.query if parsed_url.fragment: self.url += "#" + parsed_url.fragment if utils.recognized_git_location(self): self.metadata_url = construct_git_url(self, "package.xml") else: self.metadata_url = None self.metadata = None self.icon = None self.cached_icon_filename = "" self.macro = None # Bridge to Gaël Écorchard's macro management class self.updated_timestamp = None self.installed_version = None # Each repo is also a node in a directed dependency graph (referenced by name so # they cen be serialized): self.requires: Set[str] = set() self.blocks: Set[str] = set() # And maintains a list of required and optional Python dependencies from metadata.txt self.python_requires: Set[str] = set() self.python_optional: Set[str] = set() def __str__(self) -> str: result = f"FreeCAD {self.repo_type}\n" result += f"Name: {self.name}\n" result += f"URL: {self.url}\n" result += ( "Has metadata\n" if self.metadata is not None else "No metadata found\n" ) if self.macro is not None: result += "Has linked Macro object\n" return result @classmethod def from_macro(cls, macro: Macro): """Create an Addon object from a Macro wrapper object""" if macro.is_installed(): status = Addon.Status.UNCHECKED else: status = Addon.Status.NOT_INSTALLED instance = Addon(macro.name, macro.url, status, "master") instance.macro = macro instance.repo_type = Addon.Kind.MACRO instance.description = macro.desc return instance @classmethod def from_cache(cls, cache_dict: Dict): """Load basic data from cached dict data. Does not include Macro or Metadata information, which must be populated separately.""" mod_dir = os.path.join(cls.mod_directory, cache_dict["name"]) if os.path.isdir(mod_dir): status = Addon.Status.UNCHECKED else: status = Addon.Status.NOT_INSTALLED instance = Addon( cache_dict["name"], cache_dict["url"], status, cache_dict["branch"] ) for key, value in cache_dict.items(): instance.__dict__[key] = value instance.repo_type = Addon.Kind(cache_dict["repo_type"]) if instance.repo_type == Addon.Kind.PACKAGE: # There must be a cached metadata file, too cached_package_xml_file = os.path.join( instance.cache_directory, "PackageMetadata", instance.name, ) if os.path.isfile(cached_package_xml_file): instance.load_metadata_file(cached_package_xml_file) if "requires" in cache_dict: instance.requires = set(cache_dict["requires"]) instance.blocks = set(cache_dict["blocks"]) instance.python_requires = set(cache_dict["python_requires"]) instance.python_optional = set(cache_dict["python_optional"]) return instance def to_cache(self) -> Dict: """Returns a dictionary with cache information that can be used later with from_cache to recreate this object.""" return { "name": self.name, "display_name": self.display_name, "url": self.url, "branch": self.branch, "repo_type": int(self.repo_type), "description": self.description, "cached_icon_filename": self.get_cached_icon_filename(), "python2": self.python2, "obsolete": self.obsolete, "rejected": self.rejected, "requires": list(self.requires), "blocks": list(self.blocks), "python_requires": list(self.python_requires), "python_optional": list(self.python_optional), } def load_metadata_file(self, file: str) -> None: """Read a given metadata file and set it as this object's metadata""" if os.path.exists(file): metadata = FreeCAD.Metadata(file) self.set_metadata(metadata) else: FreeCAD.Console.PrintLog(f"Internal error: {file} does not exist") def set_metadata(self, metadata: FreeCAD.Metadata) -> None: """Set the given metadata object as this object's metadata, updating the object's display name and package type information to match, as well as updating any dependency information, etc.""" self.metadata = metadata self.display_name = metadata.Name self.repo_type = Addon.Kind.PACKAGE self.description = metadata.Description for url in metadata.Urls: if "type" in url and url["type"] == "repository": self.url = url["location"] if "branch" in url: self.branch = url["branch"] else: self.branch = "master" self.extract_tags(self.metadata) self.extract_metadata_dependencies(self.metadata) def version_is_ok(self, metadata) -> bool: """Checks to see if the current running version of FreeCAD meets the requirements set by the passed-in metadata parameter.""" dep_fc_min = metadata.FreeCADMin dep_fc_max = metadata.FreeCADMax fc_major = int(FreeCAD.Version()[0]) fc_minor = int(FreeCAD.Version()[1]) try: if dep_fc_min and dep_fc_min != "0.0.0": required_version = dep_fc_min.split(".") if fc_major < int(required_version[0]): return False # Major version is too low if fc_major == int(required_version[0]): if len(required_version) > 1 and fc_minor < int( required_version[1] ): return False # Same major, and minor is too low except ValueError: FreeCAD.Console.PrintMessage( f"Metadata file for {self.name} has invalid FreeCADMin version info\n" ) try: if dep_fc_max and dep_fc_max != "0.0.0": required_version = dep_fc_max.split(".") if fc_major > int(required_version[0]): return False # Major version is too high if fc_major == int(required_version[0]): if len(required_version) > 1 and fc_minor > int( required_version[1] ): return False # Same major, and minor is too high except ValueError: FreeCAD.Console.PrintMessage( f"Metadata file for {self.name} has invalid FreeCADMax version info\n" ) return True def extract_metadata_dependencies(self, metadata): """Read dependency information from a metadata object and store it in this Addon""" # Version check: if this piece of metadata doesn't apply to this version of # FreeCAD, just skip it. if not self.version_is_ok(metadata): return for dep in metadata.Depend: # Simple version for now: eventually support all of the version params... self.requires.add(dep["package"]) FreeCAD.Console.PrintLog( f"Package {self.name}: Adding dependency on {dep['package']}\n" ) for dep in metadata.Conflict: self.blocks.add(dep["package"]) # Recurse content = metadata.Content for _, value in content.items(): for item in value: self.extract_metadata_dependencies(item) def verify_url_and_branch(self, url: str, branch: str) -> None: """Print diagnostic information for Addon Developers if their metadata is inconsistent with the actual fetch location. Most often this is due to using the wrong branch name.""" if self.url != url: FreeCAD.Console.PrintWarning( translate( "AddonsInstaller", "Addon Developer Warning: Repository URL set in package.xml file for addon {} ({}) does not match the URL it was fetched from ({})", ).format(self.display_name, self.url, url) + "\n" ) if self.branch != branch: FreeCAD.Console.PrintWarning( translate( "AddonsInstaller", "Addon Developer Warning: Repository branch set in package.xml file for addon {} ({}) does not match the branch it was fetched from ({})", ).format(self.display_name, self.branch, branch) + "\n" ) def extract_tags(self, metadata: FreeCAD.Metadata) -> None: """Read the tags from the metadata object""" # Version check: if this piece of metadata doesn't apply to this version of # FreeCAD, just skip it. if not self.version_is_ok(metadata): return for new_tag in metadata.Tag: self.tags.add(new_tag) content = metadata.Content for _, value in content.items(): for item in value: self.extract_tags(item) def contains_workbench(self) -> bool: """Determine if this package contains (or is) a workbench""" if self.repo_type == Addon.Kind.WORKBENCH: return True if self.repo_type == Addon.Kind.PACKAGE: if self.metadata is None: FreeCAD.Console.PrintLog( f"Addon Manager internal error: lost metadata for package {self.name}\n" ) return False content = self.metadata.Content if not content: FreeCAD.Console.PrintLog( f"Package {self.display_name} does not list any content items in its package.xml metadata file.\n" ) return False return "workbench" in content return False def contains_macro(self) -> bool: """Determine if this package contains (or is) a macro""" if self.repo_type == Addon.Kind.MACRO: return True if self.repo_type == Addon.Kind.PACKAGE: if self.metadata is None: FreeCAD.Console.PrintLog( f"Addon Manager internal error: lost metadata for package {self.name}\n" ) return False content = self.metadata.Content return "macro" in content return False def contains_preference_pack(self) -> bool: """Determine if this package contains a preference pack""" if self.repo_type == Addon.Kind.PACKAGE: if self.metadata is None: FreeCAD.Console.PrintLog( f"Addon Manager internal error: lost metadata for package {self.name}\n" ) return False content = self.metadata.Content return "preferencepack" in content return False def get_cached_icon_filename(self) -> str: """Get the filename for the locally-cached copy of the icon""" if self.cached_icon_filename: return self.cached_icon_filename if not self.metadata: return "" real_icon = self.metadata.Icon if not real_icon: # If there is no icon set for the entire package, see if there are any workbenches, which # are required to have icons, and grab the first one we find: content = self.metadata.Content if "workbench" in content: wb = content["workbench"][0] if wb.Icon: if wb.Subdirectory: subdir = wb.Subdirectory else: subdir = wb.Name real_icon = subdir + wb.Icon real_icon = real_icon.replace( "/", os.path.sep ) # Required path separator in the metadata.xml file to local separator _, file_extension = os.path.splitext(real_icon) store = os.path.join(self.cache_directory, "PackageMetadata") self.cached_icon_filename = os.path.join( store, self.name, "cached_icon" + file_extension ) return self.cached_icon_filename def walk_dependency_tree(self, all_repos, deps): """Compute the total dependency tree for this repo (recursive) - all_repos is a dictionary of repos, keyed on the name of the repo - deps is an Addon.Dependency object encapsulating all the types of dependency information that may be needed. """ deps.python_required |= self.python_requires deps.python_optional |= self.python_optional for dep in self.requires: if dep in all_repos: if not dep in deps.required_external_addons: deps.required_external_addons.append(all_repos[dep]) all_repos[dep].walk_dependency_tree(all_repos, deps) else: # See if this is an internal workbench: if dep.upper().endswith("WB"): real_name = dep[:-2].strip().lower() elif dep.upper().endswith("WORKBENCH"): real_name = dep[:-9].strip().lower() else: real_name = dep.strip().lower() if real_name in INTERNAL_WORKBENCHES: deps.internal_workbenches.add(INTERNAL_WORKBENCHES[real_name]) else: # Assume it's a Python requirement of some kind: deps.python_required.add(dep) for dep in self.blocks: if dep in all_repos: deps.blockers[dep] = all_repos[dep] def status(self): """Threadsafe access to the current update status""" with self.status_lock: return self.update_status def set_status(self, status): """Threadsafe setting of the update status""" with self.status_lock: self.update_status = status def is_disabled(self): """Check to see if the disabling stopfile exists""" stopfile = os.path.join(self.mod_directory, self.name, "ADDON_DISABLED") return os.path.exists(stopfile) def disable(self): """Disable this addon from loading when FreeCAD starts up by creating a stopfile""" stopfile = os.path.join(mod_directory, self.name, "ADDON_DISABLED") with open(stopfile, "w", encoding="utf-8") as f: f.write( "The existence of this file prevents FreeCAD from loading this Addon. To re-enable, delete the file." ) def enable(self): """Re-enable loading this addon by deleting the stopfile""" stopfile = os.path.join(self.mod_directory, self.name, "ADDON_DISABLED") try: os.unlink(stopfile) except FileNotFoundError: pass
def cache_path(self, repo: AddonManagerRepo) -> str: cache_path = FreeCAD.getUserCachePath() full_path = os.path.join(cache_path, "AddonManager", repo.name) return full_path
def get_cache_file_name(file: str) -> str: """Get the full path to a cache file with a given name.""" cache_path = FreeCAD.getUserCachePath() am_path = os.path.join(cache_path, "AddonManager") os.makedirs(am_path, exist_ok=True) return os.path.join(am_path, file)
def __init__(self, cache_file: str): QtCore.QThread.__init__(self) self.cache_file = cache_file self.metadata_cache_path = os.path.join( FreeCAD.getUserCachePath(), "AddonManager", "PackageMetadata" )