Addon Manager: Refactor Metadata

Create a Python-native metadata class. Includes unit tests, and some PyLint cleanup.
This commit is contained in:
Chris Hennes
2023-03-03 09:36:53 -06:00
committed by Chris Hennes
parent 243088a8c3
commit 0b241f78f4
19 changed files with 1471 additions and 524 deletions

View File

@@ -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()

View File

@@ -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 ""

View File

@@ -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):

View File

@@ -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

View File

@@ -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 <a href="https://fake_url.com">Totally fake</a>'
)
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 <a href="https://fake_url.com">Totally fake</a>'
)
self.assertEqual(returned_data, "Data returned to _fetch_raw_code")
Macro.network_manager = None
Macro.blocking_get = None

View File

@@ -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 *
# * <https://www.gnu.org/licenses/>. *
# * *
# ***************************************************************************
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()

View File

@@ -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
)

View File

@@ -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:

View File

@@ -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 <icon> element found, or <icon> 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 <name> element found, or <name> 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 <version> element found, or <version> element is invalid"
)
# if not addon.metadata.Date or len(addon.metadata.Date) == 0:
# errors.append(f"No top-level <date> element found, or <date> 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 <description> element found, or <description> element is invalid"
"No top-level <description> element found, or <description> element "
"is invalid"
)
maintainers = addon.metadata.Maintainer
maintainers = addon.metadata.maintainer
if len(maintainers) == 0:
errors.append("No top-level <maintainers> 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 <license> 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 <classname> 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 <name> specified for preference pack")
return errors

View File

@@ -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

View File

@@ -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)

View File

@@ -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:
# <a rel="nofollow" class="external text" href="https://wiki.freecad.org/images/f/f5/blah.png">ToolBar Icon</a>
# <a rel="nofollow" class="external text"
# href="https://wiki.freecad.org/images/f/f5/blah.png">ToolBar Icon</a>
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:
# <div class="fullImageLink" id="file">
# <a href="/images/a/a2/Bevel.svg">
# <img alt="File:Bevel.svg" src="/images/a/a2/Bevel.svg" width="64" height="64"/>
# <img alt="File:Bevel.svg" src="/images/a/a2/Bevel.svg"
# width="64" height="64"/>
# </a>

View File

