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",