Skip to content
Open
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
13 changes: 13 additions & 0 deletions AddonManagerTest/app/test_dependency_installer.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,19 @@ def test_install_required_failure(self):
self.assertEqual(sm.call_count, 1)
self.assertIn("failure", self.signals_caught)

def test_install_required_emits_progress_and_disables_progress_bar(self):
sm = SubprocessMock()
sm.succeed = True
self.test_object._subprocess_wrapper = sm.subprocess_interceptor
progress = []
self.test_object.progress_message.connect(progress.append)
self.test_object.python_requires = ["somepackage"]
self.test_object._install_required("vendor_path")
self.assertTrue(any("somepackage" in message for message in progress))
logged_args = sm.arg_log[0]
self.assertIn("--progress-bar", logged_args)
self.assertIn("off", logged_args)

def test_install_optional_loops(self):
sm = SubprocessMock()
sm.succeed = True
Expand Down
76 changes: 76 additions & 0 deletions AddonManagerTest/app/test_utilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,37 @@
reload_git_hosts,
remember_git_host,
run_interruptable_subprocess,
run_monitored_subprocess,
ProcessInterrupted,
SubprocessTimeout,
)


class _FakeStream:
"""A minimal stand-in for a process stdout stream that yields preset lines then EOF."""

def __init__(self, lines):
self._lines = list(lines)

def readline(self):
return self._lines.pop(0) if self._lines else ""


class _FakeProcess:
"""A minimal Popen stand-in for exercising run_monitored_subprocess."""

def __init__(self, lines, returncode=0):
self.stdout = _FakeStream(lines)
self.returncode = returncode
self.killed = False

def wait(self):
return self.returncode

def kill(self):
self.killed = True


class TestUtilities(unittest.TestCase):

@classmethod
Expand Down Expand Up @@ -253,6 +281,54 @@ def fake_time():
with patch("time.time", fake_time):
run_interruptable_subprocess(["arg0", "arg1"], 0.1)

@patch("subprocess.Popen")
def test_run_interruptable_subprocess_none_timeout_never_expires(self, mock_popen):
"""With no timeout the call keeps waiting through repeated poll timeouts until the
process finishes."""

def communicate(timeout=None):
communicate.calls += 1
if communicate.calls <= 3:
raise subprocess.TimeoutExpired("Test", timeout)
return "done", ""

communicate.calls = 0

mock_process = MagicMock()
mock_process.communicate = communicate
mock_process.returncode = 0
mock_popen.return_value = mock_process

result = run_interruptable_subprocess(["arg0", "arg1"], None)
self.assertEqual(0, result.returncode)
self.assertEqual("done", result.stdout)

@patch("subprocess.Popen")
def test_run_monitored_subprocess_streams_lines_and_collects_output(self, mock_popen):
mock_popen.return_value = _FakeProcess(["Collecting x\n", "Downloading x (5 MB)\n"], 0)
received = []

result = run_monitored_subprocess(["pip", "install", "x"], line_callback=received.append)

self.assertEqual(["Collecting x", "Downloading x (5 MB)"], received)
self.assertEqual("Collecting x\nDownloading x (5 MB)\n", result.stdout)
self.assertEqual(0, result.returncode)

@patch("subprocess.Popen")
def test_run_monitored_subprocess_nonzero_exit_raises(self, mock_popen):
mock_popen.return_value = _FakeProcess(["error occurred\n"], 1)
with self.assertRaises(subprocess.CalledProcessError):
run_monitored_subprocess(["pip", "install", "x"])

@patch("subprocess.Popen")
@patch("addonmanager_utilities._interruption_requested", return_value=True)
def test_run_monitored_subprocess_interruption_raises(self, _mock_interrupt, mock_popen):
process = _FakeProcess(["Collecting x\n"], 0)
mock_popen.return_value = process
with self.assertRaises(ProcessInterrupted):
run_monitored_subprocess(["pip", "install", "x"])
self.assertTrue(process.killed)