@@ -0,0 +1,415 @@
# 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 *
# * <https://www.gnu.org/licenses/>. *
# * *
# ***************************************************************************
"""Classes for working with Addon metadata, as documented at
https://wiki.FreeCAD.org/Package_metadata"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import IntEnum, auto
from typing import Tuple, Dict, List, Optional
try:
# If this system provides a secure parser, use that:
import defusedxml.ElementTree as ET
except ImportError:
# Otherwise fall back to the Python standard parser
import xml.etree.ElementTree as ET
@dataclass
class Contact:
name: str
email: str = ""
@dataclass
class License:
name: str
file: str = ""
class UrlType(IntEnum):
bugtracker = 0
discussion = auto()
documentation = auto()
readme = auto()
repository = auto()
website = auto()
def __str__(self):
return f"{self.name}"
@dataclass
class Url:
location: str
type: UrlType
branch: str = ""
class Version:
"""Provide a more useful representation of Version information"""
def __init__(self, from_string: str = None, from_list=None):
"""If from_string is a string, it is parsed to get the version. If from_list
exists (and no string was provided), it is treated as a version list of
[major:int, minor:int, patch:int, pre:str]"""
self.version_as_list = [0, 0, 0, ""]
if from_string is not None:
self._init_from_string(from_string)
elif from_list is not None:
self._init_from_list(from_list)
def _init_from_string(self, from_string: str):
"""Find the first digit in the given string, and send that substring off for
parsing."""
counter = 0
for char in from_string:
if char.isdigit():
break
counter += 1
self._parse_string_to_tuple(from_string[counter:])
def _init_from_list(self, from_list):
for index, element in enumerate(from_list):
if index < 3:
self.version_as_list[index] = int(element)
elif index == 3:
self.version_as_list[index] = str(element)
else:
break
def _parse_string_to_tuple(self, from_string: str):
"""We hand-parse only simple version strings, of the form 1.2.3suffix -- only
the first digit is required."""
splitter = from_string.split(".", 2)
counter = 0
for component in splitter:
try:
self.version_as_list[counter] = int(component)
counter += 1
except ValueError:
if counter == 0:
raise ValueError(f"Invalid version string {from_string}")
number, text = self._parse_final_entry(component)
self.version_as_list[counter] = number
self.version_as_list[3] = text
@staticmethod
def _parse_final_entry(final_string: str) -> Tuple[int, str]:
"""The last value is permitted to contain both a number and a word, and needs
to be split"""
digits = ""
for c in final_string:
if c.isdigit():
digits += c
else:
break
return int(digits), final_string[len(digits) :]
def __repr__(self) -> str:
v = self.version_as_list
return f"{v[0]}.{v[1]}.{v[2]} {v[3]}"
def __eq__(self, other) -> bool:
return self.version_as_list == other.version_as_list
def __ne__(self, other) -> bool:
return not (self == other)
def __lt__(self, other) -> bool:
for a, b in zip(self.version_as_list, other.version_as_list):
if a != b:
return a < b
return False
def __gt__(self, other) -> bool:
if self.version_as_list == other.version_as_list:
return False
return not (self < other)
def __ge__(self, other) -> bool:
return self > other or self == other
def __le__(self, other) -> bool:
return self < other or self == other
class DependencyType(IntEnum):
automatic = 0
internal = auto()
addon = auto()
python = auto()
def __str__(self):
return f"{self.name}"
@dataclass
class Dependency:
package: str
version_lt: str = ""
version_lte: str = ""
version_eq: str = ""
version_gte: str = ""
version_gt: str = ""
condition: str = ""
optional: bool = False
dependency_type: DependencyType = DependencyType.automatic
@dataclass
class GenericMetadata:
"""Used to store unrecognized elements"""
contents: str = ""
attributes: Dict[str, str] = field(default_factory=dict)
@dataclass
class Metadata:
"""A pure-python implementation of the Addon Manager's Metadata handling class"""
name: str = ""
version: Version = None
date: str = ""
description: str = ""
maintainer: List[Contact] = field(default_factory=list)
license: List[License] = field(default_factory=list)
url: List[Url] = field(default_factory=list)
author: List[Contact] = field(default_factory=list)
depend: List[Dependency] = field(default_factory=list)
conflict: List[Dependency] = field(default_factory=list)
replace: List[Dependency] = field(default_factory=list)
tag: List[str] = field(default_factory=list)
icon: str = ""
classname: str = ""
subdirectory: str = ""
file: List[str] = field(default_factory=list)
freecadmin: Version = None
freecadmax: Version = None
pythonmin: Version = None
content: Dict[str, List[Metadata]] = field(default_factory=dict) # Recursive def.
def get_first_supported_freecad_version(metadata: Metadata) -> Optional[Version]:
"""Look through all content items of this metadata element and determine what the
first version of freecad that ANY of the items support is. For example, if it
contains several workbenches, some of which require v0.20, and some 0.21, then
0.20 is returned. Returns None if frecadmin is unset by any part of this object."""
current_earliest = metadata.freecadmin if metadata.freecadmin is not None else None
for content_class in metadata.content.values():
for content_item in content_class:
content_first = get_first_supported_freecad_version(content_item)
if content_first is not None:
if current_earliest is None:
current_earliest = content_first
else:
current_earliest = min(current_earliest, content_first)
return current_earliest
class MetadataReader:
"""Read metadata XML data and construct a Metadata object"""
@staticmethod
def from_file(filename: str) -> Metadata:
"""A convenience function for loading the Metadata from a file"""
with open(filename, "rb") as f:
data = f.read()
return MetadataReader.from_bytes(data)
@staticmethod
def from_bytes(data: bytes) -> Metadata:
"""Read XML data from bytes and use it to construct Metadata"""
element_tree = ET.fromstring(data)
return MetadataReader._process_element_tree(element_tree)
@staticmethod
def _process_element_tree(root: ET.Element) -> Metadata:
"""Parse an element tree and convert it into a Metadata object"""
namespace = MetadataReader._determine_namespace(root)
return MetadataReader._create_node(namespace, root)
@staticmethod
def _determine_namespace(root: ET.Element) -> str:
accepted_namespaces = ["{https://wiki.freecad.org/Package_Metadata}", ""]
for ns in accepted_namespaces:
if root.tag == f"{ns}package":
return ns
raise RuntimeError("No 'package' element found in metadata file")
@staticmethod
def _parse_child_element(namespace: str, child: ET.Element, metadata: Metadata):
"""Figure out what sort of metadata child represents, and add it to the
metadata object."""
tag = child.tag[len(namespace) :]
if tag in ["name", "date", "description", "icon", "classname", "subdirectory"]:
# Text-only elements
metadata.__dict__[tag] = child.text
elif tag in ["version", "freecadmin", "freecadmax", "pythonmin"]:
metadata.__dict__[tag] = Version(from_string=child.text)
elif tag in ["tag", "file"]:
# Lists of strings
if child.text:
metadata.__dict__[tag].append(child.text)
elif tag in ["maintainer", "author"]:
# Lists of contacts
metadata.__dict__[tag].append(MetadataReader._parse_contact(child))
elif tag == "license":
# List of licenses
metadata.license.append(MetadataReader._parse_license(child))
elif tag == "url":
# List of urls
metadata.url.append(MetadataReader._parse_url(child))
elif tag in ["depend", "conflict", "replace"]:
# Lists of dependencies
metadata.__dict__[tag].append(MetadataReader._parse_dependency(child))
elif tag == "content":
MetadataReader._parse_content(namespace, metadata, child)
@staticmethod
def _parse_contact(child: ET.Element) -> Contact:
email = child.attrib["email"] if "email" in child.attrib else ""
return Contact(name=child.text, email=email)
@staticmethod
def _parse_license(child: ET.Element) -> License:
file = child.attrib["file"] if "file" in child.attrib else ""
return License(name=child.text, file=file)
@staticmethod
def _parse_url(child: ET.Element) -> Url:
url_type = UrlType.website
branch = ""
if "type" in child.attrib and child.attrib["type"] in UrlType.__dict__:
url_type = UrlType[child.attrib["type"]]
if url_type == UrlType.repository:
branch = child.attrib["branch"] if "branch" in child.attrib else ""
return Url(location=child.text, type=url_type, branch=branch)
@staticmethod
def _parse_dependency(child: ET.Element) -> Dependency:
v_lt = child.attrib["version_lt"] if "version_lt" in child.attrib else ""
v_lte = child.attrib["version_lte"] if "version_lte" in child.attrib else ""
v_eq = child.attrib["version_eq"] if "version_eq" in child.attrib else ""
v_gte = child.attrib["version_gte"] if "version_gte" in child.attrib else ""
v_gt = child.attrib["version_gt"] if "version_gt" in child.attrib else ""
condition = child.attrib["condition"] if "condition" in child.attrib else ""
optional = (
"optional" in child.attrib and child.attrib["optional"].lower() == "true"
)
dependency_type = DependencyType.automatic
if "type" in child.attrib and child.attrib["type"] in DependencyType.__dict__:
dependency_type = DependencyType[child.attrib["type"]]
return Dependency(
child.text,
version_lt=v_lt,
version_lte=v_lte,
version_eq=v_eq,
version_gte=v_gte,
version_gt=v_gt,
condition=condition,
optional=optional,
dependency_type=dependency_type,
)
@staticmethod
def _parse_content(namespace: str, metadata: Metadata, root: ET.Element):
"""Given a content node, loop over its children, and if they are a recognized
element type, recurse into each one to parse it."""
known_content_types = ["workbench", "macro", "preferencepack"]
for child in root:
content_type = child.tag[len(namespace) :]
if content_type in known_content_types:
if content_type not in metadata.content:
metadata.content[content_type] = []
metadata.content[content_type].append(
MetadataReader._create_node(namespace, child)
)
@staticmethod
def _create_node(namespace, child) -> Metadata:
new_content_item = Metadata()
for content_child in child:
MetadataReader._parse_child_element(
namespace, content_child, new_content_item
)
return new_content_item
class MetadataWriter:
"""Utility class for serializing a Metadata object into the package.xml standard
XML file."""
@staticmethod
def write(metadata: Metadata, path: str):
"""Write the metadata to a file located at path. Overwrites the file if it
exists. Raises OSError if writing fails."""
tree = MetadataWriter._create_tree_from_metadata(metadata)
tree.write(path)
@staticmethod
def _create_tree_from_metadata(metadata: Metadata) -> ET.ElementTree:
"""Create the XML ElementTree representation of the given Metadata object."""
tree = ET.ElementTree()
root = tree.getroot()
root.attrib["xmlns"] = "https://wiki.freecad.org/Package_Metadata"
for key, value in metadata.__dict__.items():
if isinstance(value, str):
node = ET.SubElement(root, key)
node.text = value
else:
MetadataWriter._create_list_node(metadata, key, root)
return tree
@staticmethod
def _create_list_node(metadata: Metadata, key: str, root: ET.Element):
for item in metadata.__dict__[key]:
node = ET.SubElement(root, key)
if key in ["maintainer", "author"]:
if item.email:
node.attrib["email"] = item.email
node.text = item.name
elif key == "license":
if item.file:
node.attrib["file"] = item.file
node.text = item.name
elif key == "url":
if item.branch:
node.attrib["branch"] = item.branch
node.attrib["type"] = str(item.type)
node.text = item.location
elif key in ["depend", "conflict", "replace"]:
for dep_key, dep_value in item.__dict__.items():
if isinstance(dep_value, str) and dep_value:
node.attrib[dep_key] = dep_value
elif isinstance(dep_value, bool):
node.attrib[dep_key] = "True" if dep_value else "False"
elif isinstance(dep_value, DependencyType):
node.attrib[dep_key] = str(dep_value)

