-
Notifications
You must be signed in to change notification settings - Fork 18
Add support for an Index alongside the Catalog #332
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -79,6 +79,8 @@ class AddonCatalogEntry: | |
| branch_display_name: Optional[str] = None | ||
| metadata: Optional[CatalogEntryMetadata] = None # Generated by the cache system | ||
| last_update_time: str = "" # Generated by the cache system | ||
| curated: bool = True # Generated by the cache system | ||
| sparse_cache: bool = False # Generated by the cache system | ||
| relative_cache_path: str = "" # Generated by the cache system | ||
|
|
||
| def __init__(self, raw_data: Dict[str, str]) -> None: | ||
|
|
@@ -133,7 +135,16 @@ def instantiate_addon(self, addon_id: str) -> Addon: | |
| state = Addon.Status.UNCHECKED | ||
| else: | ||
| state = Addon.Status.NOT_INSTALLED | ||
| url = self.repository if self.repository else self.zip_url | ||
| if self.sparse_cache: | ||
| if self.zip_url: | ||
| url = self.zip_url | ||
| else: | ||
| # Technically, this should never happen, but just in case... | ||
| raise RuntimeError(f"Sparse cache entry {addon_id} has no zip_url") | ||
| elif self.repository: | ||
| url = self.repository | ||
| else: | ||
| url = self.zip_url | ||
|
Comment on lines
138
to
147
|
||
| if self.git_ref: | ||
| addon = Addon(addon_id, url, state, branch=self.git_ref) | ||
| else: | ||
|
|
@@ -179,6 +190,8 @@ def instantiate_addon(self, addon_id: str) -> Addon: | |
| self.branch_display_name if self.branch_display_name else self.git_ref | ||
| ) | ||
|
|
||
| addon.curated = self.curated | ||
|
|
||
| return addon | ||
|
|
||
| @staticmethod | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -24,6 +24,8 @@ | |||||||||||||||||||||||||||||||||||
| Addon Manager in each FreeCAD installation.""" | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| import datetime | ||||||||||||||||||||||||||||||||||||
| import shutil | ||||||||||||||||||||||||||||||||||||
| import sys | ||||||||||||||||||||||||||||||||||||
| from dataclasses import is_dataclass, fields | ||||||||||||||||||||||||||||||||||||
| from typing import Any, List, Optional, Dict | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
|
|
@@ -36,6 +38,7 @@ | |||||||||||||||||||||||||||||||||||
| import re | ||||||||||||||||||||||||||||||||||||
| import requests | ||||||||||||||||||||||||||||||||||||
| import subprocess | ||||||||||||||||||||||||||||||||||||
| from typing import List | ||||||||||||||||||||||||||||||||||||
| import xml.etree.ElementTree | ||||||||||||||||||||||||||||||||||||
| import zipfile | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
|
|
@@ -44,14 +47,15 @@ | |||||||||||||||||||||||||||||||||||
| import addonmanager_utilities as utils | ||||||||||||||||||||||||||||||||||||
| import addonmanager_icon_utilities as icon_utils | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| ADDON_CATALOG_URL = ( | ||||||||||||||||||||||||||||||||||||
| "https://raw.githubusercontent.com/FreeCAD/FreeCAD-addons/master/AddonCatalog.json" | ||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||
| ADDON_CATALOG_URL = "https://raw.githubusercontent.com/FreeCAD/Addons/main/Data/Index.json" | ||||||||||||||||||||||||||||||||||||
| BASE_DIRECTORY = "./CatalogCache" | ||||||||||||||||||||||||||||||||||||
| MAX_COUNT = 10000 # Do at most this many repos (for testing purposes this can be made smaller) | ||||||||||||||||||||||||||||||||||||
| CLONE_TIMEOUT = ( | ||||||||||||||||||||||||||||||||||||
| 300 # Seconds: repos that take longer than this are assumed to be too large to index | ||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| # Repos that are too large, or that should for some reason not be cloned here | ||||||||||||||||||||||||||||||||||||
| EXCLUDED_REPOS = ["parts_library", "offline-documentation", "FreeCAD-Documentation-html"] | ||||||||||||||||||||||||||||||||||||
| # Repos that are too large, or that should for some reason not be fully cloned here | ||||||||||||||||||||||||||||||||||||
| FORCE_SPARSE_CLONE = ["parts_library", "offline-documentation", "FreeCAD-Documentation-html"] | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| def recursive_serialize(obj: Any): | ||||||||||||||||||||||||||||||||||||
|
|
@@ -82,7 +86,7 @@ class GitRefType(enum.IntEnum): | |||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| class CatalogFetcher: | ||||||||||||||||||||||||||||||||||||
| """Fetches the addon catalog from the given URL and returns an AddonCatalog object. Separated | ||||||||||||||||||||||||||||||||||||
| """Fetches the addon index 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.""" | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
|
|
@@ -94,9 +98,7 @@ 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, timeout=10.0) | ||||||||||||||||||||||||||||||||||||
| if response.status_code != 200: | ||||||||||||||||||||||||||||||||||||
| raise RuntimeError( | ||||||||||||||||||||||||||||||||||||
| f"ERROR: Failed to fetch addon catalog from {self.addon_catalog_url}" | ||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||
| raise RuntimeError(f"ERROR: Failed to fetch addon index from {self.addon_catalog_url}") | ||||||||||||||||||||||||||||||||||||
| return AddonCatalog.AddonCatalog(response.json()) | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
|
|
@@ -106,8 +108,9 @@ class CacheWriter: | |||||||||||||||||||||||||||||||||||
| as a base64-encoded icon image. The cache is written to the current working directory.""" | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| def __init__(self): | ||||||||||||||||||||||||||||||||||||
| self.catalog: AddonCatalog = None | ||||||||||||||||||||||||||||||||||||
| self.catalog: Optional[AddonCatalog.AddonCatalog] = None | ||||||||||||||||||||||||||||||||||||
| self.icon_errors = {} | ||||||||||||||||||||||||||||||||||||
| self.clone_errors = {} | ||||||||||||||||||||||||||||||||||||
| if os.path.isabs(BASE_DIRECTORY): | ||||||||||||||||||||||||||||||||||||
| self.cwd = BASE_DIRECTORY | ||||||||||||||||||||||||||||||||||||
| else: | ||||||||||||||||||||||||||||||||||||
|
|
@@ -116,39 +119,80 @@ def __init__(self): | |||||||||||||||||||||||||||||||||||
| self._sanitize_counter = 0 | ||||||||||||||||||||||||||||||||||||
| self._directory_name_cache: Dict[str, str] = {} | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| def write(self): | ||||||||||||||||||||||||||||||||||||
| def write(self, addon_id: Optional[str] = None) -> None: | ||||||||||||||||||||||||||||||||||||
| original_working_directory = os.getcwd() | ||||||||||||||||||||||||||||||||||||
| os.makedirs(self.cwd, exist_ok=True) | ||||||||||||||||||||||||||||||||||||
| os.chdir(self.cwd) | ||||||||||||||||||||||||||||||||||||
| self.create_local_copy_of_addons() | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| with zipfile.ZipFile( | ||||||||||||||||||||||||||||||||||||
| os.path.join(self.cwd, "addon_catalog_cache.zip"), "w", zipfile.ZIP_DEFLATED | ||||||||||||||||||||||||||||||||||||
| ) as zipf: | ||||||||||||||||||||||||||||||||||||
| zipf.writestr( | ||||||||||||||||||||||||||||||||||||
| "addon_catalog_cache.json", | ||||||||||||||||||||||||||||||||||||
| json.dumps(recursive_serialize(self.catalog.get_catalog()), indent=" "), | ||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| # Also generate the sha256 hash of the zip file and store it | ||||||||||||||||||||||||||||||||||||
| with open("addon_catalog_cache.zip", "rb") as cache_file: | ||||||||||||||||||||||||||||||||||||
| cache_file_content = cache_file.read() | ||||||||||||||||||||||||||||||||||||
| sha256 = hashlib.sha256(cache_file_content).hexdigest() | ||||||||||||||||||||||||||||||||||||
| with open("addon_catalog_cache.zip.sha256", "w", encoding="utf-8") as hash_file: | ||||||||||||||||||||||||||||||||||||
| hash_file.write(sha256) | ||||||||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||||||||
| fetcher = CatalogFetcher() | ||||||||||||||||||||||||||||||||||||
| self.catalog = fetcher.catalog | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
|
Comment on lines
122
to
130
|
||||||||||||||||||||||||||||||||||||
| if addon_id is None: | ||||||||||||||||||||||||||||||||||||
| self.create_local_copy_of_addons() | ||||||||||||||||||||||||||||||||||||
| else: | ||||||||||||||||||||||||||||||||||||
| catalog = self.catalog.get_catalog() | ||||||||||||||||||||||||||||||||||||
| if addon_id not in catalog: | ||||||||||||||||||||||||||||||||||||
| raise RuntimeError(f"ERROR: Addon {addon_id} not in index") | ||||||||||||||||||||||||||||||||||||
| catalog_entries = catalog[addon_id] | ||||||||||||||||||||||||||||||||||||
| self.create_local_copy_of_single_addon(addon_id, catalog_entries) | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| # Write the entire index for versions of the Addon Manager after 2026-01-24 | ||||||||||||||||||||||||||||||||||||
| with zipfile.ZipFile( | ||||||||||||||||||||||||||||||||||||
| os.path.join(self.cwd, "addon_index_cache.zip"), "w", zipfile.ZIP_DEFLATED | ||||||||||||||||||||||||||||||||||||
| ) as zipf: | ||||||||||||||||||||||||||||||||||||
| zipf.writestr( | ||||||||||||||||||||||||||||||||||||
| "addon_index_cache.json", | ||||||||||||||||||||||||||||||||||||
| json.dumps(recursive_serialize(self.catalog.get_catalog()), indent=" "), | ||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| # Also generate the sha256 hash of the zip file and store it | ||||||||||||||||||||||||||||||||||||
| with open("addon_index_cache.zip", "rb") as cache_file: | ||||||||||||||||||||||||||||||||||||
| cache_file_content = cache_file.read() | ||||||||||||||||||||||||||||||||||||
| sha256 = hashlib.sha256(cache_file_content).hexdigest() | ||||||||||||||||||||||||||||||||||||
| with open("addon_index_cache.zip.sha256", "w", encoding="utf-8") as hash_file: | ||||||||||||||||||||||||||||||||||||
| hash_file.write(sha256) | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| # For pre-2026-01-24 write only curated addons into a separate catalog file so older | ||||||||||||||||||||||||||||||||||||
| # versions of the Addon Manager don't accidentally install uncurated addons. | ||||||||||||||||||||||||||||||||||||
| with zipfile.ZipFile( | ||||||||||||||||||||||||||||||||||||
| os.path.join(self.cwd, "addon_catalog_cache.zip"), "w", zipfile.ZIP_DEFLATED | ||||||||||||||||||||||||||||||||||||
| ) as zipf: | ||||||||||||||||||||||||||||||||||||
| catalog = self.catalog.get_catalog() | ||||||||||||||||||||||||||||||||||||
| reduced_catalog = {} | ||||||||||||||||||||||||||||||||||||
| for addon_id, catalog_entries in catalog.items(): | ||||||||||||||||||||||||||||||||||||
| approved_entries: List[AddonCatalog.AddonCatalogEntry] = [] | ||||||||||||||||||||||||||||||||||||
| for entry in catalog_entries: | ||||||||||||||||||||||||||||||||||||
| if entry.curated: | ||||||||||||||||||||||||||||||||||||
| approved_entries.append(entry) | ||||||||||||||||||||||||||||||||||||
| if approved_entries: | ||||||||||||||||||||||||||||||||||||
| reduced_catalog[addon_id] = approved_entries | ||||||||||||||||||||||||||||||||||||
| zipf.writestr( | ||||||||||||||||||||||||||||||||||||
| "addon_catalog_cache.json", | ||||||||||||||||||||||||||||||||||||
| json.dumps(recursive_serialize(reduced_catalog), indent=" "), | ||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| # Also generate the sha256 hash of the zip file and store it | ||||||||||||||||||||||||||||||||||||
| with open("addon_catalog_cache.zip", "rb") as cache_file: | ||||||||||||||||||||||||||||||||||||
| cache_file_content = cache_file.read() | ||||||||||||||||||||||||||||||||||||
| sha256 = hashlib.sha256(cache_file_content).hexdigest() | ||||||||||||||||||||||||||||||||||||
| with open("addon_catalog_cache.zip.sha256", "w", encoding="utf-8") as hash_file: | ||||||||||||||||||||||||||||||||||||
| hash_file.write(sha256) | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| with open(os.path.join(self.cwd, "icon_errors.json"), "w") as f: | ||||||||||||||||||||||||||||||||||||
| json.dump(self.icon_errors, f, indent=" ") | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| with open(os.path.join(self.cwd, "icon_errors.json"), "w") as f: | ||||||||||||||||||||||||||||||||||||
| json.dump(self.icon_errors, f, indent=" ") | ||||||||||||||||||||||||||||||||||||
| with open(os.path.join(self.cwd, "clone_errors.json"), "w") as f: | ||||||||||||||||||||||||||||||||||||
| json.dump(self.clone_errors, f, indent=" ") | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| os.chdir(original_working_directory) | ||||||||||||||||||||||||||||||||||||
| print(f"Wrote cache to {os.path.join(self.cwd, 'addon_catalog_cache.zip')}") | ||||||||||||||||||||||||||||||||||||
| print(f"Wrote index to {os.path.join(self.cwd, 'addon_index_cache.zip')}") | ||||||||||||||||||||||||||||||||||||
| print(f"Wrote cache to {os.path.join(self.cwd, 'addon_catalog_cache.zip')}") | ||||||||||||||||||||||||||||||||||||
| finally: | ||||||||||||||||||||||||||||||||||||
| os.chdir(original_working_directory) | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| 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: | ||||||||||||||||||||||||||||||||||||
|
|
@@ -158,7 +202,22 @@ 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: | ||||||||||||||||||||||||||||||||||||
| if addon_id in FORCE_SPARSE_CLONE: | ||||||||||||||||||||||||||||||||||||
| if catalog_entry.repository is None: | ||||||||||||||||||||||||||||||||||||
| print( | ||||||||||||||||||||||||||||||||||||
| f"ERROR: Cannot use sparse clone for {addon_id} because it has no git repo." | ||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||
| continue | ||||||||||||||||||||||||||||||||||||
| if catalog_entry.zip_url is None: | ||||||||||||||||||||||||||||||||||||
| print( | ||||||||||||||||||||||||||||||||||||
| f"ERROR: Cannot use sparse clone for {addon_id} because it has no zip URL." | ||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||
| continue | ||||||||||||||||||||||||||||||||||||
| catalog_entry.sparse_cache = True | ||||||||||||||||||||||||||||||||||||
| self.create_local_copy_of_single_addon_with_git_sparse( | ||||||||||||||||||||||||||||||||||||
| addon_id, index, catalog_entry | ||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||
|
Comment on lines
+216
to
+219
|
||||||||||||||||||||||||||||||||||||
| catalog_entry.sparse_cache = True | |
| self.create_local_copy_of_single_addon_with_git_sparse( | |
| addon_id, index, catalog_entry | |
| ) | |
| if catalog_entry.zip_url is not None: | |
| catalog_entry.sparse_cache = True | |
| self.create_local_copy_of_single_addon_with_git_sparse( | |
| addon_id, index, catalog_entry | |
| ) | |
| else: | |
| print( | |
| f"WARNING: Cannot use sparse clone for {addon_id} because it has no zip_url; " | |
| "falling back to normal git clone." | |
| ) | |
| self.create_local_copy_of_single_addon_with_git( | |
| addon_id, index, catalog_entry | |
| ) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
instantiate_addon()raisesRuntimeErrorwhensparse_cacheis true butzip_urlis missing. SinceCacheWritercan setsparse_cache=Truewithout ensuringzip_urlexists, this can cause addons/branches to be skipped at runtime. Consider falling back torepositorywhenzip_urlis absent (or validate/enforcezip_urlat cache-generation time).