Skip to content

Commit

Permalink
Addon Manager: PythonDeps Cleanup and Testing
Browse files Browse the repository at this point in the history
  • Loading branch information
chennes authored and yorikvanhavre committed Dec 16, 2024
1 parent 6254cb9 commit b2619f3
Show file tree
Hide file tree
Showing 8 changed files with 323 additions and 139 deletions.
2 changes: 1 addition & 1 deletion src/Mod/AddonManager/AddonManager.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@
from Widgets.addonmanager_widget_progress_bar import Progress
from package_list import PackageListItemModel
from Addon import Addon
from manage_python_dependencies import (
from addonmanager_python_deps_gui import (
PythonPackageManager,
)
from addonmanager_cache import local_cache_needs_update
Expand Down
31 changes: 19 additions & 12 deletions src/Mod/AddonManager/AddonManagerTest/app/test_utilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,26 +194,33 @@ def raises_first_five_times(timeout):
self.assertEqual(result.returncode, 0)

@patch("subprocess.Popen")
def test_run_interruptable_subprocess_timeout_ten_times(self, mock_popen):
"""Ten times is the limit for an error to be raised (e.g. the real timeout is ten seconds)"""
def test_run_interruptable_subprocess_timeout_exceeded(self, mock_popen):
"""Exceeding the set timeout gives a CalledProcessError exception"""

def raises_first_ten_times(timeout=0):
raises_first_ten_times.counter += 1
if not raises_first_ten_times.mock_access.kill.called:
if raises_first_ten_times.counter <= 10:
raise subprocess.TimeoutExpired("Test", timeout)
return "Mocked stdout", "Mocked stderr"
def raises_one_time(timeout=0):
if not raises_one_time.raised:
raises_one_time.raised = True
raise subprocess.TimeoutExpired("Test", timeout)
return "Mocked stdout", None

raises_one_time.raised = False

def fake_time():
"""Time that advances by one second every time it is called"""
fake_time.time += 1.0
return fake_time.time

raises_first_ten_times.counter = 0
fake_time.time = 0.0

mock_process = MagicMock()
mock_process.communicate = raises_first_ten_times
raises_first_ten_times.mock_access = mock_process
mock_process.communicate = raises_one_time
raises_one_time.mock_access = mock_process
mock_process.returncode = None
mock_popen.return_value = mock_process

with self.assertRaises(subprocess.CalledProcessError):
run_interruptable_subprocess(["arg0", "arg1"], 10)
with patch("time.time", fake_time):
run_interruptable_subprocess(["arg0", "arg1"], 0.1)


if __name__ == "__main__":
Expand Down
12 changes: 11 additions & 1 deletion src/Mod/AddonManager/AddonManagerTest/gui/gui_mocks.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,17 @@
# * *
# ***************************************************************************

from PySide import QtCore, QtWidgets
import sys

try:
from PySide import QtCore, QtWidgets
except ImportError:
try:
from PySide6 import QtCore, QtWidgets
except ImportError:
from PySide2 import QtCore, QtWidgets

sys.path.append("../../") # For running in standalone mode during testing

from AddonManagerTest.app.mocks import SignalCatcher

Expand Down
139 changes: 139 additions & 0 deletions src/Mod/AddonManager/AddonManagerTest/gui/test_python_deps_gui.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import logging

Check warning on line 1 in src/Mod/AddonManager/AddonManagerTest/gui/test_python_deps_gui.py

View workflow job for this annotation

GitHub Actions / Lint / Lint

would reformat src/Mod/AddonManager/AddonManagerTest/gui/test_python_deps_gui.py
import subprocess
import sys
import unittest
from unittest.mock import MagicMock, patch

try:
import FreeCAD
import FreeCADGui
except ImportError:
try:
from PySide6 import QtCore, QtWidgets
except ImportError:
from PySide2 import QtCore, QtWidgets

sys.path.append(
"../.."
) # So that when run standalone, the Addon Manager classes imported below are available

from addonmanager_python_deps_gui import (
PythonPackageManager,
call_pip,
PipFailed,
python_package_updates_are_available,
parse_pip_list_output,
)
from AddonManagerTest.gui.gui_mocks import DialogInteractor, DialogWatcher


class TestPythonPackageManager(unittest.TestCase):

def setUp(self) -> None:
self.manager = PythonPackageManager([])

def tearDown(self) -> None:
if self.manager.worker_thread:
self.manager.worker_thread.terminate()
self.manager.worker_thread.wait()

@patch("addonmanager_python_deps_gui.PythonPackageManager._create_list_from_pip")
def test_show(self, patched_create_list_from_pip):
dialog_watcher = DialogWatcher("Manage Python Dependencies")
self.manager.show()
self.assertTrue(dialog_watcher.dialog_found, "Failed to find the expected dialog box")


class TestPythonDepsStandaloneFunctions(unittest.TestCase):

@patch("addonmanager_utilities.run_interruptable_subprocess")
def test_call_pip(self, mock_run_subprocess: MagicMock):
call_pip(["arg1", "arg2", "arg3"])
mock_run_subprocess.assert_called()
args = mock_run_subprocess.call_args[0][0]
self.assertTrue("pip" in args)

@patch("addonmanager_python_deps_gui.get_python_exe")
def test_call_pip_no_python(self, mock_get_python_exe: MagicMock):
mock_get_python_exe.return_value = None
with self.assertRaises(PipFailed):
call_pip(["arg1", "arg2", "arg3"])

@patch("addonmanager_utilities.run_interruptable_subprocess")
def test_call_pip_exception_raised(self, mock_run_subprocess: MagicMock):
mock_run_subprocess.side_effect = subprocess.CalledProcessError(
-1, "dummy_command", "Fake contents of stdout", "Fake contents of stderr"
)
with self.assertRaises(PipFailed):
call_pip(["arg1", "arg2", "arg3"])

@patch("addonmanager_utilities.run_interruptable_subprocess")
def test_call_pip_splits_results(self, mock_run_subprocess: MagicMock):
result_mock = MagicMock()
result_mock.stdout = "\n".join(["Value 1", "Value 2", "Value 3"])
mock_run_subprocess.return_value = result_mock
result = call_pip(["arg1", "arg2", "arg3"])
self.assertEqual(len(result), 3)

@patch("addonmanager_python_deps_gui.call_pip")
def test_python_package_updates_are_available(self, mock_call_pip: MagicMock):
mock_call_pip.return_value = "Some result"
result = python_package_updates_are_available()
self.assertEqual(result, True)

@patch("addonmanager_python_deps_gui.call_pip")
def test_python_package_updates_are_available_no_results(self, mock_call_pip: MagicMock):
"""An empty string is an indication that no updates are available"""
mock_call_pip.return_value = ""
result = python_package_updates_are_available()
self.assertEqual(result, False)

@patch("addonmanager_python_deps_gui.call_pip")
def test_python_package_updates_are_available_pip_failure(self, mock_call_pip: MagicMock):
logging.disable()
mock_call_pip.side_effect = PipFailed("Test error message")
logging.disable() # A logging error message is expected here, but not desirable during test runs
result = python_package_updates_are_available()
self.assertEqual(result, False)
logging.disable(logging.NOTSET)

def test_parse_pip_list_output_no_input(self):
results_dict = parse_pip_list_output("", "")
self.assertEqual(len(results_dict), 0)

def test_parse_pip_list_output_all_packages_no_updates(self):
results_dict = parse_pip_list_output(
["Package Version", "---------- -------", "gitdb 4.0.9", "setuptools 41.2.0"],
[],
)
self.assertEqual(len(results_dict), 2)
self.assertTrue("gitdb" in results_dict)
self.assertTrue("setuptools" in results_dict)
self.assertEqual(results_dict["gitdb"]["installed_version"], "4.0.9")
self.assertEqual(results_dict["gitdb"]["available_version"], "")
self.assertEqual(results_dict["setuptools"]["installed_version"], "41.2.0")
self.assertEqual(results_dict["setuptools"]["available_version"], "")

def test_parse_pip_list_output_all_packages_with_updates(self):
results_dict = parse_pip_list_output(
[],
[
"Package Version Latest Type",
"---------- ------- ------ -----",
"pip 21.0.1 22.1.2 wheel",
"setuptools 41.2.0 63.2.0 wheel",
],
)
self.assertEqual(len(results_dict), 2)
self.assertTrue("pip" in results_dict)
self.assertTrue("setuptools" in results_dict)
self.assertEqual(results_dict["pip"]["installed_version"], "21.0.1")
self.assertEqual(results_dict["pip"]["available_version"], "22.1.2")
self.assertEqual(results_dict["setuptools"]["installed_version"], "41.2.0")
self.assertEqual(results_dict["setuptools"]["available_version"], "63.2.0")


if __name__ == "__main__":
app = QtWidgets.QApplication(sys.argv)

Check failure on line 137 in src/Mod/AddonManager/AddonManagerTest/gui/test_python_deps_gui.py

View workflow job for this annotation

GitHub Actions / Lint / Lint

Using variable 'QtWidgets' before assignment (used-before-assignment)
QtCore.QTimer.singleShot(0, unittest.main)

Check failure on line 138 in src/Mod/AddonManager/AddonManagerTest/gui/test_python_deps_gui.py

View workflow job for this annotation

GitHub Actions / Lint / Lint

Using variable 'QtCore' before assignment (used-before-assignment)
app.exec()
28 changes: 14 additions & 14 deletions src/Mod/AddonManager/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,20 @@ IF (BUILD_GUI)
ENDIF (BUILD_GUI)

SET(AddonManager_SRCS
add_toolbar_button_dialog.ui
ALLOWED_PYTHON_PACKAGES.txt
Addon.py
AddonStats.py
AddonManager.py
AddonManager.ui
AddonManagerOptions.py
AddonManagerOptions.ui
AddonManagerOptions_AddCustomRepository.ui
AddonStats.py
Init.py
InitGui.py
NetworkManager.py
PythonDependencyUpdateDialog.ui
TestAddonManagerApp.py
add_toolbar_button_dialog.ui
addonmanager_cache.py
addonmanager_connection_checker.py
addonmanager_dependency_installer.py
Expand All @@ -17,8 +26,8 @@ SET(AddonManager_SRCS
addonmanager_devmode_license_selector.py
addonmanager_devmode_licenses_table.py
addonmanager_devmode_metadata_checker.py
addonmanager_devmode_person_editor.py
addonmanager_devmode_people_table.py
addonmanager_devmode_person_editor.py
addonmanager_devmode_predictor.py
addonmanager_devmode_validators.py
addonmanager_firstrun.py
Expand All @@ -33,18 +42,15 @@ SET(AddonManager_SRCS
addonmanager_package_details_controller.py
addonmanager_preferences_defaults.json
addonmanager_pyside_interface.py
addonmanager_python_deps_gui.py
addonmanager_readme_controller.py
addonmanager_update_all_gui.py
addonmanager_uninstaller.py
addonmanager_uninstaller_gui.py
addonmanager_update_all_gui.py
addonmanager_utilities.py
addonmanager_workers_installation.py
addonmanager_workers_startup.py
addonmanager_workers_utility.py
AddonManagerOptions.ui
AddonManagerOptions_AddCustomRepository.ui
AddonManagerOptions.py
ALLOWED_PYTHON_PACKAGES.txt
change_branch.py
change_branch.ui
compact_view.py
Expand All @@ -65,16 +71,10 @@ SET(AddonManager_SRCS
developer_mode_tags.ui
expanded_view.py
first_run.ui
Init.py
InitGui.py
install_to_toolbar.py
loading.html
manage_python_dependencies.py
NetworkManager.py
package_list.py
PythonDependencyUpdateDialog.ui
select_toolbar_dialog.ui
TestAddonManagerApp.py
update_all.ui
)
IF (BUILD_GUI)
Expand Down
15 changes: 12 additions & 3 deletions src/Mod/AddonManager/addonmanager_freecad_interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ class DataPaths:
all paths are temp directories. If not run within FreeCAD, all directories are
deleted when the last reference to this class is deleted."""

data_dir = None
mod_dir = None
macro_dir = None
cache_dir = None
Expand All @@ -152,6 +153,8 @@ class DataPaths:

def __init__(self):
if FreeCAD:
if self.data_dir is None:
self.data_dir = getUserAppDataDir()
if self.mod_dir is None:
self.mod_dir = os.path.join(getUserAppDataDir(), "Mod")
if self.cache_dir is None:
Expand All @@ -162,6 +165,8 @@ def __init__(self):
self.home_dir = FreeCAD.getHomePath()
else:
self.reference_count += 1
if self.data_dir is None:
self.data_dir = tempfile.mkdtemp()
if self.mod_dir is None:
self.mod_dir = tempfile.mkdtemp()
if self.cache_dir is None:
Expand All @@ -174,9 +179,13 @@ def __init__(self):
def __del__(self):
self.reference_count -= 1
if not FreeCAD and self.reference_count <= 0:
os.rmdir(self.mod_dir)
os.rmdir(self.cache_dir)
os.rmdir(self.macro_dir)
paths = [self.data_dir, self.mod_dir, self.cache_dir, self.macro_dir, self.mod_dir]
for path in paths:
try:
os.rmdir(path)
except FileNotFoundError:
pass
self.data_dir = None
self.mod_dir = None
self.cache_dir = None
self.macro_dir = None
Expand Down
Loading

0 comments on commit b2619f3

Please sign in to comment.