View File

@@ -35,12 +35,15 @@ from typing import Optional, Any
from urllib.parse import urlparse
from PySide import QtCore, QtWidgets
try:
from PySide import QtCore, QtWidgets
except ImportError:
QtCore = None
QtWidgets = None
import FreeCAD
import addonmanager_freecad_interface as fci
if FreeCAD.GuiUp:
import FreeCADGui
if fci.FreeCADGui:
# If the GUI is up, we can use the NetworkManager to handle our downloads. If there is no event
# loop running this is not possible, so fall back to requests (if available), or the native
@@ -60,7 +63,7 @@ else:
# @{
translate = FreeCAD.Qt.translate
translate = fci.translate
class ProcessInterrupted(RuntimeError):
@@ -116,7 +119,7 @@ def update_macro_details(old_macro, new_macro):
"""
if old_macro.on_git and new_macro.on_git:
FreeCAD.Console.PrintLog(
fci.Console.PrintLog(
f'The macro "{old_macro.name}" is present twice in github, please report'
)
# We don't report macros present twice on the wiki because a link to a
@@ -132,7 +135,7 @@ def remove_directory_if_empty(dir_to_remove):
"""Remove the directory if it is empty, with one exception: the directory returned by
FreeCAD.getUserMacroDir(True) will not be removed even if it is empty."""
if dir_to_remove == FreeCAD.getUserMacroDir(True):
if dir_to_remove == fci.DataPaths().macro_dir:
return
if not os.listdir(dir_to_remove):
os.rmdir(dir_to_remove)
@@ -140,9 +143,12 @@ def remove_directory_if_empty(dir_to_remove):
def restart_freecad():
"""Shuts down and restarts FreeCAD"""
if not QtCore or not QtWidgets:
return
args = QtWidgets.QApplication.arguments()[1:]
if FreeCADGui.getMainWindow().close():
if fci.FreeCADGui.getMainWindow().close():
QtCore.QProcess.startDetached(
QtWidgets.QApplication.applicationFilePath(), args
)
@@ -156,7 +162,7 @@ def get_zip_url(repo):
return f"{repo.url}/archive/{repo.branch}.zip"
if parsed_url.netloc in ["gitlab.com", "framagit.org", "salsa.debian.org"]:
return f"{repo.url}/-/archive/{repo.branch}/{repo.name}-{repo.branch}.zip"
FreeCAD.Console.PrintLog(
fci.Console.PrintLog(
"Debug: addonmanager_utilities.get_zip_url: Unknown git host fetching zip URL:"
+ parsed_url.netloc
+ "\n"
@@ -185,7 +191,7 @@ def construct_git_url(repo, filename):
return f"{repo.url}/raw/{repo.branch}/{filename}"
if parsed_url.netloc in ["gitlab.com", "framagit.org", "salsa.debian.org"]:
return f"{repo.url}/-/raw/{repo.branch}/{filename}"
FreeCAD.Console.PrintLog(
fci.Console.PrintLog(
"Debug: addonmanager_utilities.construct_git_url: Unknown git host:"
+ parsed_url.netloc
+ f" for file {filename}\n"
@@ -215,7 +221,7 @@ def get_desc_regex(repo):
return r'<meta property="og:description" content="(.*?)"'
if parsed_url.netloc in ["gitlab.com", "salsa.debian.org", "framagit.org"]:
return r'<meta.*?content="(.*?)".*?og:description.*?>'
FreeCAD.Console.PrintLog(
fci.Console.PrintLog(
"Debug: addonmanager_utilities.get_desc_regex: Unknown git host:",
repo.url,
"\n",
@@ -231,7 +237,7 @@ def get_readme_html_url(repo):
return f"{repo.url}/blob/{repo.branch}/README.md"
if parsed_url.netloc in ["gitlab.com", "salsa.debian.org", "framagit.org"]:
return f"{repo.url}/-/blob/{repo.branch}/README.md"
FreeCAD.Console.PrintLog(
fci.Console.PrintLog(
"Unrecognized git repo location '' -- guessing it is a GitLab instance..."
)
return f"{repo.url}/-/blob/{repo.branch}/README.md"
@@ -239,7 +245,7 @@ def get_readme_html_url(repo):
def is_darkmode() -> bool:
"""Heuristics to determine if we are in a darkmode stylesheet"""
pl = FreeCADGui.getMainWindow().palette()
pl = fci.FreeCADGui.getMainWindow().palette()
return pl.color(pl.Background).lightness() < 128
@@ -296,7 +302,7 @@ def get_macro_version_from_file(filename: str) -> str:
if date:
return date
# pylint: disable=line-too-long,consider-using-f-string
FreeCAD.Console.PrintWarning(
fci.Console.PrintWarning(
translate(
"AddonsInstaller",
"Macro {} specified '__version__ = __date__' prior to setting a value for __date__".format(
@@ -315,11 +321,11 @@ def update_macro_installation_details(repo) -> None:
"""Determine if a given macro is installed, either in its plain name,
or prefixed with "Macro_" """
if repo is None or not hasattr(repo, "macro") or repo.macro is None:
FreeCAD.Console.PrintLog("Requested macro details for non-macro object\n")
fci.Console.PrintLog("Requested macro details for non-macro object\n")
return
test_file_one = os.path.join(FreeCAD.getUserMacroDir(True), repo.macro.filename)
test_file_one = os.path.join(fci.DataPaths().macro_dir, repo.macro.filename)
test_file_two = os.path.join(
FreeCAD.getUserMacroDir(True), "Macro_" + repo.macro.filename
fci.DataPaths().macro_dir, "Macro_" + repo.macro.filename
)
if os.path.exists(test_file_one):
repo.updated_timestamp = os.path.getmtime(test_file_one)
@@ -341,10 +347,6 @@ def is_float(element: Any) -> bool:
except ValueError:
return False
# @}
def get_python_exe() -> str:
"""Find Python. In preference order
A) The value of the PythonExecutableForPip user preference
@@ -352,9 +354,9 @@ def get_python_exe() -> str:
C) The executable located in the same bin directory as FreeCAD and called "python"
D) The result of a shutil search for your system's "python3" executable
E) The result of a shutil search for your system's "python" executable"""
prefs = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Addons")
prefs = fci.ParamGet("User parameter:BaseApp/Preferences/Addons")
python_exe = prefs.GetString("PythonExecutableForPip", "Not set")
fc_dir = FreeCAD.getHomePath()
fc_dir = fci.DataPaths().home_dir
if not python_exe or python_exe == "Not set" or not os.path.exists(python_exe):
python_exe = os.path.join(fc_dir, "bin", "python3")
if "Windows" in platform.system():
@@ -383,36 +385,37 @@ def get_pip_target_directory():
# Get the default location to install new pip packages
major, minor, _ = platform.python_version_tuple()
vendor_path = os.path.join(
FreeCAD.getUserAppDataDir(), "AdditionalPythonPackages", f"py{major}{minor}"
fci.DataPaths().mod_dir, "..", "AdditionalPythonPackages", f"py{major}{minor}"
)
return vendor_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()
cache_path = fci.DataPaths().cache_dir
am_path = os.path.join(cache_path, "AddonManager")
os.makedirs(am_path, exist_ok=True)
return os.path.join(am_path, file)
def blocking_get(url: str, method=None) -> str:
def blocking_get(url: str, method=None) -> bytes:
"""Wrapper around three possible ways of accessing data, depending on the current run mode and
Python installation. Blocks until complete, and returns the text results of the call if it
succeeded, or an empty string if it failed, or returned no data. The method argument is
provided mainly for testing purposes."""
p = ""
if FreeCAD.GuiUp and method is None or method == "networkmanager":
p = b""
if fci.FreeCADGui and method is None or method == "networkmanager":
NetworkManager.InitializeNetworkManager()
p = NetworkManager.AM_NETWORK_MANAGER.blocking_get(url)
p = p.data()
elif requests and method is None or method == "requests":
response = requests.get(url)
if response.status_code == 200:
p = response.text
p = response.content
else:
ctx = ssl.create_default_context()
with urllib.request.urlopen(url, context=ctx) as f:
p = f.read().decode("utf-8")
p = f.read()
return p

