Addon Manager: Create AddonCatalog class
This commit is contained in:
committed by
Yorik van Havre
parent
7dab93abac
commit
325ae6fd9a
143
src/Mod/AddonManager/AddonCatalog.py
Normal file
143
src/Mod/AddonManager/AddonCatalog.py
Normal file
@@ -0,0 +1,143 @@
|
||||
# SPDX-License-Identifier: LGPL-2.1-or-later
|
||||
# ***************************************************************************
|
||||
# * *
|
||||
# * Copyright (c) 2025 The FreeCAD project association AISBL *
|
||||
# * *
|
||||
# * 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/>. *
|
||||
# * *
|
||||
# ***************************************************************************
|
||||
|
||||
"""The Addon Catalog is the main list of all Addons along with their various
|
||||
sources and compatible versions. Added in FreeCAD 1.1 to replace .gitmodules."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from hashlib import sha256
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from addonmanager_metadata import Version
|
||||
from Addon import Addon
|
||||
|
||||
import addonmanager_freecad_interface as fci
|
||||
|
||||
|
||||
@dataclass
|
||||
class AddonCatalogEntry:
|
||||
"""Each individual entry in the catalog, storing data about a particular version of an
|
||||
Addon. Note that this class needs to be identical to the one that is used in the remote cache
|
||||
generation, so don't make changes here without ensuring that the classes are synchronized."""
|
||||
|
||||
freecad_min: Optional[Version] = None
|
||||
freecad_max: Optional[Version] = None
|
||||
repository: Optional[str] = None
|
||||
git_ref: Optional[str] = None
|
||||
zip_url: Optional[str] = None
|
||||
note: Optional[str] = None
|
||||
branch_display_name: Optional[str] = None
|
||||
|
||||
def __init__(self, raw_data: Dict[str, str]) -> None:
|
||||
"""Create an AddonDictionaryEntry from the raw JSON data"""
|
||||
super().__init__()
|
||||
for key, value in raw_data.items():
|
||||
if hasattr(self, key):
|
||||
if key in ("freecad_min", "freecad_max"):
|
||||
value = Version(from_string=value)
|
||||
setattr(self, key, value)
|
||||
|
||||
def is_compatible(self) -> bool:
|
||||
"""Check whether this AddonCatalogEntry is compatible with the current version of FreeCAD"""
|
||||
if self.freecad_min is None and self.freecad_max is None:
|
||||
return True
|
||||
current_version = Version(from_list=fci.Version())
|
||||
if self.freecad_min is None:
|
||||
return current_version <= self.freecad_max
|
||||
if self.freecad_max is None:
|
||||
return current_version >= self.freecad_min
|
||||
return self.freecad_min <= current_version <= self.freecad_max
|
||||
|
||||
def unique_identifier(self) -> str:
|
||||
"""Return a unique identifier of the AddonCatalogEntry, guaranteed to be repeatable: when
|
||||
given the same basic information, the same ID is created. Used as the key when storing
|
||||
the metadata for a given AddonCatalogEntry."""
|
||||
sha256_hash = sha256()
|
||||
sha256_hash.update(str(self).encode("utf-8"))
|
||||
return sha256_hash.hexdigest()
|
||||
|
||||
|
||||
class AddonCatalog:
|
||||
"""A catalog of addons grouped together into sets representing versions that are
|
||||
compatible with different versions of FreeCAD and/or represent different available branches
|
||||
of a given addon (e.g. a Development branch that users are presented)."""
|
||||
|
||||
def __init__(self, data: Dict[str, Any]):
|
||||
self._original_data = data
|
||||
self._dictionary = {}
|
||||
self._parse_raw_data()
|
||||
|
||||
def _parse_raw_data(self):
|
||||
self._dictionary = {} # Clear pre-existing contents
|
||||
for key, value in self._original_data.items():
|
||||
if key == "_meta": # Don't add the documentation object to the tree
|
||||
continue
|
||||
self._dictionary[key] = []
|
||||
for entry in value:
|
||||
self._dictionary[key].append(AddonCatalogEntry(entry))
|
||||
|
||||
def load_metadata_cache(self, cache: Dict[str, Any]):
|
||||
"""Given the raw dictionary, couple that with the remote metadata cache to create the
|
||||
final working addon dictionary. Only create Addons that are compatible with the current
|
||||
version of FreeCAD."""
|
||||
for value in self._dictionary.values():
|
||||
for entry in value:
|
||||
sha256_hash = entry.unique_identifier()
|
||||
print(sha256_hash)
|
||||
if sha256_hash in cache and entry.is_compatible():
|
||||
entry.addon = Addon.from_cache(cache[sha256_hash])
|
||||
|
||||
def get_available_addon_ids(self) -> List[str]:
|
||||
"""Get a list of IDs that have at least one entry compatible with the current version of
|
||||
FreeCAD"""
|
||||
id_list = []
|
||||
for key, value in self._dictionary.items():
|
||||
for entry in value:
|
||||
if entry.is_compatible():
|
||||
id_list.append(key)
|
||||
break
|
||||
return id_list
|
||||
|
||||
def get_available_branches(self, addon_id: str) -> List[Tuple[str, str]]:
|
||||
"""For a given ID, get the list of available branches compatible with this version of
|
||||
FreeCAD along with the branch display name. Either field may be empty, but not both. The
|
||||
first entry in the list is expected to be the "primary"."""
|
||||
if addon_id not in self._dictionary:
|
||||
return []
|
||||
result = []
|
||||
for entry in self._dictionary[addon_id]:
|
||||
if entry.is_compatible():
|
||||
result.append((entry.git_ref, entry.branch_display_name))
|
||||
return result
|
||||
|
||||
def get_addon_from_id(self, addon_id: str, branch: Optional[Tuple[str, str]] = None) -> Addon:
|
||||
"""Get the instantiated Addon object for the given ID and optionally branch. If no
|
||||
branch is provided, whichever branch is the "primary" branch will be returned (i.e. the
|
||||
first branch that matches). Raises a ValueError if no addon matches the request."""
|
||||
if addon_id not in self._dictionary:
|
||||
raise ValueError(f"Addon '{addon_id}' not found")
|
||||
for entry in self._dictionary[addon_id]:
|
||||
if not entry.is_compatible():
|
||||
continue
|
||||
if not branch or entry.branch_display_name == branch:
|
||||
return entry.addon
|
||||
raise ValueError(f"Addon '{addon_id}' has no compatible branches named '{branch}'")
|
||||
213
src/Mod/AddonManager/AddonManagerTest/app/test_addoncatalog.py
Normal file
213
src/Mod/AddonManager/AddonManagerTest/app/test_addoncatalog.py
Normal file
@@ -0,0 +1,213 @@
|
||||
# SPDX-License-Identifier: LGPL-2.1-or-later
|
||||
|
||||
# pylint: disable=global-at-module-level,global-statement,import-outside-toplevel,
|
||||
|
||||
"""Tests for the AddonCatalog and AddonCatalogEntry classes."""
|
||||
|
||||
import unittest
|
||||
from unittest import mock
|
||||
from unittest.mock import patch
|
||||
|
||||
global AddonCatalogEntry
|
||||
global AddonCatalog
|
||||
global Version
|
||||
|
||||
|
||||
class TestAddonCatalogEntry(unittest.TestCase):
|
||||
"""Tests for the AddonCatalogEntry class."""
|
||||
|
||||
def setUp(self):
|
||||
"""Start mock for addonmanager_licenses class."""
|
||||
global AddonCatalogEntry
|
||||
global AddonCatalog
|
||||
global Version
|
||||
self.addon_patch = mock.patch.dict("sys.modules", {"addonmanager_licenses": mock.Mock()})
|
||||
self.mock_addon_module = self.addon_patch.start()
|
||||
from AddonCatalog import AddonCatalogEntry, AddonCatalog
|
||||
from addonmanager_metadata import Version
|
||||
|
||||
def tearDown(self):
|
||||
"""Stop patching the addonmanager_licenses class"""
|
||||
self.addon_patch.stop()
|
||||
|
||||
def test_version_match_without_restrictions(self):
|
||||
"""Given an AddonCatalogEntry that has no version restrictions, a fixed version matches."""
|
||||
with patch("addonmanager_freecad_interface.Version") as mock_freecad:
|
||||
mock_freecad.Version = lambda: (1, 2, 3, "dev")
|
||||
ac = AddonCatalogEntry({})
|
||||
self.assertTrue(ac.is_compatible())
|
||||
|
||||
def test_version_match_with_min_no_max_good_match(self):
|
||||
"""Given an AddonCatalogEntry with a minimum FreeCAD version, a version smaller than that
|
||||
does not match."""
|
||||
with patch("addonmanager_freecad_interface.Version", return_value=(1, 2, 3, "dev")):
|
||||
ac = AddonCatalogEntry({"freecad_min": Version(from_string="1.2")})
|
||||
self.assertTrue(ac.is_compatible())
|
||||
|
||||
def test_version_match_with_max_no_min_good_match(self):
|
||||
"""Given an AddonCatalogEntry with a maximum FreeCAD version, a version larger than that
|
||||
does not match."""
|
||||
with patch("addonmanager_freecad_interface.Version", return_value=(1, 2, 3, "dev")):
|
||||
ac = AddonCatalogEntry({"freecad_max": Version(from_string="1.3")})
|
||||
self.assertTrue(ac.is_compatible())
|
||||
|
||||
def test_version_match_with_min_and_max_good_match(self):
|
||||
"""Given an AddonCatalogEntry with both a minimum and maximum FreeCAD version, a version
|
||||
between the two matches."""
|
||||
with patch("addonmanager_freecad_interface.Version", return_value=(1, 2, 3, "dev")):
|
||||
ac = AddonCatalogEntry(
|
||||
{
|
||||
"freecad_min": Version(from_string="1.1"),
|
||||
"freecad_max": Version(from_string="1.3"),
|
||||
}
|
||||
)
|
||||
self.assertTrue(ac.is_compatible())
|
||||
|
||||
def test_version_match_with_min_and_max_bad_match_high(self):
|
||||
"""Given an AddonCatalogEntry with both a minimum and maximum FreeCAD version, a version
|
||||
higher than the maximum does not match."""
|
||||
with patch("addonmanager_freecad_interface.Version", return_value=(1, 3, 3, "dev")):
|
||||
ac = AddonCatalogEntry(
|
||||
{
|
||||
"freecad_min": Version(from_string="1.1"),
|
||||
"freecad_max": Version(from_string="1.3"),
|
||||
}
|
||||
)
|
||||
self.assertFalse(ac.is_compatible())
|
||||
|
||||
def test_version_match_with_min_and_max_bad_match_low(self):
|
||||
"""Given an AddonCatalogEntry with both a minimum and maximum FreeCAD version, a version
|
||||
lower than the minimum does not match."""
|
||||
with patch("addonmanager_freecad_interface.Version", return_value=(1, 0, 3, "dev")):
|
||||
ac = AddonCatalogEntry(
|
||||
{
|
||||
"freecad_min": Version(from_string="1.1"),
|
||||
"freecad_max": Version(from_string="1.3"),
|
||||
}
|
||||
)
|
||||
self.assertFalse(ac.is_compatible())
|
||||
|
||||
|
||||
class TestAddonCatalog(unittest.TestCase):
|
||||
"""Tests for the AddonCatalog class."""
|
||||
|
||||
def setUp(self):
|
||||
"""Start mock for addonmanager_licenses class."""
|
||||
global AddonCatalog
|
||||
global Version
|
||||
self.addon_patch = mock.patch.dict("sys.modules", {"addonmanager_licenses": mock.Mock()})
|
||||
self.mock_addon_module = self.addon_patch.start()
|
||||
from AddonCatalog import AddonCatalog
|
||||
from addonmanager_metadata import Version
|
||||
|
||||
def tearDown(self):
|
||||
"""Stop patching the addonmanager_licenses class"""
|
||||
self.addon_patch.stop()
|
||||
|
||||
def test_single_addon_simple_entry(self):
|
||||
"""Test that an addon entry for an addon with only a git ref is accepted and added, and
|
||||
appears as an available addon."""
|
||||
data = {"AnAddon": [{"git_ref": "main"}]}
|
||||
catalog = AddonCatalog(data)
|
||||
ids = catalog.get_available_addon_ids()
|
||||
self.assertEqual(len(ids), 1)
|
||||
self.assertIn("AnAddon", ids)
|
||||
|
||||
def test_single_addon_max_single_entry(self):
|
||||
"""Test that an addon with the maximum possible data load is accepted."""
|
||||
data = {
|
||||
"AnAddon": [
|
||||
{
|
||||
"freecad_min": "0.21.0",
|
||||
"freecad_max": "1.99.99",
|
||||
"repository": "https://github.com/FreeCAD/FreeCAD",
|
||||
"git_ref": "main",
|
||||
"zip_url": "https://github.com/FreeCAD/FreeCAD/archive/main.zip",
|
||||
"note": "This is a fake repo, don't use it",
|
||||
"branch_display_name": "main",
|
||||
}
|
||||
]
|
||||
}
|
||||
catalog = AddonCatalog(data)
|
||||
ids = catalog.get_available_addon_ids()
|
||||
self.assertEqual(len(ids), 1)
|
||||
self.assertIn("AnAddon", ids)
|
||||
|
||||
def test_single_addon_multiple_entries(self):
|
||||
"""Test that an addon with multiple entries is accepted and only appears as a single
|
||||
addon."""
|
||||
data = {
|
||||
"AnAddon": [
|
||||
{
|
||||
"freecad_min": "1.0.0",
|
||||
"repository": "https://github.com/FreeCAD/FreeCAD",
|
||||
"git_ref": "main",
|
||||
},
|
||||
{
|
||||
"freecad_min": "0.21.0",
|
||||
"freecad_max": "0.21.99",
|
||||
"repository": "https://github.com/FreeCAD/FreeCAD",
|
||||
"git_ref": "0_21_compatibility_branch",
|
||||
"branch_display_name": "FreeCAD 0.21 Compatibility Branch",
|
||||
},
|
||||
]
|
||||
}
|
||||
catalog = AddonCatalog(data)
|
||||
ids = catalog.get_available_addon_ids()
|
||||
self.assertEqual(len(ids), 1)
|
||||
self.assertIn("AnAddon", ids)
|
||||
|
||||
def test_multiple_addon_entries(self):
|
||||
"""Test that multiple distinct addon entries are added as distinct addons"""
|
||||
data = {
|
||||
"AnAddon": [{"git_ref": "main"}],
|
||||
"AnotherAddon": [{"git_ref": "main"}],
|
||||
"YetAnotherAddon": [{"git_ref": "main"}],
|
||||
}
|
||||
catalog = AddonCatalog(data)
|
||||
ids = catalog.get_available_addon_ids()
|
||||
self.assertEqual(len(ids), 3)
|
||||
self.assertIn("AnAddon", ids)
|
||||
self.assertIn("AnotherAddon", ids)
|
||||
self.assertIn("YetAnotherAddon", ids)
|
||||
|
||||
def test_multiple_branches_single_match(self):
|
||||
"""Test that an addon with multiple branches representing different configurations of
|
||||
min and max FreeCAD versions returns only the appropriate match."""
|
||||
data = {
|
||||
"AnAddon": [
|
||||
{
|
||||
"freecad_min": "1.0.0",
|
||||
"repository": "https://github.com/FreeCAD/FreeCAD",
|
||||
"git_ref": "main",
|
||||
},
|
||||
{
|
||||
"freecad_min": "0.21.0",
|
||||
"freecad_max": "0.21.99",
|
||||
"repository": "https://github.com/FreeCAD/FreeCAD",
|
||||
"git_ref": "0_21_compatibility_branch",
|
||||
"branch_display_name": "FreeCAD 0.21 Compatibility Branch",
|
||||
},
|
||||
{
|
||||
"freecad_min": "0.19.0",
|
||||
"freecad_max": "0.20.99",
|
||||
"repository": "https://github.com/FreeCAD/FreeCAD",
|
||||
"git_ref": "0_19_compatibility_branch",
|
||||
"branch_display_name": "FreeCAD 0.19 Compatibility Branch",
|
||||
},
|
||||
]
|
||||
}
|
||||
with patch("addonmanager_freecad_interface.Version", return_value=(1, 0, 3, "dev")):
|
||||
catalog = AddonCatalog(data)
|
||||
branches = catalog.get_available_branches("AnAddon")
|
||||
self.assertEqual(len(branches), 1)
|
||||
|
||||
def test_load_metadata_cache(self):
|
||||
"""Test that an addon with a known hash is correctly loaded (e.g. no exception is raised)"""
|
||||
data = {"AnAddon": [{"git_ref": "main"}]}
|
||||
catalog = AddonCatalog(data)
|
||||
sha = "cbce6737d7d058dca2b5ae3f2fdb8cc45b0c02bf711e75bdf5f12fb71ce87790"
|
||||
cache = {sha: "CacheData"}
|
||||
with patch("addonmanager_freecad_interface.Version", return_value=cache):
|
||||
with patch("Addon.Addon") as addon_mock:
|
||||
catalog.load_metadata_cache(cache)
|
||||
@@ -92,6 +92,7 @@ SET(AddonManagerTestsApp_SRCS
|
||||
AddonManagerTest/app/__init__.py
|
||||
AddonManagerTest/app/mocks.py
|
||||
AddonManagerTest/app/test_addon.py
|
||||
AddonManagerTest/app/test_addoncatalog.py
|
||||
AddonManagerTest/app/test_cache.py
|
||||
AddonManagerTest/app/test_dependency_installer.py
|
||||
AddonManagerTest/app/test_freecad_interface.py
|
||||
@@ -100,8 +101,8 @@ SET(AddonManagerTestsApp_SRCS
|
||||
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
|
||||
AddonManagerTest/app/test_utilities.py
|
||||
)
|
||||
|
||||
SET(AddonManagerTestsGui_SRCS
|
||||
@@ -109,8 +110,10 @@ SET(AddonManagerTestsGui_SRCS
|
||||
AddonManagerTest/gui/gui_mocks.py
|
||||
AddonManagerTest/gui/test_gui.py
|
||||
AddonManagerTest/gui/test_installer_gui.py
|
||||
AddonManagerTest/gui/test_update_all_gui.py
|
||||
AddonManagerTest/gui/test_python_deps_gui.py
|
||||
AddonManagerTest/gui/test_uninstaller_gui.py
|
||||
AddonManagerTest/gui/test_update_all_gui.py
|
||||
AddonManagerTest/gui/test_widget_progress_bar.py
|
||||
AddonManagerTest/gui/test_workers_startup.py
|
||||
AddonManagerTest/gui/test_workers_utility.py
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user