def test_process_date_string_to_python_datetime_non_numeric(self):
with self.assertRaises(ValueError):
process_date_string_to_python_datetime("TwentyTwentyFour-January-ThirtyFirst")
Expand Down
17 changes: 15 additions & 2 deletions AddonManagerTest/gui/test_installer_gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,7 @@ class MockDependencyInstaller(QtCore.QObject):
no_pip = QtCore.Signal(str)
failure = QtCore.Signal(str, str)
finished = QtCore.Signal(bool)
progress_message = QtCore.Signal(str)

class MockResult(IntEnum):
OK = 0
Expand Down Expand Up @@ -595,6 +596,17 @@ def test_run_with_required_allowed_package_installs_package(
self.assertTrue(gui.dependency_installer.moved_to_thread)
self.assertEqual(["in_the_allowlist"], gui.dependency_installer.python_requires)

def test_update_dependency_progress_shows_message_in_label(self):
"""A progress message from the installer is displayed in the dialog's label."""
gui = AddonDependencyInstallerGUI([], self.create_mock_deps())
holder = QtWidgets.QWidget()
holder.label = QtWidgets.QLabel(holder)
gui.dependency_installation_dialog = holder

gui._update_dependency_progress("Collecting somepackage")

self.assertIn("Collecting", holder.label.text())

@patch("addonmanager_installer_gui.utils.blocking_get", MagicMock(return_value=None))
@patch("addonmanager_installer_gui.AddonInstaller")
@patch("addonmanager_installer_gui.DependencyInstaller")
Expand Down Expand Up @@ -626,9 +638,10 @@ def test_run_with_optional_unchecked_allowed_package_does_not_install_package(
self.assertTrue(
dialog_watcher.dialog_found, "Failed to find the Resolve Dependencies dialog box"
)
self.assertTrue(
# Nothing was selected, so no dependency installation runs and no installing dialog opens.
self.assertFalse(
installing_dialog_watcher.dialog_found,
"Failed to find the Installing Dependencies dialog box",
"Unexpectedly found an Installing Dependencies dialog when nothing was selected",
)
proceed_monitor.wait_for_at_most(500)
self.assertTrue(proceed_monitor.good())
Expand Down
19 changes: 15 additions & 4 deletions addonmanager_dependency_installer.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ class DependencyInstaller(QtCore.QObject):
no_pip = QtCore.Signal(str) # Attempted command
failure = QtCore.Signal(str, str) # Short message, detailed message
finished = QtCore.Signal(bool) # True if everything completed normally, otherwise false
progress_message = QtCore.Signal(str) # A human-readable line describing current activity

def __init__(
self,
Expand Down Expand Up @@ -125,10 +126,15 @@ def _install_required(self, vendor_path: str) -> bool:
continue # Do not attempt to install PySide, which must be part of FreeCAD already
if QtCore.QThread.currentThread().isInterruptionRequested():
return False
self.progress_message.emit(
translate("AddonsInstaller", "Installing Python package {}").format(pymod)
)
try:
proc = self._run_pip(
[
"install",
"--progress-bar",
"off",
"--target",
vendor_path,
pymod,
Expand All @@ -153,10 +159,15 @@ def _install_optional(self, vendor_path: str):
for pymod in self.python_optional:
if QtCore.QThread.currentThread().isInterruptionRequested():
return
self.progress_message.emit(
translate("AddonsInstaller", "Installing Python package {}").format(pymod)
)
try:
proc = self._run_pip(
[
"install",
"--progress-bar",
"off",
"--target",
vendor_path,
pymod,
Expand All @@ -175,10 +186,10 @@ def _run_pip(self, args):
final_args = utils.create_pip_call(args)
return self._subprocess_wrapper(final_args)

@staticmethod
def _subprocess_wrapper(args) -> subprocess.CompletedProcess:
"""Wrap subprocess call so test code can mock it."""
return utils.run_interruptable_subprocess(args, timeout_secs=120)
def _subprocess_wrapper(self, args) -> subprocess.CompletedProcess:
"""Run pip with no wall-clock timeout, forwarding each output line as a progress message.
Wrapped in a single method so test code can mock it."""
return utils.run_monitored_subprocess(args, line_callback=self.progress_message.emit)

def _install_addons(self):
for addon in self.addons:
Expand Down
29 changes: 21 additions & 8 deletions addonmanager_installer_gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -754,27 +754,42 @@ def _run_dependency_installer(self, addons, python_requires, python_optional):
self.dependency_installer.no_pip.connect(self._report_no_pip)
self.dependency_installer.failure.connect(self._report_dependency_failure)
self.dependency_installer.finished.connect(self._dependencies_finished)
self.dependency_installer.progress_message.connect(self._update_dependency_progress)

self.dependency_worker_thread = QtCore.QThread(self)
self.dependency_worker_thread.setObjectName("Dependency Installer Thread")
self.dependency_installer.moveToThread(self.dependency_worker_thread)
self.dependency_worker_thread.started.connect(self.dependency_installer.run)
self.dependency_installer.finished.connect(self.dependency_worker_thread.quit)

self.dependency_installation_dialog = QtWidgets.QMessageBox(
QtWidgets.QMessageBox.Information,
translate("AddonsInstaller", "Installing Dependencies", "Window title"),
translate("AddonsInstaller", "Installing dependencies…", "Window text"),
QtWidgets.QMessageBox.Cancel,
parent=utils.get_main_am_window(),
self.dependency_installation_dialog = fci.loadUi(
os.path.join(os.path.dirname(__file__), "progress.ui")
)
self.dependency_installation_dialog.setObjectName(
"AddonManager_InstallingDependenciesDialog"
)
self.dependency_installation_dialog.setWindowTitle(
translate("AddonsInstaller", "Installing Dependencies", "Window title")
)
self.dependency_installation_dialog.label.setText(
translate("AddonsInstaller", "Installing dependencies…", "Window text")
)
# An indeterminate range animates the bar so the user can see work is ongoing during a
# download whose duration is not known in advance.
self.dependency_installation_dialog.progressBar.setRange(0, 0)
self.dependency_installation_dialog.rejected.connect(self._cancel_dependency_installation)
self.dependency_installation_dialog.show()
self.dependency_worker_thread.start()

def _update_dependency_progress(self, message: str) -> None:
"""Show the latest line of pip activity in the dependency installation dialog, elided so
a long line does not resize the dialog."""
if self.dependency_installation_dialog is None:
return
label = self.dependency_installation_dialog.label
elided = label.fontMetrics().elidedText(message, QtCore.Qt.ElideMiddle, 360)
label.setText(elided)

def _report_no_python_exe(self) -> None:
"""Callback for the dependency installer failing to locate a Python executable."""
if self.dependency_installation_dialog is not None:
Expand Down Expand Up @@ -830,8 +845,6 @@ def _report_dependency_failure(self, short_message: str, details: str) -> None:
"""Callback for dependency installation failure."""
if self.dependency_installation_dialog is not None:
self.dependency_installation_dialog.hide()
if self.dependency_installer and hasattr(self.dependency_installer, "finished"):
self.dependency_installer.finished.disconnect(self._report_dependency_success)
fci.Console.PrintError(details + "\n")
result = MessageDialog.show_modal(
MessageDialog.DialogType.ERROR,
Expand Down
2 changes: 1 addition & 1 deletion addonmanager_python_deps.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ def call_pip(args: List[str]) -> List[str]:
raise PipFailed() from exception

try:
proc = run_interruptable_subprocess(call_args, 120)
proc = run_interruptable_subprocess(call_args, timeout_secs=None)
except subprocess.CalledProcessError as exception:
raise PipFailed(f"pip call failed:\n{exception}") from exception

Expand Down
Loading