View File

@@ -40,6 +40,7 @@ from PySide import QtCore
import FreeCAD
import addonmanager_utilities as utils
from addonmanager_metadata import MetadataReader
from Addon import Addon
import NetworkManager
@@ -166,7 +167,7 @@ class UpdateMetadataCacheWorker(QtCore.QThread):
new_xml_file = os.path.join(package_cache_directory, "package.xml")
with open(new_xml_file, "wb") as f:
f.write(data.data())
metadata = FreeCAD.Metadata(new_xml_file)
metadata = MetadataReader.from_file(new_xml_file)
repo.set_metadata(metadata)
FreeCAD.Console.PrintLog(f"Downloaded package.xml for {repo.name}\n")
self.status_message.emit(
@@ -177,21 +178,21 @@ class UpdateMetadataCacheWorker(QtCore.QThread):
# Grab a new copy of the icon as well: we couldn't enqueue this earlier because
# we didn't know the path to it, which is stored in the package.xml file.
icon = metadata.Icon
icon = metadata.icon
if not 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 = repo.metadata.Content
content = repo.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
repo.Icon = subdir + wb.Icon
icon = repo.Icon
subdir = wb.name
repo.icon = subdir + wb.icon
icon = repo.icon
icon_url = utils.construct_git_url(repo, icon)
index = NetworkManager.AM_NETWORK_MANAGER.submit_unmonitored_get(icon_url)

View File

@@ -43,6 +43,7 @@ from addonmanager_macro import Macro
from Addon import Addon
import NetworkManager
from addonmanager_git import initialize_git, GitFailed
from addonmanager_metadata import MetadataReader
translate = FreeCAD.Qt.translate
@@ -195,7 +196,7 @@ class CreateAddonListWorker(QtCore.QThread):
md_file = os.path.join(addondir, "package.xml")
if os.path.isfile(md_file):
repo.load_metadata_file(md_file)
repo.installed_version = repo.metadata.Version
repo.installed_version = repo.metadata.version
repo.updated_timestamp = os.path.getmtime(md_file)
repo.verify_url_and_branch(addon["url"], addon["branch"])
@@ -238,7 +239,7 @@ class CreateAddonListWorker(QtCore.QThread):
md_file = os.path.join(addondir, "package.xml")
if os.path.isfile(md_file):
repo.load_metadata_file(md_file)
repo.installed_version = repo.metadata.Version
repo.installed_version = repo.metadata.version
repo.updated_timestamp = os.path.getmtime(md_file)
repo.verify_url_and_branch(url, branch)
@@ -457,7 +458,7 @@ class LoadPackagesFromCacheWorker(QtCore.QThread):
if os.path.isfile(repo_metadata_cache_path):
try:
repo.load_metadata_file(repo_metadata_cache_path)
repo.installed_version = repo.metadata.Version
repo.installed_version = repo.metadata.version
repo.updated_timestamp = os.path.getmtime(
repo_metadata_cache_path
)
@@ -644,12 +645,12 @@ class UpdateChecker:
return
package.updated_timestamp = os.path.getmtime(installed_metadata_file)
try:
installed_metadata = FreeCAD.Metadata(installed_metadata_file)
package.installed_version = installed_metadata.Version
# Packages are considered up-to-date if the metadata version matches. Authors
# should update their version string when they want the addon manager to alert
# users of a new version.
if package.metadata.Version != installed_metadata.Version:
installed_metadata = MetadataReader.from_file(installed_metadata_file)
package.installed_version = installed_metadata.version
# Packages are considered up-to-date if the metadata version matches.
# Authors should update their version string when they want the addon
# manager to alert users of a new version.
if package.metadata.version != installed_metadata.version:
package.set_status(Addon.Status.UPDATE_AVAILABLE)
else:
package.set_status(Addon.Status.NO_UPDATE_AVAILABLE)

View File

@@ -27,7 +27,7 @@ from typing import Optional
import FreeCAD
from PySide import QtCore
import NetworkManager
import addonmanager_utilities as utils
translate = FreeCAD.Qt.translate
@@ -67,7 +67,7 @@ class ConnectionChecker(QtCore.QThread):
"""The main work of this object: returns the decoded result of the connection request, or
None if the request failed"""
url = "https://api.github.com/zen"
result = NetworkManager.AM_NETWORK_MANAGER.blocking_get(url)
result = utils.blocking_get(url)
if result:
return result.data().decode("utf8")
return result.decode("utf8")
return None

View File

@@ -31,9 +31,9 @@ from PySide import QtCore, QtGui, QtWidgets
import addonmanager_freecad_interface as fci
import addonmanager_utilities as utils
from addonmanager_metadata import Version, UrlType, get_first_supported_freecad_version
from addonmanager_workers_startup import GetMacroDetailsWorker, CheckSingleUpdateWorker
from Addon import Addon
import NetworkManager
from change_branch import ChangeBranchDialog
have_git = False
@@ -210,7 +210,7 @@ class PackageDetails(QtWidgets.QWidget):
).format(repo.branch)
+ " "
)
installed_version_string += repo.metadata.Version
installed_version_string += str(repo.metadata.version)
installed_version_string += ".</b>"
elif repo.macro and repo.macro.version:
installed_version_string += (
@@ -385,7 +385,7 @@ class PackageDetails(QtWidgets.QWidget):
else:
self.ui.labelWarningInfo.hide()
def requires_newer_freecad(self) -> Optional[str]:
def requires_newer_freecad(self) -> Optional[Version]:
"""If the current package is not installed, returns the first supported version of
FreeCAD, if one is set, or None if no information is available (or if the package is
already installed)."""
@@ -396,19 +396,13 @@ class PackageDetails(QtWidgets.QWidget):
# it's possible that this package actually provides versions of itself
# for newer and older versions
first_supported_version = (
self.repo.metadata.getFirstSupportedFreeCADVersion()
first_supported_version = get_first_supported_freecad_version(
self.repo.metadata
)
if first_supported_version is not None:
required_version = first_supported_version.split(".")
fc_major = int(fci.Version()[0])
fc_minor = int(fci.Version()[1])
if int(required_version[0]) > fc_major:
fc_version = Version(from_list=fci.Version())
if first_supported_version > fc_version:
return first_supported_version
if int(required_version[0]) == fc_major and len(required_version) > 1:
if int(required_version[1]) > fc_minor:
return first_supported_version
return None
def set_change_branch_button_state(self):
@@ -463,8 +457,8 @@ class PackageDetails(QtWidgets.QWidget):
self.ui.webView.load(QtCore.QUrl(url))
self.ui.urlBar.setText(url)
else:
readme_data = NetworkManager.AM_NETWORK_MANAGER.blocking_get(url)
text = readme_data.data().decode("utf8")
readme_data = utils.blocking_get(url)
text = readme_data.decode("utf8")
self.ui.textBrowserReadMe.setHtml(text)
def show_package(self, repo: Addon) -> None:
@@ -472,10 +466,9 @@ class PackageDetails(QtWidgets.QWidget):
readme_url = None
if repo.metadata:
urls = repo.metadata.Urls
for url in urls:
if url["type"] == "readme":
readme_url = url["location"]
for url in repo.metadata.url:
if url.type == UrlType.readme:
readme_url = url.location
break
if not readme_url:
readme_url = utils.get_readme_html_url(repo)
@@ -483,8 +476,8 @@ class PackageDetails(QtWidgets.QWidget):
self.ui.webView.load(QtCore.QUrl(readme_url))
self.ui.urlBar.setText(readme_url)
else:
readme_data = NetworkManager.AM_NETWORK_MANAGER.blocking_get(readme_url)
text = readme_data.data().decode("utf8")
readme_data = utils.blocking_get(readme_url)
text = readme_data.decode("utf8")
self.ui.textBrowserReadMe.setHtml(text)
def show_macro(self, repo: Addon) -> None:
@@ -518,8 +511,8 @@ class PackageDetails(QtWidgets.QWidget):
)
else:
if url:
readme_data = NetworkManager.AM_NETWORK_MANAGER.blocking_get(url)
text = readme_data.data().decode("utf8")
readme_data = utils.blocking_get(url)
text = readme_data.decode("utf8")
self.ui.textBrowserReadMe.setHtml(text)
else:
self.ui.textBrowserReadMe.setHtml(
@@ -606,7 +599,8 @@ class PackageDetails(QtWidgets.QWidget):
):
self.timeout.stop()
if load_succeeded:
# It says it succeeded, but it might have only succeeded in loading a "Page not found" page!
# It says it succeeded, but it might have only succeeded in loading a
# "Page not found" page!
title = self.ui.webView.title()
path_components = url.path().split("/")
expected_content = path_components[-1]
@@ -643,7 +637,8 @@ class PackageDetails(QtWidgets.QWidget):
change_branch_dialog.exec()
def enable_clicked(self) -> None:
"""Called by the Enable button, enables this Addon and updates GUI to reflect that status."""
"""Called by the Enable button, enables this Addon and updates GUI to reflect
that status."""
self.repo.enable()
self.repo.set_status(Addon.Status.PENDING_RESTART)
self.set_disable_button_state()
@@ -660,7 +655,8 @@ class PackageDetails(QtWidgets.QWidget):
self.ui.labelWarningInfo.setStyleSheet("color:" + utils.bright_color_string())
def disable_clicked(self) -> None:
"""Called by the Disable button, disables this Addon and updates the GUI to reflect that status."""
"""Called by the Disable button, disables this Addon and updates the GUI to
reflect that status."""
self.repo.disable()
self.repo.set_status(Addon.Status.PENDING_RESTART)
self.set_disable_button_state()
@@ -679,7 +675,8 @@ class PackageDetails(QtWidgets.QWidget):
)
def branch_changed(self, name: str) -> None:
"""Displays a dialog confirming the branch changed, and tries to access the metadata file from that branch."""
"""Displays a dialog confirming the branch changed, and tries to access the
metadata file from that branch."""
QtWidgets.QMessageBox.information(
self,
translate("AddonsInstaller", "Success"),
@@ -693,12 +690,14 @@ class PackageDetails(QtWidgets.QWidget):
path_to_metadata = os.path.join(basedir, "Mod", self.repo.name, "package.xml")
if os.path.isfile(path_to_metadata):
self.repo.load_metadata_file(path_to_metadata)
self.repo.installed_version = self.repo.metadata.Version
self.repo.installed_version = self.repo.metadata.version
else:
self.repo.repo_type = Addon.Kind.WORKBENCH
self.repo.metadata = None
self.repo.installed_version = None
self.repo.updated_timestamp = QtCore.QDateTime.currentDateTime().toSecsSinceEpoch()
self.repo.updated_timestamp = (
QtCore.QDateTime.currentDateTime().toSecsSinceEpoch()
)
self.repo.branch = name
self.repo.set_status(Addon.Status.PENDING_RESTART)
@@ -717,19 +716,24 @@ class PackageDetails(QtWidgets.QWidget):
if HAS_QTWEBENGINE:
class RestrictedWebPage(QtWebEngineWidgets.QWebEnginePage):
"""A class that follows links to FreeCAD wiki pages, but opens all other clicked links in the system web browser"""
"""A class that follows links to FreeCAD wiki pages, but opens all other
clicked links in the system web browser"""
def __init__(self, parent):
super().__init__(parent)
self.settings().setAttribute(QtWebEngineWidgets.QWebEngineSettings.ErrorPageEnabled, False)
self.settings().setAttribute(
QtWebEngineWidgets.QWebEngineSettings.ErrorPageEnabled, False
)
self.stored_url = None
def acceptNavigationRequest(self, requested_url, _type, isMainFrame):
"""A callback for navigation requests: this widget will only display navigation requests to the
FreeCAD Wiki (for translation purposes) -- anything else will open in a new window.
"""A callback for navigation requests: this widget will only display
navigation requests to the FreeCAD Wiki (for translation purposes) --
anything else will open in a new window.
"""
if _type == QtWebEngineWidgets.QWebEnginePage.NavigationTypeLinkClicked:
# See if the link is to a FreeCAD Wiki page -- if so, follow it, otherwise ask the OS to open it
# See if the link is to a FreeCAD Wiki page -- if so, follow it,
# otherwise ask the OS to open it
if (
requested_url.host() == "wiki.freecad.org"
or requested_url.host() == "wiki.freecadweb.org"
@@ -744,9 +748,9 @@ if HAS_QTWEBENGINE:
return super().acceptNavigationRequest(requested_url, _type, isMainFrame)
def javaScriptConsoleMessage(self, level, message, lineNumber, _):
"""Handle JavaScript console messages by optionally outputting them to the FreeCAD Console. This
must be manually enabled in this Python file by setting the global show_javascript_console_output
to true."""
"""Handle JavaScript console messages by optionally outputting them to
the FreeCAD Console. This must be manually enabled in this Python file by
setting the global show_javascript_console_output to true."""
global show_javascript_console_output
if show_javascript_console_output:
tag = translate("AddonsInstaller", "Page JavaScript reported")
@@ -834,7 +838,9 @@ class Ui_PackageDetails(object):
self.verticalLayout_2.addWidget(self.labelPackageDetails)
self.labelInstallationLocation = QtWidgets.QLabel(PackageDetails)
self.labelInstallationLocation.setTextInteractionFlags(QtCore.Qt.TextSelectableByMouse)
self.labelInstallationLocation.setTextInteractionFlags(
QtCore.Qt.TextSelectableByMouse
)
self.labelInstallationLocation.hide()
self.verticalLayout_2.addWidget(self.labelInstallationLocation)
@@ -844,7 +850,9 @@ class Ui_PackageDetails(object):
self.verticalLayout_2.addWidget(self.labelWarningInfo)
sizePolicy1 = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding)
sizePolicy1 = QtWidgets.QSizePolicy(
QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding
)
sizePolicy1.setHorizontalStretch(0)
sizePolicy1.setVerticalStretch(0)
@@ -909,7 +917,9 @@ class Ui_PackageDetails(object):
QtCore.QCoreApplication.translate("AddonsInstaller", "Update", None)
)
self.buttonCheckForUpdate.setText(
QtCore.QCoreApplication.translate("AddonsInstaller", "Check for Update", None)
QtCore.QCoreApplication.translate(
"AddonsInstaller", "Check for Update", None
)
)
self.buttonExecute.setText(
QtCore.QCoreApplication.translate("AddonsInstaller", "Run Macro", None)
@@ -933,7 +943,7 @@ class Ui_PackageDetails(object):
"<h3>"
+ QtCore.QCoreApplication.translate(
"AddonsInstaller",
"QtWebEngine Python bindings not installed -- using fallback README display. See Report View for details and installation instructions.",
"QtWebEngine Python bindings not installed -- using fallback README display.",
None,
)
+ "</h3>"

View File

@@ -36,12 +36,14 @@ from compact_view import Ui_CompactView
from expanded_view import Ui_ExpandedView
import addonmanager_utilities as utils
from addonmanager_metadata import get_first_supported_freecad_version
translate = FreeCAD.Qt.translate
# pylint: disable=too-few-public-methods
class ListDisplayStyle(IntEnum):
"""The display mode of the list"""
@@ -345,7 +347,8 @@ class PackageListItemDelegate(QtWidgets.QStyledItemDelegate):
self.displayStyle = style
def sizeHint(self, _option, index):
"""Attempt to figure out the correct height for the widget based on its current contents."""
"""Attempt to figure out the correct height for the widget based on its
current contents."""
self.update_content(index)
return self.widget.sizeHint()
@@ -365,8 +368,8 @@ class PackageListItemDelegate(QtWidgets.QStyledItemDelegate):
if self.displayStyle == ListDisplayStyle.EXPANDED:
self.widget.ui.labelTags.setText("")
if repo.metadata:
self.widget.ui.labelDescription.setText(repo.metadata.Description)
self.widget.ui.labelVersion.setText(f"<i>v{repo.metadata.Version}</i>")
self.widget.ui.labelDescription.setText(repo.metadata.description)
self.widget.ui.labelVersion.setText(f"<i>v{repo.metadata.version}</i>")
if self.displayStyle == ListDisplayStyle.EXPANDED:
self._setup_expanded_package(repo)
elif repo.macro and repo.macro.parsed:
@@ -387,18 +390,18 @@ class PackageListItemDelegate(QtWidgets.QStyledItemDelegate):
def _setup_expanded_package(self, repo: Addon):
"""Set up the display for a package in expanded view"""
maintainers = repo.metadata.Maintainer
maintainers = repo.metadata.maintainer
maintainers_string = ""
if len(maintainers) == 1:
maintainers_string = (
translate("AddonsInstaller", "Maintainer")
+ f": {maintainers[0]['name']} <{maintainers[0]['email']}>"
+ f": {maintainers[0].name} <{maintainers[0].email}>"
)
elif len(maintainers) > 1:
n = len(maintainers)
maintainers_string = translate("AddonsInstaller", "Maintainers:", "", n)
for maintainer in maintainers:
maintainers_string += f"\n{maintainer['name']} <{maintainer['email']}>"
maintainers_string += f"\n{maintainer.name} <{maintainer.email}>"
self.widget.ui.labelMaintainer.setText(maintainers_string)
if repo.tags:
self.widget.ui.labelTags.setText(
@@ -435,8 +438,10 @@ class PackageListItemDelegate(QtWidgets.QStyledItemDelegate):
else:
self.widget.ui.labelMaintainer.setText("")
def get_compact_update_string(self, repo: Addon) -> str:
"""Get a single-line string listing details about the installed version and date"""
@staticmethod
def get_compact_update_string(repo: Addon) -> str:
"""Get a single-line string listing details about the installed version and
date"""
result = ""
if repo.status() == Addon.Status.UNCHECKED:
@@ -460,8 +465,10 @@ class PackageListItemDelegate(QtWidgets.QStyledItemDelegate):
return result
def get_expanded_update_string(self, repo: Addon) -> str:
"""Get a multi-line string listing details about the installed version and date"""
@staticmethod
def get_expanded_update_string(repo: Addon) -> str:
"""Get a multi-line string listing details about the installed version and
date"""
result = ""
@@ -471,7 +478,7 @@ class PackageListItemDelegate(QtWidgets.QStyledItemDelegate):
installed_version_string = (
"<br/>" + translate("AddonsInstaller", "Installed version") + ": "
)
installed_version_string += repo.installed_version
installed_version_string += str(repo.installed_version)
else:
installed_version_string = "<br/>" + translate(
"AddonsInstaller", "Unknown version"
@@ -493,7 +500,7 @@ class PackageListItemDelegate(QtWidgets.QStyledItemDelegate):
available_version_string = (
"<br/>" + translate("AddonsInstaller", "Available version") + ": "
)
available_version_string += repo.metadata.Version
available_version_string += str(repo.metadata.version)
if repo.status() == Addon.Status.UNCHECKED:
result = translate("AddonsInstaller", "Installed")
@@ -529,8 +536,8 @@ class PackageListItemDelegate(QtWidgets.QStyledItemDelegate):
option: QtWidgets.QStyleOptionViewItem,
_: QtCore.QModelIndex,
):
"""Main paint function: renders this widget into a given rectangle, successively drawing
all of its children."""
"""Main paint function: renders this widget into a given rectangle,
successively drawing all of its children."""
painter.save()
self.widget.resize(option.rect.size())
painter.translate(option.rect.topLeft())
@@ -641,7 +648,7 @@ class PackageListFilter(QtCore.QSortFilterProxyModel):
# it's possible that this package actually provides versions of itself
# for newer and older versions
first_supported_version = data.metadata.getFirstSupportedFreeCADVersion()
first_supported_version = get_first_supported_freecad_version(data.metadata)
if first_supported_version is not None:
required_version = first_supported_version.split(".")
fc_major = int(FreeCAD.Version()[0])
@@ -692,9 +699,11 @@ class PackageListFilter(QtCore.QSortFilterProxyModel):
return True
return False
# pylint: disable=attribute-defined-outside-init, missing-function-docstring
class Ui_PackageList():
class Ui_PackageList:
"""The contents of the PackageList widget"""
def setupUi(self, form):