Addon Manager: Refactor Metadata
Create a Python-native metadata class. Includes unit tests, and some PyLint cleanup.
This commit is contained in:
committed by
Chris Hennes
parent
243088a8c3
commit
0b241f78f4
@@ -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()
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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):
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
620
src/Mod/AddonManager/AddonManagerTest/app/test_metadata.py
Normal file
620
src/Mod/AddonManager/AddonManagerTest/app/test_metadata.py
Normal 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()
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
|
||||
415
src/Mod/AddonManager/addonmanager_metadata.py
Normal file
415
src/Mod/AddonManager/addonmanager_metadata.py
Normal 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)
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>"
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user