Skip to content

Commit c6d69c8

Browse files
committed
Create AddonCatalogCacheCreator classes and tests
1 parent 266208b commit c6d69c8

File tree

6 files changed

+561
-5
lines changed

6 files changed

+561
-5
lines changed

.github/workflows/qt5-tests.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ jobs:
3939
- name: Install Python dependencies
4040
run: |
4141
python -m pip install --upgrade pip
42-
pip install vermin || true
42+
pip install vermin pyfakefs || true
4343
4444
- name: Run App tests
4545
run: |

.github/workflows/qt6-tests.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ jobs:
5959
- name: Install Python dependencies
6060
run: |
6161
python -m pip install --upgrade pip
62-
pip install PySide6 vermin || true
62+
pip install pyfakefs PySide6 vermin || true
6363
6464
- name: Run App tests
6565
run: |

AddonCatalog.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ class AddonCatalog:
8383

8484
def __init__(self, data: Dict[str, Any]):
8585
self._original_data = data
86-
self._dictionary = {}
86+
self._dictionary: Dict[str, List[AddonCatalogEntry]] = {}
8787
self._parse_raw_data()
8888

8989
def _parse_raw_data(self):
@@ -117,6 +117,16 @@ def get_available_addon_ids(self) -> List[str]:
117117
break
118118
return id_list
119119

