Addon Manager: Create AddonCatalog class

This commit is contained in:
Chris Hennes
2025-03-02 16:43:13 -06:00
committed by Yorik van Havre
parent 7dab93abac
commit 325ae6fd9a
3 changed files with 361 additions and 2 deletions

View 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}'")

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

View File

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