diff --git a/src/Mod/AddonManager/AddonManager.py b/src/Mod/AddonManager/AddonManager.py
index cb8bb63d8a..dc66b8b7be 100644
--- a/src/Mod/AddonManager/AddonManager.py
+++ b/src/Mod/AddonManager/AddonManager.py
@@ -27,11 +27,9 @@
import os
import functools
import tempfile
-import hashlib
import threading
import json
-import re # Needed for py 3.6 and earlier, can remove later, search for "re."
-from datetime import date, timedelta
+from datetime import date
from typing import Dict
from PySide import QtGui, QtCore, QtWidgets
@@ -59,6 +57,7 @@ from Addon import Addon
from manage_python_dependencies import (
PythonPackageManager,
)
+from addonmanager_cache import local_cache_needs_update
from addonmanager_devmode import DeveloperMode
from addonmanager_firstrun import FirstRunDialog
from addonmanager_connection_checker import ConnectionCheckerGUI
@@ -179,7 +178,7 @@ class CommandAddonManager:
self.packages_with_updates = set()
self.startup_sequence = []
self.cleanup_workers()
- self.determine_cache_update_status()
+ self.update_cache = local_cache_needs_update()
# restore window geometry from stored state
pref = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Addons")
@@ -295,79 +294,6 @@ class CommandAddonManager:
+ "\n"
)
- def determine_cache_update_status(self) -> None:
- """Determine whether we need to update the cache, based on user preference, and previous
- cache update status. Sets self.update_cache to either True or False."""
-
- # Figure out our cache update frequency: there is a combo box in the preferences dialog
- # with three options: never, daily, and weekly. Check that first, but allow it to be
- # overridden by a more specific DaysBetweenUpdates selection, if the user has provided it.
- # For that parameter we use:
- # -1: Only manual updates (default)
- # 0: Update every launch
- # >0: Update every n days
- pref = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Addons")
- self.update_cache = False
- update_frequency = pref.GetInt("UpdateFrequencyComboEntry", 0)
- if update_frequency == 0:
- days_between_updates = -1
- elif update_frequency == 1:
- days_between_updates = 1
- elif update_frequency == 2:
- days_between_updates = 7
- days_between_updates = pref.GetInt("DaysBetweenUpdates", days_between_updates)
- last_cache_update_string = pref.GetString("LastCacheUpdate", "never")
- cache_path = FreeCAD.getUserCachePath()
- am_path = os.path.join(cache_path, "AddonManager")
- if last_cache_update_string == "never":
- self.update_cache = True
- elif days_between_updates > 0:
- if hasattr(date, "fromisoformat"):
- last_cache_update = date.fromisoformat(last_cache_update_string)
- else:
- # Python 3.6 and earlier don't have date.fromisoformat
- date_re = re.compile("([0-9]{4})-?(1[0-2]|0[1-9])-?(3[01]|0[1-9]|[12][0-9])")
- matches = date_re.match(last_cache_update_string)
- last_cache_update = date(
- int(matches.group(1)), int(matches.group(2)), int(matches.group(3))
- )
- delta_update = timedelta(days=days_between_updates)
- if date.today() >= last_cache_update + delta_update:
- self.update_cache = True
- elif days_between_updates == 0:
- self.update_cache = True
- elif not os.path.isdir(am_path):
- self.update_cache = True
- stopfile = utils.get_cache_file_name("CACHE_UPDATE_INTERRUPTED")
- if os.path.exists(stopfile):
- self.update_cache = True
- os.remove(stopfile)
- FreeCAD.Console.PrintMessage(
- translate(
- "AddonsInstaller",
- "Previous cache process was interrupted, restarting...\n",
- )
- )
-
- # See if the user has changed the custom repos list since our last re-cache:
- stored_hash = pref.GetString("CustomRepoHash", "")
- custom_repos = pref.GetString("CustomRepositories", "")
- if custom_repos:
- hasher = hashlib.sha1()
- hasher.update(custom_repos.encode("utf-8"))
- new_hash = hasher.hexdigest()
- else:
- new_hash = ""
- if new_hash != stored_hash:
- stored_hash = pref.SetString("CustomRepoHash", new_hash)
- self.update_cache = True
- FreeCAD.Console.PrintMessage(
- translate(
- "AddonsInstaller",
- "Custom repo list changed, forcing recache...\n",
- )
- )
-
def reject(self) -> None:
"""called when the window has been closed"""
diff --git a/src/Mod/AddonManager/AddonManagerTest/app/mocks.py b/src/Mod/AddonManager/AddonManagerTest/app/mocks.py
index 695e6e0b56..3e94f7e161 100644
--- a/src/Mod/AddonManager/AddonManagerTest/app/mocks.py
+++ b/src/Mod/AddonManager/AddonManagerTest/app/mocks.py
@@ -35,7 +35,7 @@ class GitFailed(RuntimeError):
class MockConsole:
- """Mock for the FreeCAD.Console -- does NOT print anything out, just logs it."""
+ """Spy for the FreeCAD.Console -- does NOT print anything out, just logs it."""
def __init__(self):
self.log = []
@@ -401,3 +401,63 @@ class MockThread:
):
return True
return False
+
+
+class MockPref:
+ def __init__(self):
+ self.prefs = {}
+ self.pref_set_counter = {}
+ self.pref_get_counter = {}
+
+ def set_prefs(self, pref_dict: dict) -> None:
+ self.prefs = pref_dict
+
+ def GetInt(self, key: str, default: int) -> int:
+ return self.Get(key, default)
+
+ def GetString(self, key: str, default: str) -> str:
+ return self.Get(key, default)
+
+ def GetBool(self, key: str, default: bool) -> bool:
+ return self.Get(key, default)
+
+ def Get(self, key: str, default):
+ if key not in self.pref_set_counter:
+ self.pref_get_counter[key] = 1
+ else:
+ self.pref_get_counter[key] += 1
+ if key in self.prefs:
+ return self.prefs[key]
+ raise ValueError(f"Expected key not in mock preferences: {key}")
+
+ def SetInt(self, key: str, value: int) -> None:
+ return self.Set(key, value)
+
+ def SetString(self, key: str, value: str) -> None:
+ return self.Set(key, value)
+
+ def SetBool(self, key: str, value: bool) -> None:
+ return self.Set(key, value)
+
+ def Set(self, key: str, value):
+ if key not in self.pref_set_counter:
+ self.pref_set_counter[key] = 1
+ else:
+ self.pref_set_counter[key] += 1
+ self.prefs[key] = value
+
+
+class MockExists:
+ def __init__(self, files: List[str] = None):
+ """Returns True for all files in files, and False for all others"""
+ self.files = files
+ self.files_checked = []
+
+ def exists(self, check_file: str):
+ self.files_checked.append(check_file)
+ if not self.files:
+ return False
+ for file in self.files:
+ if check_file.endswith(file):
+ return True
+ return False
diff --git a/src/Mod/AddonManager/AddonManagerTest/app/test_cache.py b/src/Mod/AddonManager/AddonManagerTest/app/test_cache.py
new file mode 100644
index 0000000000..098f1b3a01
--- /dev/null
+++ b/src/Mod/AddonManager/AddonManagerTest/app/test_cache.py
@@ -0,0 +1,126 @@
+# SPDX-License-Identifier: LGPL-2.1-or-later
+# ***************************************************************************
+# * *
+# * Copyright (c) 2022-2023 FreeCAD Project Association *
+# * *
+# * This file is part of FreeCAD. *
+# * *
+# * FreeCAD is free software: you can redistribute it and/or modify it *
+# * under the terms of the GNU Lesser General Public License as *
+# * published by the Free Software Foundation, either version 2.1 of the *
+# * License, or (at your option) any later version. *
+# * *
+# * FreeCAD is distributed in the hope that it will be useful, but *
+# * WITHOUT ANY WARRANTY; without even the implied warranty of *
+# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
+# * Lesser General Public License for more details. *
+# * *
+# * You should have received a copy of the GNU Lesser General Public *
+# * License along with FreeCAD. If not, see *
+# * . *
+# * *
+# ***************************************************************************
+import datetime
+import sys
+import unittest
+from datetime import date
+from unittest import TestCase
+from unittest.mock import MagicMock, patch
+
+sys.path.append("../..")
+
+import addonmanager_cache as cache
+from AddonManagerTest.app.mocks import MockPref, MockExists
+
+
+class TestCache(TestCase):
+ @patch("addonmanager_freecad_interface.getUserCachePath")
+ @patch("addonmanager_freecad_interface.ParamGet")
+ @patch("os.remove", MagicMock())
+ @patch("os.makedirs", MagicMock())
+ def test_local_cache_needs_update(self, param_mock: MagicMock, cache_mock: MagicMock):
+ cache_mock.return_value = ""
+ param_mock.return_value = MockPref()
+ default_prefs = {
+ "UpdateFrequencyComboEntry": 0,
+ "LastCacheUpdate": "2000-01-01",
+ "CustomRepoHash": "",
+ "CustomRepositories": "",
+ }
+ today = date.today().isoformat()
+ yesterday = (date.today() - datetime.timedelta(1)).isoformat()
+
+ # Organize these as subtests because of all the patching that has to be done: once we are in this function,
+ # the patch is complete, and we can just modify the return values of the fakes one by one
+ tests = (
+ {
+ "case": "No existing cache",
+ "files_that_exist": [],
+ "prefs_to_set": {},
+ "expect": True,
+ },
+ {
+ "case": "Last cache update was interrupted",
+ "files_that_exist": ["CACHE_UPDATE_INTERRUPTED"],
+ "prefs_to_set": {},
+ "expect": True,
+ },
+ {
+ "case": "Cache exists and updating is blocked",
+ "files_that_exist": ["AddonManager"],
+ "prefs_to_set": {},
+ "expect": False,
+ },
+ {
+ "case": "Daily updates set and last update was long ago",
+ "files_that_exist": ["AddonManager"],
+ "prefs_to_set": {"UpdateFrequencyComboEntry": 1},
+ "expect": True,
+ },
+ {
+ "case": "Daily updates set and last update was today",
+ "files_that_exist": ["AddonManager"],
+ "prefs_to_set": {"UpdateFrequencyComboEntry": 1, "LastCacheUpdate": today},
+ "expect": False,
+ },
+ {
+ "case": "Daily updates set and last update was yesterday",
+ "files_that_exist": ["AddonManager"],
+ "prefs_to_set": {"UpdateFrequencyComboEntry": 1, "LastCacheUpdate": yesterday},
+ "expect": True,
+ },
+ {
+ "case": "Weekly updates set and last update was long ago",
+ "files_that_exist": ["AddonManager"],
+ "prefs_to_set": {"UpdateFrequencyComboEntry": 1},
+ "expect": True,
+ },
+ {
+ "case": "Weekly updates set and last update was yesterday",
+ "files_that_exist": ["AddonManager"],
+ "prefs_to_set": {"UpdateFrequencyComboEntry": 2, "LastCacheUpdate": yesterday},
+ "expect": False,
+ },
+ {
+ "case": "Custom repo list changed",
+ "files_that_exist": ["AddonManager"],
+ "prefs_to_set": {"CustomRepositories": "NewRepo"},
+ "expect": True,
+ },
+ )
+ for test_case in tests:
+ with self.subTest(test_case["case"]):
+ case_prefs = default_prefs
+ for pref, setting in test_case["prefs_to_set"].items():
+ case_prefs[pref] = setting
+ param_mock.return_value.set_prefs(case_prefs)
+ exists_mock = MockExists(test_case["files_that_exist"])
+ with patch("os.path.exists", exists_mock.exists):
+ if test_case["expect"]:
+ self.assertTrue(cache.local_cache_needs_update())
+ else:
+ self.assertFalse(cache.local_cache_needs_update())
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/src/Mod/AddonManager/CMakeLists.txt b/src/Mod/AddonManager/CMakeLists.txt
index 7cad775fa4..dc9e527c49 100644
--- a/src/Mod/AddonManager/CMakeLists.txt
+++ b/src/Mod/AddonManager/CMakeLists.txt
@@ -7,6 +7,7 @@ SET(AddonManager_SRCS
Addon.py
AddonManager.py
AddonManager.ui
+ addonmanager_cache.py
addonmanager_connection_checker.py
addonmanager_dependency_installer.py
addonmanager_devmode.py
@@ -85,6 +86,7 @@ SET(AddonManagerTestsApp_SRCS
AddonManagerTest/app/__init__.py
AddonManagerTest/app/mocks.py
AddonManagerTest/app/test_addon.py
+ AddonManagerTest/app/test_cache.py
AddonManagerTest/app/test_dependency_installer.py
AddonManagerTest/app/test_freecad_interface.py
AddonManagerTest/app/test_git.py
diff --git a/src/Mod/AddonManager/TestAddonManagerApp.py b/src/Mod/AddonManager/TestAddonManagerApp.py
index 5b3237b528..12392ea2c5 100644
--- a/src/Mod/AddonManager/TestAddonManagerApp.py
+++ b/src/Mod/AddonManager/TestAddonManagerApp.py
@@ -30,6 +30,9 @@ from AddonManagerTest.app.test_utilities import (
from AddonManagerTest.app.test_addon import (
TestAddon as AddonManagerTestAddon,
)
+from AddonManagerTest.app.test_cache import (
+ TestCache as AddonManagerTestCache,
+)
from AddonManagerTest.app.test_macro import (
TestMacro as AddonManagerTestMacro,
)
@@ -74,6 +77,7 @@ except ImportError:
loaded_gui_tests = [
AddonManagerTestUtilities,
AddonManagerTestAddon,
+ AddonManagerTestCache,
AddonManagerTestMacro,
AddonManagerTestGit,
AddonManagerTestAddonInstaller,
diff --git a/src/Mod/AddonManager/addonmanager_cache.py b/src/Mod/AddonManager/addonmanager_cache.py
new file mode 100644
index 0000000000..a72d290be0
--- /dev/null
+++ b/src/Mod/AddonManager/addonmanager_cache.py
@@ -0,0 +1,118 @@
+# SPDX-License-Identifier: LGPL-2.1-or-later
+# ***************************************************************************
+# * *
+# * Copyright (c) 2022-2023 FreeCAD Project Association *
+# * *
+# * This file is part of FreeCAD. *
+# * *
+# * FreeCAD is free software: you can redistribute it and/or modify it *
+# * under the terms of the GNU Lesser General Public License as *
+# * published by the Free Software Foundation, either version 2.1 of the *
+# * License, or (at your option) any later version. *
+# * *
+# * FreeCAD is distributed in the hope that it will be useful, but *
+# * WITHOUT ANY WARRANTY; without even the implied warranty of *
+# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
+# * Lesser General Public License for more details. *
+# * *
+# * You should have received a copy of the GNU Lesser General Public *
+# * License along with FreeCAD. If not, see *
+# * . *
+# * *
+# ***************************************************************************
+
+from datetime import date, timedelta
+import hashlib
+import os
+
+import addonmanager_freecad_interface as fci
+import addonmanager_utilities as utils
+
+translate = fci.translate
+
+
+def local_cache_needs_update() -> bool:
+ """Determine whether we need to update the cache, based on user preference, and previous
+ cache update status. Returns either True or False."""
+
+ if not _cache_exists():
+ return True
+
+ if _last_update_was_interrupted(reset_status=True):
+ return True
+
+ if _custom_repo_list_changed():
+ return True
+
+ # Figure out our cache update frequency: there is a combo box in the preferences dialog
+ # with three options: never, daily, and weekly.
+ days_between_updates = _days_between_updates()
+ pref = fci.ParamGet("User parameter:BaseApp/Preferences/Addons")
+ last_cache_update_string = pref.GetString("LastCacheUpdate", "never")
+
+ if last_cache_update_string == "never":
+ return True
+ elif days_between_updates > 0:
+ last_cache_update = date.fromisoformat(last_cache_update_string)
+ delta_update = timedelta(days=days_between_updates)
+ if date.today() >= last_cache_update + delta_update:
+ return True
+ elif days_between_updates == 0:
+ return True
+
+ return False
+
+
+def _days_between_updates() -> int:
+ pref = fci.ParamGet("User parameter:BaseApp/Preferences/Addons")
+ update_frequency = pref.GetInt("UpdateFrequencyComboEntry", 0)
+ if update_frequency == 0:
+ return -1
+ elif update_frequency == 1:
+ return 1
+ elif update_frequency == 2:
+ return 7
+ else:
+ return 0
+
+
+def _cache_exists() -> bool:
+ cache_path = fci.getUserCachePath()
+ am_path = os.path.join(cache_path, "AddonManager")
+ return os.path.exists(am_path)
+
+
+def _last_update_was_interrupted(reset_status: bool) -> bool:
+ flag_file = utils.get_cache_file_name("CACHE_UPDATE_INTERRUPTED")
+ if os.path.exists(flag_file):
+ if reset_status:
+ os.remove(flag_file)
+ fci.Console.PrintMessage(
+ translate(
+ "AddonsInstaller",
+ "Previous cache process was interrupted, restarting...\n",
+ )
+ )
+ return True
+
+
+def _custom_repo_list_changed() -> bool:
+ pref = fci.ParamGet("User parameter:BaseApp/Preferences/Addons")
+ stored_hash = pref.GetString("CustomRepoHash", "")
+ custom_repos = pref.GetString("CustomRepositories", "")
+ if custom_repos:
+ hasher = hashlib.sha1()
+ hasher.update(custom_repos.encode("utf-8"))
+ new_hash = hasher.hexdigest()
+ else:
+ new_hash = ""
+ if new_hash != stored_hash:
+ pref.SetString("CustomRepoHash", new_hash)
+ fci.Console.PrintMessage(
+ translate(
+ "AddonsInstaller",
+ "Custom repo list changed, forcing recache...\n",
+ )
+ )
+ return True
+ return False