From 958fb0643217afcc9c2b5d38c35a73bb33b5921b Mon Sep 17 00:00:00 2001 From: Chris Hennes Date: Sun, 12 Jul 2026 21:17:20 -0500 Subject: [PATCH] Add 'quiet' option to NetworkManager fetches (cherry picked from commit 7ced6c71e348010f0f002f3a48f8c07a5dc9da3d) --- AddonManagerTest/app/test_network_manager.py | 119 +++++++++++++++++++ NetworkManager.py | 42 +++++-- addonmanager_workers_startup.py | 1 + 3 files changed, 152 insertions(+), 10 deletions(-) create mode 100644 AddonManagerTest/app/test_network_manager.py diff --git a/AddonManagerTest/app/test_network_manager.py b/AddonManagerTest/app/test_network_manager.py new file mode 100644 index 00000000..b83a76c4 --- /dev/null +++ b/AddonManagerTest/app/test_network_manager.py @@ -0,0 +1,119 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later +# SPDX-FileCopyrightText: 2026 FreeCAD Project Association +# SPDX-FileNotice: Part of the AddonManager. + +################################################################################ +# # +# This addon 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. # +# # +# This addon 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 this addon. If not, see https://www.gnu.org/licenses # +# # +################################################################################ + +import threading +import unittest +from unittest.mock import patch + +import NetworkManager + + +class SynchronousRequests: + """Stands in for a NetworkManager, providing only the state that its completion handlers use. + + The handlers themselves are borrowed from NetworkManager, so they are the real code under test. + A NetworkManager cannot be constructed here: it creates a QNetworkAccessManager, which needs a + running application, and the app test suite deliberately runs without one. + """ + + complete_request = NetworkManager.NetworkManager._NetworkManager__complete_synchronous_request + + # The handlers are private, so Python has mangled the name of the call from one to the other. + # The stub has to offer that mangled name for the borrowed code to find it. + _NetworkManager__synchronous_process_completion = ( + NetworkManager.NetworkManager._NetworkManager__synchronous_process_completion + ) + + def __init__(self): + self.synchronous_lock = threading.Lock() + self.synchronous_complete = {} + self.synchronous_result_data = {} + self.synchronous_quiet = set() + + def await_response(self, index: int, quiet: bool) -> None: + """Set up the state that blocking_get() creates while it waits for a response.""" + self.synchronous_complete[index] = False + if quiet: + self.synchronous_quiet.add(index) + + +class TestSynchronousCompletion(unittest.TestCase): + """Tests for the reporting of failed requests made through the blocking interface.""" + + def setUp(self): + console_patch = patch("NetworkManager.fci.Console") + self.mock_console = console_patch.start() + self.addCleanup(console_patch.stop) + + self.requests = SynchronousRequests() + + def test_failure_is_reported(self): + """By default, a failed request is reported to the user.""" + self.requests.await_response(index=1, quiet=False) + + self.requests.complete_request(1, 404, None) + + self.mock_console.PrintWarning.assert_called_once() + + def test_quiet_failure_is_not_reported(self): + """A failed request marked quiet is not reported to the user.""" + self.requests.await_response(index=1, quiet=True) + + self.requests.complete_request(1, 404, None) + + self.mock_console.PrintWarning.assert_not_called() + + def test_quiet_request_still_completes(self): + """A quiet request is still marked complete, so that the caller stops waiting for it.""" + self.requests.await_response(index=1, quiet=True) + + self.requests.complete_request(1, 404, None) + + self.assertTrue(self.requests.synchronous_complete[1]) + + def test_quiet_does_not_affect_other_requests(self): + """Marking one request quiet does not suppress the reporting of any other request.""" + self.requests.await_response(index=1, quiet=True) + self.requests.await_response(index=2, quiet=False) + + self.requests.complete_request(2, 404, None) + + self.mock_console.PrintWarning.assert_called_once() + + def test_successful_quiet_request_returns_its_data(self): + """A quiet request that succeeds stores its data, just like any other request.""" + self.requests.await_response(index=1, quiet=True) + + self.requests.complete_request(1, 200, b"file contents") + + self.assertEqual(b"file contents", self.requests.synchronous_result_data[1]) + self.mock_console.PrintWarning.assert_not_called() + + def test_completion_of_an_unknown_request_is_ignored(self): + """A request that nobody is waiting for (an asynchronous one) is left alone.""" + self.requests.complete_request(1, 404, None) + + self.assertEqual({}, self.requests.synchronous_complete) + self.mock_console.PrintWarning.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/NetworkManager.py b/NetworkManager.py index 20bd8f20..5449d6dd 100644 --- a/NetworkManager.py +++ b/NetworkManager.py @@ -57,7 +57,7 @@ import itertools import tempfile import time -from typing import Dict, List, Optional +from typing import Dict, List, Optional, Set import addonmanager_freecad_interface as fci from addonmanager_preferences_migrations import migrate_proxy_settings_2025 @@ -140,6 +140,7 @@ def __init__(self): self.synchronous_lock = threading.Lock() self.synchronous_complete: Dict[int, bool] = {} self.synchronous_result_data: Dict[int, QtCore.QByteArray] = {} + self.synchronous_quiet: Set[int] = set() # Indices whose failures are not reported # Only one size request at a time: self.download_size_lock = threading.Lock() @@ -164,7 +165,7 @@ def __init__(self): self._setup_proxy() # A helper connection for our blocking interface - self.completed.connect(self.__synchronous_process_completion) + self.completed.connect(self.__complete_synchronous_request) # Set up our worker connection self.__request_queued.connect(self.__setup_network_request) @@ -322,6 +323,7 @@ def blocking_get_with_retries( max_attempts: int = 3, delay_ms: int = 1000, disable_cache: bool = False, + quiet: bool = False, ): """Submits a GET request to the QNetworkAccessManager and blocks until it is complete. Do not use on the main GUI thread, it will prevent any event processing while it blocks. @@ -329,6 +331,7 @@ def blocking_get_with_retries( :timeout_ms: The timeout in milliseconds :max_attempts: The number of attempts to make if the request fails :delay_ms: The delay between attempts in milliseconds + :quiet: Do not report a failed request to the user: the file is allowed to be missing :returns: The response data, or None if the request failed after max_attempts attempts. """ if max_attempts < 1: @@ -338,29 +341,37 @@ def blocking_get_with_retries( attempt = 0 while True: attempt += 1 - p = self.blocking_get(url, timeout_ms) + p = self.blocking_get(url, timeout_ms, disable_cache, quiet) if p is not None or attempt >= max_attempts: return p if QtCore.QThread.currentThread().isInterruptionRequested(): return None - fci.Console.PrintWarning( - f"Failed to get {url}, retrying in {delay_ms}ms... (attempt {attempt} of {max_attempts})\n" - ) + if not quiet: + fci.Console.PrintWarning( + f"Failed to get {url}, retrying in {delay_ms}ms... (attempt {attempt} of {max_attempts})\n" + ) time.sleep(delay_ms / 1000) def blocking_get( - self, url: str, timeout_ms: int = default_timeout, disable_cache: bool = False + self, + url: str, + timeout_ms: int = default_timeout, + disable_cache: bool = False, + quiet: bool = False, ) -> Optional[QtCore.QByteArray]: """Submits a GET request to the QNetworkAccessManager and blocks until it is complete. Do not use on the main GUI thread, it will prevent any event processing while it blocks. :url: The URL to fetch :timeout_ms: The timeout in milliseconds + :quiet: Do not report a failed request to the user: the file is allowed to be missing :returns: The response data, or None if the request failed after max_attempts attempts. """ current_index = next(self.counting_iterator) # A thread-safe counter with self.synchronous_lock: self.synchronous_complete[current_index] = False + if quiet: + self.synchronous_quiet.add(current_index) self.queue.put( QueueItem( @@ -380,20 +391,31 @@ def blocking_get( with self.synchronous_lock: self.synchronous_complete.pop(current_index) + self.synchronous_quiet.discard(current_index) if current_index in self.synchronous_result_data: return self.synchronous_result_data.pop(current_index) return None - def __synchronous_process_completion( + def __complete_synchronous_request( self, index: int, code: int, data: QtCore.QByteArray + ) -> None: + """Slot for the completed signal. The signal is shared with the asynchronous interface and + cannot carry the quiet flag, which applies to a single request, so it is looked up here.""" + with self.synchronous_lock: + quiet = index in self.synchronous_quiet + self.__synchronous_process_completion(index, code, data, quiet) + + def __synchronous_process_completion( + self, index: int, code: int, data: QtCore.QByteArray, quiet: bool = False ) -> None: """Check the return status of a completed process, and handle its returned data (if - any).""" + any). When quiet is set, a failed request is not reported to the user: the caller has + asked for a file that it knows may not exist.""" with self.synchronous_lock: if index in self.synchronous_complete: if code == 200: self.synchronous_result_data[index] = data - else: + elif not quiet: fci.Console.PrintWarning( translate( "AddonsInstaller", diff --git a/addonmanager_workers_startup.py b/addonmanager_workers_startup.py index 672d820b..0b846351 100644 --- a/addonmanager_workers_startup.py +++ b/addonmanager_workers_startup.py @@ -171,6 +171,7 @@ def _fetch_remote_custom_addon_metadata(self, repo: Addon) -> None: CreateAddonListWorker.ATTEMPT_TIMEOUT_MS, 1, # single attempt – don't slow down startup for missing files 0, + quiet=True, # Most addons do not have all of these files, so 404 is not an error ) except Exception as e: fci.Console.PrintLog(