diff --git a/.github/workflows/qt5-tests.yml b/.github/workflows/qt5-tests.yml index 0f66ce0..3f09c29 100644 --- a/.github/workflows/qt5-tests.yml +++ b/.github/workflows/qt5-tests.yml @@ -39,7 +39,7 @@ jobs: - name: Install Python dependencies run: | python -m pip install --upgrade pip - pip install vermin || true + pip install vermin pyfakefs requests || true - name: Run App tests run: | diff --git a/.github/workflows/qt6-tests.yml b/.github/workflows/qt6-tests.yml index 292b623..4e9bcab 100644 --- a/.github/workflows/qt6-tests.yml +++ b/.github/workflows/qt6-tests.yml @@ -59,7 +59,7 @@ jobs: - name: Install Python dependencies run: | python -m pip install --upgrade pip - pip install PySide6 vermin || true + pip install pyfakefs PySide6 vermin requests || true - name: Run App tests run: | diff --git a/AddonCatalog.py b/AddonCatalog.py index 61d521d..fe11327 100644 --- a/AddonCatalog.py +++ b/AddonCatalog.py @@ -83,7 +83,7 @@ class AddonCatalog: def __init__(self, data: Dict[str, Any]): self._original_data = data - self._dictionary = {} + self._dictionary: Dict[str, List[AddonCatalogEntry]] = {} self._parse_raw_data() def _parse_raw_data(self): @@ -117,6 +117,16 @@ def get_available_addon_ids(self) -> List[str]: break return id_list + def get_all_addon_ids(self) -> List[str]: + """Get a list of all Addon IDs, even those that have no compatible versions for the current + version of FreeCAD.""" + id_list = [] + for key, value in self._dictionary.items(): + if len(value) == 0: + continue + id_list.append(key) + 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 @@ -129,6 +139,10 @@ def get_available_branches(self, addon_id: str) -> List[Tuple[str, str]]: result.append((entry.git_ref, entry.branch_display_name)) return result + def get_catalog(self) -> Dict[str, List[AddonCatalogEntry]]: + """Get access to the entire catalog, without any filtering applied.""" + return self._dictionary + 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 diff --git a/AddonCatalogCacheCreator.py b/AddonCatalogCacheCreator.py new file mode 100644 index 0000000..e6539ca --- /dev/null +++ b/AddonCatalogCacheCreator.py @@ -0,0 +1,338 @@ +# 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 * +# * . * +# * * +# *************************************************************************** + +"""Classes and utility functions to generate a remotely hosted cache of all addon catalog entries. +Intended to be run by a server-side systemd timer to generate a file that is then loaded by the +Addon Manager in each FreeCAD installation.""" +import enum +import xml.etree.ElementTree +from dataclasses import dataclass, asdict +from typing import List, Optional + +import base64 +import io +import json +import os +import requests +import shutil +import subprocess +import zipfile + +import AddonCatalog +import addonmanager_metadata + + +ADDON_CATALOG_URL = ( + "https://raw.githubusercontent.com/FreeCAD/FreeCAD-addons/master/AddonCatalog.json" +) +BASE_DIRECTORY = "./CatalogCache" +MAX_COUNT = 10000 # Do at most this many repos (for testing purposes this can be made smaller) + +# Repos that are too large, or that should for some reason not be cloned here +EXCLUDED_REPOS = ["parts_library"] + + +@dataclass +class CacheEntry: + """All contents of a CacheEntry are the text contents of the file listed. The icon data is + base64-encoded (although it was probably an SVG, other formats are supported).""" + + package_xml: str = "" + requirements_txt: str = "" + metadata_txt: str = "" + icon_data: str = "" + + +class GitRefType(enum.IntEnum): + """Enum for the type of git ref (tag, branch, or hash).""" + + TAG = 1 + BRANCH = 2 + HASH = 3 + + +class CatalogFetcher: + """Fetches the addon catalog from the given URL and returns an AddonCatalog object. Separated + from the main class for easy mocking during tests. Note that every instantiation of this class + will run a new fetch of the catalog.""" + + def __init__(self, addon_catalog_url: str = ADDON_CATALOG_URL): + self.addon_catalog_url = addon_catalog_url + self.catalog = self.fetch_catalog() + + def fetch_catalog(self) -> AddonCatalog.AddonCatalog: + """Fetch the addon catalog from the given URL and return an AddonCatalog object.""" + response = requests.get(self.addon_catalog_url) + if response.status_code != 200: + raise RuntimeError( + f"ERROR: Failed to fetch addon catalog from {self.addon_catalog_url}" + ) + return AddonCatalog.AddonCatalog(response.json()) + + +class CacheWriter: + """Writes a JSON file containing a cache of all addon catalog entries. The cache is a copy of + the package.xml, requirements.txt, and metadata.txt files from the addon repositories, as well + as a base64-encoded icon image. The cache is written to the current working directory.""" + + def __init__(self): + self.catalog: AddonCatalog = None + if os.path.isabs(BASE_DIRECTORY): + self.cwd = BASE_DIRECTORY + else: + self.cwd = os.path.normpath(os.path.join(os.getcwd(), BASE_DIRECTORY)) + self._cache = {} + + def write(self): + original_working_directory = os.getcwd() + os.makedirs(self.cwd, exist_ok=True) + os.chdir(self.cwd) + self.create_local_copy_of_addons() + with open("addon_catalog_cache.json", "w", encoding="utf-8") as f: + f.write(json.dumps(self._cache, indent=" ")) + os.chdir(original_working_directory) + print(f"Wrote cache to {os.path.join(self.cwd, 'addon_catalog_cache.json')}") + + def create_local_copy_of_addons(self): + self.catalog = CatalogFetcher().catalog + counter = 0 + for addon_id, catalog_entries in self.catalog.get_catalog().items(): + if addon_id in EXCLUDED_REPOS: + continue + self.create_local_copy_of_single_addon(addon_id, catalog_entries) + counter += 1 + if counter >= MAX_COUNT: + break + + def create_local_copy_of_single_addon( + self, addon_id: str, catalog_entries: List[AddonCatalog.AddonCatalogEntry] + ): + for index, catalog_entry in enumerate(catalog_entries): + if catalog_entry.repository is not None: + self.create_local_copy_of_single_addon_with_git(addon_id, index, catalog_entry) + elif catalog_entry.zip_url is not None: + self.create_local_copy_of_single_addon_with_zip(addon_id, index, catalog_entry) + else: + print( + f"ERROR: Invalid catalog entry for {addon_id}. " + "Neither git info nor zip info was specified." + ) + continue + entry = self.generate_cache_entry(addon_id, index, catalog_entry) + if addon_id not in self._cache: + self._cache[addon_id] = [] + if entry is not None: + self._cache[addon_id].append(asdict(entry)) + else: + self._cache[addon_id].append({}) + + def generate_cache_entry( + self, addon_id: str, index: int, catalog_entry: AddonCatalog.AddonCatalogEntry + ) -> Optional[CacheEntry]: + """Create the cache entry for this catalog entry if there is data to cache. If there is + nothing to cache, returns None.""" + path_to_package_xml = self.find_file("package.xml", addon_id, index, catalog_entry) + cache_entry = None + if path_to_package_xml and os.path.exists(path_to_package_xml): + cache_entry = self.generate_cache_entry_from_package_xml(path_to_package_xml) + + path_to_requirements = self.find_file("requirements.txt", addon_id, index, catalog_entry) + if path_to_requirements and os.path.exists(path_to_requirements): + if cache_entry is None: + cache_entry = CacheEntry() + with open(path_to_requirements, "r", encoding="utf-8") as f: + cache_entry.requirements_txt = f.read() + + path_to_metadata = self.find_file("metadata.txt", addon_id, index, catalog_entry) + if path_to_metadata and os.path.exists(path_to_metadata): + if cache_entry is None: + cache_entry = CacheEntry() + with open(path_to_metadata, "r", encoding="utf-8") as f: + cache_entry.metadata_txt = f.read() + + return cache_entry + + def generate_cache_entry_from_package_xml( + self, path_to_package_xml: str + ) -> Optional[CacheEntry]: + cache_entry = CacheEntry() + with open(path_to_package_xml, "r", encoding="utf-8") as f: + cache_entry.package_xml = f.read() + try: + metadata = addonmanager_metadata.MetadataReader.from_bytes( + cache_entry.package_xml.encode("utf-8") + ) + except xml.etree.ElementTree.ParseError: + print(f"ERROR: Failed to parse XML from {path_to_package_xml}") + return None + except RuntimeError: + print(f"ERROR: Failed to read metadata from {path_to_package_xml}") + return None + + relative_icon_path = self.get_icon_from_metadata(metadata) + if relative_icon_path is not None: + absolute_icon_path = os.path.join( + os.path.dirname(path_to_package_xml), relative_icon_path + ) + if os.path.exists(absolute_icon_path): + with open(absolute_icon_path, "rb") as f: + cache_entry.icon_data = base64.b64encode(f.read()).decode("utf-8") + else: + print(f"ERROR: Could not find icon file {absolute_icon_path}") + return cache_entry + + def create_local_copy_of_single_addon_with_git( + self, addon_id: str, index: int, catalog_entry: AddonCatalog.AddonCatalogEntry + ): + expected_name = self.get_directory_name(addon_id, index, catalog_entry) + self.clone_or_update(expected_name, catalog_entry.repository, catalog_entry.git_ref) + + @staticmethod + def get_directory_name(addon_id, index, catalog_entry): + expected_name = os.path.join(addon_id, str(index) + "-") + if catalog_entry.branch_display_name: + expected_name += catalog_entry.branch_display_name.replace("/", "-") + elif catalog_entry.git_ref: + expected_name += catalog_entry.git_ref.replace("/", "-") + else: + expected_name += "unknown-branch-name" + return expected_name + + def create_local_copy_of_single_addon_with_zip( + self, addon_id: str, index: int, catalog_entry: AddonCatalog.AddonCatalogEntry + ): + response = requests.get(catalog_entry.zip_url) + if response.status_code != 200: + print(f"ERROR: Failed to fetch zip data for {addon_id} from {catalog_entry.zip_url}.") + return + extract_to_dir = self.get_directory_name(addon_id, index, catalog_entry) + if os.path.exists(extract_to_dir): + shutil.rmtree(extract_to_dir) + os.makedirs(extract_to_dir, exist_ok=True) + + with zipfile.ZipFile(io.BytesIO(response.content)) as zip_file: + zip_file.extractall(path=extract_to_dir) + + @staticmethod + def clone_or_update(name: str, url: str, branch: str) -> None: + """If a directory called "name" exists, and it contains a subdirectory called .git, + then 'git fetch' is called; otherwise we use 'git clone' to make a bare, shallow + copy of the repo (in the normal case where minimal is True), or a normal clone, + if minimal is set to False.""" + + if not os.path.exists(os.path.join(os.getcwd(), name, ".git")): + print(f"Cloning {url} to {name}", flush=True) + # Shallow, but do include the last commit on each branch and tag + command = [ + "git", + "clone", + "--depth", + "1", + "--branch", + branch, + url, + name, + ] + completed_process = subprocess.run(command) + if completed_process.returncode != 0: + raise RuntimeError(f"Clone failed for {url}") + else: + print(f"Updating {name}", flush=True) + old_dir = os.getcwd() + os.chdir(os.path.join(old_dir, name)) + # Determine if we are dealing with a tag, branch, or hash + git_ref_type = CacheWriter.determine_git_ref_type(name, url, branch) + command = ["git", "fetch"] + completed_process = subprocess.run(command) + if completed_process.returncode != 0: + os.chdir(old_dir) + raise RuntimeError(f"git fetch failed for {name}") + command = ["git", "checkout", branch, "--quiet"] + completed_process = subprocess.run(command) + if completed_process.returncode != 0: + os.chdir(old_dir) + raise RuntimeError(f"git checkout failed for {name} branch {branch}") + if git_ref_type == GitRefType.BRANCH: + command = ["git", "merge", "--quiet"] + completed_process = subprocess.run(command) + if completed_process.returncode != 0: + os.chdir(old_dir) + raise RuntimeError(f"git merge failed for {name} branch {branch}") + os.chdir(old_dir) + + def find_file( + self, + filename: str, + addon_id: str, + index: int, + catalog_entry: AddonCatalog.AddonCatalogEntry, + ) -> Optional[str]: + """Find a given file in the downloaded cache for this addon. Returns None if the file does + not exist.""" + start_dir = os.path.join(self.cwd, self.get_directory_name(addon_id, index, catalog_entry)) + for dirpath, _, filenames in os.walk(start_dir): + if filename in filenames: + return os.path.join(dirpath, filename) + return None + + @staticmethod + def get_icon_from_metadata(metadata: addonmanager_metadata.Metadata) -> Optional[str]: + """Try to locate the icon file specified for this Addon. Recursively search through the + levels of the metadata and return the first specified icon file path. Returns None of there + is no icon specified for this Addon (which is not allowed by the standard, but we don't want + to crash the cache writer).""" + if metadata.icon: + return metadata.icon + for content_type in metadata.content: + for content_item in metadata.content[content_type]: + icon = CacheWriter.get_icon_from_metadata(content_item) + if icon: + return icon + return None + + @staticmethod + def determine_git_ref_type(name: str, url: str, branch: str) -> GitRefType: + """Determine if the given branch, tag, or hash is a tag, branch, or hash. Returns the type + if determinable, otherwise raises a RuntimeError.""" + command = ["git", "show-ref", "--verify", f"refs/remotes/origin/{branch}"] + completed_process = subprocess.run(command) + if completed_process.returncode == 0: + return GitRefType.BRANCH + command = ["git", "show-ref", "--tags"] + completed_process = subprocess.run(command, capture_output=True) + completed_process_output = completed_process.stdout.decode("utf-8") + if branch in completed_process_output: + return GitRefType.TAG + command = ["git", "rev-parse", branch] + completed_process = subprocess.run(command) + if completed_process.returncode == 0: + return GitRefType.HASH + raise RuntimeError( + f"Could not determine if {branch} of {name} is a tag, branch, or hash. " + f"Output was: {completed_process_output}" + ) + + +if __name__ == "__main__": + writer = CacheWriter() + writer.write() diff --git a/AddonManagerTest/app/test_addon_catalog_cache_creator.py b/AddonManagerTest/app/test_addon_catalog_cache_creator.py new file mode 100644 index 0000000..0c19b3a --- /dev/null +++ b/AddonManagerTest/app/test_addon_catalog_cache_creator.py @@ -0,0 +1,252 @@ +# 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 AddonCatalogCacheCreator is an independent script that is run server-side to generate a +cache of the addon metadata and icons. These tests verify the functionality of its methods.""" +import base64 +from unittest import mock + +from pyfakefs.fake_filesystem_unittest import TestCase +from unittest.mock import patch + +import os + + +import AddonCatalogCacheCreator as accc +import AddonCatalog +from AddonCatalogCacheCreator import EXCLUDED_REPOS + + +class TestCacheWriter(TestCase): + + def setUp(self): + self.setUpPyfakefs() + + def test_get_directory_name_with_branch_name(self): + """If a branch display name is present, that should be appended to the name.""" + ace = AddonCatalog.AddonCatalogEntry({"branch_display_name": "test_branch"}) + result = accc.CacheWriter.get_directory_name("test_addon", 99, ace) + self.assertEqual(result, os.path.join("test_addon", "99-test_branch")) + + def test_get_directory_name_with_git_ref(self): + """If a branch display name is present, that should be appended to the name.""" + ace = AddonCatalog.AddonCatalogEntry({"git_ref": "test_ref"}) + result = accc.CacheWriter.get_directory_name("test_addon", 99, ace) + self.assertEqual(result, os.path.join("test_addon", "99-test_ref")) + + def test_get_directory_name_with_branch_and_ref(self): + """If a branch and git ref are both present, then the branch display name is used.""" + ace = AddonCatalog.AddonCatalogEntry( + {"branch_display_name": "test_branch", "git_ref": "test_ref"} + ) + result = accc.CacheWriter.get_directory_name("test_addon", 99, ace) + self.assertEqual(result, os.path.join("test_addon", "99-test_branch")) + + def test_get_directory_name_with_no_information(self): + """If there is no branch name or git ref information, a valid directory name is still generated.""" + ace = AddonCatalog.AddonCatalogEntry({}) + result = accc.CacheWriter.get_directory_name("test_addon", 99, ace) + self.assertTrue(result.startswith(os.path.join("test_addon", "99"))) + + def test_find_file_with_existing_file(self): + """Find file locates the first occurrence of a given file""" + ace = AddonCatalog.AddonCatalogEntry({"git_ref": "main"}) + file_path = os.path.abspath( + os.path.join("home", "cache", "TestMod", "1-main", "some_fake_file.txt") + ) + self.fake_fs().create_file(file_path, contents="test") + writer = accc.CacheWriter() + writer.cwd = os.path.abspath(os.path.join("home", "cache")) + result = writer.find_file("some_fake_file.txt", "TestMod", 1, ace) + self.assertEqual(result, file_path) + + def test_find_file_with_non_existent_file(self): + """Find file returns None if the file is not present""" + ace = AddonCatalog.AddonCatalogEntry({"git_ref": "main"}) + writer = accc.CacheWriter() + writer.cwd = os.path.abspath(os.path.join("home", "cache")) + self.fake_fs().create_dir(os.path.join("home", "cache", "TestMod", "1-main")) + result = writer.find_file("some_other_fake_file.txt", "TestMod", 1, ace) + self.assertIsNone(result) + + def test_generate_cache_entry_from_package_xml_bad_metadata(self): + """Given an invalid metadata file, no cache entry is generated, but also no exception is + raised.""" + file_path = os.path.abspath( + os.path.join("home", "cache", "TestMod", "1-main", "package.xml") + ) + self.fake_fs().create_file(file_path, contents="this is not valid metadata") + writer = accc.CacheWriter() + writer.cwd = os.path.abspath(os.path.join("home", "cache")) + cache_entry = writer.generate_cache_entry_from_package_xml(file_path) + self.assertIsNone(cache_entry) + + @patch("AddonCatalogCacheCreator.addonmanager_metadata.MetadataReader.from_bytes") + def test_generate_cache_entry_from_package_xml(self, _): + """Given a good metadata file, its contents are embedded into the cache.""" + + file_path = os.path.abspath( + os.path.join("home", "cache", "TestMod", "1-main", "package.xml") + ) + xml_data = "Some data for testing" + self.fake_fs().create_file(file_path, contents=xml_data) + writer = accc.CacheWriter() + writer.cwd = os.path.abspath(os.path.join("home", "cache")) + cache_entry = writer.generate_cache_entry_from_package_xml(file_path) + self.assertIsNotNone(cache_entry) + self.assertEqual(cache_entry.package_xml, xml_data) + + @patch("AddonCatalogCacheCreator.addonmanager_metadata.MetadataReader.from_bytes") + @patch("AddonCatalogCacheCreator.CacheWriter.get_icon_from_metadata") + def test_generate_cache_entry_from_package_xml_with_icon(self, mock_icon, _): + """Given a metadata file that contains an icon, that icon's contents are + base64-encoded and embedded in the cache.""" + + file_path = os.path.abspath( + os.path.join("home", "cache", "TestMod", "1-main", "package.xml") + ) + icon_path = os.path.abspath( + os.path.join("home", "cache", "TestMod", "1-main", "icons", "TestMod.svg") + ) + self.fake_fs().create_file(file_path, contents="test data") + self.fake_fs().create_file(icon_path, contents="test icon data") + mock_icon.return_value = os.path.join("icons", "TestMod.svg") + writer = accc.CacheWriter() + writer.cwd = os.path.abspath(os.path.join("home", "cache")) + cache_entry = writer.generate_cache_entry_from_package_xml(file_path) + self.assertEqual( + base64.b64encode("test icon data".encode("utf-8")).decode("utf-8"), + cache_entry.icon_data, + ) + + def test_generate_cache_entry_with_requirements(self): + """Given an addon that includes a requirements.txt file, the requirements.txt file is added + to the cache""" + ace = AddonCatalog.AddonCatalogEntry({"git_ref": "main"}) + file_path = os.path.abspath( + os.path.join("home", "cache", "TestMod", "1-main", "requirements.txt") + ) + self.fake_fs().create_file(file_path, contents="test data") + writer = accc.CacheWriter() + writer.cwd = os.path.abspath(os.path.join("home", "cache")) + cache_entry = writer.generate_cache_entry("TestMod", 1, ace) + self.assertEqual("test data", cache_entry.requirements_txt) + + def test_generate_cache_entry_with_metadata(self): + """Given an addon that includes a metadata.txt file, the metadata.txt file is added to + the cache""" + ace = AddonCatalog.AddonCatalogEntry({"git_ref": "main"}) + file_path = os.path.abspath( + os.path.join("home", "cache", "TestMod", "1-main", "metadata.txt") + ) + self.fake_fs().create_file(file_path, contents="test data") + writer = accc.CacheWriter() + writer.cwd = os.path.abspath(os.path.join("home", "cache")) + cache_entry = writer.generate_cache_entry("TestMod", 1, ace) + self.assertEqual("test data", cache_entry.metadata_txt) + + def test_generate_cache_entry_with_nothing_to_cache(self): + """If there is no package.xml file, requirements.txt file, or metadata.txt file, then there + should be no cache entry created.""" + ace = AddonCatalog.AddonCatalogEntry({"git_ref": "main"}) + self.fake_fs().create_dir(os.path.join("home", "cache", "TestMod", "1-main")) + writer = accc.CacheWriter() + writer.cwd = os.path.abspath(os.path.join("home", "cache")) + cache_entry = writer.generate_cache_entry("TestMod", 1, ace) + self.assertIsNone(cache_entry) + + @patch("AddonCatalogCacheCreator.CacheWriter.create_local_copy_of_single_addon_with_git") + def test_create_local_copy_of_single_addon_using_git(self, mock_create_with_git): + """Given a single addon, each catalog entry is fetched with git if git info is available.""" + catalog_entries = [ + AddonCatalog.AddonCatalogEntry( + {"repository": "https://some.url", "git_ref": "branch-1"} + ), + AddonCatalog.AddonCatalogEntry( + {"repository": "https://some.url", "git_ref": "branch-2"} + ), + AddonCatalog.AddonCatalogEntry( + {"repository": "https://some.url", "git_ref": "branch-3", "zip_url": "zip"} + ), + ] + writer = accc.CacheWriter() + writer.cwd = os.path.abspath(os.path.join("home", "cache")) + writer.create_local_copy_of_single_addon("TestMod", catalog_entries) + self.assertEqual(mock_create_with_git.call_count, 3) + self.assertIn("TestMod", writer._cache) + self.assertEqual(3, len(writer._cache["TestMod"])) + + @patch("AddonCatalogCacheCreator.CacheWriter.create_local_copy_of_single_addon_with_git") + @patch("AddonCatalogCacheCreator.CacheWriter.create_local_copy_of_single_addon_with_zip") + def test_create_local_copy_of_single_addon_using_zip( + self, mock_create_with_zip, mock_create_with_git + ): + """Given a single addon, each catalog entry is fetched with zip if zip info is available + and no git info is available.""" + catalog_entries = [ + AddonCatalog.AddonCatalogEntry({"zip_url": "zip1"}), + AddonCatalog.AddonCatalogEntry({"zip_url": "zip2"}), + AddonCatalog.AddonCatalogEntry( + {"repository": "https://some.url", "git_ref": "branch-3", "zip_url": "zip3"} + ), + ] + writer = accc.CacheWriter() + writer.cwd = os.path.abspath(os.path.join("home", "cache")) + writer.create_local_copy_of_single_addon("TestMod", catalog_entries) + self.assertEqual(mock_create_with_zip.call_count, 2) + self.assertEqual(mock_create_with_git.call_count, 1) + self.assertIn("TestMod", writer._cache) + self.assertEqual(3, len(writer._cache["TestMod"])) + + @patch("AddonCatalogCacheCreator.CacheWriter.create_local_copy_of_single_addon") + @patch("AddonCatalogCacheCreator.CatalogFetcher.fetch_catalog") + def test_create_local_copy_of_addons(self, mock_fetch_catalog, mock_create_single_addon): + """Given a catalog, each addon is fetched and cached.""" + + class MockCatalog: + def get_catalog(self): + return { + "TestMod1": [ + AddonCatalog.AddonCatalogEntry( + {"repository": "https://some.url", "git_ref": "branch-1"} + ), + AddonCatalog.AddonCatalogEntry( + {"repository": "https://some.url", "git_ref": "branch-2"} + ), + ], + "TestMod2": [ + AddonCatalog.AddonCatalogEntry({"zip_url": "zip1"}), + AddonCatalog.AddonCatalogEntry({"zip_url": "zip2"}), + ], + EXCLUDED_REPOS[0]: [ + AddonCatalog.AddonCatalogEntry({"zip_url": "zip1"}), + AddonCatalog.AddonCatalogEntry({"zip_url": "zip2"}), + ], + } + + mock_fetch_catalog.return_value = MockCatalog() + writer = accc.CacheWriter() + writer.create_local_copy_of_addons() + mock_create_single_addon.assert_any_call("TestMod1", mock.ANY) + mock_create_single_addon.assert_any_call("TestMod2", mock.ANY) + self.assertEqual(2, mock_create_single_addon.call_count) # NOT three diff --git a/Resources/AddonManager.qrc b/Resources/AddonManager.qrc deleted file mode 100644 index a95e380..0000000 --- a/Resources/AddonManager.qrc +++ /dev/null @@ -1,71 +0,0 @@ - - - icons/addon_manager.svg - icons/button_left.svg - icons/button_valid.svg - icons/compact_view.svg - icons/composite_view.svg - icons/debug-stop.svg - icons/document-package.svg - icons/document-python.svg - icons/expanded_view.svg - icons/process-stop.svg - icons/sort_ascending.svg - icons/sort_descending.svg - icons/view-refresh.svg - licenses/Apache-2.0.txt - licenses/BSD-2-Clause.txt - licenses/BSD-3-Clause.txt - licenses/CC0-1.0.txt - licenses/GPL-2.0-or-later.txt - licenses/GPL-3.0-or-later.txt - licenses/LGPL-2.1-or-later.txt - licenses/LGPL-3.0-or-later.txt - licenses/MIT.txt - licenses/MPL-2.0.txt - licenses/spdx.json - translations/AddonManager_af.qm - translations/AddonManager_ar.qm - translations/AddonManager_ca.qm - translations/AddonManager_cs.qm - translations/AddonManager_de.qm - translations/AddonManager_el.qm - translations/AddonManager_es-ES.qm - translations/AddonManager_eu.qm - translations/AddonManager_fi.qm - translations/AddonManager_fil.qm - translations/AddonManager_fr.qm - translations/AddonManager_gl.qm - translations/AddonManager_hr.qm - translations/AddonManager_hu.qm - translations/AddonManager_id.qm - translations/AddonManager_it.qm - translations/AddonManager_ja.qm - translations/AddonManager_kab.qm - translations/AddonManager_ko.qm - translations/AddonManager_lt.qm - translations/AddonManager_nl.qm - translations/AddonManager_no.qm - translations/AddonManager_pl.qm - translations/AddonManager_pt-BR.qm - translations/AddonManager_pt-PT.qm - translations/AddonManager_ro.qm - translations/AddonManager_ru.qm - translations/AddonManager_sk.qm - translations/AddonManager_sl.qm - translations/AddonManager_sr.qm - translations/AddonManager_sv-SE.qm - translations/AddonManager_tr.qm - translations/AddonManager_uk.qm - translations/AddonManager_val-ES.qm - translations/AddonManager_vi.qm - translations/AddonManager_zh-CN.qm - translations/AddonManager_zh-TW.qm - translations/AddonManager_es-AR.qm - translations/AddonManager_bg.qm - translations/AddonManager_ka.qm - translations/AddonManager_sr-CS.qm - translations/AddonManager_be.qm - translations/AddonManager_da.qm - - diff --git a/addonmanager_preferences_defaults.json b/addonmanager_preferences_defaults.json index 52d6f13..163260d 100644 --- a/addonmanager_preferences_defaults.json +++ b/addonmanager_preferences_defaults.json @@ -1,6 +1,6 @@ { - "AddonFlagsURL": - "https://raw.githubusercontent.com/FreeCAD/FreeCAD-addons/master/addonflags.json", + "AddonFlagsURL": "https://raw.githubusercontent.com/FreeCAD/FreeCAD-addons/master/addonflags.json", + "AddonCatalogURL": "https://raw.githubusercontent.com/FreeCAD/FreeCAD-addons/master/AddonCatalog.json", "AddonsRemoteCacheURL": "https://addons.freecad.org/metadata.zip", "AddonsStatsURL": "https://freecad.org/addon_stats.json", "AddonsCacheURL": "https://freecad.org/addons/addon_cache.json",