Files
create/src/Mod/AddonManager/AddonManagerRepo.py
Chris Hennes d1a94de371 Addon manager: install dependencies (#5376)
Addon Manager: Dependency Installation

The Addon Manager can now attempt to use pip directly to install
required packages as specified in either metadata.txt or
requirements.txt files provided by AddOns. The packages are installed
into FreeCAD.getUserAppDataDir()/AdditionalPythonPackages directory.
Only simplified requirements.txt data is supported: any version
information is stripped out, and only packages are supported (that is,
no pip arguments, included files, etc.). Further, packages are checked
against a list of allowed packages prior to being suggested for
installation. Addon authors should submit a PR to the FreeCAD repo
adding their requirements to the allowed list, for packages that are not
already on the list (this is a malware-prevention mechanism).
2022-01-21 10:19:21 -06:00

322 lines
12 KiB
Python

# ***************************************************************************
# * *
# * Copyright (c) 2021 Chris Hennes <chennes@pioneerlibrarysystem.org> *
# * *
# * This program is free software; you can redistribute it and/or modify *
# * it under the terms of the GNU Lesser General Public License (LGPL) *
# * as published by the Free Software Foundation; either version 2 of *
# * the License, or (at your option) any later version. *
# * for detail see the LICENCE text file. *
# * *
# * This program is distributed in the hope that it will be useful, *
# * but WITHOUT ANY WARRANTY; without even the implied warranty of *
# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
# * GNU Library General Public License for more details. *
# * *
# * You should have received a copy of the GNU Library General Public *
# * License along with this program; if not, write to the Free Software *
# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 *
# * USA *
# * *
# ***************************************************************************
import FreeCAD
import os
from typing import Dict, Set, List
from addonmanager_macro import Macro
translate = FreeCAD.Qt.translate
class AddonManagerRepo:
"Encapsulate information about a FreeCAD addon"
from enum import IntEnum
class RepoType(IntEnum):
WORKBENCH = 1
MACRO = 2
PACKAGE = 3
def __str__(self) -> str:
if self.value == 1:
return "Workbench"
elif self.value == 2:
return "Macro"
elif self.value == 3:
return "Package"
class UpdateStatus(IntEnum):
NOT_INSTALLED = 0
UNCHECKED = 1
NO_UPDATE_AVAILABLE = 2
UPDATE_AVAILABLE = 3
PENDING_RESTART = 4
def __lt__(self, other):
if self.__class__ is other.__class__:
return self.value < other.value
return NotImplemented
def __str__(self) -> str:
if self.value == 0:
return "Not installed"
elif self.value == 1:
return "Unchecked"
elif self.value == 2:
return "No update available"
elif self.value == 3:
return "Update available"
elif self.value == 4:
return "Restart required"
class Dependencies:
def __init__(self):
self.required_external_addons = dict()
self.blockers = dict()
self.replaces = dict()
self.unrecognized_addons: Set[str] = set()
self.python_required: Set[str] = set()
self.python_optional: Set[str] = set()
class ResolutionFailed(RuntimeError):
def __init__(self, msg):
super().__init__(msg)
def __init__(self, name: str, url: str, status: UpdateStatus, branch: str):
self.name = name.strip()
self.display_name = self.name
self.url = url.strip()
self.branch = branch.strip()
self.update_status = status
self.python2 = False
self.obsolete = False
self.rejected = False
self.repo_type = AddonManagerRepo.RepoType.WORKBENCH
self.description = None
from addonmanager_utilities import construct_git_url
if "github" in self.url or "gitlab" in self.url or "salsa" in self.url:
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(self, macro: Macro):
if macro.is_installed():
status = AddonManagerRepo.UpdateStatus.UNCHECKED
else:
status = AddonManagerRepo.UpdateStatus.NOT_INSTALLED
instance = AddonManagerRepo(macro.name, macro.url, status, "master")
instance.macro = macro
instance.repo_type = AddonManagerRepo.RepoType.MACRO
instance.description = macro.desc
return instance
@classmethod
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 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:
if os.path.isfile(file):
metadata = FreeCAD.Metadata(file)
self.set_metadata(metadata)
def set_metadata(self, metadata: FreeCAD.Metadata) -> None:
self.metadata = metadata
self.display_name = metadata.Name
self.repo_type = AddonManagerRepo.RepoType.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"
def contains_workbench(self) -> bool:
"""Determine if this package contains (or is) a workbench"""
if self.repo_type == AddonManagerRepo.RepoType.WORKBENCH:
return True
elif self.repo_type == AddonManagerRepo.RepoType.PACKAGE:
if self.metadata is None:
FreeCAD.Console.PrintWarning(
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
else:
return False
def contains_macro(self) -> bool:
"""Determine if this package contains (or is) a macro"""
if self.repo_type == AddonManagerRepo.RepoType.MACRO:
return True
elif self.repo_type == AddonManagerRepo.RepoType.PACKAGE:
if self.metadata is None:
FreeCAD.Console.PrintWarning(
f"Addon Manager internal error: lost metadata for package {self.name}\n"
)
return False
content = self.metadata.Content
return "macro" in content
else:
return False
def contains_preference_pack(self) -> bool:
"""Determine if this package contains a preference pack"""
if self.repo_type == AddonManagerRepo.RepoType.PACKAGE:
if self.metadata is None:
FreeCAD.Console.PrintWarning(
f"Addon Manager internal error: lost metadata for package {self.name}\n"
)
return False
content = self.metadata.Content
return "preferencepack" in content
else:
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(
FreeCAD.getUserCachePath(), "AddonManager", "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)"""
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:
deps.required_external_addons.append(all_repos[dep])
all_repos[dep].walk_dependency_tree(all_repos, deps)
else:
# Maybe this is an internal workbench, just store its name
deps.unrecognized_addons.add(dep)
for dep in self.blocks:
if dep in all_repos:
deps.blockers[dep] = all_repos[dep]