Files
test/tests/python/bl_http_downloader.py

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

139 lines
4.5 KiB
Python
Raw Normal View History

Python: add HTTP file downloader Add a new package `scripts/modules/_bpy_internal/http`, containing classes to download files via HTTP. The code is intentionally put into the `_bpy_internal` package, as I don't intend it to be the end-all-be-all of downloaders for general use in add-ons. It's been written to support the Remote Asset Library project (#134495), where it will be used to download JSON files (to get the list of assets on the server) as well as the asset files themselves. The module consists of several parts. The main ones are: `class ConditionalDownloader` : File downloader, which downloads a URL to a file on disk. It supports conditional requests via `ETag`/`If-None-Match` and `Last-Modified`/`If-Modified-Since` HTTP headers (RFC 7273, section 3. Precondition Header Fields). A `304 Not Modified` response is treated as a succesful download. Metadata of the request (the response length in bytes, and the above headers) are stored on disk, in a location that is determined by the user of the class. Probably in the future it would be nice to have a single sqlite database for this (there's a TODO in the code about this). The downloader uses the Requests library, and manages its own HTTP session object. This way it can handle TCP/IP connection reuse, automatically retry failing connections, and in the future HTTP-level authentication. `class BackgroundDownloader` : Wrapper for a `ConditionalDownloader` that manages a background process for the actual downloading. It runs the downloader in a background process, while ensuring that its reporters (see below) get called on the main process. This way it's possible to do background downloading, while still receiving progress reports in a modal operator, which in turn can directly call Blender's Python API. Care was taken to [not use Python threads][1] `class DownloadReporter` : Protocol class. Objects adhering to the protocol can be given to a `ConditionalDownloader` or `BackgroundDownloader`. The protocol has functions like `download_starts(…)`, `download_progress(…)`, `download_error(…)`, which will be called by the downloader to report on what it's doing. I chose to make this a protocol, rather than an abstract superclass, because then it's possible to make an Operator a DownloadReporter without requiring multi-classing. [1]: https://docs.blender.org/api/main/info_gotchas_threading.html Pull Request: https://projects.blender.org/blender/blender/pulls/138327
2025-08-01 12:27:56 +02:00
# SPDX-FileCopyrightText: 2025 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
"""
blender -b --factory-startup -P tests/python/bl_http_downloader.py -- output-dir /tmp/should-not-exist --verbose
"""
__all__ = (
"main",
)
import unittest
from pathlib import Path
output_dir: Path
class BasicImportTest(unittest.TestCase):
"""Just do a basic import and instantiation of classes.
This doesn't test the functionality, but does ensure that dependencies like
third-party libraries are available.
"""
def test_downloader(self) -> None:
from _bpy_internal.http import downloader as http_dl
metadata_provider = http_dl.MetadataProviderFilesystem(
cache_location=output_dir / "http_metadata"
)
downloader = http_dl.ConditionalDownloader(metadata_provider=metadata_provider)
self.assertIsNotNone(downloader)
def test_background_downloader(self) -> None:
from _bpy_internal.http import downloader as http_dl
metadata_provider = http_dl.MetadataProviderFilesystem(
cache_location=output_dir / "http_metadata"
)
options = http_dl.DownloaderOptions(
metadata_provider=metadata_provider,
timeout=1,
http_headers={'X-Unit-Test': self.__class__.__name__}
)
def on_error(req_desc: http_dl.RequestDescription, local_path: Path, ex: Exception) -> None:
self.fail(f"unexpected call to on_error({req_desc}, {local_path}, {ex})")
downloader = http_dl.BackgroundDownloader(
options=options,
on_callback_error=on_error,
)
# Test some trivial properties that don't require anything running.
self.assertTrue(downloader.all_downloads_done)
self.assertEqual(0, downloader.num_pending_downloads)
self.assertFalse(downloader.is_shutdown_requested)
self.assertFalse(downloader.is_shutdown_complete)
self.assertFalse(downloader.is_subprocess_alive)
class BackgroundDownloaderProcessTest(unittest.TestCase):
"""Start & stop the background process for the BackgroundDownloader.
This doesn't test any HTTP requests, but does start & stop the background
process to check that this is at least possible.
"""
def test_start_stop(self) -> None:
from _bpy_internal.http import downloader as http_dl
metadata_provider = http_dl.MetadataProviderFilesystem(
cache_location=output_dir / "http_metadata"
)
options = http_dl.DownloaderOptions(
metadata_provider=metadata_provider,
timeout=1,
http_headers={'X-Unit-Test': self.__class__.__name__}
)
def on_error(req_desc: http_dl.RequestDescription, local_path: Path, ex: Exception) -> None:
self.fail(f"unexpected call to on_error({req_desc}, {local_path}, {ex})")
downloader = http_dl.BackgroundDownloader(
options=options,
on_callback_error=on_error,
)
# Queueing a download before the downloader has started should be rejected.
with self.assertRaises(RuntimeError):
downloader.queue_download("https://example.com/", output_dir / "download.tmp")
downloader.start()
try:
self.assertFalse(downloader.is_shutdown_requested)
self.assertFalse(downloader.is_shutdown_complete)
self.assertTrue(downloader.is_subprocess_alive)
# For good measure, call the update function a few times to ensure that
# any messages are sent. There shouldn't be any, but this should also
# not be a problem.
downloader.update()
downloader.update()
downloader.update()
finally:
# In case any of the pre-shutdown assertions fail, the downloader
# should still be shut down.
if downloader.is_subprocess_alive:
downloader.shutdown()
downloader.shutdown()
self.assertTrue(downloader.is_shutdown_requested)
self.assertTrue(downloader.is_shutdown_complete)
self.assertFalse(downloader.is_subprocess_alive)
def main() -> None:
global output_dir
import sys
import tempfile
argv = [sys.argv[0]]
if '--' in sys.argv:
argv.extend(sys.argv[sys.argv.index('--') + 1:])
with tempfile.TemporaryDirectory() as temp_dir:
output_dir = Path(temp_dir)
unittest.main(argv=argv)
if __name__ == "__main__":
main()