120+
def get_all_addon_ids(self) -> List[str]:
121+
"""Get a list of all Addon IDs, even those that have no compatible versions for the current
122+
version of FreeCAD."""
123+
id_list = []
124+
for key, value in self._dictionary.items():
125+
if len(value) == 0:
126+
continue
127+
id_list.append(key)
128+
return id_list
129+
120130
def get_available_branches(self, addon_id: str) -> List[Tuple[str, str]]:
121131
"""For a given ID, get the list of available branches compatible with this version of
122132
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]]:
129139
result.append((entry.git_ref, entry.branch_display_name))
130140
return result
131141

142+
def get_catalog(self) -> Dict[str, List[AddonCatalogEntry]]:
143+
"""Get access to the entire catalog, without any filtering applied."""
144+
return self._dictionary
145+
132146
def get_addon_from_id(self, addon_id: str, branch: Optional[Tuple[str, str]] = None) -> Addon:
133147
"""Get the instantiated Addon object for the given ID and optionally branch. If no
134148
branch is provided, whichever branch is the "primary" branch will be returned (i.e. the

AddonCatalogCacheCreator.py

Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
# SPDX-License-Identifier: LGPL-2.1-or-later
2+
# ***************************************************************************
3+
# * *
4+
# * Copyright (c) 2025 The FreeCAD project association AISBL *
5+
# * *
6+
# * This file is part of FreeCAD. *
7+
# * *
8+
# * FreeCAD is free software: you can redistribute it and/or modify it *
9+
# * under the terms of the GNU Lesser General Public License as *
10+
# * published by the Free Software Foundation, either version 2.1 of the *
11+
# * License, or (at your option) any later version. *
12+
# * *
13+
# * FreeCAD is distributed in the hope that it will be useful, but *
14+
# * WITHOUT ANY WARRANTY; without even the implied warranty of *
15+
# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
16+
# * Lesser General Public License for more details. *
17+
# * *
18+
# * You should have received a copy of the GNU Lesser General Public *
19+
# * License along with FreeCAD. If not, see *
20+
# * <https://www.gnu.org/licenses/>. *
21+
# * *
22+
# ***************************************************************************
23+
24+
"""Classes and utility functions to generate a remotely hosted cache of all addon catalog entries.
25+
Intended to be run by a server-side systemd timer to generate a file that is then loaded by the
26+
Addon Manager in each FreeCAD installation."""
27+
import xml.etree.ElementTree
28+
from dataclasses import dataclass, asdict
29+
from typing import List, Optional
30+
31+
import base64
32+
import io
33+
import json
34+
import os
35+
import requests
36+
import shutil
37+
import subprocess
38+
import zipfile
39+
40+
import AddonCatalog
41+
import addonmanager_metadata
42+
43+
44+
ADDON_CATALOG_URL = (
45+
"https://raw.githubusercontent.com/FreeCAD/FreeCAD-addons/master/AddonCatalog.json"
46+
)
47+
BASE_DIRECTORY = "./"
48+
MAX_COUNT = 10 # Do at most this many repos (for testing purposes)
49+
50+
# Repos that are too large, or that should for some reason not be cloned here
51+
EXCLUDED_REPOS = ["parts_library"]
52+
53+
54+
@dataclass
55+
class CacheEntry:
56+
"""All contents of a CacheEntry are the text contents of the file listed. The icon data is
57+
base64-encoded (although it was probably an SVG, other formats are supported)."""
58+
59+
package_xml: str = ""
60+
requirements_txt: str = ""
61+
metadata_txt: str = ""
62+
icon_data: str = ""
63+
64+
65+
class AddonCatalogCacheCreator:
66+
67+
def __init__(self, addon_catalog_url=ADDON_CATALOG_URL):
68+
self.addon_catalog_url = addon_catalog_url
69+
self.catalog = self.fetch_catalog()
70+
71+
def fetch_catalog(self) -> AddonCatalog.AddonCatalog:
72+
response = requests.get(self.addon_catalog_url)
73+
if response.status_code != 200:
74+
raise RuntimeError(
75+
f"ERROR: Failed to fetch addon catalog from {self.addon_catalog_url}"
76+
)
77+
return AddonCatalog.AddonCatalog(response.json())
78+
79+
80+
class CacheWriter:
81+
82+
def __init__(self):
83+
self.catalog: AddonCatalog = None
84+
if os.path.isabs(BASE_DIRECTORY):
85+
self.cwd = BASE_DIRECTORY
86+
else:
87+
self.cwd = os.path.normpath(os.path.join(os.path.curdir, BASE_DIRECTORY))
88+
self._cache = {}
89+
90+
def write(self):
91+
self.create_local_copy_of_addons()
92+
with open("addon_catalog_cache.json", "w", encoding="utf-8") as f:
93+
f.write(json.dumps(self._cache, indent=" "))
94+
95+
def create_local_copy_of_addons(self):
96+
self.catalog = AddonCatalogCacheCreator().catalog
97+
counter = 0
98+
for addon_id, catalog_entries in self.catalog.get_catalog().items():
99+
if addon_id in EXCLUDED_REPOS:
100+
continue
101+
self.create_local_copy_of_single_addon(addon_id, catalog_entries)
102+
counter += 1
103+
if counter >= MAX_COUNT:
104+
break
105+
106+
def create_local_copy_of_single_addon(
107+
self, addon_id: str, catalog_entries: List[AddonCatalog.AddonCatalogEntry]
108+
):
109+
for index, catalog_entry in enumerate(catalog_entries):
110+
if catalog_entry.repository is not None:
111+
self.create_local_copy_of_single_addon_with_git(addon_id, index, catalog_entry)
112+
elif catalog_entry.zip_url is not None:
113+
self.create_local_copy_of_single_addon_with_zip(addon_id, index, catalog_entry)
114+
else:
115+
print(
116+
f"ERROR: Invalid catalog entry for {addon_id}. "
117+
"Neither git info nor zip info was specified."
118+
)
119+
continue
120+
entry = self.generate_cache_entry(addon_id, index, catalog_entry)
121+
if addon_id not in self._cache:
122+
self._cache[addon_id] = []
123+
if entry is not None:
124+
self._cache[addon_id].append(asdict(entry))
125+
else:
126+
self._cache[addon_id].append({})
127+
128+
def generate_cache_entry(
129+
self, addon_id: str, index: int, catalog_entry: AddonCatalog.AddonCatalogEntry
130+
) -> Optional[CacheEntry]:
131+
"""Create the cache entry for this catalog entry, if there is data to cache. If there is
132+
nothing to cache, returns None."""
133+
path_to_package_xml = self.find_file("package.xml", addon_id, index, catalog_entry)
134+
cache_entry = None
135+
if path_to_package_xml and os.path.exists(path_to_package_xml):
136+
cache_entry = self.generate_cache_entry_from_package_xml(path_to_package_xml)
137+
138+
path_to_requirements = self.find_file("requirements.txt", addon_id, index, catalog_entry)
139+
if path_to_requirements and os.path.exists(path_to_requirements):
140+
if cache_entry is None:
141+
cache_entry = CacheEntry()
142+
with open(path_to_requirements, "r", encoding="utf-8") as f:
143+
cache_entry.requirements_txt = f.read()
144+
145+
path_to_metadata = self.find_file("metadata.txt", addon_id, index, catalog_entry)
146+
if path_to_metadata and os.path.exists(path_to_metadata):
147+
if cache_entry is None:
148+
cache_entry = CacheEntry()
149+
with open(path_to_metadata, "r", encoding="utf-8") as f:
150+
cache_entry.metadata_txt = f.read()
151+
152+
return cache_entry
153+
154+
def generate_cache_entry_from_package_xml(
155+
self, path_to_package_xml: str
156+
) -> Optional[CacheEntry]:
157+
cache_entry = CacheEntry()
158+
with open(path_to_package_xml, "r", encoding="utf-8") as f:
159+
cache_entry.package_xml = f.read()
160+
try:
161+
metadata = addonmanager_metadata.MetadataReader.from_bytes(
162+
cache_entry.package_xml.encode("utf-8")
163+
)
164+
except xml.etree.ElementTree.ParseError:
165+
print(f"ERROR: Failed to parse XML from {path_to_package_xml}")
166+
return None
167+
except RuntimeError:
168+
print(f"ERROR: Failed to read metadata from {path_to_package_xml}")
169+
return None
170+
171+
relative_icon_path = self.get_icon_from_metadata(metadata)
172+
absolute_icon_path = os.path.join(os.path.dirname(path_to_package_xml), relative_icon_path)
173+
if os.path.exists(absolute_icon_path):
174+
with open(absolute_icon_path, "rb") as f:
175+
cache_entry.icon_data = base64.b64encode(f.read()).decode("utf-8")
176+
return cache_entry
177+
178+
def create_local_copy_of_single_addon_with_git(
179+
self, addon_id: str, index: int, catalog_entry: AddonCatalog.AddonCatalogEntry
180+
):
181+
expected_name = self.get_directory_name(addon_id, index, catalog_entry)
182+
self.clone_or_update(expected_name, catalog_entry.repository, catalog_entry.git_ref)
183+
184+
@staticmethod
185+
def get_directory_name(addon_id, index, catalog_entry):
186+
expected_name = os.path.join(addon_id, str(index) + "-")
187+
if catalog_entry.branch_display_name:
188+
expected_name += catalog_entry.branch_display_name.replace("/", "-")
189+
elif catalog_entry.git_ref:
190+
expected_name += catalog_entry.git_ref.replace("/", "-")
191+
else:
192+
expected_name += "unknown-branch-name"
193+
return expected_name
194+
195+
def create_local_copy_of_single_addon_with_zip(
196+
self, addon_id: str, index: int, catalog_entry: AddonCatalog.AddonCatalogEntry
197+
):
198+
response = requests.get(catalog_entry.zip_url)
199+
if response.status_code != 200:
200+
print(f"ERROR: Failed to fetch zip data for {addon_id} from {catalog_entry.zip_url}.")
201+
return
202+
extract_to_dir = self.get_directory_name(addon_id, index, catalog_entry)
203+
if os.path.exists(extract_to_dir):
204+
shutil.rmtree(extract_to_dir)
205+
os.makedirs(extract_to_dir, exist_ok=True)
206+
207+
with zipfile.ZipFile(io.BytesIO(response.content)) as zip_file:
208+
zip_file.extractall(path=extract_to_dir)
209+
210+
@staticmethod
211+
def clone_or_update(name: str, url: str, branch: str) -> None:
212+
"""If a directory called "name" exists, and it contains a subdirectory called .git,
213+
then 'git fetch' is called; otherwise we use 'git clone' to make a bare, shallow
214+
copy of the repo (in the normal case where minimal is True), or a normal clone,
215+
if minimal is set to False."""
216+
217+
if not os.path.exists(os.path.join(os.getcwd(), name, ".git")):
218+
print(f"Cloning {url} to {name}", flush=True)
219+
# Shallow, but do include the last commit on each branch and tag
220+
command = [
221+
"git",
222+
"clone",
223+
"--depth",
224+
"1",
225+
"--branch",
226+
branch,
227+
url,
228+
name,
229+
]
230+
completed_process = subprocess.run(command)
231+
if completed_process.returncode != 0:
232+
raise RuntimeError(f"Clone failed for {url}")
233+
else:
234+
print(f"Updating {name}", flush=True)
235+
old_dir = os.getcwd()
236+
os.chdir(os.path.join(old_dir, name))
237+
command = ["git", "fetch"]
238+
completed_process = subprocess.run(command)
239+
if completed_process.returncode != 0:
240+
os.chdir(old_dir)
241+
raise RuntimeError(f"git fetch failed for {name}")
242+
command = ["git", "checkout", branch, "--quiet"]
243+
completed_process = subprocess.run(command)
244+
if completed_process.returncode != 0:
245+
os.chdir(old_dir)
246+
raise RuntimeError(f"git checkout failed for {name} branch {branch}")
247+
command = ["git", "merge", "--quiet"]
248+
completed_process = subprocess.run(command)
249+
if completed_process.returncode != 0:
250+
os.chdir(old_dir)
251+
raise RuntimeError(f"git merge failed for {name} branch {branch}")
252+
os.chdir(old_dir)
253+
254+
def find_file(
255+
self,
256+
filename: str,
257+
addon_id: str,
258+
index: int,
259+
catalog_entry: AddonCatalog.AddonCatalogEntry,
260+
) -> Optional[str]:
261+
"""Find a given file in the downloaded cache for this addon. Returns None if the file does
262+
not exist."""
263+
start_dir = os.path.join(self.cwd, self.get_directory_name(addon_id, index, catalog_entry))
264+
for dirpath, _, filenames in os.walk(start_dir):
265+
if filename in filenames:
266+
return os.path.join(dirpath, filename)
267+
return None
268+
269+
@staticmethod
270+
def get_icon_from_metadata(metadata: addonmanager_metadata.Metadata) -> Optional[str]:
271+
"""Try to locate the icon file specified for this Addon. Recursively search through the
272+
levels of the metadata and return the first specified icon file path. Returns None of there
273+
is no icon specified for this Addon (which is not allowed by the standard, but we don't want
274+
to crash the cache writer)."""
275+
if metadata.icon:
276+
return metadata.icon
277+
for content_type in metadata.content:
278+
for content_item in metadata.content[content_type]:
279+
icon = CacheWriter.get_icon_from_metadata(content_item)
280+
if icon:
281+
return icon
282+
return None
283+
284+
285+
if __name__ == "__main__":
286+
writer = CacheWriter()
287+
writer.write()

0 commit comments

Comments
 (0)