Skip to content

Commit fa3ee93

Browse files
committed
Addon Manager: Beginnings of a Catalog class
1 parent d57e008 commit fa3ee93

File tree

2 files changed

+218
-0
lines changed

2 files changed

+218
-0
lines changed
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
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+
"""The Addon Catalog is the main list of all Addons along with their various
25+
sources and compatible versions. Added in FreeCAD 1.1 to replace .gitmodules."""
26+
27+
from dataclasses import dataclass
28+
from hashlib import sha256
29+
from typing import Any, Dict, List, Optional, Tuple
30+
from addonmanager_metadata import Version
31+
from Addon import Addon
32+
33+
import addonmanager_freecad_interface as fci
34+
35+
36+
@dataclass
37+
class AddonCatalogEntry:
38+
"""Each individual entry in the catalog, storing data about a particular version of an
39+
Addon."""
40+
41+
freecad_min: Optional[Version] = None
42+
freecad_max: Optional[Version] = None
43+
repository: Optional[str] = None
44+
git_ref: Optional[str] = None
45+
zip_url: Optional[str] = None
46+
note: Optional[str] = None
47+
branch_display_name: Optional[str] = None
48+
49+
def __init__(self, raw_data: Dict[str, str]) -> None:
50+
"""Create an AddonDictionaryEntry from the raw JSON data"""
51+
super().__init__()
52+
for key, value in raw_data.items():
53+
if hasattr(self, key):
54+
setattr(self, key, value)
55+
56+
def is_compatible(self) -> bool:
57+
"""Check whether this AddonCatalogEntry is compatible with the current version of FreeCAD"""
58+
if self.freecad_min is None and self.freecad_max is None:
59+
return True
60+
current_version = Version(from_list=fci.Version())
61+
if self.freecad_min is None:
62+
return current_version <= self.freecad_max
63+
if self.freecad_max is None:
64+
return current_version >= self.freecad_min
65+
return self.freecad_min <= current_version <= self.freecad_max
66+
67+
def unique_identifier(self) -> str:
68+
"""Return a unique identifier of the AddonCatalogEntry, guaranteed to be repeatable: when
69+
given the same basic information, the same ID is created. Used as the key when storing
70+
the metadata for a given AddonCatalogEntry."""
71+
sha256_hash = sha256()
72+
sha256_hash.update(str(self))
73+
return sha256_hash.hexdigest()
74+
75+
76+
class AddonCatalog:
77+
"""A catalog of addons grouped together into sets representing versions that are
78+
compatible with different versions of FreeCAD and/or represent different available branches
79+
of a given addon (e.g. a Development branch that users are presented)."""
80+
81+
def __init__(self, data: Dict[str, Any]):
82+
self._original_data = data
83+
self._dictionary = {}
84+
self._parse_raw_data()
85+
86+
def _parse_raw_data(self):
87+
self._dictionary = {} # Clear pre-existing contents
88+
for key, value in self._original_data.items():
89+
self._dictionary[key] = []
90+
for entry in value:
91+
self._dictionary[key].append(AddonCatalogEntry(entry))
92+
93+
def _load_metadata_cache(self, cache: Dict[str, Any]):
94+
"""Given the raw dictionary, couple that with the remote metadata cache to create the
95+
final working addon dictionary. Only create Addons that are compatible with the current
96+
version of FreeCAD."""
97+
for value in self._dictionary:
98+
for entry in value:
99+
sha256_hash = entry.unique_identifier()
100+
if sha256_hash in cache and entry.is_compatible():
101+
entry.addon = Addon.from_cache(cache[sha256_hash])
102+
103+
def get_available_addon_ids(self) -> List[str]:
104+
"""Get a list of IDs that have at least one entry compatible with the current version of
105+
FreeCAD"""
106+
id_list = []
107+
for key, value in self._dictionary.items():
108+
for entry in value:
109+
if entry.is_compatible():
110+
id_list.append(key)
111+
break
112+
return id_list
113+
114+
def get_available_branches(self, addon_id: str) -> List[Tuple[str, str]]:
115+
"""For a given ID, get the list of available branches compatible with this version of
116+
FreeCAD along with the branch display name. Either field may be empty, but not both. The
117+
first entry in the list is expected to be the "primary"."""
118+
if addon_id not in self._dictionary:
119+
return []
120+
result = []
121+
for entry in self._dictionary[addon_id]:
122+
if entry.is_compatible():
123+
result.append((entry.git_ref, entry.branch_display_name))
124+
return result
125+
126+
def get_addon_from_id(self, addon_id: str, branch: Optional[Tuple[str, str]] = None) -> Addon:
127+
"""Get the instantiated Addon object for the given ID and optionally branch. If no
128+
branch is provided, whichever branch is the "primary" branch will be returned (i.e. the
129+
first branch that matches). Raises a ValueError if no addon matches the request."""
130+
if addon_id not in self._dictionary:
131+
raise ValueError(f"Addon '{addon_id}' not found")
132+
for entry in self._dictionary[addon_id]:
133+
if not entry.is_compatible():
134+
continue
135+
if not branch or entry.branch_display_name == branch:
136+
return entry.addon
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
# SPDX-License-Identifier: LGPL-2.1-or-later
2+
3+
import unittest
4+
from unittest import mock
5+
from unittest.mock import patch
6+
7+
8+
global AddonCatalogEntry
9+
global AddonCatalog
10+
global Version
11+
12+
13+
class TestAddonCatalogEntry(unittest.TestCase):
14+
15+
def setUp(self):
16+
"""Start mock for Addon class."""
17+
global AddonCatalogEntry
18+
global AddonCatalog
19+
global Version
20+
self.addon_patch = mock.patch.dict("sys.modules", {"addonmanager_licenses": mock.Mock()})
21+
self.mock_addon_module = self.addon_patch.start()
22+
from AddonCatalog import AddonCatalogEntry, AddonCatalog
23+
from addonmanager_metadata import Version
24+
25+
def tearDown(self):
26+
"""Stop mock and remove posthog from modules cache."""
27+
self.addon_patch.stop()
28+
29+
def test_version_match_without_restrictions(self):
30+
with patch("addonmanager_freecad_interface.Version") as mock_freecad:
31+
mock_freecad.Version = lambda: (1, 2, 3, "dev")
32+
ac = AddonCatalogEntry({})
33+
self.assertTrue(ac.is_compatible())
34+
35+
def test_version_match_with_min_no_max_good_match(self):
36+
with patch("addonmanager_freecad_interface.Version", return_value=(1, 2, 3, "dev")):
37+
ac = AddonCatalogEntry({"freecad_min": Version(from_string="1.2")})
38+
self.assertTrue(ac.is_compatible())
39+
40+
def test_version_match_with_max_no_min_good_match(self):
41+
with patch("addonmanager_freecad_interface.Version", return_value=(1, 2, 3, "dev")):
42+
ac = AddonCatalogEntry({"freecad_max": Version(from_string="1.3")})
43+
self.assertTrue(ac.is_compatible())
44+
45+
def test_version_match_with_min_and_max_good_match(self):
46+
with patch("addonmanager_freecad_interface.Version", return_value=(1, 2, 3, "dev")):
47+
ac = AddonCatalogEntry(
48+
{
49+
"freecad_min": Version(from_string="1.1"),
50+
"freecad_max": Version(from_string="1.3"),
51+
}
52+
)
53+
self.assertTrue(ac.is_compatible())
54+
55+
def test_version_match_with_min_and_max_bad_match_high(self):
56+
with patch("addonmanager_freecad_interface.Version", return_value=(1, 3, 3, "dev")):
57+
ac = AddonCatalogEntry(
58+
{
59+
"freecad_min": Version(from_string="1.1"),
60+
"freecad_max": Version(from_string="1.3"),
61+
}
62+
)
63+
self.assertFalse(ac.is_compatible())
64+
65+
def test_version_match_with_min_and_max_bad_match_low(self):
66+
with patch("addonmanager_freecad_interface.Version", return_value=(1, 0, 3, "dev")):
67+
ac = AddonCatalogEntry(
68+
{
69+
"freecad_min": Version(from_string="1.1"),
70+
"freecad_max": Version(from_string="1.3"),
71+
}
72+
)
73+
self.assertFalse(ac.is_compatible())
74+
75+
76+
class TestAddonCatalog(unittest.TestCase):
77+
78+
def setUp(self):
79+
pass
80+
81+
def tearDown(self):
82+
pass

0 commit comments

Comments
 (0)