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