Skip to content
Merged
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
119 changes: 119 additions & 0 deletions AddonManagerTest/app/test_network_manager.py
Original file line number Diff line number Diff line change
@@ -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()
42 changes: 32 additions & 10 deletions NetworkManager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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)
Expand Down Expand Up @@ -322,13 +323,15 @@ 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.
:url: The URL to fetch
: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:
Expand All @@ -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(
Expand All @@ -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",
Expand Down
1 change: 1 addition & 0 deletions addonmanager_workers_startup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading