Addon Manager: Refactor local cache update check

This commit is contained in:
Chris Hennes
2023-09-04 15:11:51 -05:00
parent 9a7523fb4b
commit 92016c4c9c
6 changed files with 314 additions and 78 deletions

View File

@@ -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"""

View File

@@ -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

View File

@@ -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 *
# * <https://www.gnu.org/licenses/>. *
# * *
# ***************************************************************************
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()

View File

@@ -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

View File

@@ -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,

View File

@@ -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 *
# * <https://www.gnu.org/licenses/>. *
# * *
# ***************************************************************************
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