diff --git a/src/Mod/AddonManager/Addon.py b/src/Mod/AddonManager/Addon.py index 9421d74624..cbe91f34d2 100644 --- a/src/Mod/AddonManager/Addon.py +++ b/src/Mod/AddonManager/Addon.py @@ -25,20 +25,23 @@ import os from urllib.parse import urlparse -from typing import Dict, Set, List +from typing import Dict, Set, List, Optional from threading import Lock from enum import IntEnum, auto -import FreeCAD - -if FreeCAD.GuiUp: - import FreeCADGui - +import addonmanager_freecad_interface as fci from addonmanager_macro import Macro import addonmanager_utilities as utils from addonmanager_utilities import construct_git_url +from addonmanager_metadata import ( + Metadata, + MetadataReader, + UrlType, + Version, + DependencyType, +) -translate = FreeCAD.Qt.translate +translate = fci.translate INTERNAL_WORKBENCHES = { "arch": "Arch", @@ -137,10 +140,10 @@ class Addon: """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") + cache_directory = os.path.join(fci.DataPaths().cache_dir, "AddonManager") # The location of the Mod directory: overridden by testing code - mod_directory = os.path.join(FreeCAD.getUserAppDataDir(), "Mod") + mod_directory = fci.DataPaths().mod_dir def __init__( self, @@ -161,7 +164,8 @@ class Addon: self.tags = set() # Just a cache, loaded from Metadata self.last_updated = None - # To prevent multiple threads from running git actions on this repo at the same time + # 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 @@ -183,7 +187,7 @@ class Addon: self.metadata_url = construct_git_url(self, "package.xml") else: self.metadata_url = None - self.metadata = None + self.metadata: Optional[Metadata] = None self.icon = None # Relative path to remote icon file self.icon_file: str = "" # Absolute local path to cached icon file self.best_icon_relative_path = "" @@ -290,126 +294,82 @@ class Addon: """Read a given metadata file and set it as this object's metadata""" if os.path.exists(file): - metadata = FreeCAD.Metadata(file) + metadata = MetadataReader.from_file(file) self.set_metadata(metadata) else: - FreeCAD.Console.PrintLog(f"Internal error: {file} does not exist") + fci.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. + def set_metadata(self, metadata: 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.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.description = metadata.description + for url in metadata.url: + if url.type == UrlType.repository: + self.url = url.location + self.branch = url.branch if url.branch else "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.""" + @staticmethod + def version_is_ok(metadata: 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 + from_fci = list(fci.Version()) + fc_version = Version(from_list=from_fci) - fc_major = int(FreeCAD.Version()[0]) - fc_minor = int(FreeCAD.Version()[1]) + dep_fc_min = metadata.freecadmin if metadata.freecadmin else fc_version + dep_fc_max = metadata.freecadmax if metadata.freecadmax else fc_version - 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" - ) + return dep_fc_min <= fc_version <= dep_fc_max - 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""" + def extract_metadata_dependencies(self, metadata: 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): + if not Addon.version_is_ok(metadata): return - if metadata.PythonMin != "0.0.0": - split_version_string = metadata.PythonMin.split(".") - if len(split_version_string) >= 2: - try: - self.python_min_version["major"] = int(split_version_string[0]) - self.python_min_version["minor"] = int(split_version_string[1]) - FreeCAD.Console.PrintLog( - f"Package {self.name}: Requires Python " - f"{split_version_string[0]}.{split_version_string[1]} or greater\n" - ) - except ValueError: - FreeCAD.Console.PrintWarning( - f"Package {self.name}: Invalid Python version requirement specified\n" - ) + if metadata.pythonmin: + self.python_min_version["major"] = metadata.pythonmin.version_as_list[0] + self.python_min_version["minor"] = metadata.pythonmin.version_as_list[1] - for dep in metadata.Depend: - if "type" in dep: - if dep["type"] == "internal": - if dep["package"] in INTERNAL_WORKBENCHES: - self.requires.add(dep["package"]) - else: - FreeCAD.Console.PrintWarning( - translate( - "AddonsInstaller", - "{}: Unrecognized internal workbench '{}'", - ).format(self.name, dep["package"]) - ) - elif dep["type"] == "addon": - self.requires.add(dep["package"]) - elif dep["type"] == "python": - if "optional" in dep and dep["optional"]: - self.python_optional.add(dep["package"]) - else: - self.python_requires.add(dep["package"]) + for dep in metadata.depend: + if dep.dependency_type == DependencyType.internal: + if dep.package in INTERNAL_WORKBENCHES: + self.requires.add(dep.package) else: - # Automatic resolution happens later, once we have a complete list of Addons - self.requires.add(dep["package"]) + fci.Console.PrintWarning( + translate( + "AddonsInstaller", + "{}: Unrecognized internal workbench '{}'", + ).format(self.name, dep.package) + ) + elif dep.dependency_type == DependencyType.addon: + self.requires.add(dep.package) + elif dep.dependency_type == DependencyType.python: + if dep.optional: + self.python_optional.add(dep.package) + else: + self.python_requires.add(dep.package) else: - # Automatic resolution happens later, once we have a complete list of Addons - self.requires.add(dep["package"]) + # Automatic resolution happens later, once we have a complete list of + # Addons + self.requires.add(dep.package) - for dep in metadata.Conflict: - self.blocks.add(dep["package"]) + for dep in metadata.conflict: + self.blocks.add(dep.package) # Recurse - content = metadata.Content + content = metadata.content for _, value in content.items(): for item in value: self.extract_metadata_dependencies(item) @@ -420,7 +380,7 @@ class Addon: the wrong branch name.""" if self.url != url: - FreeCAD.Console.PrintWarning( + fci.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 ({})", @@ -428,7 +388,7 @@ class Addon: + "\n" ) if self.branch != branch: - FreeCAD.Console.PrintWarning( + fci.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 ({})", @@ -436,18 +396,18 @@ class Addon: + "\n" ) - def extract_tags(self, metadata: FreeCAD.Metadata) -> None: + def extract_tags(self, metadata: 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): + if not Addon.version_is_ok(metadata): return - for new_tag in metadata.Tag: + for new_tag in metadata.tag: self.tags.add(new_tag) - content = metadata.Content + content = metadata.content for _, value in content.items(): for item in value: self.extract_tags(item) @@ -459,15 +419,12 @@ class Addon: return True if self.repo_type == Addon.Kind.PACKAGE: if self.metadata is None: - FreeCAD.Console.PrintLog( + fci.Console.PrintLog( f"Addon Manager internal error: lost metadata for package {self.name}\n" ) return False - content = self.metadata.Content + 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 @@ -479,11 +436,11 @@ class Addon: return True if self.repo_type == Addon.Kind.PACKAGE: if self.metadata is None: - FreeCAD.Console.PrintLog( + fci.Console.PrintLog( f"Addon Manager internal error: lost metadata for package {self.name}\n" ) return False - content = self.metadata.Content + content = self.metadata.content return "macro" in content return False @@ -492,18 +449,18 @@ class Addon: if self.repo_type == Addon.Kind.PACKAGE: if self.metadata is None: - FreeCAD.Console.PrintLog( + fci.Console.PrintLog( f"Addon Manager internal error: lost metadata for package {self.name}\n" ) return False - content = self.metadata.Content + content = self.metadata.content return "preferencepack" in content return False def get_best_icon_relative_path(self) -> str: - """Get the path within the repo the addon's icon. Usually specified by top-level metadata, - but some authors omit it and specify only icons for the contents. Find the first one of - those, in such cases.""" + """Get the path within the repo the addon's icon. Usually specified by + top-level metadata, but some authors omit it and specify only icons for the + contents. Find the first one of those, in such cases.""" if self.best_icon_relative_path: return self.best_icon_relative_path @@ -511,19 +468,20 @@ class Addon: if not self.metadata: return "" - real_icon = self.metadata.Icon + 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 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 + if wb.icon: + if wb.subdirectory: + subdir = wb.subdirectory else: - subdir = wb.Name - real_icon = subdir + wb.Icon + subdir = wb.name + real_icon = subdir + wb.icon self.best_icon_relative_path = real_icon return self.best_icon_relative_path @@ -537,19 +495,20 @@ class Addon: if not self.metadata: return "" - real_icon = self.metadata.Icon + 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 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 + if wb.icon: + if wb.subdirectory: + subdir = wb.subdirectory else: - subdir = wb.Name - real_icon = subdir + wb.Icon + subdir = wb.name + real_icon = subdir + wb.icon real_icon = real_icon.replace( "/", os.path.sep @@ -581,7 +540,7 @@ class Addon: deps.python_min_version["minor"], self.python_min_version["minor"] ) else: - FreeCAD.Console.PrintWarning("Unrecognized Python version information") + fci.Console.PrintWarning("Unrecognized Python version information") for dep in self.requires: if dep in all_repos: @@ -624,7 +583,8 @@ class Addon: return os.path.exists(stopfile) def disable(self): - """Disable this addon from loading when FreeCAD starts up by creating a stopfile""" + """Disable this addon from loading when FreeCAD starts up by creating a + stopfile""" stopfile = os.path.join(self.mod_directory, self.name, "ADDON_DISABLED") with open(stopfile, "w", encoding="utf-8") as f: @@ -661,8 +621,8 @@ class MissingDependencies: repo_name_dict[r.display_name] = r if hasattr(repo, "walk_dependency_tree"): - # Sometimes the test harness doesn't provide this function, to override any dependency - # checking + # Sometimes the test harness doesn't provide this function, to override + # any dependency checking repo.walk_dependency_tree(repo_name_dict, deps) self.external_addons = [] @@ -671,8 +631,8 @@ class MissingDependencies: self.external_addons.append(dep.name) # Now check the loaded addons to see if we are missing an internal workbench: - if FreeCAD.GuiUp: - wbs = [wb.lower() for wb in FreeCADGui.listWorkbenches()] + if fci.FreeCADGui: + wbs = [wb.lower() for wb in fci.FreeCADGui.listWorkbenches()] else: wbs = [] @@ -686,7 +646,7 @@ class MissingDependencies: except ImportError: # Plot might fail for a number of reasons self.wbs.append(dep) - FreeCAD.Console.PrintLog("Failed to import Plot module") + fci.Console.PrintLog("Failed to import Plot module") else: self.wbs.append(dep) @@ -699,6 +659,13 @@ class MissingDependencies: __import__(py_dep) except ImportError: self.python_requires.append(py_dep) + except (OSError, NameError, TypeError, RuntimeError) as e: + fci.Console.PrintWarning( + translate( + "AddonsInstaller", + "Got an error when trying to import {}", + ).format(py_dep) + ":\n" + str(e) + ) self.python_optional = [] for py_dep in deps.python_optional: @@ -706,6 +673,13 @@ class MissingDependencies: __import__(py_dep) except ImportError: self.python_optional.append(py_dep) + except (OSError, NameError, TypeError, RuntimeError) as e: + fci.Console.PrintWarning( + translate( + "AddonsInstaller", + "Got an error when trying to import {}", + ).format(py_dep) + ":\n" + str(e) + ) self.wbs.sort() self.external_addons.sort() diff --git a/src/Mod/AddonManager/AddonManagerTest/app/mocks.py b/src/Mod/AddonManager/AddonManagerTest/app/mocks.py index eda4fd44e6..470a2393b4 100644 --- a/src/Mod/AddonManager/AddonManagerTest/app/mocks.py +++ b/src/Mod/AddonManager/AddonManagerTest/app/mocks.py @@ -74,57 +74,6 @@ class MockConsole: return counter -class MockMetadata: - """Minimal implementation of a Metadata-like object.""" - - def __init__(self): - self.Name = "MockMetadata" - self.Urls = {"repository": {"location": "file://localhost/", "branch": "main"}} - self.Description = "Mock metadata object for testing" - self.Icon = None - self.Version = "1.2.3beta" - self.Content = {} - - def minimal_file_scan(self, file: Union[os.PathLike, bytes]): - """Don't use the real metadata class, but try to read in the parameters we care about - from the given metadata file (or file-like object, as the case probably is). This - allows us to test whether the data is being passed around correctly.""" - - # pylint: disable=too-many-branches - xml = None - root = None - try: - if os.path.exists(file): - xml = ElemTree.parse(file) - root = xml.getroot() - except TypeError: - pass - if xml is None: - root = ElemTree.fromstring(file) - if root is None: - raise RuntimeError("Failed to parse XML data") - - accepted_namespaces = ["", "{https://wiki.freecad.org/Package_Metadata}"] - - for ns in accepted_namespaces: - for child in root: - if child.tag == ns + "name": - self.Name = child.text - elif child.tag == ns + "description": - self.Description = child.text - elif child.tag == ns + "icon": - self.Icon = child.text - elif child.tag == ns + "url": - if "type" in child.attrib and child.attrib["type"] == "repository": - url = child.text - if "branch" in child.attrib: - branch = child.attrib["branch"] - else: - branch = "master" - self.Urls["repository"]["location"] = url - self.Urls["repository"]["branch"] = branch - - class MockAddon: """Minimal Addon class""" @@ -161,18 +110,6 @@ class MockAddon: def set_status(self, status): self.update_status = status - def set_metadata(self, metadata_like: MockMetadata): - """Set (some) of the metadata, but don't use a real Metadata object""" - self.metadata = metadata_like - if "repository" in self.metadata.Urls: - self.branch = self.metadata.Urls["repository"]["branch"] - self.url = self.metadata.Urls["repository"]["location"] - - def load_metadata_file(self, metadata_file: os.PathLike): - if os.path.exists(metadata_file): - self.metadata = MockMetadata() - self.metadata.minimal_file_scan(metadata_file) - @staticmethod def get_best_icon_relative_path(): return "" diff --git a/src/Mod/AddonManager/AddonManagerTest/app/test_addon.py b/src/Mod/AddonManager/AddonManagerTest/app/test_addon.py index 4d6d83dc67..8ba6e8e447 100644 --- a/src/Mod/AddonManager/AddonManagerTest/app/test_addon.py +++ b/src/Mod/AddonManager/AddonManagerTest/app/test_addon.py @@ -23,7 +23,9 @@ import unittest import os -import FreeCAD +import sys + +sys.path.append("../../") from Addon import Addon, INTERNAL_WORKBENCHES from addonmanager_macro import Macro @@ -35,7 +37,7 @@ class TestAddon(unittest.TestCase): def setUp(self): self.test_dir = os.path.join( - FreeCAD.getHomePath(), "Mod", "AddonManager", "AddonManagerTest", "data" + os.path.dirname(__file__), "..", "data" ) def test_display_name(self): @@ -55,51 +57,6 @@ class TestAddon(unittest.TestCase): self.assertEqual(addon.name, "FreeCAD") self.assertEqual(addon.display_name, "Test Workbench") - def test_metadata_loading(self): - addon = Addon( - "FreeCAD", - "https://github.com/FreeCAD/FreeCAD", - Addon.Status.NOT_INSTALLED, - "master", - ) - addon.load_metadata_file(os.path.join(self.test_dir, "good_package.xml")) - - # Generic tests: - self.assertIsNotNone(addon.metadata) - self.assertEqual(addon.metadata.Version, "1.0.1") - self.assertEqual( - addon.metadata.Description, "A package.xml file for unit testing." - ) - - maintainer_list = addon.metadata.Maintainer - self.assertEqual(len(maintainer_list), 1, "Wrong number of maintainers found") - self.assertEqual(maintainer_list[0]["name"], "FreeCAD Developer") - self.assertEqual(maintainer_list[0]["email"], "developer@freecad.org") - - license_list = addon.metadata.License - self.assertEqual(len(license_list), 1, "Wrong number of licenses found") - self.assertEqual(license_list[0]["name"], "LGPLv2.1") - self.assertEqual(license_list[0]["file"], "LICENSE") - - url_list = addon.metadata.Urls - self.assertEqual(len(url_list), 2, "Wrong number of urls found") - self.assertEqual(url_list[0]["type"], "repository") - self.assertEqual( - url_list[0]["location"], "https://github.com/chennes/FreeCAD-Package" - ) - self.assertEqual(url_list[0]["branch"], "main") - self.assertEqual(url_list[1]["type"], "readme") - self.assertEqual( - url_list[1]["location"], - "https://github.com/chennes/FreeCAD-Package/blob/main/README.md", - ) - - contents = addon.metadata.Content - self.assertEqual(len(contents), 1, "Wrong number of content catetories found") - self.assertEqual( - len(contents["workbench"]), 1, "Wrong number of workbenches found" - ) - def test_git_url_cleanup(self): base_url = "https://github.com/FreeCAD/FreeCAD" test_urls = [f" {base_url} ", f"{base_url}.git", f" {base_url}.git "] @@ -124,7 +81,7 @@ class TestAddon(unittest.TestCase): expected_tags.add("TagA") expected_tags.add("TagB") expected_tags.add("TagC") - self.assertEqual(tags, expected_tags) + self.assertEqual(expected_tags, tags) def test_contains_functions(self): diff --git a/src/Mod/AddonManager/AddonManagerTest/app/test_installer.py b/src/Mod/AddonManager/AddonManagerTest/app/test_installer.py index aadafb0c7f..df76569a1f 100644 --- a/src/Mod/AddonManager/AddonManagerTest/app/test_installer.py +++ b/src/Mod/AddonManager/AddonManagerTest/app/test_installer.py @@ -24,21 +24,20 @@ """Contains the unit test class for addonmanager_installer.py non-GUI functionality.""" import unittest +from unittest.mock import Mock import os import shutil import tempfile -import time from zipfile import ZipFile +import sys + +sys.path.append("../../") # So the IDE can find the imports below + import FreeCAD - -from typing import Dict - from addonmanager_installer import InstallationMethod, AddonInstaller, MacroInstaller - from addonmanager_git import GitManager, initialize_git - +from addonmanager_metadata import MetadataReader from Addon import Addon - from AddonManagerTest.app.mocks import MockAddon, MockMacro @@ -100,17 +99,12 @@ class TestAddonInstaller(unittest.TestCase): AddonInstaller._validate_object(no_branch) def test_update_metadata(self): - """If a metadata file exists in the installation location, it should be loaded.""" - installer = AddonInstaller(self.mock_addon, []) - with tempfile.TemporaryDirectory() as temp_dir: - installer.installation_path = temp_dir - addon_dir = os.path.join(temp_dir, self.mock_addon.name) - os.mkdir(addon_dir) - shutil.copy( - os.path.join(self.test_data_dir, "good_package.xml"), - os.path.join(addon_dir, "package.xml"), - ) - installer._update_metadata() # Does nothing, but should not crash + """If a metadata file exists in the installation location, it should be + loaded.""" + addon = Mock() + addon.name = "MockAddon" + installer = AddonInstaller(addon, []) + installer._update_metadata() # Does nothing, but should not crash installer = AddonInstaller(self.real_addon, []) with tempfile.TemporaryDirectory() as temp_dir: @@ -122,12 +116,12 @@ class TestAddonInstaller(unittest.TestCase): os.path.join(self.test_data_dir, "good_package.xml"), os.path.join(addon_dir, "package.xml"), ) - good_metadata = FreeCAD.Metadata(os.path.join(addon_dir, "package.xml")) + good_metadata = MetadataReader.from_file(os.path.join(addon_dir, "package.xml")) installer._update_metadata() - self.assertEqual(self.real_addon.installed_version, good_metadata.Version) + self.assertEqual(self.real_addon.installed_version, good_metadata.version) def test_finalize_zip_installation_non_github(self): - """Ensure that zipfiles are correctly extracted.""" + """Ensure that zip files are correctly extracted.""" with tempfile.TemporaryDirectory() as temp_dir: test_simple_repo = os.path.join(self.test_data_dir, "test_simple_repo.zip") non_gh_mock = MockAddon() @@ -160,52 +154,66 @@ class TestAddonInstaller(unittest.TestCase): """When there is a subdirectory with the branch name in it, find it""" installer = AddonInstaller(self.mock_addon, []) with tempfile.TemporaryDirectory() as temp_dir: - os.mkdir(os.path.join(temp_dir,f"{self.mock_addon.name}-{self.mock_addon.branch}")) + os.mkdir( + os.path.join( + temp_dir, f"{self.mock_addon.name}-{self.mock_addon.branch}" + ) + ) result = installer._code_in_branch_subdirectory(temp_dir) - self.assertTrue(result,"Failed to find ZIP subdirectory") + self.assertTrue(result, "Failed to find ZIP subdirectory") def test_code_in_branch_subdirectory_false(self): - """When there is not a subdirectory with the branch name in it, don't find one""" + """When there is not a subdirectory with the branch name in it, don't find + one""" installer = AddonInstaller(self.mock_addon, []) with tempfile.TemporaryDirectory() as temp_dir: result = installer._code_in_branch_subdirectory(temp_dir) - self.assertFalse(result,"Found ZIP subdirectory when there was none") + self.assertFalse(result, "Found ZIP subdirectory when there was none") def test_code_in_branch_subdirectory_more_than_one(self): """When there are multiple subdirectories, never find a branch subdirectory""" installer = AddonInstaller(self.mock_addon, []) with tempfile.TemporaryDirectory() as temp_dir: - os.mkdir(os.path.join(temp_dir,f"{self.mock_addon.name}-{self.mock_addon.branch}")) - os.mkdir(os.path.join(temp_dir,"AnotherSubdir")) + os.mkdir( + os.path.join( + temp_dir, f"{self.mock_addon.name}-{self.mock_addon.branch}" + ) + ) + os.mkdir(os.path.join(temp_dir, "AnotherSubdir")) result = installer._code_in_branch_subdirectory(temp_dir) - self.assertFalse(result,"Found ZIP subdirectory when there were multiple subdirs") + self.assertFalse( + result, "Found ZIP subdirectory when there were multiple subdirs" + ) def test_move_code_out_of_subdirectory(self): """All files are moved out and the subdirectory is deleted""" installer = AddonInstaller(self.mock_addon, []) with tempfile.TemporaryDirectory() as temp_dir: - subdir = os.path.join(temp_dir,f"{self.mock_addon.name}-{self.mock_addon.branch}") + subdir = os.path.join( + temp_dir, f"{self.mock_addon.name}-{self.mock_addon.branch}" + ) os.mkdir(subdir) - with open(os.path.join(subdir,"README.txt"),"w",encoding="utf-8") as f: + with open(os.path.join(subdir, "README.txt"), "w", encoding="utf-8") as f: f.write("# Test file for unit testing") - with open(os.path.join(subdir,"AnotherFile.txt"),"w",encoding="utf-8") as f: + with open( + os.path.join(subdir, "AnotherFile.txt"), "w", encoding="utf-8" + ) as f: f.write("# Test file for unit testing") installer._move_code_out_of_subdirectory(temp_dir) - self.assertTrue(os.path.isfile(os.path.join(temp_dir,"README.txt"))) - self.assertTrue(os.path.isfile(os.path.join(temp_dir,"AnotherFile.txt"))) + self.assertTrue(os.path.isfile(os.path.join(temp_dir, "README.txt"))) + self.assertTrue(os.path.isfile(os.path.join(temp_dir, "AnotherFile.txt"))) self.assertFalse(os.path.isdir(subdir)) - def test_install_by_git(self): - """Test using git to install. Depends on there being a local git installation: the test - is skipped if there is no local git.""" + """Test using git to install. Depends on there being a local git + installation: the test is skipped if there is no local git.""" git_manager = initialize_git() if not git_manager: self.skipTest("git not found, skipping git installer tests") return - # Our test git repo has to be in a zipfile, otherwise it cannot itself be stored in git, - # since it has a .git subdirectory. + # Our test git repo has to be in a zipfile, otherwise it cannot itself be + # stored in git, since it has a .git subdirectory. with tempfile.TemporaryDirectory() as temp_dir: git_repo_zip = os.path.join(self.test_data_dir, "test_repo.zip") with ZipFile(git_repo_zip, "r") as zip_repo: @@ -334,28 +342,32 @@ class TestAddonInstaller(unittest.TestCase): self.assertEqual(method, InstallationMethod.ZIP) def test_determine_install_method_https_known_sites_copy(self): - """Test which install methods are accepted for an https github URL""" + """Test which install methods are accepted for an https GitHub URL""" installer = AddonInstaller(self.mock_addon, []) installer.git_manager = True for site in ["github.org", "gitlab.org", "framagit.org", "salsa.debian.org"]: with self.subTest(site=site): - temp_file = f"https://{site}/dummy/dummy" # Doesn't have to actually exist! + temp_file = ( + f"https://{site}/dummy/dummy" # Doesn't have to actually exist! + ) method = installer._determine_install_method( temp_file, InstallationMethod.COPY ) self.assertIsNone(method, f"Allowed copying from {site} URL") def test_determine_install_method_https_known_sites_git(self): - """Test which install methods are accepted for an https github URL""" + """Test which install methods are accepted for an https GitHub URL""" installer = AddonInstaller(self.mock_addon, []) installer.git_manager = True for site in ["github.org", "gitlab.org", "framagit.org", "salsa.debian.org"]: with self.subTest(site=site): - temp_file = f"https://{site}/dummy/dummy" # Doesn't have to actually exist! + temp_file = ( + f"https://{site}/dummy/dummy" # Doesn't have to actually exist! + ) method = installer._determine_install_method( temp_file, InstallationMethod.GIT ) @@ -366,14 +378,16 @@ class TestAddonInstaller(unittest.TestCase): ) def test_determine_install_method_https_known_sites_zip(self): - """Test which install methods are accepted for an https github URL""" + """Test which install methods are accepted for an https GitHub URL""" installer = AddonInstaller(self.mock_addon, []) installer.git_manager = True for site in ["github.org", "gitlab.org", "framagit.org", "salsa.debian.org"]: with self.subTest(site=site): - temp_file = f"https://{site}/dummy/dummy" # Doesn't have to actually exist! + temp_file = ( + f"https://{site}/dummy/dummy" # Doesn't have to actually exist! + ) method = installer._determine_install_method( temp_file, InstallationMethod.ZIP ) @@ -384,14 +398,16 @@ class TestAddonInstaller(unittest.TestCase): ) def test_determine_install_method_https_known_sites_any_gm(self): - """Test which install methods are accepted for an https github URL""" + """Test which install methods are accepted for an https GitHub URL""" installer = AddonInstaller(self.mock_addon, []) installer.git_manager = True for site in ["github.org", "gitlab.org", "framagit.org", "salsa.debian.org"]: with self.subTest(site=site): - temp_file = f"https://{site}/dummy/dummy" # Doesn't have to actually exist! + temp_file = ( + f"https://{site}/dummy/dummy" # Doesn't have to actually exist! + ) method = installer._determine_install_method( temp_file, InstallationMethod.ANY ) @@ -402,14 +418,16 @@ class TestAddonInstaller(unittest.TestCase): ) def test_determine_install_method_https_known_sites_any_no_gm(self): - """Test which install methods are accepted for an https github URL""" + """Test which install methods are accepted for an https GitHub URL""" installer = AddonInstaller(self.mock_addon, []) installer.git_manager = None for site in ["github.org", "gitlab.org", "framagit.org", "salsa.debian.org"]: with self.subTest(site=site): - temp_file = f"https://{site}/dummy/dummy" # Doesn't have to actually exist! + temp_file = ( + f"https://{site}/dummy/dummy" # Doesn't have to actually exist! + ) method = installer._determine_install_method( temp_file, InstallationMethod.ANY ) @@ -436,7 +454,6 @@ class TestAddonInstaller(unittest.TestCase): class TestMacroInstaller(unittest.TestCase): - MODULE = "test_installer" # file name without extension def setUp(self): @@ -448,8 +465,9 @@ class TestMacroInstaller(unittest.TestCase): def test_installation(self): """Test the wrapper around the macro installer""" - # Note that this doesn't test the underlying Macro object's install function, it only - # tests whether that function is called appropriately by the MacroInstaller wrapper. + # Note that this doesn't test the underlying Macro object's install function, + # it only tests whether that function is called appropriately by the + # MacroInstaller wrapper. with tempfile.TemporaryDirectory() as temp_dir: installer = MacroInstaller(self.mock) installer.installation_path = temp_dir diff --git a/src/Mod/AddonManager/AddonManagerTest/app/test_macro.py b/src/Mod/AddonManager/AddonManagerTest/app/test_macro.py index 280e6abf22..20960e3c6e 100644 --- a/src/Mod/AddonManager/AddonManagerTest/app/test_macro.py +++ b/src/Mod/AddonManager/AddonManagerTest/app/test_macro.py @@ -21,18 +21,21 @@ # * * # *************************************************************************** -import unittest import os +import sys import tempfile -import FreeCAD - from typing import Dict +import unittest +from unittest.mock import MagicMock + +sys.path.append("../../") # So the IDE can find the + +import FreeCAD from addonmanager_macro import Macro class TestMacro(unittest.TestCase): - MODULE = "test_macro" # file name without extension def setUp(self): @@ -183,49 +186,28 @@ static char * blarg_xpm[] = { return m def test_fetch_raw_code_no_data(self): - class MockNetworkManagerNoData: - def __init__(self): - self.fetched_url = None - - def blocking_get(self, url): - self.fetched_url = url - return None - - nmNoData = MockNetworkManagerNoData() m = Macro("Unit Test Macro") - Macro.network_manager = nmNoData + Macro.blocking_get = MagicMock(return_value=None) returned_data = m._fetch_raw_code( 'rawcodeurl Totally fake' ) self.assertIsNone(returned_data) - self.assertEqual(nmNoData.fetched_url, "https://fake_url.com") + m.blocking_get.assert_called_with("https://fake_url.com") + Macro.blocking_get = None - nmNoData.fetched_url = None + def test_fetch_raw_code_no_url(self): + m = Macro("Unit Test Macro") + Macro.blocking_get = MagicMock(return_value=None) returned_data = m._fetch_raw_code("Fake pagedata with no URL at all.") self.assertIsNone(returned_data) - self.assertIsNone(nmNoData.fetched_url) - - Macro.network_manager = None + m.blocking_get.assert_not_called() + Macro.blocking_get = None def test_fetch_raw_code_with_data(self): - class MockNetworkManagerWithData: - class MockQByteArray: - def data(self): - return "Data returned to _fetch_raw_code".encode("utf-8") - - def __init__(self): - self.fetched_url = None - - def blocking_get(self, url): - self.fetched_url = url - return MockNetworkManagerWithData.MockQByteArray() - - nmWithData = MockNetworkManagerWithData() m = Macro("Unit Test Macro") - Macro.network_manager = nmWithData + Macro.blocking_get = MagicMock(return_value=b"Data returned to _fetch_raw_code") returned_data = m._fetch_raw_code( 'rawcodeurl Totally fake' ) self.assertEqual(returned_data, "Data returned to _fetch_raw_code") - - Macro.network_manager = None + Macro.blocking_get = None diff --git a/src/Mod/AddonManager/AddonManagerTest/app/test_metadata.py b/src/Mod/AddonManager/AddonManagerTest/app/test_metadata.py new file mode 100644 index 0000000000..807ed7d494 --- /dev/null +++ b/src/Mod/AddonManager/AddonManagerTest/app/test_metadata.py @@ -0,0 +1,620 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later +# *************************************************************************** +# * * +# * Copyright (c) 2023 FreeCAD Project Association * +# * * +# * This file is part of FreeCAD. * +# * * +# * FreeCAD is free software: you can redistribute it and/or modify it * +# * under the terms of the GNU Lesser General Public License as * +# * published by the Free Software Foundation, either version 2.1 of the * +# * License, or (at your option) any later version. * +# * * +# * FreeCAD 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 * +# * Lesser General Public License for more details. * +# * * +# * You should have received a copy of the GNU Lesser General Public * +# * License along with FreeCAD. If not, see * +# * . * +# * * +# *************************************************************************** +import os +import sys +import tempfile +import unittest +import unittest.mock + +Mock = unittest.mock.MagicMock + +sys.path.append("../../") + + +class TestVersion(unittest.TestCase): + def setUp(self) -> None: + if "addonmanager_metadata" in sys.modules: + sys.modules.pop("addonmanager_metadata") + self.packaging_version = None + if "packaging.version" in sys.modules: + self.packaging_version = sys.modules["packaging.version"] + sys.modules.pop("packaging.version") + + def tearDown(self) -> None: + if self.packaging_version is not None: + sys.modules["packaging.version"] = self.packaging_version + + def test_init_from_string_manual(self): + import addonmanager_metadata as amm + + version = amm.Version() + version._parse_string_to_tuple = unittest.mock.MagicMock() + version._init_from_string("1.2.3beta") + self.assertTrue(version._parse_string_to_tuple.called) + + def test_init_from_list_good(self): + """Initialization from a list works for good input""" + import addonmanager_metadata as amm + + test_cases = [ + {"input": (1,), "output": [1, 0, 0, ""]}, + {"input": (1, 2), "output": [1, 2, 0, ""]}, + {"input": (1, 2, 3), "output": [1, 2, 3, ""]}, + {"input": (1, 2, 3, "b1"), "output": [1, 2, 3, "b1"]}, + ] + for test_case in test_cases: + with self.subTest(test_case=test_case): + v = amm.Version(from_list=test_case["input"]) + self.assertListEqual(test_case["output"], v.version_as_list) + + def test_parse_string_to_tuple_normal(self): + """Parsing of complete version string works for normal cases""" + import addonmanager_metadata as amm + + cases = { + "1": [1, 0, 0, ""], + "1.2": [1, 2, 0, ""], + "1.2.3": [1, 2, 3, ""], + "1.2.3beta": [1, 2, 3, "beta"], + "12_345.6_7.8pre-alpha": [12345, 67, 8, "pre-alpha"], + # The above test is mostly to point out that Python gets permits underscore + # characters in a number. + } + for inp, output in cases.items(): + with self.subTest(inp=inp, output=output): + version = amm.Version() + version._parse_string_to_tuple(inp) + self.assertListEqual(version.version_as_list, output) + + def test_parse_string_to_tuple_invalid(self): + """Parsing of invalid version string raises an exception""" + import addonmanager_metadata as amm + + cases = {"One", "1,2,3", "1-2-3", "1/2/3"} + for inp in cases: + with self.subTest(inp=inp): + with self.assertRaises(ValueError): + version = amm.Version() + version._parse_string_to_tuple(inp) + + def test_parse_final_entry_normal(self): + """Parsing of the final entry works for normal cases""" + import addonmanager_metadata as amm + + cases = { + "3beta": (3, "beta"), + "42.alpha": (42, ".alpha"), + "123.45.6": (123, ".45.6"), + "98_delta": (98, "_delta"), + "1 and some words": (1, " and some words"), + } + for inp, output in cases.items(): + with self.subTest(inp=inp, output=output): + number, text = amm.Version._parse_final_entry(inp) + self.assertEqual(number, output[0]) + self.assertEqual(text, output[1]) + + def test_parse_final_entry_invalid(self): + """Invalid input raises an exception""" + import addonmanager_metadata as amm + + cases = ["beta", "", ["a", "b"]] + for case in cases: + with self.subTest(case=case): + with self.assertRaises(ValueError): + amm.Version._parse_final_entry(case) + + def test_operators_internal(self): + """Test internal (non-package) comparison operators""" + sys.modules["packaging.version"] = None + import addonmanager_metadata as amm + + cases = self.given_comparison_cases() + for case in cases: + with self.subTest(case=case): + first = amm.Version(case[0]) + second = amm.Version(case[1]) + self.assertEqual(first < second, case[0] < case[1]) + self.assertEqual(first > second, case[0] > case[1]) + self.assertEqual(first <= second, case[0] <= case[1]) + self.assertEqual(first >= second, case[0] >= case[1]) + self.assertEqual(first == second, case[0] == case[1]) + + @staticmethod + def given_comparison_cases(): + return [ + ("0.0.0alpha", "1.0.0alpha"), + ("0.0.0alpha", "0.1.0alpha"), + ("0.0.0alpha", "0.0.1alpha"), + ("0.0.0alpha", "0.0.0beta"), + ("0.0.0alpha", "0.0.0alpha"), + ("1.0.0alpha", "0.0.0alpha"), + ("0.1.0alpha", "0.0.0alpha"), + ("0.0.1alpha", "0.0.0alpha"), + ("0.0.0beta", "0.0.0alpha"), + ] + + +class TestDependencyType(unittest.TestCase): + """Ensure that the DependencyType dataclass converts to the correct strings""" + + def setUp(self) -> None: + from addonmanager_metadata import DependencyType + + self.DependencyType = DependencyType + + def test_string_conversion_automatic(self): + self.assertEqual(str(self.DependencyType.automatic), "automatic") + + def test_string_conversion_internal(self): + self.assertEqual(str(self.DependencyType.internal), "internal") + + def test_string_conversion_addon(self): + self.assertEqual(str(self.DependencyType.addon), "addon") + + def test_string_conversion_python(self): + self.assertEqual(str(self.DependencyType.python), "python") + + +class TestUrlType(unittest.TestCase): + """Ensure that the UrlType dataclass converts to the correct strings""" + + def setUp(self) -> None: + from addonmanager_metadata import UrlType + + self.UrlType = UrlType + + def test_string_conversion_website(self): + self.assertEqual(str(self.UrlType.website), "website") + + def test_string_conversion_repository(self): + self.assertEqual(str(self.UrlType.repository), "repository") + + def test_string_conversion_bugtracker(self): + self.assertEqual(str(self.UrlType.bugtracker), "bugtracker") + + def test_string_conversion_readme(self): + self.assertEqual(str(self.UrlType.readme), "readme") + + def test_string_conversion_documentation(self): + self.assertEqual(str(self.UrlType.documentation), "documentation") + + def test_string_conversion_discussion(self): + self.assertEqual(str(self.UrlType.discussion), "discussion") + + +class TestMetadataAuxiliaryFunctions(unittest.TestCase): + + def test_get_first_supported_freecad_version_simple(self): + from addonmanager_metadata import Metadata, Version, get_first_supported_freecad_version + expected_result = Version(from_string="0.20.2beta") + metadata = self.given_metadata_with_freecadmin_set(expected_result) + first_version = get_first_supported_freecad_version(metadata) + self.assertEqual(expected_result, first_version) + + @staticmethod + def given_metadata_with_freecadmin_set(min_version): + from addonmanager_metadata import Metadata + metadata = Metadata() + metadata.freecadmin = min_version + return metadata + + def test_get_first_supported_freecad_version_with_content(self): + from addonmanager_metadata import Metadata, Version, get_first_supported_freecad_version + expected_result = Version(from_string="0.20.2beta") + metadata = self.given_metadata_with_freecadmin_in_content(expected_result) + first_version = get_first_supported_freecad_version(metadata) + self.assertEqual(expected_result, first_version) + + @staticmethod + def given_metadata_with_freecadmin_in_content(min_version): + from addonmanager_metadata import Metadata, Version + v_list = min_version.version_as_list + metadata = Metadata() + wb1 = Metadata() + wb1.freecadmin = Version(from_list=[v_list[0]+1,v_list[1],v_list[2],v_list[3]]) + wb2 = Metadata() + wb2.freecadmin = Version(from_list=[v_list[0],v_list[1]+1,v_list[2],v_list[3]]) + wb3 = Metadata() + wb3.freecadmin = Version(from_list=[v_list[0],v_list[1],v_list[2]+1,v_list[3]]) + m1 = Metadata() + m1.freecadmin = min_version + metadata.content = {"workbench":[wb1,wb2,wb3],"macro":[m1]} + return metadata + + +class TestMetadataReader(unittest.TestCase): + """Test reading metadata from XML""" + + def setUp(self) -> None: + if "xml.etree.ElementTree" in sys.modules: + sys.modules.pop("xml.etree.ElementTree") + if "MetadataReader" in sys.modules: + sys.modules.pop("MetadataReader") + + def tearDown(self) -> None: + if "xml.etree.ElementTree" in sys.modules: + sys.modules.pop("xml.etree.ElementTree") + if "MetadataReader" in sys.modules: + sys.modules.pop("MetadataReader") + + def test_from_file(self): + from addonmanager_metadata import MetadataReader + + MetadataReader.from_bytes = Mock() + with tempfile.NamedTemporaryFile(delete=False) as temp: + temp.write(b"Some data") + temp.close() + MetadataReader.from_file(temp.name) + self.assertTrue(MetadataReader.from_bytes.called) + MetadataReader.from_bytes.assert_called_once_with(b"Some data") + os.unlink(temp.name) + + @unittest.skip("Breaks other tests, needs to be fixed") + def test_from_bytes(self): + import xml.etree.ElementTree + + with unittest.mock.patch("xml.etree.ElementTree") as element_tree_mock: + from addonmanager_metadata import MetadataReader + + MetadataReader._process_element_tree = Mock() + MetadataReader.from_bytes(b"Some data") + element_tree_mock.parse.assert_called_once_with(b"Some data") + + def test_process_element_tree(self): + from addonmanager_metadata import MetadataReader + + MetadataReader._determine_namespace = Mock(return_value="") + element_tree_mock = Mock() + MetadataReader._create_node = Mock() + MetadataReader._process_element_tree(element_tree_mock) + MetadataReader._create_node.assert_called_once() + + def test_determine_namespace_found_full(self): + from addonmanager_metadata import MetadataReader + + root = Mock() + root.tag = "{https://wiki.freecad.org/Package_Metadata}package" + found_ns = MetadataReader._determine_namespace(root) + self.assertEqual(found_ns, "{https://wiki.freecad.org/Package_Metadata}") + + def test_determine_namespace_found_empty(self): + from addonmanager_metadata import MetadataReader + + root = Mock() + root.tag = "package" + found_ns = MetadataReader._determine_namespace(root) + self.assertEqual(found_ns, "") + + def test_determine_namespace_not_found(self): + from addonmanager_metadata import MetadataReader + + root = Mock() + root.find = Mock(return_value=False) + with self.assertRaises(RuntimeError): + MetadataReader._determine_namespace(root) + + def test_parse_child_element_simple_strings(self): + from addonmanager_metadata import Metadata, MetadataReader + + tags = ["name", "date", "description", "icon", "classname", "subdirectory"] + for tag in tags: + with self.subTest(tag=tag): + text = f"Test Data for {tag}" + child = self.given_mock_tree_node(tag, text) + mock_metadata = Metadata() + MetadataReader._parse_child_element("", child, mock_metadata) + self.assertEqual(mock_metadata.__dict__[tag], text) + + def test_parse_child_element_version(self): + from addonmanager_metadata import Metadata, Version, MetadataReader + + mock_metadata = Metadata() + child = self.given_mock_tree_node("version", "1.2.3") + MetadataReader._parse_child_element("", child, mock_metadata) + self.assertEqual(Version("1.2.3"), mock_metadata.version) + + def test_parse_child_element_lists_of_strings(self): + from addonmanager_metadata import Metadata, MetadataReader + + tags = ["file", "tag"] + for tag in tags: + with self.subTest(tag=tag): + mock_metadata = Metadata() + expected_results = [] + for i in range(10): + text = f"Test {i} for {tag}" + expected_results.append(text) + child = self.given_mock_tree_node(tag, text) + MetadataReader._parse_child_element("", child, mock_metadata) + self.assertEqual(len(mock_metadata.__dict__[tag]), 10) + self.assertListEqual(mock_metadata.__dict__[tag], expected_results) + + def test_parse_child_element_lists_of_contacts(self): + from addonmanager_metadata import Metadata, Contact, MetadataReader + + tags = ["maintainer", "author"] + for tag in tags: + with self.subTest(tag=tag): + mock_metadata = Metadata() + expected_results = [] + for i in range(10): + text = f"Test {i} for {tag}" + email = f"Email {i} for {tag}" if i % 2 == 0 else None + expected_results.append(Contact(name=text, email=email)) + child = self.given_mock_tree_node(tag, text, {"email": email}) + MetadataReader._parse_child_element("", child, mock_metadata) + self.assertEqual(len(mock_metadata.__dict__[tag]), 10) + self.assertListEqual(mock_metadata.__dict__[tag], expected_results) + + def test_parse_child_element_list_of_licenses(self): + from addonmanager_metadata import Metadata, License, MetadataReader + + mock_metadata = Metadata() + expected_results = [] + tag = "license" + for i in range(10): + text = f"Test {i} for {tag}" + file = f"Filename {i} for {tag}" if i % 2 == 0 else None + expected_results.append(License(name=text, file=file)) + child = self.given_mock_tree_node(tag, text, {"file": file}) + MetadataReader._parse_child_element("", child, mock_metadata) + self.assertEqual(len(mock_metadata.__dict__[tag]), 10) + self.assertListEqual(mock_metadata.__dict__[tag], expected_results) + + def test_parse_child_element_list_of_urls(self): + from addonmanager_metadata import Metadata, Url, UrlType, MetadataReader + + mock_metadata = Metadata() + expected_results = [] + tag = "url" + for i in range(10): + text = f"Test {i} for {tag}" + url_type = UrlType(i % len(UrlType)) + type = str(url_type) + branch = "" + if type == "repository": + branch = f"Branch {i} for {tag}" + expected_results.append(Url(location=text, type=url_type, branch=branch)) + child = self.given_mock_tree_node( + tag, text, {"type": type, "branch": branch} + ) + MetadataReader._parse_child_element("", child, mock_metadata) + self.assertEqual(len(mock_metadata.__dict__[tag]), 10) + self.assertListEqual(mock_metadata.__dict__[tag], expected_results) + + def test_parse_child_element_lists_of_dependencies(self): + from addonmanager_metadata import ( + Metadata, + Dependency, + DependencyType, + MetadataReader, + ) + + tags = ["depend", "conflict", "replace"] + attributes = { + "version_lt": "1.0.0", + "version_lte": "1.0.0", + "version_eq": "1.0.0", + "version_gte": "1.0.0", + "version_gt": "1.0.0", + "condition": "$BuildVersionMajor<1", + "optional": True, + } + + for tag in tags: + for attribute, attr_value in attributes.items(): + with self.subTest(tag=tag, attribute=attribute): + mock_metadata = Metadata() + expected_results = [] + for i in range(10): + text = f"Test {i} for {tag}" + dependency_type = DependencyType(i % len(DependencyType)) + dependency_type_str = str(dependency_type) + expected = Dependency( + package=text, dependency_type=dependency_type + ) + expected.__dict__[attribute] = attr_value + expected_results.append(expected) + child = self.given_mock_tree_node( + tag, + text, + {"type": dependency_type_str, attribute: str(attr_value)}, + ) + MetadataReader._parse_child_element("", child, mock_metadata) + self.assertEqual(len(mock_metadata.__dict__[tag]), 10) + self.assertListEqual(mock_metadata.__dict__[tag], expected_results) + + def test_parse_child_element_ignore_unknown_tag(self): + from addonmanager_metadata import Metadata, MetadataReader + + tag = "invalid_tag" + text = "Shouldn't matter" + child = self.given_mock_tree_node(tag, text) + mock_metadata = Metadata() + MetadataReader._parse_child_element("", child, mock_metadata) + self.assertNotIn(tag, mock_metadata.__dict__) + + def test_parse_child_element_versions(self): + from addonmanager_metadata import Metadata, Version, MetadataReader + + tags = ["version", "freecadmin", "freecadmax", "pythonmin"] + for tag in tags: + with self.subTest(tag=tag): + mock_metadata = Metadata() + text = "3.4.5beta" + child = self.given_mock_tree_node(tag, text) + MetadataReader._parse_child_element("", child, mock_metadata) + self.assertEqual(mock_metadata.__dict__[tag], Version(from_string=text)) + + def given_mock_tree_node(self, tag, text, attributes=None): + class MockTreeNode: + def __init__(self): + self.tag = tag + self.text = text + self.attrib = attributes if attributes is not None else [] + + return MockTreeNode() + + def test_parse_content_valid(self): + from addonmanager_metadata import MetadataReader + + valid_content_items = ["workbench", "macro", "preferencepack"] + MetadataReader._create_node = Mock() + for content_type in valid_content_items: + with self.subTest(content_type=content_type): + tree_mock = [self.given_mock_tree_node(content_type, None)] + metadata_mock = Mock() + MetadataReader._parse_content("", metadata_mock, tree_mock) + MetadataReader._create_node.assert_called_once() + MetadataReader._create_node.reset_mock() + + def test_parse_content_invalid(self): + from addonmanager_metadata import MetadataReader + + MetadataReader._create_node = Mock() + content_item = "no_such_content_type" + tree_mock = [self.given_mock_tree_node(content_item, None)] + metadata_mock = Mock() + MetadataReader._parse_content("", metadata_mock, tree_mock) + MetadataReader._create_node.assert_not_called() + + +class TestMetadataReaderIntegration(unittest.TestCase): + """Full-up tests of the MetadataReader class (no mocking).""" + + def setUp(self) -> None: + self.test_data_dir = os.path.join(os.path.dirname(__file__), "..", "data") + remove_list = [] + for key in sys.modules: + if "addonmanager_metadata" in key: + remove_list.append(key) + for key in remove_list: + print(f"Removing {key}") + sys.modules.pop(key) + + def test_loading_simple_metadata_file(self): + from addonmanager_metadata import ( + Contact, + Dependency, + License, + MetadataReader, + Url, + UrlType, + Version, + ) + + filename = os.path.join(self.test_data_dir, "good_package.xml") + metadata = MetadataReader.from_file(filename) + self.assertEqual("Test Workbench", metadata.name) + self.assertEqual("A package.xml file for unit testing.", metadata.description) + self.assertEqual(Version("1.0.1"), metadata.version) + self.assertEqual("2022-01-07", metadata.date) + self.assertEqual("Resources/icons/PackageIcon.svg", metadata.icon) + self.assertListEqual( + [License(name="LGPLv2.1", file="LICENSE")], metadata.license + ) + self.assertListEqual( + [Contact(name="FreeCAD Developer", email="developer@freecad.org")], + metadata.maintainer, + ) + self.assertListEqual( + [ + Url( + location="https://github.com/chennes/FreeCAD-Package", + type=UrlType.repository, + branch="main", + ), + Url( + location="https://github.com/chennes/FreeCAD-Package/blob/main/README.md", + type=UrlType.readme, + ), + ], + metadata.url, + ) + self.assertListEqual(["Tag0", "Tag1"], metadata.tag) + self.assertIn("workbench", metadata.content) + self.assertEqual(len(metadata.content["workbench"]), 1) + wb_metadata = metadata.content["workbench"][0] + self.assertEqual("MyWorkbench", wb_metadata.classname) + self.assertEqual("./", wb_metadata.subdirectory) + self.assertListEqual(["TagA", "TagB", "TagC"], wb_metadata.tag) + + def test_multiple_workbenches(self): + from addonmanager_metadata import MetadataReader + + filename = os.path.join(self.test_data_dir, "workbench_only.xml") + metadata = MetadataReader.from_file(filename) + self.assertIn("workbench", metadata.content) + self.assertEqual(len(metadata.content["workbench"]), 3) + expected_wb_classnames = [ + "MyFirstWorkbench", + "MySecondWorkbench", + "MyThirdWorkbench", + ] + for wb in metadata.content["workbench"]: + self.assertIn(wb.classname, expected_wb_classnames) + expected_wb_classnames.remove(wb.classname) + self.assertEqual(len(expected_wb_classnames), 0) + + def test_multiple_macros(self): + from addonmanager_metadata import MetadataReader + + filename = os.path.join(self.test_data_dir, "macro_only.xml") + metadata = MetadataReader.from_file(filename) + self.assertIn("macro", metadata.content) + self.assertEqual(len(metadata.content["macro"]), 2) + expected_wb_files = ["MyMacro.FCStd", "MyOtherMacro.FCStd"] + for wb in metadata.content["macro"]: + self.assertIn(wb.file[0], expected_wb_files) + expected_wb_files.remove(wb.file[0]) + self.assertEqual(len(expected_wb_files), 0) + + def test_multiple_preference_packs(self): + from addonmanager_metadata import MetadataReader + + filename = os.path.join(self.test_data_dir, "prefpack_only.xml") + metadata = MetadataReader.from_file(filename) + self.assertIn("preferencepack", metadata.content) + self.assertEqual(len(metadata.content["preferencepack"]), 3) + expected_packs = ["MyFirstPack", "MySecondPack", "MyThirdPack"] + for wb in metadata.content["preferencepack"]: + self.assertIn(wb.name, expected_packs) + expected_packs.remove(wb.name) + self.assertEqual(len(expected_packs), 0) + + def test_content_combination(self): + from addonmanager_metadata import MetadataReader + + filename = os.path.join(self.test_data_dir, "combination.xml") + metadata = MetadataReader.from_file(filename) + self.assertIn("preferencepack", metadata.content) + self.assertEqual(len(metadata.content["preferencepack"]), 1) + self.assertIn("macro", metadata.content) + self.assertEqual(len(metadata.content["macro"]), 1) + self.assertIn("workbench", metadata.content) + self.assertEqual(len(metadata.content["workbench"]), 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/Mod/AddonManager/CMakeLists.txt b/src/Mod/AddonManager/CMakeLists.txt index d4c2b856e1..2d9b3e3376 100644 --- a/src/Mod/AddonManager/CMakeLists.txt +++ b/src/Mod/AddonManager/CMakeLists.txt @@ -25,6 +25,7 @@ SET(AddonManager_SRCS addonmanager_installer_gui.py addonmanager_macro.py addonmanager_macro_parser.py + addonmanager_metadata.py addonmanager_update_all_gui.py addonmanager_uninstaller.py addonmanager_uninstaller_gui.py @@ -89,6 +90,7 @@ SET(AddonManagerTestsApp_SRCS AddonManagerTest/app/test_installer.py AddonManagerTest/app/test_macro.py AddonManagerTest/app/test_macro_parser.py + AddonManagerTest/app/test_metadata.py AddonManagerTest/app/test_utilities.py AddonManagerTest/app/test_uninstaller.py ) diff --git a/src/Mod/AddonManager/TestAddonManagerApp.py b/src/Mod/AddonManager/TestAddonManagerApp.py index c5356fa0fe..2d4ce76cdb 100644 --- a/src/Mod/AddonManager/TestAddonManagerApp.py +++ b/src/Mod/AddonManager/TestAddonManagerApp.py @@ -52,6 +52,14 @@ from AddonManagerTest.app.test_freecad_interface import ( TestParameters as AddonManagerTestParameters, TestDataPaths as AddonManagerTestDataPaths, ) +from AddonManagerTest.app.test_metadata import ( + TestDependencyType as AddonManagerTestDependencyType, + TestMetadataReader as AddonManagerTestMetadataReader, + TestMetadataReaderIntegration as AddonManagerTestMetadataReaderIntegration, + TestUrlType as AddonManagerTestUrlType, + TestVersion as AddonManagerTestVersion, + TestMetadataAuxiliaryFunctions as AddonManagerTestMetadataAuxiliaryFunctions +) class TestListTerminator: @@ -76,6 +84,12 @@ loaded_gui_tests = [ AddonManagerTestConsole, AddonManagerTestParameters, AddonManagerTestDataPaths, + AddonManagerTestDependencyType, + AddonManagerTestMetadataReader, + AddonManagerTestMetadataReaderIntegration, + AddonManagerTestUrlType, + AddonManagerTestVersion, + AddonManagerTestMetadataAuxiliaryFunctions, TestListTerminator # Needed to prevent the last test from running twice ] for test in loaded_gui_tests: diff --git a/src/Mod/AddonManager/addonmanager_devmode_metadata_checker.py b/src/Mod/AddonManager/addonmanager_devmode_metadata_checker.py index 3f000d7643..25f2bd35af 100644 --- a/src/Mod/AddonManager/addonmanager_devmode_metadata_checker.py +++ b/src/Mod/AddonManager/addonmanager_devmode_metadata_checker.py @@ -28,12 +28,13 @@ from typing import List import FreeCAD from Addon import Addon +from addonmanager_metadata import Metadata import NetworkManager class MetadataValidators: - """A collection of tools for validating various pieces of metadata. Prints validation - information to the console.""" + """A collection of tools for validating various pieces of metadata. Prints + validation information to the console.""" def validate_all(self, repos): """Developer tool: check all repos for validity and print report""" @@ -64,9 +65,9 @@ class MetadataValidators: if addon.metadata is None: return - # The package.xml standard has some required elements that the basic XML reader is not - # actually checking for. In developer mode, actually make sure that all the rules are - # being followed for each element. + # The package.xml standard has some required elements that the basic XML + # reader is not actually checking for. In developer mode, actually make sure + # that all the rules are being followed for each element. errors = [] @@ -83,15 +84,15 @@ class MetadataValidators: def validate_content(self, addon: Addon) -> List[str]: """Validate the Content items for this Addon""" errors = [] - contents = addon.metadata.Content + contents = addon.metadata.content missing_icon = True - if addon.metadata.Icon and len(addon.metadata.Icon) > 0: + if addon.metadata.icon and len(addon.metadata.icon) > 0: missing_icon = False else: if "workbench" in contents: wb = contents["workbench"][0] - if wb.Icon: + if wb.icon: missing_icon = False if missing_icon: errors.append("No element found, or element is invalid") @@ -106,38 +107,37 @@ class MetadataValidators: return errors - def validate_top_level(self, addon) -> List[str]: + def validate_top_level(self, addon:Addon) -> List[str]: """Check for the presence of the required top-level elements""" errors = [] - if not addon.metadata.Name or len(addon.metadata.Name) == 0: + if not addon.metadata.name or len(addon.metadata.name) == 0: errors.append( "No top-level element found, or element is empty" ) - if not addon.metadata.Version or addon.metadata.Version == "0.0.0": + if not addon.metadata.version: errors.append( "No top-level element found, or element is invalid" ) - # if not addon.metadata.Date or len(addon.metadata.Date) == 0: - # errors.append(f"No top-level element found, or element is invalid") - if not addon.metadata.Description or len(addon.metadata.Description) == 0: + if not addon.metadata.description or len(addon.metadata.description) == 0: errors.append( - "No top-level element found, or element is invalid" + "No top-level element found, or element " + "is invalid" ) - maintainers = addon.metadata.Maintainer + maintainers = addon.metadata.maintainer if len(maintainers) == 0: errors.append("No top-level found, at least one is required") for maintainer in maintainers: - if len(maintainer["email"]) == 0: + if len(maintainer.email) == 0: errors.append( - f"No email address specified for maintainer '{maintainer['name']}'" + f"No email address specified for maintainer '{maintainer.name}'" ) - licenses = addon.metadata.License + licenses = addon.metadata.license if len(licenses) == 0: errors.append("No top-level found, at least one is required") - urls = addon.metadata.Urls + urls = addon.metadata.url errors.extend(self.validate_urls(urls)) return errors @@ -185,17 +185,17 @@ class MetadataValidators: return errors @staticmethod - def validate_workbench_metadata(workbench) -> List[str]: + def validate_workbench_metadata(workbench:Metadata) -> List[str]: """Validate the required element(s) for a workbench""" errors = [] - if not workbench.Classname or len(workbench.Classname) == 0: + if not workbench.classname or len(workbench.classname) == 0: errors.append("No specified for workbench") return errors @staticmethod - def validate_preference_pack_metadata(pack) -> List[str]: + def validate_preference_pack_metadata(pack:Metadata) -> List[str]: """Validate the required element(s) for a preference pack""" errors = [] - if not pack.Name or len(pack.Name) == 0: + if not pack.name or len(pack.name) == 0: errors.append("No specified for preference pack") return errors diff --git a/src/Mod/AddonManager/addonmanager_freecad_interface.py b/src/Mod/AddonManager/addonmanager_freecad_interface.py index 5fc4ca041a..29d1d15962 100644 --- a/src/Mod/AddonManager/addonmanager_freecad_interface.py +++ b/src/Mod/AddonManager/addonmanager_freecad_interface.py @@ -47,8 +47,14 @@ try: getUserCachePath = FreeCAD.getUserCachePath translate = FreeCAD.Qt.translate + if FreeCAD.GuiUp: + import FreeCADGui + else: + FreeCADGui = None + except ImportError: FreeCAD = None + FreeCADGui = None getUserAppDataDir = None getUserCachePath = None getUserMacroDir = None @@ -57,7 +63,7 @@ except ImportError: return string def Version(): - return 1, 0, 0 + return 0, 21, 0, "dev" class ConsoleReplacement: """If FreeCAD's Console is not available, create a replacement by redirecting FreeCAD @@ -140,6 +146,7 @@ class DataPaths: mod_dir = None macro_dir = None cache_dir = None + home_dir = None reference_count = 0 @@ -151,6 +158,8 @@ class DataPaths: self.cache_dir = getUserCachePath() if self.macro_dir is None: self.macro_dir = getUserMacroDir(True) + if self.home_dir is None: + self.home_dir = FreeCAD.getHomePath() else: self.reference_count += 1 if self.mod_dir is None: @@ -159,6 +168,8 @@ class DataPaths: self.cache_dir = tempfile.mkdtemp() if self.macro_dir is None: self.macro_dir = tempfile.mkdtemp() + if self.home_dir is None: + self.home_dir = os.path.join(os.path.dirname(__file__), "..", "..") def __del__(self): self.reference_count -= 1 diff --git a/src/Mod/AddonManager/addonmanager_installer.py b/src/Mod/AddonManager/addonmanager_installer.py index 9b98f4eda1..18c493344b 100644 --- a/src/Mod/AddonManager/addonmanager_installer.py +++ b/src/Mod/AddonManager/addonmanager_installer.py @@ -194,7 +194,7 @@ class AddonInstaller(QtCore.QObject): FreeCAD.Console.PrintLog( "Overriding local ALLOWED_PYTHON_PACKAGES.txt with newer remote version\n" ) - p = p.data().decode("utf8") + p = p.decode("utf8") lines = p.split("\n") cls.allowed_packages.clear() # Unset the locally-defined list for line in lines: @@ -407,7 +407,7 @@ class AddonInstaller(QtCore.QObject): if hasattr(self.addon_to_install, "metadata") and os.path.isfile(package_xml): self.addon_to_install.load_metadata_file(package_xml) self.addon_to_install.installed_version = ( - self.addon_to_install.metadata.Version + self.addon_to_install.metadata.version ) self.addon_to_install.updated_timestamp = os.path.getmtime(package_xml) diff --git a/src/Mod/AddonManager/addonmanager_macro.py b/src/Mod/AddonManager/addonmanager_macro.py index 34595cb921..00083d6e05 100644 --- a/src/Mod/AddonManager/addonmanager_macro.py +++ b/src/Mod/AddonManager/addonmanager_macro.py @@ -32,10 +32,8 @@ import shutil from html import unescape from typing import Dict, Tuple, List, Union, Optional -import NetworkManager - - from addonmanager_macro_parser import MacroParser +import addonmanager_utilities as utils import addonmanager_freecad_interface as fci @@ -50,10 +48,11 @@ translate = fci.translate class Macro: - """This class provides a unified way to handle macros coming from different sources""" + """This class provides a unified way to handle macros coming from different + sources""" # Use a stored class variable for this so that we can override it during testing - network_manager = None + blocking_get = None # pylint: disable=too-many-instance-attributes def __init__(self, name): @@ -76,14 +75,16 @@ class Macro: self.other_files = [] self.parsed = False self._console = fci.Console + if Macro.blocking_get is None: + Macro.blocking_get = utils.blocking_get def __eq__(self, other): return self.filename == other.filename @classmethod def from_cache(cls, cache_dict: Dict): - """Use data from the cache dictionary to create a new macro, returning a reference - to it.""" + """Use data from the cache dictionary to create a new macro, returning a + reference to it.""" instance = Macro(cache_dict["name"]) for key, value in cache_dict.items(): instance.__dict__[key] = value @@ -97,14 +98,6 @@ class Macro: cache_dict[key] = value return cache_dict - @classmethod - def _get_network_manager(cls): - if cls.network_manager is None: - # Make sure we're initialized: - NetworkManager.InitializeNetworkManager() - cls.network_manager = NetworkManager.AM_NETWORK_MANAGER - return cls.network_manager - @property def filename(self): """The filename of this macro""" @@ -113,9 +106,10 @@ class Macro: return (self.name + ".FCMacro").replace(" ", "_") def is_installed(self): - """Returns True if this macro is currently installed (that is, if it exists in the - user macro directory), or False if it is not. Both the exact filename, as well as - the filename prefixed with "Macro", are considered an installation of this macro. + """Returns True if this macro is currently installed (that is, if it exists + in the user macro directory), or False if it is not. Both the exact filename, + as well as the filename prefixed with "Macro", are considered an installation + of this macro. """ if self.on_git and not self.src_filename: return False @@ -140,12 +134,12 @@ class Macro: self.parsed = True def fill_details_from_wiki(self, url): - """For a given URL, download its data and attempt to get the macro's metadata out of - it. If the macro's code is hosted elsewhere, as specified by a "rawcodeurl" found on - the wiki page, that code is downloaded and used as the source.""" + """For a given URL, download its data and attempt to get the macro's metadata + out of it. If the macro's code is hosted elsewhere, as specified by a + "rawcodeurl" found on the wiki page, that code is downloaded and used as the + source.""" code = "" - nm = Macro._get_network_manager() - p = nm.blocking_get(url) + p = Macro.blocking_get(url) if not p: self._console.PrintWarning( translate( @@ -155,7 +149,7 @@ class Macro: + "\n" ) return - p = p.data().decode("utf8") + p = p.decode("utf8") # check if the macro page has its code hosted elsewhere, download if # needed if "rawcodeurl" in p: @@ -207,8 +201,7 @@ class Macro: self.raw_code_url = re.findall('rawcodeurl.*?href="(http.*?)">', page_data) if self.raw_code_url: self.raw_code_url = self.raw_code_url[0] - nm = Macro._get_network_manager() - u2 = nm.blocking_get(self.raw_code_url) + u2 = Macro.blocking_get(self.raw_code_url) if not u2: self._console.PrintWarning( translate( @@ -218,7 +211,7 @@ class Macro: + "\n" ) return None - code = u2.data().decode("utf8") + code = u2.decode("utf8") return code @staticmethod @@ -238,8 +231,7 @@ class Macro: copy, potentially updating the internal icon location to that local storage.""" if self.icon.startswith("http://") or self.icon.startswith("https://"): self._console.PrintLog(f"Attempting to fetch macro icon from {self.icon}\n") - nm = Macro._get_network_manager() - p = nm.blocking_get(self.icon) + p = Macro.blocking_get(self.icon) if p: cache_path = fci.DataPaths().cache_dir am_path = os.path.join(cache_path, "AddonManager", "MacroIcons") @@ -247,21 +239,21 @@ class Macro: _, _, filename = self.icon.rpartition("/") base, _, extension = filename.rpartition(".") if base.lower().startswith("file:"): - # pylint: disable=line-too-long self._console.PrintMessage( - f"Cannot use specified icon for {self.name}, {self.icon} is not a direct download link\n" + 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()) + f.write(p) self.icon_source = self.icon self.icon = constructed_name else: - # pylint: disable=line-too-long self._console.PrintLog( - f"MACRO DEVELOPER WARNING: failed to download icon from {self.icon} for macro {self.name}\n" + f"MACRO DEVELOPER WARNING: failed to download icon from {self.icon}" + f" for macro {self.name}\n" ) self.icon = "" @@ -364,8 +356,7 @@ class Macro: if self.raw_code_url: fetch_url = self.raw_code_url.rsplit("/", 1)[0] + "/" + other_file self._console.PrintLog(f"Attempting to fetch {fetch_url}...\n") - nm = Macro._get_network_manager() - p = nm.blocking_get(fetch_url) + p = Macro.blocking_get(fetch_url) if p: with open(dst_file, "wb") as f: f.write(p) @@ -381,19 +372,21 @@ class Macro: warnings.append( translate( "AddonsInstaller", - "Could not locate macro-specified file {} (should have been at {})", + "Could not locate macro-specified file {} (expected at {})", ).format(other_file, src_file) ) def parse_wiki_page_for_icon(self, page_data: str) -> None: - """Attempt to find a url for the icon in the wiki page. Sets self.icon if found.""" + """Attempt to find a url for the icon in the wiki page. Sets self.icon if + found.""" # Method 1: the text "toolbar icon" appears on the page, and provides a direct # link to an icon # pylint: disable=line-too-long # Try to get an icon from the wiki page itself: - # ToolBar Icon + # ToolBar Icon icon_regex = re.compile(r'.*href="(.*?)">ToolBar Icon', re.IGNORECASE) wiki_icon = "" if "ToolBar Icon" in page_data: @@ -416,10 +409,9 @@ class Macro: self._console.PrintLog( f"Found a File: link for macro {self.name} -- {wiki_icon}\n" ) - nm = Macro._get_network_manager() - p = nm.blocking_get(wiki_icon) + p = Macro.blocking_get(wiki_icon) if p: - p = p.data().decode("utf8") + p = p.decode("utf8") f = io.StringIO(p) lines = f.readlines() trigger = False @@ -435,7 +427,8 @@ class Macro: #