From 325ae6fd9afb912568e533d6c1f4a7a2d8ece0f5 Mon Sep 17 00:00:00 2001 From: Chris Hennes Date: Sun, 2 Mar 2025 16:43:13 -0600 Subject: [PATCH] Addon Manager: Create AddonCatalog class --- src/Mod/AddonManager/AddonCatalog.py | 143 ++++++++++++ .../AddonManagerTest/app/test_addoncatalog.py | 213 ++++++++++++++++++ src/Mod/AddonManager/CMakeLists.txt | 7 +- 3 files changed, 361 insertions(+), 2 deletions(-) create mode 100644 src/Mod/AddonManager/AddonCatalog.py create mode 100644 src/Mod/AddonManager/AddonManagerTest/app/test_addoncatalog.py diff --git a/src/Mod/AddonManager/AddonCatalog.py b/src/Mod/AddonManager/AddonCatalog.py new file mode 100644 index 0000000000..9140c48e94 --- /dev/null +++ b/src/Mod/AddonManager/AddonCatalog.py @@ -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 * +# * . * +# * * +# *************************************************************************** + +"""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}'") diff --git a/src/Mod/AddonManager/AddonManagerTest/app/test_addoncatalog.py b/src/Mod/AddonManager/AddonManagerTest/app/test_addoncatalog.py new file mode 100644 index 0000000000..5b058c53c2 --- /dev/null +++ b/src/Mod/AddonManager/AddonManagerTest/app/test_addoncatalog.py @@ -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) diff --git a/src/Mod/AddonManager/CMakeLists.txt b/src/Mod/AddonManager/CMakeLists.txt index 8efcf67a0e..895b5e3bd2 100644 --- a/src/Mod/AddonManager/CMakeLists.txt +++ b/src/Mod/AddonManager/CMakeLists.txt @@ -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 )