Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/qt6-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ jobs:
- name: Install Python dependencies
run: |
python -m pip install --upgrade pip
pip install pyfakefs "PySide6<6.11" vermin requests defusedxml || true
pip install pyfakefs "PySide6<6.11" vermin requests defusedxml scour || true

- name: Run App tests
run: |
Expand Down
30 changes: 28 additions & 2 deletions AddonCatalogCacheCreator.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@
import addonmanager_utilities as utils
import addonmanager_icon_utilities as icon_utils

from scour import scour
from types import SimpleNamespace

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)
Expand Down Expand Up @@ -325,6 +328,7 @@ def generate_cache_entry_from_package_xml(
)
if os.path.exists(absolute_icon_path):
icon_data_is_good = True
icon_is_svg = absolute_icon_path.lower().endswith(".svg")
with open(absolute_icon_path, "rb") as f:
icon_data = None
try:
Expand All @@ -338,7 +342,7 @@ def generate_cache_entry_from_package_xml(
print(e)
icon_data_is_good = False
if icon_data is not None:
if absolute_icon_path.lower().endswith(".svg"):
if icon_is_svg:
try:
if not icon_utils.is_svg_bytes(icon_data):
self.icon_errors[metadata.name] = {
Expand All @@ -360,8 +364,30 @@ def generate_cache_entry_from_package_xml(
}
icon_data_is_good = False

if icon_data_is_good and icon_is_svg:
try:
options = SimpleNamespace(
enable_comment_stripping=True,
shorten_ids=True,
enable_id_stripping=True,
indent="none",
)
optimized_icon_data = scour.scourString(
icon_data.decode("utf-8"),
options=options,
).encode("utf-8")
except Exception:
self.icon_errors[metadata.name] = {
"valid_icon_path": relative_icon_path,
"error_message": "SVG Icon cannot be optimized",
}
else:
if len(optimized_icon_data) < len(icon_data):
icon_data = optimized_icon_data

if icon_data_is_good:
cache_entry.icon_data = base64.b64encode(icon_data).decode("utf-8")
icon_data = base64.b64encode(icon_data)
cache_entry.icon_data = icon_data.decode("utf-8")
else:
self.icon_errors[metadata.name] = {"bad_icon_path": relative_icon_path}
print(f"ERROR: Could not find icon file {absolute_icon_path}")
Expand Down
5 changes: 3 additions & 2 deletions AddonManagerTest/app/test_macro_cache_creator.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,9 @@ def fake_wiki(self):
fci.Console.PrintWarning("wiki failure")

instance = MacroCatalog()
with patch.object(type(instance), "retrieve_macros_from_git", fake_git), patch.object(
type(instance), "retrieve_macros_from_wiki", fake_wiki
with (
patch.object(type(instance), "retrieve_macros_from_git", fake_git),
patch.object(type(instance), "retrieve_macros_from_wiki", fake_wiki),
):
instance.fetch_macros()

Expand Down
38 changes: 22 additions & 16 deletions AddonManagerTest/gui/test_icon_utilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,9 +81,10 @@ def test_is_svg_bytes_returns_false_for_non_svg_header(self):
def test_is_svg_bytes_sniffs_only_first_MAX_ICON_BYTES(self):
# Place "<svg" after the sniff window; should NOT be detected.
raw = b"A" * 100 + b"<svg></svg>"
with patch("addonmanager_icon_utilities.MAX_ICON_BYTES", 64), patch(
"addonmanager_icon_utilities.is_valid_xml"
) as m:
with (
patch("addonmanager_icon_utilities.MAX_ICON_BYTES", 64),
patch("addonmanager_icon_utilities.is_valid_xml") as m,
):
self.assertFalse(iu.is_svg_bytes(raw))
m.assert_not_called()

Expand All @@ -99,9 +100,10 @@ def fake_is_valid_xml(arg):
calls.append(arg)
return True

with patch(
"addonmanager_icon_utilities.is_valid_xml", side_effect=fake_is_valid_xml
), patch("addonmanager_icon_utilities.MAX_ICON_BYTES", 32):
with (
patch("addonmanager_icon_utilities.is_valid_xml", side_effect=fake_is_valid_xml),
patch("addonmanager_icon_utilities.MAX_ICON_BYTES", 32),
):
self.assertTrue(iu.is_svg_bytes(raw))
self.assertIs(calls[0], raw)
self.assertEqual(len(calls[0]), len(raw))
Expand Down Expand Up @@ -147,9 +149,11 @@ class TestDecompressGzipLimited(unittest.TestCase):
def test_small_valid_within_ratio(self):
raw = b"hello world"
data = gz(raw)
with patch("addonmanager_icon_utilities.MAX_ICON_BYTES", len(data)), patch(
"addonmanager_icon_utilities.MAX_GZIP_EXPANSION_RATIO", 16
), patch("addonmanager_icon_utilities.MAX_GZIP_OUTPUT_ABS", 512 * 1024):
with (
patch("addonmanager_icon_utilities.MAX_ICON_BYTES", len(data)),
patch("addonmanager_icon_utilities.MAX_GZIP_EXPANSION_RATIO", 16),
patch("addonmanager_icon_utilities.MAX_GZIP_OUTPUT_ABS", 512 * 1024),
):
self.assertEqual(iu.decompress_gzip_limited(data), raw)

def test_respects_input_cap_inclusive(self):
Expand All @@ -168,19 +172,21 @@ def test_overflows_ratio_returns_none(self):
# Choose caps so ratio*len(data) < len(uncompressed)
payload = b"x" * 129
data = gz(payload)
with patch("addonmanager_icon_utilities.MAX_ICON_BYTES", len(data)), patch(
"addonmanager_icon_utilities.MAX_GZIP_EXPANSION_RATIO", 1
), patch(
"addonmanager_icon_utilities.MAX_GZIP_OUTPUT_ABS", 10**9
with (
patch("addonmanager_icon_utilities.MAX_ICON_BYTES", len(data)),
patch("addonmanager_icon_utilities.MAX_GZIP_EXPANSION_RATIO", 1),
patch("addonmanager_icon_utilities.MAX_GZIP_OUTPUT_ABS", 10**9),
): # let ratio drive the bound
self.assertIsNone(iu.decompress_gzip_limited(data))

def test_overflows_absolute_cap_returns_none(self):
payload = b"x" * 600_000 # >512 KiB default abs cap
data = gz(payload)
with patch("addonmanager_icon_utilities.MAX_ICON_BYTES", len(data)), patch(
"addonmanager_icon_utilities.MAX_GZIP_EXPANSION_RATIO", 1000
), patch("addonmanager_icon_utilities.MAX_GZIP_OUTPUT_ABS", 512 * 1024):
with (
patch("addonmanager_icon_utilities.MAX_ICON_BYTES", len(data)),
patch("addonmanager_icon_utilities.MAX_GZIP_EXPANSION_RATIO", 1000),
patch("addonmanager_icon_utilities.MAX_GZIP_OUTPUT_ABS", 512 * 1024),
):
self.assertIsNone(iu.decompress_gzip_limited(data))

def test_truncated_or_non_gzip_returns_none(self):
Expand Down
19 changes: 19 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# SPDX-License-Identifier: LGPL-2.1-or-later
# SPDX-FileCopyrightText: 2026 FreeCAD Project Association
# SPDX-FileNotice: Part of the AddonManager.

[project]
name = "AddonManager"
version = "2026.6.2dev"
description = "FreeCAD Addon Manager — install and manage workbenches, macros, and more"
requires-python = ">=3.11"
dependencies = [
"defusedxml",
"scour",
"requests",
"PySide6<6.11",
]
license = { text = "LGPL-2.1-or-later" }

[tool.black]
line-length = 100
Loading
Loading