mirror of
https://github.com/blender/blender
synced 2026-09-27 19:44:35 +03:00
Assets: Python code for generating and downloading assets & listings
Part of the remote asset libraries project, see: https://projects.blender.org/blender/blender/issues/134495 No user visible changes expected. This commit contains all the Python code necessary integration with Blender's asset system (so for downloading asset listings, previews, and the assets themselves), and for the `blender -c asset_listing` CLI command. The OpenAPI datamodel generator no longer includes the OpenAPI schema in the generated code. That was intended to be used by a validation library, but as it turns out, that couldn't handle the recursive definitions we need. Now the code just relies on the validation from the cattrs library, which is good enough for our purpose. Available CLI commands: blender -c asset_listing generate: Generates the asset listing for all assets in the current directory & subdirectories. blender -c asset_listing download <URL>: This is more of a test command, to download the asset listing. The URL should point to the root of the asset library, so basically a HTTP-exposed version of the same directory in which the `generate` command above was given. The files are downloaded to `./_asset_download_location`. That way the current directory isn't spammed with multiple files. Downloading includes a time-based 'stamp' for cache-busting, when requesting the top-level JSON file of a remote asset library's listing. This stamp is the number of seconds since some point in time, divided by 60, to make it change once per minute. This means that a caching service like CloudFlare can cache the response, and only forward the query to our HTTP server once every minute. Co-authored-by: Julian Eisel <julian@blender.org> Pull Request: https://projects.blender.org/blender/blender/pulls/153284
This commit is contained in:
parent
8f504c35d1
commit
ce34c5de34
22 changed files with 3117 additions and 104 deletions
|
|
@ -789,6 +789,9 @@ def register():
|
|||
|
||||
cli_commands.append(bpy.utils.register_cli_command("extension", cli_extension))
|
||||
|
||||
from _bpy_internal.assets import remote_library_listing
|
||||
cli_commands.append(bpy.utils.register_cli_command("asset_listing", remote_library_listing.asset_listing_main))
|
||||
|
||||
monkeypatch_install()
|
||||
|
||||
if not bpy.app.background:
|
||||
|
|
|
|||
|
|
@ -1,3 +1,27 @@
|
|||
# SPDX-FileCopyrightText: 2025 Blender Authors
|
||||
# SPDX-FileCopyrightText: 2026 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
# The function below is (un)registered from scripts/addons_core/bl_pkg/__init__.py:
|
||||
def asset_listing_main(args: list[str]) -> int:
|
||||
"""Run the `blender -c asset_listing` CLI command.
|
||||
|
||||
This is late-importing the cli module, so that it (and its
|
||||
dependencies) are only imported when actually used.
|
||||
"""
|
||||
import traceback
|
||||
from . import cli
|
||||
|
||||
try:
|
||||
cli.main(args)
|
||||
except SystemExit as ex:
|
||||
if isinstance(ex.code, int):
|
||||
return ex.code
|
||||
return 2
|
||||
except BaseException:
|
||||
traceback.print_exc()
|
||||
return 1
|
||||
return 0
|
||||
|
|
|
|||
|
|
@ -0,0 +1,515 @@
|
|||
# SPDX-FileCopyrightText: 2026 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
__all__ = [
|
||||
"download_asset_file",
|
||||
"downloader_status",
|
||||
"DownloadStatus",
|
||||
]
|
||||
|
||||
import enum
|
||||
import logging
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
from typing import Callable, TypeAlias
|
||||
|
||||
import bpy
|
||||
|
||||
from _bpy_internal.http import downloader as http_dl
|
||||
from _bpy_internal.assets.remote_library_listing.listing_downloader import RemoteAssetListingLocator
|
||||
from _bpy_internal.assets.remote_library_listing import hashing
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Preview images will NOT be downloaded if they already exist on disk AND their
|
||||
# timestamp is younger than this age.
|
||||
PREVIEW_DOWNLOAD_AGE_THRESHOLD_SEC = 7 * 24 * 3600 # 1 week
|
||||
|
||||
_asset_downloaders: dict[str, AssetDownloader] = {}
|
||||
_preview_downloaders: dict[str, AssetDownloader] = {}
|
||||
|
||||
|
||||
def download_asset_file(
|
||||
asset_library_url: str,
|
||||
asset_library_local_path: Path,
|
||||
asset_url: str,
|
||||
asset_hash: str,
|
||||
save_to: Path) -> None:
|
||||
"""Download an asset file to a file on disk.
|
||||
|
||||
:param asset_library_url: Root URL of the remote asset library. Used as an
|
||||
identifier of this library (to create a downloader per library), as well
|
||||
as for resolving relative URLs.
|
||||
|
||||
:param asset_library_local_path: Root path of the local asset cache. Used to
|
||||
resolve relative `save_to` paths, but also to find the HTTP metadata
|
||||
cache for this asset library (for conditional downloads).
|
||||
|
||||
:param asset_url: the URL to download. Can be absolute or relative to the
|
||||
asset library URL. If it is an empty string, the `save_to` path is used
|
||||
as the URL.
|
||||
:param asset_hash: the hash of the asset file, will be appended to the URL.
|
||||
|
||||
:param save_to: the path on disk where to download to. While the download is
|
||||
pending, ".part" will be appended to the filename. When the download
|
||||
finishes succesfully, it is renamed to the final path.
|
||||
"""
|
||||
try:
|
||||
downloader = _asset_downloaders[asset_library_url]
|
||||
assert downloader.local_path == asset_library_local_path, "This code assumes that remote asset libraries do not move on the local disk"
|
||||
except KeyError:
|
||||
downloader = AssetDownloader(
|
||||
asset_library_url,
|
||||
asset_library_local_path,
|
||||
lambda x: None, # on-update callback.
|
||||
_asset_download_done,
|
||||
lambda x: None, # on-queue-empty callback.
|
||||
)
|
||||
downloader.start()
|
||||
_asset_downloaders[asset_library_url] = downloader
|
||||
|
||||
# Construct the URL if not given explicitly.
|
||||
if not asset_url:
|
||||
if save_to.is_absolute():
|
||||
relative_path = save_to.relative_to(asset_library_local_path)
|
||||
else:
|
||||
relative_path = save_to
|
||||
asset_url = urllib.parse.quote(relative_path.as_posix())
|
||||
|
||||
# Include the hash in the URL, and download the asset.
|
||||
download_url = hashing.url((asset_url, asset_hash))
|
||||
downloader.download_asset_file(download_url, save_to)
|
||||
|
||||
|
||||
def download_preview(
|
||||
asset_library_url: str,
|
||||
asset_library_local_path: Path,
|
||||
preview_url: str,
|
||||
preview_hash: str,
|
||||
dst_filepath: Path) -> None:
|
||||
"""Download an asset preview to a file on disk.
|
||||
|
||||
:param asset_library_url: Root URL of the remote asset library. Used as an
|
||||
identifier of this library (to create a downloader per library), as well
|
||||
as for resolving relative URLs.
|
||||
|
||||
:param asset_library_local_path: Root path of the local asset cache. Used to
|
||||
resolve relative `save_to` paths, but also to find the HTTP metadata
|
||||
cache for this asset library (for conditional downloads).
|
||||
|
||||
:param preview_url: the URL to download. Can be absolute or relative.
|
||||
:param preview_hash: the hash of the thumbnail, will be appended to the URL.
|
||||
|
||||
:param savedst_filepath_to: the path on disk where to download to. While the
|
||||
download is pending, ".part" will be appended to the filename. When the
|
||||
download finishes succesfully, it is renamed to the final path.
|
||||
"""
|
||||
import time
|
||||
|
||||
# Check if the file exists and is new enough. If it is, don't bother the server.
|
||||
try:
|
||||
stat = dst_filepath.stat()
|
||||
except FileNotFoundError:
|
||||
pass # Fine, something new to download.
|
||||
else:
|
||||
# File exists, let's see if it's young enough to use as-is.
|
||||
age_in_seconds = time.time() - stat.st_mtime
|
||||
if age_in_seconds < PREVIEW_DOWNLOAD_AGE_THRESHOLD_SEC:
|
||||
# The local file is still fresh, just pretend we just downloaded it.
|
||||
wm = bpy.context.window_manager
|
||||
wm.asset_library_status_ping_loaded_new_preview(str(dst_filepath))
|
||||
return
|
||||
|
||||
try:
|
||||
downloader = _preview_downloaders[asset_library_url]
|
||||
assert downloader.local_path == asset_library_local_path, "This code assumes that remote asset libraries do not move on the local disk"
|
||||
except KeyError:
|
||||
downloader = AssetDownloader(
|
||||
asset_library_url,
|
||||
asset_library_local_path,
|
||||
lambda x: None, # on-update callback.
|
||||
_preview_download_done,
|
||||
lambda x: None, # on-queue-empty callback.
|
||||
)
|
||||
downloader.start()
|
||||
_preview_downloaders[asset_library_url] = downloader
|
||||
|
||||
# Include the hash in the URL, and download the preview.
|
||||
download_url = hashing.url((preview_url, preview_hash))
|
||||
downloader.download_asset_file(download_url, dst_filepath)
|
||||
|
||||
|
||||
def _asset_download_done(
|
||||
downloader: AssetDownloader,
|
||||
_http_req_descr: http_dl.RequestDescription,
|
||||
_preview_local_path: Path,
|
||||
) -> None:
|
||||
wm = bpy.context.window_manager
|
||||
wm.asset_library_status_ping_loaded_new_assets(downloader.remote_url)
|
||||
|
||||
|
||||
def _preview_download_done(
|
||||
downloader: AssetDownloader,
|
||||
http_req_descr: http_dl.RequestDescription,
|
||||
preview_local_path: Path,
|
||||
) -> None:
|
||||
# Check whether the file was actually an image.
|
||||
assert http_req_descr.response_headers
|
||||
content_type = http_req_descr.response_headers.get('content-type', "")
|
||||
|
||||
# Only check the content type if the server sends it back. Otherwise
|
||||
# just trust that it's valid. For example, when sending a `304 Not
|
||||
# Modified`, the server may actually skip the Content-Type header.
|
||||
if content_type and not content_type.startswith('image/'):
|
||||
logger.warning("Thumbnail URL %r has content type %r, expected an image",
|
||||
http_req_descr.url, content_type)
|
||||
# TODO: mark as 'failed' so that this file isn't repeatedly
|
||||
# downloaded and rejected. For now I'll just keep the file
|
||||
# around, so that at least the timestamping works to prevent
|
||||
# hammering the server.
|
||||
|
||||
# Indicate to a future run that we just confirmed this file is still fresh.
|
||||
preview_local_path.touch()
|
||||
|
||||
# Poke Blender so it knows there's a thumbnail update.
|
||||
wm = bpy.context.window_manager
|
||||
wm.asset_library_status_ping_loaded_new_preview(str(preview_local_path))
|
||||
|
||||
|
||||
def downloader_status(asset_library_url: str) -> DownloadStatus:
|
||||
"""Returns the asset downloader status.
|
||||
|
||||
Raises a KeyError if there never was a downloader for this URL.
|
||||
"""
|
||||
return _asset_downloaders[asset_library_url].status
|
||||
|
||||
|
||||
class DownloadStatus(enum.Enum):
|
||||
IDLE = 'idle'
|
||||
DOWNLOADING = 'downloading'
|
||||
|
||||
FINISHED = 'finished'
|
||||
"""The downloader has downloaded everything that was queued.
|
||||
|
||||
Note: this does NOT mean that all downloads were perfect. It just means that
|
||||
there were no exceptions raised.
|
||||
"""
|
||||
|
||||
FAILED = 'failed'
|
||||
"""Unexpected exceptions occurred."""
|
||||
|
||||
|
||||
class AssetDownloader:
|
||||
_locator: RemoteAssetListingLocator
|
||||
|
||||
# Called for download progres
|
||||
OnUpdateCallback: TypeAlias = Callable[['AssetDownloader'], None]
|
||||
_on_update_callback: OnUpdateCallback
|
||||
|
||||
# Called when the entire queue is 'done':
|
||||
OnDoneCallback: TypeAlias = Callable[['AssetDownloader'], None]
|
||||
_on_done_callback: OnDoneCallback
|
||||
|
||||
# Called for each downloaded file being 'done':
|
||||
OnAssetDoneCallback: TypeAlias = Callable[['AssetDownloader', http_dl.RequestDescription, Path], None]
|
||||
_on_asset_done_callback: OnAssetDoneCallback | None
|
||||
|
||||
_bg_downloader: http_dl.BackgroundDownloader | None
|
||||
_num_assets_pending: int
|
||||
|
||||
_status: DownloadStatus
|
||||
_error_message: str
|
||||
"""An error message to show to the user.
|
||||
|
||||
Should be set on errors to communicate a message to users. Calling report()
|
||||
with 'ERROR' as the level will set this to the given message.
|
||||
"""
|
||||
|
||||
_DOWNLOAD_POLL_INTERVAL: float = 0.01
|
||||
"""How often the background download process is polled, in seconds.
|
||||
|
||||
Each 'poll' involves sending queued messages back & forth between the main
|
||||
Blender process and the background download process.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
remote_url: str,
|
||||
local_path: Path | str,
|
||||
on_update_callback: OnUpdateCallback,
|
||||
on_asset_done_callback: OnAssetDoneCallback,
|
||||
on_done_callback: OnDoneCallback,
|
||||
) -> None:
|
||||
"""Create a downloader for assets of a specific asset library.
|
||||
|
||||
:param remote_url: Base URL of the remote asset library server.
|
||||
|
||||
:param local_path: The directory to download the index files to.
|
||||
|
||||
:param on_update_callback: Called with one parameter (this
|
||||
AssetDownloader) in short, regular intervals
|
||||
(_DOWNLOAD_POLL_INTERVAL) while the download is ongoing, and once
|
||||
just after the download is done.
|
||||
|
||||
:param on_done_callback: called with one parameter (this
|
||||
AssetDownloader) whenever the downloader is "done".
|
||||
|
||||
Here "done" does not imply "successful", as cancellations, network
|
||||
errors, or other issues can cause things to abort. In that case,
|
||||
this function is still called.
|
||||
|
||||
:param on_asset_done_callback: called with one parameter (this
|
||||
AssetDownloader) when at least one new asset finished downloading
|
||||
and was put in its final location, ready to be picked up by the
|
||||
asset system.
|
||||
"""
|
||||
self._locator = RemoteAssetListingLocator(remote_url, local_path)
|
||||
|
||||
self._on_done_callback = on_done_callback
|
||||
self._on_update_callback = on_update_callback
|
||||
self._on_asset_done_callback = on_asset_done_callback
|
||||
|
||||
self._num_assets_pending = 0
|
||||
|
||||
self._status = DownloadStatus.IDLE
|
||||
self._error_message = ""
|
||||
|
||||
# Work around a limitation of Blender, see bug report #139720 for details.
|
||||
self.on_timer_event = self.on_timer_event # type: ignore[method-assign]
|
||||
|
||||
self._http_metadata_provider = http_dl.MetadataProviderFilesystem(
|
||||
cache_location=self._locator.http_metadata_cache_location,
|
||||
)
|
||||
|
||||
self._bg_downloader = None
|
||||
|
||||
def _create_bg_downloader(self) -> None:
|
||||
self._bg_downloader = http_dl.BackgroundDownloader(
|
||||
options=http_dl.DownloaderOptions(
|
||||
metadata_provider=self._http_metadata_provider,
|
||||
timeout=300,
|
||||
http_headers={
|
||||
'X-Blender': "{:d}.{:d}".format(*bpy.app.version),
|
||||
},
|
||||
),
|
||||
on_callback_error=self._on_callback_error,
|
||||
)
|
||||
self._bg_downloader.add_reporter(self)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return "{!s}(remote_url={!r}, local_path={!r})".format(
|
||||
type(self),
|
||||
self._locator.remote_url,
|
||||
self._locator.local_path,
|
||||
)
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start the background process."""
|
||||
if not self._bg_downloader:
|
||||
self._create_bg_downloader()
|
||||
assert self._bg_downloader
|
||||
self._bg_downloader.start()
|
||||
|
||||
# Register the timer for periodic message passing between the main and
|
||||
# background processes.
|
||||
if not bpy.app.timers.is_registered(self.on_timer_event):
|
||||
bpy.app.timers.register(
|
||||
self.on_timer_event,
|
||||
first_interval=self._DOWNLOAD_POLL_INTERVAL,
|
||||
persistent=True,
|
||||
)
|
||||
# Double-check the registration worked, see #139720 for details.
|
||||
assert bpy.app.timers.is_registered(self.on_timer_event)
|
||||
|
||||
def download_asset_file(self, asset_url: str, save_to: Path) -> None:
|
||||
"""Download an asset or preview file to a local file."""
|
||||
|
||||
# If the downloader was shut down, start it up again.
|
||||
if not self._bg_downloader:
|
||||
self.start()
|
||||
|
||||
self._status = DownloadStatus.DOWNLOADING
|
||||
self._queue_download(asset_url, save_to)
|
||||
|
||||
def _shutdown_if_done(self) -> None:
|
||||
if self._num_assets_pending == 0 and (self._bg_downloader is None or self._bg_downloader.all_downloads_done):
|
||||
# Done downloading everything, let's shut down.
|
||||
|
||||
# TODO: delay this for a few minutes, so that we don't need a new
|
||||
# background process for every asset.
|
||||
self.shutdown(DownloadStatus.FINISHED)
|
||||
|
||||
def _on_callback_error(
|
||||
self,
|
||||
http_req_descr: http_dl.RequestDescription,
|
||||
local_file: Path,
|
||||
exception: Exception) -> None:
|
||||
logger.exception(
|
||||
"exception while handling downloaded file ({!r}, saved to {!r})".format(
|
||||
http_req_descr, local_file))
|
||||
self.report({'ERROR'}, "Resource download had an issue, download aborted")
|
||||
self.shutdown(DownloadStatus.FAILED)
|
||||
|
||||
def _queue_download(self, asset_url: str, download_to_path: Path | str) -> Path:
|
||||
"""Queue up this download, returning the path to which it will be downloaded."""
|
||||
remote_url = urllib.parse.urljoin(self._locator.remote_url, asset_url)
|
||||
download_to_path = self._locator.local_path / download_to_path
|
||||
|
||||
logger.info("downloading %s to %s", remote_url, download_to_path)
|
||||
|
||||
assert self._bg_downloader, "downloads can only be queued when the bgdownloader is available"
|
||||
self._bg_downloader.queue_download(
|
||||
remote_url,
|
||||
download_to_path,
|
||||
self._on_asset_done,
|
||||
)
|
||||
return download_to_path
|
||||
|
||||
def _on_asset_done(self,
|
||||
http_req_descr: http_dl.RequestDescription,
|
||||
local_file: Path,
|
||||
) -> None:
|
||||
if self._on_asset_done_callback:
|
||||
self._on_asset_done_callback(self, http_req_descr, local_file)
|
||||
|
||||
# TODO: implement this in a more useful way:
|
||||
def report(self, level: set[str], message: str) -> None:
|
||||
# logger.info("Report: {:s}: {:s}".format("/".join(level), message))
|
||||
if 'ERROR' in level:
|
||||
self._error_message = message
|
||||
|
||||
def shutdown(self, status: DownloadStatus) -> None:
|
||||
"""Stop the background downloader, update the status and call the 'done' callback."""
|
||||
|
||||
self._status = status
|
||||
|
||||
# The timer is no longer necessary, the bg_downloader.shutdown() call
|
||||
# takes care of the last queued messages.
|
||||
if bpy.app.timers.is_registered(self.on_timer_event):
|
||||
bpy.app.timers.unregister(self.on_timer_event)
|
||||
|
||||
try:
|
||||
if self._bg_downloader:
|
||||
# Only report if this is actually triggering a shutdown. If that was
|
||||
# already triggered somehow, don't bother.
|
||||
if not self._bg_downloader.is_shutdown_requested:
|
||||
# It may be tempting to call self.report(...) here, and report on the
|
||||
# cancellation. However, this should be done by the caller, when they know
|
||||
# of the reason of the cancellation and thus can provide more info.
|
||||
num_pending = self._bg_downloader.num_pending_downloads
|
||||
if num_pending:
|
||||
logger.warning("Shutting down background downloader, %d downloads pending", num_pending)
|
||||
|
||||
self._bg_downloader.shutdown()
|
||||
finally:
|
||||
# Regardless of whether the shutdown had some issues, the timer has
|
||||
# been unregistered, so there will be no more message handling, and
|
||||
# so for all intents and purposes, the downloader is done.
|
||||
self._bg_downloader = None
|
||||
self._on_done_callback(self)
|
||||
|
||||
def on_timer_event(self) -> float:
|
||||
assert self._bg_downloader, "timer events should only come in while the bgdownloader is available"
|
||||
|
||||
try:
|
||||
self._bg_downloader.update()
|
||||
except http_dl.BackgroundProcessNotRunningError:
|
||||
logger.error("Background downloader subprocess died, aborting.")
|
||||
self.shutdown(DownloadStatus.FAILED)
|
||||
return 0 # Deactivate the timer.
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Unexpected error downloading remote asset library ilisting from %s to %s",
|
||||
self._locator.remote_url,
|
||||
self._locator.local_path)
|
||||
|
||||
# Automatically switch between IDLE and DOWNLOADING, but never overwrite
|
||||
# FAILED or FINISHED_SUCCESFULLY.
|
||||
if self._status in {DownloadStatus.DOWNLOADING, DownloadStatus.IDLE}:
|
||||
if self._bg_downloader.num_pending_downloads > 0:
|
||||
self._status = DownloadStatus.DOWNLOADING
|
||||
else:
|
||||
self._status = DownloadStatus.IDLE
|
||||
|
||||
self._on_update_callback(self)
|
||||
|
||||
return self._DOWNLOAD_POLL_INTERVAL
|
||||
|
||||
@property
|
||||
def remote_url(self) -> str:
|
||||
return self._locator.remote_url
|
||||
|
||||
@property
|
||||
def local_path(self) -> Path:
|
||||
return self._locator.local_path
|
||||
|
||||
@property
|
||||
def status(self) -> DownloadStatus:
|
||||
return self._status
|
||||
|
||||
@property
|
||||
def error_message(self) -> str:
|
||||
return self._error_message
|
||||
|
||||
# Below here: CachingDownloadReporter functions:
|
||||
|
||||
def download_starts(self, http_req_descr: http_dl.RequestDescription) -> None:
|
||||
self.report({'INFO'}, "Download starting: {}".format(http_req_descr.url))
|
||||
logger.debug("Download starting: %s", http_req_descr)
|
||||
|
||||
def already_downloaded(
|
||||
self,
|
||||
http_req_descr: http_dl.RequestDescription,
|
||||
local_file: Path,
|
||||
) -> None:
|
||||
logger.debug("Download unnecessary, file already downloaded: %s", http_req_descr.url)
|
||||
# TODO: tell Blender this file is done.
|
||||
self._shutdown_if_done()
|
||||
|
||||
def download_error(
|
||||
self,
|
||||
http_req_descr: http_dl.RequestDescription,
|
||||
local_file: Path,
|
||||
error: Exception,
|
||||
) -> None:
|
||||
if isinstance(error, http_dl.DownloadCancelled):
|
||||
if self._num_assets_pending:
|
||||
self.report({'WARNING'}, "Cancelled {} pending download".format(self._num_assets_pending))
|
||||
logger.warning("Download cancelled: %s", http_req_descr)
|
||||
self.shutdown(DownloadStatus.FAILED)
|
||||
return
|
||||
|
||||
# TODO: tell Blender there was an error downloading.
|
||||
|
||||
# Contrary to the RemoteAssetListingDownloader, this downloader treats
|
||||
# all downloads as independent, and thus a failure to download one
|
||||
# should never completely shut down the downloader.
|
||||
logger.warning("Could not download file %s: %s", http_req_descr, error)
|
||||
|
||||
# This could have been the last to-be-downloaded file, so better
|
||||
# check if there's anything left to do.
|
||||
self._shutdown_if_done()
|
||||
|
||||
def download_progress(
|
||||
self,
|
||||
http_req_descr: http_dl.RequestDescription,
|
||||
content_length_bytes: int,
|
||||
downloaded_bytes: int,
|
||||
) -> None:
|
||||
percentage = downloaded_bytes / content_length_bytes * 100
|
||||
self.report({'INFO'}, "File download progress: {:.0f}%".format(percentage))
|
||||
# logger.info("File download progress: %.0f%%", percentage)
|
||||
|
||||
def download_finished(
|
||||
self,
|
||||
http_req_descr: http_dl.RequestDescription,
|
||||
local_file: Path,
|
||||
) -> None:
|
||||
self.report({'INFO'}, "Download finished: {}".format(http_req_descr.url))
|
||||
logger.info("Download finished: %s", http_req_descr)
|
||||
|
||||
# TODO: tell Blender the download is done.
|
||||
self._shutdown_if_done()
|
||||
|
|
@ -8,6 +8,8 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -17,81 +19,91 @@ class Contact:
|
|||
email: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class URLWithHash:
|
||||
url: str
|
||||
hash: str
|
||||
|
||||
|
||||
type AssetIDTypeV1 = str
|
||||
|
||||
|
||||
class CustomPropertyTypeV1(StrEnum):
|
||||
STRING = "STRING"
|
||||
INT = "INT"
|
||||
FLOAT = "FLOAT"
|
||||
ARRAY = "ARRAY"
|
||||
GROUP = "GROUP"
|
||||
DOUBLE = "DOUBLE"
|
||||
BOOLEAN = "BOOLEAN"
|
||||
|
||||
|
||||
@dataclass
|
||||
class CatalogV1:
|
||||
path: str
|
||||
uuids: list[str]
|
||||
simple_name: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class FileV1:
|
||||
path: str
|
||||
size_in_bytes: int
|
||||
hash: str
|
||||
blender_version: str
|
||||
url: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class AssetLibraryMeta:
|
||||
api_versions: dict[str, str]
|
||||
api_versions: dict[str, URLWithHash]
|
||||
name: str
|
||||
contact: Contact
|
||||
|
||||
|
||||
# This OpenAPI specification was used to generate the above code.
|
||||
# It is here so that Blender does not have to parse the YAML file.
|
||||
OPENAPI_SPEC = {
|
||||
'openapi': '3.0.0',
|
||||
'info': {
|
||||
'version': '1.0.0',
|
||||
'title': 'Blender Asset Library API',
|
||||
'description': "Blender's API for describing and fetching assets from online libraries.",
|
||||
'contact': {
|
||||
'name': 'Blender',
|
||||
'url': 'https://www.blender.org/'},
|
||||
'license': {
|
||||
'name': 'GPLv3',
|
||||
'url': 'https://www.gnu.org/licenses/gpl-3.0.en.html'}},
|
||||
'servers': [
|
||||
{
|
||||
'url': '/'}],
|
||||
'paths': {
|
||||
'/_asset-library-meta.json': {
|
||||
'summary': 'Meta-information about this asset library.',
|
||||
'get': {
|
||||
'summary': 'Retrieve the asset library meta info.',
|
||||
'operationId': 'getLibraryMeta',
|
||||
'responses': {
|
||||
'200': {
|
||||
'description': 'normal response',
|
||||
'content': {
|
||||
'application/json': {
|
||||
'schema': {
|
||||
'$ref': '#/components/schemas/AssetLibraryMeta'}}}}}}}},
|
||||
'components': {
|
||||
'schemas': {
|
||||
'AssetLibraryMeta': {
|
||||
'type': 'object',
|
||||
'description': 'Meta-data of this asset library.',
|
||||
'properties': {
|
||||
'api_versions': {
|
||||
'type': 'object',
|
||||
'description': 'API versions of this asset library. This is reflected in the URLs of all OpenAPI operations except the one to get this metadata.\nA single asset library can expose multiple versions, in order to be backward-compatible with older versions of Blender.\nProperties should be "v1", "v2", etc. and their values should point to their respective index files.\n',
|
||||
'additionalProperties': {
|
||||
'type': 'string'},
|
||||
'patternProperties': {
|
||||
'^v[0-9]+$': {
|
||||
'type': 'string'}}},
|
||||
'name': {
|
||||
'type': 'string',
|
||||
'description': 'Name of this asset library.'},
|
||||
'contact': {
|
||||
'$ref': '#/components/schemas/Contact'}},
|
||||
'required': [
|
||||
'api_versions',
|
||||
'name',
|
||||
'contact'],
|
||||
'example': {
|
||||
'api_versions': {
|
||||
'v1': '_v1/asset-index.json'},
|
||||
'name': 'Blender Essentials',
|
||||
'contact': {
|
||||
'name': 'Blender',
|
||||
'url': 'https://www.blender.org/'}}},
|
||||
'Contact': {
|
||||
'type': 'object',
|
||||
'description': 'Owner / publisher of this asset library.',
|
||||
'properties': {
|
||||
'name': {
|
||||
'type': 'string'},
|
||||
'url': {
|
||||
'type': 'string'},
|
||||
'email': {
|
||||
'type': 'string'}},
|
||||
'required': ['name']}}}}
|
||||
@dataclass
|
||||
class AssetLibraryIndexV1:
|
||||
schema_version: str
|
||||
asset_size_bytes: int
|
||||
asset_count: int
|
||||
file_count: int
|
||||
pages: list[URLWithHash]
|
||||
catalogs: list[CatalogV1] | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class AssetLibraryIndexPageV1:
|
||||
asset_count: int
|
||||
file_count: int
|
||||
assets: list[AssetV1]
|
||||
files: list[FileV1]
|
||||
|
||||
|
||||
@dataclass
|
||||
class AssetV1:
|
||||
name: str
|
||||
id_type: AssetIDTypeV1
|
||||
files: list[str]
|
||||
thumbnail: URLWithHash | None = None
|
||||
meta: AssetMetadataV1 | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class AssetMetadataV1:
|
||||
catalog_id: str | None = None
|
||||
tags: list[str] | None = None
|
||||
author: str | None = None
|
||||
description: str | None = None
|
||||
license: str | None = None
|
||||
copyright: str | None = None
|
||||
custom: CustomPropertiesV1 | None = None
|
||||
|
||||
|
||||
type CustomPropertiesV1 = dict[str, CustomPropertyV1]
|
||||
|
||||
|
||||
@dataclass
|
||||
class CustomPropertyV1:
|
||||
type: CustomPropertyTypeV1
|
||||
value: CustomPropertiesV1 | list[Any] | float | int | str | bool
|
||||
itemtype: CustomPropertyTypeV1 | None = None
|
||||
|
|
|
|||
|
|
@ -1,15 +1,21 @@
|
|||
# SPDX-FileCopyrightText: 2025 Blender Authors
|
||||
# SPDX-FileCopyrightText: 2026 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
# This is the OpenAPI specification for Blender's Remote Assets system.
|
||||
#
|
||||
# It has been intentionally trimmed down to the bare minimum for a review of the
|
||||
# code generator in general, and how it integrates into Blender's build system.
|
||||
# At this moment, the `paths` section is not used by the Blender code, and is
|
||||
# here just for referencing by humans. It is also still being designed, so don't
|
||||
# take it as set in stone.
|
||||
#
|
||||
# The `paths` section is not used by the Blender code, and is here just for
|
||||
# referencing by humans. The Python code generator just uses the data structures
|
||||
# specified by the `components` section.
|
||||
# The Python code generator just uses the data structures specified by the
|
||||
# `components` section.
|
||||
#
|
||||
# --------------------------------------------------------------------------
|
||||
# Run `ninja generate_datamodels` from the build directory to regenerate the
|
||||
# Python code in blender_asset_library_openapi.py. Replace `ninja` with your
|
||||
# build tool of choice.
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
openapi: 3.0.0
|
||||
info:
|
||||
|
|
@ -25,11 +31,14 @@ info:
|
|||
servers:
|
||||
- url: /
|
||||
paths:
|
||||
## Meta
|
||||
|
||||
/_asset-library-meta.json:
|
||||
summary: Meta-information about this asset library.
|
||||
get:
|
||||
summary: Retrieve the asset library meta info.
|
||||
operationId: getLibraryMeta
|
||||
tags: [meta]
|
||||
responses:
|
||||
"200":
|
||||
description: normal response
|
||||
|
|
@ -38,8 +47,53 @@ paths:
|
|||
schema:
|
||||
$ref: "#/components/schemas/AssetLibraryMeta"
|
||||
|
||||
## Index
|
||||
/_v1/asset-index.json:
|
||||
summary: The index of the asset library, containing the metadata of all available assets.
|
||||
get:
|
||||
summary: Get the asset library index.
|
||||
operationId: getLibraryIndex
|
||||
tags: [index]
|
||||
responses:
|
||||
"200":
|
||||
description: normal response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AssetLibraryIndexV1"
|
||||
|
||||
/_v1/assets-{page}.json:
|
||||
summary: >
|
||||
The index of the asset library, containing the metadata of all available assets.
|
||||
Note that the actual URLs of these pages are listed in the `asset-index.json` above.
|
||||
The path specified here is merely a suggestion.
|
||||
get:
|
||||
summary: Get the asset library index.
|
||||
operationId: getLibraryIndexPage
|
||||
tags: [index]
|
||||
parameters:
|
||||
- name: page
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: integer }
|
||||
responses:
|
||||
"200":
|
||||
description: normal response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AssetLibraryIndexPageV1"
|
||||
|
||||
tags:
|
||||
- name: meta
|
||||
description: Info about the asset library itself.
|
||||
- name: index
|
||||
description: Access to the asset library's list of assets.
|
||||
|
||||
components:
|
||||
schemas:
|
||||
## Meta
|
||||
|
||||
AssetLibraryMeta:
|
||||
type: object
|
||||
description: "Meta-data of this asset library."
|
||||
|
|
@ -53,13 +107,9 @@ components:
|
|||
A single asset library can expose multiple versions, in order to be
|
||||
backward-compatible with older versions of Blender.
|
||||
|
||||
Properties should be "v1", "v2", etc. and their values should point
|
||||
to their respective index files.
|
||||
additionalProperties:
|
||||
type: string
|
||||
patternProperties:
|
||||
"^v[0-9]+$":
|
||||
type: string
|
||||
Keys should be "v1", "v2", etc. and their values should be a
|
||||
URLWithHash that points to each version's index file.
|
||||
additionalProperties: { $ref: "#/components/schemas/URLWithHash" }
|
||||
"name":
|
||||
type: string
|
||||
description: Name of this asset library.
|
||||
|
|
@ -67,7 +117,9 @@ components:
|
|||
required: [api_versions, name, contact]
|
||||
example:
|
||||
api_versions:
|
||||
v1: _v1/asset-index.json
|
||||
v1:
|
||||
url: _v1/asset-index.json
|
||||
hash: "SHA256:22c9d2d5e9fe119b43fb8437df06c88e61d3bbad315690284b9eece66641c1e9"
|
||||
name: Blender Essentials
|
||||
contact:
|
||||
name: Blender
|
||||
|
|
@ -81,3 +133,239 @@ components:
|
|||
"url": { type: string }
|
||||
"email": { type: string }
|
||||
required: [name]
|
||||
|
||||
## Index
|
||||
|
||||
AssetLibraryIndexV1:
|
||||
type: object
|
||||
description: The available assets at this library.
|
||||
properties:
|
||||
"schema_version":
|
||||
type: string
|
||||
description: >
|
||||
Version number of the used schema. This should be the same as the
|
||||
version of this OpenAPI definition, as described in its
|
||||
'info.version' field.
|
||||
"asset_size_bytes": { type: integer }
|
||||
"asset_count":
|
||||
type: integer
|
||||
description: >
|
||||
Total number of assets in this index. This is the sum of all
|
||||
`asset_count` fields of each page.
|
||||
"file_count":
|
||||
type: integer
|
||||
description: >
|
||||
Total number of files in this index. This is the sum of all
|
||||
`file_count` fields of each page (after deduplication).
|
||||
"pages":
|
||||
type: array
|
||||
items: { $ref: "#/components/schemas/URLWithHash" }
|
||||
description: >
|
||||
URLs of the individual asset index pages. When relative, these are
|
||||
taken as relative to the main server URL (i.e. the root of all paths
|
||||
defined in this OpenAPI spec).
|
||||
"catalogs":
|
||||
type: array
|
||||
items: { $ref: "#/components/schemas/CatalogV1" }
|
||||
required:
|
||||
[schema_version, asset_size_bytes, asset_count, file_count, pages]
|
||||
|
||||
URLWithHash:
|
||||
type: object
|
||||
description: >
|
||||
Resource that's identified by a URL. The resource should be fetched by
|
||||
including the hash in the query string, like `GET {URL}?hash={HASH}`.
|
||||
Here `{HASH}` should _not_ include the hash type. The purpose of
|
||||
including this on the URL is for cache busting, and thus the hash type
|
||||
is not relevant here.
|
||||
properties:
|
||||
"url":
|
||||
type: string
|
||||
description: URL of the page file
|
||||
"hash":
|
||||
type: string
|
||||
description: >
|
||||
Hash of the resource obtained at that URL. This should be in the
|
||||
format "HASHTYPE:HASH-AS-HEX". Currently only the "SHA256" hash type
|
||||
is supported.
|
||||
|
||||
Note that for dynamic API servers, which may perform a server-side
|
||||
filter on the data, the actual response may not have the same hash.
|
||||
Static servers send content that matches the hash.
|
||||
required: [url, hash]
|
||||
|
||||
AssetLibraryIndexPageV1:
|
||||
type: object
|
||||
description: Any number of assets.
|
||||
properties:
|
||||
"asset_count":
|
||||
type: integer
|
||||
description: >
|
||||
Number of assets in this page. This is declared separately, so that
|
||||
a partial JSON parser has this information before the entire file is
|
||||
downloaded and parsed.
|
||||
"file_count":
|
||||
type: integer
|
||||
description: >
|
||||
Number of files in this page. This is declared separately, so that
|
||||
a partial JSON parser has this information before the entire file is
|
||||
downloaded and parsed.
|
||||
"assets":
|
||||
type: array
|
||||
items: { $ref: "#/components/schemas/AssetV1" }
|
||||
"files":
|
||||
type: array
|
||||
items: { $ref: "#/components/schemas/FileV1" }
|
||||
description: >
|
||||
The files that are referenced by the above assets. Note that there
|
||||
may be duplication of this information between asset pages, as each
|
||||
file can contain multiple assets, and those assets might be
|
||||
scattered across multiple pages.
|
||||
required: [asset_count, file_count, assets, files]
|
||||
|
||||
AssetV1:
|
||||
type: object
|
||||
description: >
|
||||
Representation of a single asset. Assets are always Blender data-blocks
|
||||
in some blend file.
|
||||
|
||||
This asset may be stored in the same blend file as other assets, and so
|
||||
it does _not_ represent a single downloadable item.
|
||||
properties:
|
||||
"name":
|
||||
type: string
|
||||
description: Name of the Blender data-block.
|
||||
"id_type": { $ref: "#/components/schemas/AssetIDTypeV1" }
|
||||
"files":
|
||||
type: array
|
||||
items: { type: string }
|
||||
minItems: 1
|
||||
description: >
|
||||
Relative paths of the files that contain this asset. The first entry
|
||||
in the list MUST contain the asset data-block itself, while the
|
||||
remaining entries can be in any order. These relative paths are used
|
||||
to look up more file information in the asset library's list of
|
||||
files.
|
||||
"thumbnail": { $ref: "#/components/schemas/URLWithHash" }
|
||||
"meta": { $ref: "#/components/schemas/AssetMetadataV1" }
|
||||
required:
|
||||
- "name"
|
||||
- "id_type"
|
||||
- "files"
|
||||
|
||||
AssetIDTypeV1:
|
||||
type: string
|
||||
description: >
|
||||
Type of the Blender data-block.
|
||||
|
||||
This can be obtained via BPY with `datablock.id_type`. Any comparisons
|
||||
should be done in a case-insensitive manner.
|
||||
|
||||
Note that this list is just a list of data-block types in Blender. This
|
||||
type being in this list does not mean that Blender supports making this
|
||||
data-block an asset. It's just here to ensure that if that changes, and
|
||||
more data-block types can become assets, this schema doesn't need
|
||||
updating.
|
||||
|
||||
AssetMetadataV1:
|
||||
type: object
|
||||
description: >
|
||||
Metadata of an asset, as defined by Blender's `AssetMeta` DNA struct.
|
||||
Fields should either be non-empty or absent.
|
||||
properties:
|
||||
"catalog_id":
|
||||
type: string
|
||||
description: >
|
||||
The catalog UUID that contains this asset. Having the UUID here
|
||||
makes it easier to create a per-blendfile .cats.txt file, if that's
|
||||
ever necessary.
|
||||
"tags":
|
||||
type: array
|
||||
items: { type: string }
|
||||
minItems: 1
|
||||
"author": { type: string }
|
||||
"description": { type: string }
|
||||
"license": { type: string }
|
||||
"copyright": { type: string }
|
||||
"custom": { $ref: "#/components/schemas/CustomPropertiesV1" }
|
||||
|
||||
CustomPropertiesV1:
|
||||
type: object
|
||||
description: >
|
||||
Arbitrary custom properties of the asset. Keys are the property names.
|
||||
additionalProperties:
|
||||
$ref: "#/components/schemas/CustomPropertyV1"
|
||||
|
||||
CustomPropertyV1:
|
||||
type: object
|
||||
description: >
|
||||
Single 'custom property' value of the asset. The value should be
|
||||
compatible with the given type; GROUP properties should be represented
|
||||
as `CustomPropertiesV1` object again. Arrays should specify an
|
||||
`itemtype`.
|
||||
properties:
|
||||
"type": { $ref: "#/components/schemas/CustomPropertyTypeV1" }
|
||||
"itemtype": { $ref: "#/components/schemas/CustomPropertyTypeV1" }
|
||||
"value":
|
||||
oneOf:
|
||||
- { $ref: "#/components/schemas/CustomPropertiesV1" }
|
||||
- { type: array }
|
||||
- { type: number }
|
||||
- { type: integer }
|
||||
- { type: string }
|
||||
- { type: boolean }
|
||||
required: [type, value]
|
||||
|
||||
CustomPropertyTypeV1:
|
||||
type: string
|
||||
description: >
|
||||
Type of IDProperty without the `IDP_` prefix, see `eIDPropertyType` in `DNA_ID_enumms.h`.
|
||||
For now, type `ID` and `IDPARRAY` are not supported.
|
||||
enum: [STRING, INT, FLOAT, ARRAY, GROUP, DOUBLE, BOOLEAN]
|
||||
|
||||
CatalogV1:
|
||||
type: object
|
||||
description: An asset catalog, which can be represented by one or more UUIDs.
|
||||
properties:
|
||||
"path": { type: string }
|
||||
"simple_name": { type: string }
|
||||
"uuids":
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
minItems: 1
|
||||
required: [path, uuids]
|
||||
|
||||
FileV1:
|
||||
type: object
|
||||
description: >
|
||||
Single file in the asset library. Identified by its relative path in that library.
|
||||
properties:
|
||||
"path":
|
||||
type: string
|
||||
description: >
|
||||
Relative path of where this file is located in the asset library.
|
||||
"url":
|
||||
type: string
|
||||
description: >
|
||||
URL where the file can be downloaded. If the URL is relative, it is
|
||||
to be interpreted as relative to the library's root URL.
|
||||
|
||||
If the URL is not given, or an empty string, it is assumed to be the
|
||||
same as 'path'.
|
||||
"size_in_bytes": { type: integer }
|
||||
"hash":
|
||||
type: string
|
||||
description: >
|
||||
Hash of the file. This should be in the format "HASHTYPE:HASH-AS-HEX".
|
||||
Currently only the "SHA256" hash type is supported.
|
||||
"blender_version":
|
||||
type: string
|
||||
description: >
|
||||
Version of Blender used to create this file.. Should be a semantic
|
||||
version, which may be shortened ('3.1' matches any '3.1.x' version).
|
||||
required:
|
||||
- "path"
|
||||
- "size_in_bytes"
|
||||
- "hash"
|
||||
- "blender_version"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,75 @@
|
|||
# SPDX-FileCopyrightText: 2026 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime
|
||||
import logging
|
||||
import time
|
||||
|
||||
|
||||
def main(cli_args: list[str]) -> None:
|
||||
"""CLI entry point for the 'asset_listing' CLI commands."""
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="blender -c asset_listing",
|
||||
description="Manage asset library index files.",
|
||||
)
|
||||
|
||||
# func is set by subparsers to indicate which function to run.
|
||||
parser.set_defaults(func=None, loglevel=logging.INFO)
|
||||
|
||||
loggroup = parser.add_mutually_exclusive_group()
|
||||
loggroup.add_argument(
|
||||
"-v",
|
||||
"--verbose",
|
||||
dest="loglevel",
|
||||
action="store_const",
|
||||
const=logging.DEBUG,
|
||||
help="Log DEBUG level and higher",
|
||||
)
|
||||
loggroup.add_argument(
|
||||
"-q",
|
||||
"--quiet",
|
||||
dest="loglevel",
|
||||
action="store_const",
|
||||
const=logging.WARNING,
|
||||
help="Log at WARNING level and higher",
|
||||
)
|
||||
|
||||
subparsers = parser.add_subparsers(
|
||||
help="Choose a subcommand to actually make Blender do something. "
|
||||
"Global options go before the subcommand, "
|
||||
"whereas subcommand-specific options go after it. "
|
||||
"Use --help after the subcommand to get more info."
|
||||
)
|
||||
|
||||
from . import cli_listing_generator, cli_listing_downloader
|
||||
|
||||
cli_listing_generator.add_cli_parser(subparsers)
|
||||
cli_listing_downloader.add_cli_parser(subparsers)
|
||||
|
||||
args = parser.parse_args(cli_args)
|
||||
|
||||
config_logging(args)
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
if not args.func:
|
||||
parser.error("No subcommand was given")
|
||||
|
||||
start_time = time.monotonic()
|
||||
args.func(args)
|
||||
|
||||
duration = datetime.timedelta(seconds=time.monotonic() - start_time)
|
||||
log.info("Command took %s to complete", duration)
|
||||
|
||||
|
||||
def config_logging(args) -> None: # type: ignore
|
||||
"""Configures the logging system based on CLI arguments."""
|
||||
|
||||
logging.basicConfig(
|
||||
level=args.loglevel,
|
||||
format="%(asctime)-15s %(levelname)8s %(threadName)10s %(name)16s %(message)s",
|
||||
)
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
# SPDX-FileCopyrightText: 2026 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import dataclasses
|
||||
import logging
|
||||
import time
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
|
||||
from . import listing_downloader
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class CLIArguments:
|
||||
"""Parsed commandline arguments."""
|
||||
|
||||
url: str
|
||||
|
||||
|
||||
def cli_main(arguments_raw: argparse.Namespace) -> None:
|
||||
"""Generate the index for the passed-on-the-CLI asset library path."""
|
||||
|
||||
# Parse CLI arguments.
|
||||
arguments = _parse_cli_args(arguments_raw)
|
||||
|
||||
base_path = Path(".").resolve() / "_asset_download_location" # TODO: be sensible.
|
||||
|
||||
is_done = False
|
||||
|
||||
def on_done_callback(_: listing_downloader.RemoteAssetListingDownloader) -> None:
|
||||
nonlocal is_done
|
||||
|
||||
is_done = True
|
||||
|
||||
downloader = listing_downloader.RemoteAssetListingDownloader(
|
||||
arguments.url,
|
||||
base_path,
|
||||
lambda *args: None,
|
||||
on_done_callback)
|
||||
downloader.download_and_process()
|
||||
|
||||
while not is_done:
|
||||
# Ordinarily Blender's timer system will call the right method. But
|
||||
# because this is intended to run headless, and we're blocking the main
|
||||
# thread here, that doesn't happen.
|
||||
downloader.on_timer_event()
|
||||
time.sleep(downloader._DOWNLOAD_POLL_INTERVAL)
|
||||
|
||||
print("Done!")
|
||||
|
||||
|
||||
# Ignore the type of the `subparsers` argument, because there doesn't seem
|
||||
# to be a way to make both static mypy and the runtime Python happy at the
|
||||
# same time.
|
||||
|
||||
|
||||
def add_cli_parser(subparsers: argparse._SubParsersAction) -> None: # type: ignore[type-arg]
|
||||
"""Add argparser for this subcommand."""
|
||||
|
||||
parser = subparsers.add_parser("download", help="Download and parse a remote asset library index")
|
||||
parser.set_defaults(func=cli_main)
|
||||
|
||||
parser.add_argument(
|
||||
"url",
|
||||
type=str,
|
||||
help="""URL of the remote asset library""",
|
||||
)
|
||||
|
||||
|
||||
def _parse_cli_args(arguments_raw: argparse.Namespace) -> CLIArguments:
|
||||
"""Make sure the passed arguments are valid."""
|
||||
|
||||
try:
|
||||
urllib.parse.urlparse(arguments_raw.url)
|
||||
except ValueError as ex:
|
||||
logger.error("invalid URL specified: {}".format(ex))
|
||||
|
||||
arguments = CLIArguments(
|
||||
url=arguments_raw.url,
|
||||
)
|
||||
|
||||
return arguments
|
||||
|
||||
|
||||
class APIVersionError(Exception):
|
||||
"""Raised when none of the API versions declared by a remote asset library are supported by Blender."""
|
||||
|
|
@ -0,0 +1,261 @@
|
|||
# SPDX-FileCopyrightText: 2026 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
"""Blender Online Asset Repository Listing Generator."""
|
||||
|
||||
__all__ = (
|
||||
'cli_main',
|
||||
'SCHEMA_VERSION',
|
||||
)
|
||||
|
||||
import argparse
|
||||
import dataclasses
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import cattrs.preconf.json
|
||||
|
||||
from . import hashing, listing_asset_catalogs, listing_common, json_parsing
|
||||
from . import cli_listing_generator_asset_finder as asset_finder
|
||||
from . import cli_listing_generator_pagination as pagination
|
||||
from . import blender_asset_library_openapi as api_models
|
||||
|
||||
SCHEMA_VERSION = "1.0.0"
|
||||
DEFAULT_METADATA = api_models.AssetLibraryMeta(
|
||||
api_versions={}, # Determined by cli_main().
|
||||
name="Your Asset Library",
|
||||
contact=api_models.Contact(
|
||||
name="Your Name",
|
||||
url="https://example.org/",
|
||||
email="example@example.org",
|
||||
),
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_converter = cattrs.preconf.json.JsonConverter(omit_if_default=True)
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class CLIArguments:
|
||||
"""Parsed commandline arguments."""
|
||||
|
||||
repository: Path
|
||||
limit: int
|
||||
page_size: int
|
||||
|
||||
|
||||
def cli_main(arguments_raw: argparse.Namespace) -> None:
|
||||
"""Generate the index for the passed-on-the-CLI asset library path."""
|
||||
|
||||
# Parse CLI arguments.
|
||||
arguments = _parse_cli_args(arguments_raw)
|
||||
|
||||
# Read the top-level meta file first. If this already exists, an attempt
|
||||
# at parsing & upgrading it is performed. Better to do this (and stop on
|
||||
# errors) before diving into the assets themselves.
|
||||
meta_json_path = arguments.repository / listing_common.ASSET_TOP_METADATA_FILENAME
|
||||
toplevel_meta = _toplevel_meta_read(meta_json_path)
|
||||
|
||||
# Find all .blend files.
|
||||
filepaths: list[Path] = []
|
||||
logger.info("Traversing %s", arguments.repository)
|
||||
for filepath in arguments.repository.rglob("*.blend"):
|
||||
filepaths.append(filepath)
|
||||
|
||||
files_total = len(filepaths)
|
||||
logger.info(f"* {files_total} .blend files found.")
|
||||
|
||||
limit = _total_files_to_process(arguments, files_total)
|
||||
|
||||
# Find the assets in the blend files.
|
||||
logger.info("Parsing the files...")
|
||||
assets: list[api_models.AssetV1] = []
|
||||
files: list[api_models.FileV1] = []
|
||||
|
||||
for i, filepath in enumerate(filepaths[:limit]):
|
||||
logger.info(f"* {i + 1}/{limit}: {filepath.relative_to(arguments.repository)}")
|
||||
|
||||
bfile_info, assets_in_file = asset_finder.list_assets(filepath, arguments.repository)
|
||||
if not assets_in_file:
|
||||
continue
|
||||
|
||||
assets.extend(assets_in_file)
|
||||
files.append(bfile_info)
|
||||
|
||||
# Write the listing index and the pages:
|
||||
asset_index_pages = pagination.paginate_asset_list(assets, files, arguments.page_size)
|
||||
index_path = _write_json_files(arguments, asset_index_pages)
|
||||
|
||||
# Write the top-level meta file:
|
||||
api_version_key = "v{:d}".format(listing_common.API_VERSION)
|
||||
index_relpath: Path = index_path.relative_to(arguments.repository)
|
||||
toplevel_meta.api_versions[api_version_key] = api_models.URLWithHash(
|
||||
url=urllib.parse.quote(index_relpath.as_posix()),
|
||||
hash=hashing.hash_file(index_path),
|
||||
)
|
||||
_save_json(toplevel_meta, meta_json_path)
|
||||
|
||||
|
||||
def _toplevel_meta_read(meta_json_path: Path) -> api_models.AssetLibraryMeta:
|
||||
try:
|
||||
metadata = _toplevel_metadata(meta_json_path)
|
||||
except (json.JSONDecodeError, cattrs.errors.ClassValidationError) as ex:
|
||||
msg = "Metadata file {} could not be parsed: {}"
|
||||
logger.error(msg.format(meta_json_path, ex))
|
||||
raise SystemExit(1) from None
|
||||
return metadata
|
||||
|
||||
|
||||
def _write_json_files(
|
||||
arguments: CLIArguments,
|
||||
asset_index_pages: list[api_models.AssetLibraryIndexPageV1],
|
||||
) -> Path:
|
||||
"""Write the asset listing page files and the index file.
|
||||
|
||||
:returns: the path of the index file.
|
||||
"""
|
||||
outdir_root = arguments.repository
|
||||
outdir_versioned = outdir_root / listing_common.API_VERSIONED_SUBDIR
|
||||
|
||||
# Remove old pages, in case the number of assets per page was increased and
|
||||
# so less page files are needed.
|
||||
existing_pages = outdir_versioned.glob("assets-*.json")
|
||||
for filepath in existing_pages:
|
||||
filepath.unlink()
|
||||
|
||||
# Library Index Page /_v1/assets-{page}.json
|
||||
#
|
||||
# Note that these paths are determined by the generator, and their URLs are
|
||||
# listed explicitly in the index file, so there is no need to have those in
|
||||
# the listing_common.py file.
|
||||
page_infos: list[api_models.URLWithHash] = []
|
||||
for page_index, page in enumerate(asset_index_pages):
|
||||
page_relpath = listing_common.api_versioned(f"assets-{page_index:05}.json")
|
||||
_save_json(page, outdir_root / page_relpath)
|
||||
|
||||
page_infos.append(api_models.URLWithHash(
|
||||
url=urllib.parse.quote(page_relpath.as_posix()),
|
||||
hash=hashing.hash_file(page_relpath),
|
||||
))
|
||||
|
||||
# Library Index file /_v1/asset-index.json:
|
||||
total_asset_count = sum(page.asset_count for page in asset_index_pages)
|
||||
total_file_count = sum(page.file_count for page in asset_index_pages)
|
||||
asset_size_bytes = sum(file.size_in_bytes
|
||||
for page in asset_index_pages
|
||||
for file in page.files)
|
||||
asset_cats = listing_asset_catalogs.parse_catalogs(arguments.repository)
|
||||
index = api_models.AssetLibraryIndexV1(
|
||||
schema_version=SCHEMA_VERSION,
|
||||
asset_size_bytes=asset_size_bytes,
|
||||
asset_count=total_asset_count,
|
||||
file_count=total_file_count,
|
||||
pages=page_infos,
|
||||
catalogs=asset_cats,
|
||||
)
|
||||
index_path = outdir_versioned / listing_common.ASSET_INDEX_JSON_FILENAME
|
||||
_save_json(index, index_path)
|
||||
|
||||
return index_path
|
||||
|
||||
|
||||
def _save_json(model: Any, json_path: Path) -> None:
|
||||
as_json = _converter.dumps(model, indent=2)
|
||||
|
||||
json_path.parent.mkdir(exist_ok=True, parents=True)
|
||||
|
||||
logger.info("Writing %s", json_path)
|
||||
with json_path.open("wt") as json_file:
|
||||
json_file.write(as_json)
|
||||
|
||||
|
||||
def _toplevel_metadata(json_path: Path) -> api_models.AssetLibraryMeta:
|
||||
"""Construct the top-level metadata.
|
||||
|
||||
Returns the metadata, or raises an exception (see json_parsing.ValidatingParser)
|
||||
if it is not valid JSON.
|
||||
|
||||
Writing is considered safe, except when the file exists but does not contain
|
||||
valid JSON. In that case, it's better to warn about this and keep the file
|
||||
as-is, so that the user can either delete or fix it.
|
||||
"""
|
||||
try:
|
||||
json_data = json_path.read_bytes()
|
||||
except IOError:
|
||||
# Ignore any read errors, as this likely means the file simply doesn't exist.
|
||||
return DEFAULT_METADATA
|
||||
|
||||
parser = json_parsing.ValidatingParser()
|
||||
metadata = parser.parse_and_validate(api_models.AssetLibraryMeta, json_data)
|
||||
|
||||
# Update the metadata to declare the API version for which we're going to
|
||||
# write the data.
|
||||
metadata.api_versions = DEFAULT_METADATA.api_versions.copy()
|
||||
|
||||
return metadata
|
||||
|
||||
|
||||
# Ignore the type of the `subparsers` argument, because there doesn't seem
|
||||
# to be a way to make both static mypy and the runtime Python happy at the
|
||||
# same time.
|
||||
def add_cli_parser(subparsers: argparse._SubParsersAction) -> None: # type: ignore[type-arg]
|
||||
"""Add argparser for this subcommand."""
|
||||
|
||||
parser = subparsers.add_parser("generate", help="Generate files necessary to serve an asset library")
|
||||
parser.set_defaults(func=cli_main)
|
||||
|
||||
parser.add_argument(
|
||||
"repository",
|
||||
type=Path,
|
||||
help="""Asset repository folder""",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--limit",
|
||||
"-l",
|
||||
metavar="NUM_BLEND_FILES",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Limit the number of files to process",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--page",
|
||||
"-p",
|
||||
metavar="ASSETS_PER_PAGE",
|
||||
type=int,
|
||||
default=1000,
|
||||
help="Number of assets per JSON file, set to 0 to disable pagination",
|
||||
)
|
||||
|
||||
|
||||
def _parse_cli_args(arguments_raw: argparse.Namespace) -> CLIArguments:
|
||||
"""Make sure the passed arguments are valid."""
|
||||
|
||||
repository = arguments_raw.repository.absolute()
|
||||
if not repository.is_dir():
|
||||
print(f"Error: Repository specified is not a folder: {repository}")
|
||||
sys.exit(1)
|
||||
|
||||
arguments = CLIArguments(
|
||||
repository=repository,
|
||||
limit=arguments_raw.limit or 0,
|
||||
page_size=arguments_raw.page or 0,
|
||||
)
|
||||
|
||||
return arguments
|
||||
|
||||
|
||||
def _total_files_to_process(arguments: CLIArguments, files_total: int) -> int:
|
||||
if not arguments.limit:
|
||||
return files_total
|
||||
|
||||
return min(arguments.limit, files_total)
|
||||
|
|
@ -0,0 +1,261 @@
|
|||
# SPDX-FileCopyrightText: 2026 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import unicodedata
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
|
||||
import bpy
|
||||
|
||||
from . import blender_asset_library_openapi as api_models
|
||||
from . import hashing
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def list_assets(blendfile: Path, asset_library_root: Path) -> tuple[api_models.FileV1, list[api_models.AssetV1]]:
|
||||
# Start by erasing everything from memory.
|
||||
bpy.ops.wm.read_homefile(use_factory_startup=True, use_empty=True, load_ui=False)
|
||||
|
||||
blendfile_info = _blendfile_info(blendfile, asset_library_root)
|
||||
|
||||
# TODO: when this is possible, get the version of Blender used to write this
|
||||
# blendfile (bpy.data.version when you open it 'for real'), rather than the
|
||||
# currently-running version.
|
||||
blendfile_info.blender_version = ".".join(map(str, bpy.app.version))
|
||||
|
||||
# Tell Blender to only load asset data-blocks.
|
||||
with bpy.data.libraries.load(str(blendfile), assets_only=True) as (
|
||||
data_from,
|
||||
data_to,
|
||||
):
|
||||
for attr in dir(data_to):
|
||||
setattr(data_to, attr, getattr(data_from, attr))
|
||||
|
||||
# Get the last modification timestamp of the blend file, to compare against
|
||||
# the thumbnails.
|
||||
thumbnail_dir = blendfile.with_name(blendfile.stem + "_thumbnails")
|
||||
blend_stat = blendfile.stat()
|
||||
|
||||
thumbnail_timestamper = thumbnail_dir / ".last_modified"
|
||||
if thumbnail_timestamper.exists():
|
||||
thumb_mtime = thumbnail_timestamper.stat().st_mtime
|
||||
should_write_thumbnails = abs(blend_stat.st_mtime - thumb_mtime) > 0.001
|
||||
else:
|
||||
should_write_thumbnails = True
|
||||
|
||||
if should_write_thumbnails:
|
||||
# Remove the entire thumbnail tree, so that thumbnails of deleted assets
|
||||
# are also deleted. All thumbnails are going to be re-written anyway.
|
||||
log.debug("thumbnails will be exported to %s", thumbnail_dir)
|
||||
assert thumbnail_dir
|
||||
if Path(thumbnail_dir.root) == thumbnail_dir:
|
||||
raise RuntimeError(f"Refusing to remove a root directory: {thumbnail_dir}")
|
||||
if thumbnail_dir.exists():
|
||||
shutil.rmtree(thumbnail_dir)
|
||||
|
||||
# Collect the asset data.
|
||||
assets: list[api_models.AssetV1] = []
|
||||
for attr in dir(data_to):
|
||||
if attr == 'version':
|
||||
continue
|
||||
datablocks = getattr(data_from, attr)
|
||||
datablocks_assets = _find_assets(
|
||||
asset_library_root,
|
||||
blendfile_info,
|
||||
datablocks,
|
||||
thumbnail_dir,
|
||||
should_write_thumbnails,
|
||||
)
|
||||
assets.extend(datablocks_assets)
|
||||
|
||||
# After processing is done, set the thumbnail dir mtime to that of the
|
||||
# blendfile. By tracking the mtime of the directory itself, not every
|
||||
# individual thumbnail needs to be time-checked.
|
||||
thumbnail_timestamper.touch(exist_ok=True)
|
||||
os.utime(thumbnail_timestamper, (blend_stat.st_atime, blend_stat.st_mtime))
|
||||
|
||||
return blendfile_info, assets
|
||||
|
||||
|
||||
def _find_assets(
|
||||
asset_library_root: Path,
|
||||
file: api_models.FileV1,
|
||||
datablocks: bpy.types.BlendData,
|
||||
thumbnail_dir: Path,
|
||||
should_write_thumbnails: bool,
|
||||
) -> list[api_models.AssetV1]:
|
||||
|
||||
assets = []
|
||||
for datablock in datablocks:
|
||||
asset_data: bpy.types.AssetData = datablock.asset_data
|
||||
if not asset_data:
|
||||
continue
|
||||
|
||||
thumbnail_path = _thumbnail_path(datablock, thumbnail_dir)
|
||||
if thumbnail_path and should_write_thumbnails:
|
||||
_save_thumbnail(datablock, thumbnail_path)
|
||||
|
||||
if thumbnail_path:
|
||||
as_posix = thumbnail_path.relative_to(asset_library_root).as_posix()
|
||||
thumbnail = api_models.URLWithHash(
|
||||
url=urllib.parse.quote(as_posix),
|
||||
hash=hashing.hash_file(thumbnail_path),
|
||||
)
|
||||
else:
|
||||
thumbnail = None
|
||||
|
||||
asset = api_models.AssetV1(
|
||||
name=datablock.name,
|
||||
id_type=datablock.id_type,
|
||||
files=[file.path],
|
||||
thumbnail=thumbnail,
|
||||
meta=_get_asset_meta(asset_data),
|
||||
)
|
||||
|
||||
assets.append(asset)
|
||||
return assets
|
||||
|
||||
|
||||
def _get_asset_meta(asset_data: bpy.types.AssetData) -> api_models.AssetMetadataV1 | None:
|
||||
# Only set the fields that have a value. That way we can detect whether
|
||||
# none of them are set, and prevent the empty metadata from being
|
||||
# included.
|
||||
meta = api_models.AssetMetadataV1()
|
||||
if asset_data.catalog_id and asset_data.catalog_id != "00000000-0000-0000-0000-000000000000":
|
||||
meta.catalog_id = asset_data.catalog_id
|
||||
if asset_data.tags:
|
||||
meta.tags = [tag.name for tag in asset_data.tags]
|
||||
if asset_data.author:
|
||||
meta.author = asset_data.author
|
||||
if asset_data.description:
|
||||
meta.description = asset_data.description
|
||||
if asset_data.license:
|
||||
meta.license = asset_data.license
|
||||
if asset_data.copyright:
|
||||
meta.copyright = asset_data.copyright
|
||||
|
||||
# Convert custom properties.
|
||||
import rna_prop_ui
|
||||
|
||||
custom_props: api_models.CustomPropertiesV1 = {}
|
||||
for prop_name, prop_value in asset_data.items():
|
||||
is_array = isinstance(prop_value, rna_prop_ui.ARRAY_TYPES) and len(prop_value) > 0
|
||||
item_value = prop_value[0] if is_array else prop_value
|
||||
|
||||
match item_value:
|
||||
case bool():
|
||||
value_type = api_models.CustomPropertyTypeV1.BOOLEAN
|
||||
case int():
|
||||
value_type = api_models.CustomPropertyTypeV1.INT
|
||||
case str():
|
||||
value_type = api_models.CustomPropertyTypeV1.STRING
|
||||
case float():
|
||||
value_type = api_models.CustomPropertyTypeV1.FLOAT
|
||||
case _:
|
||||
# Unsupported type, just ignore it.
|
||||
continue
|
||||
|
||||
if is_array:
|
||||
custom_prop = api_models.CustomPropertyV1(
|
||||
type=api_models.CustomPropertyTypeV1.ARRAY,
|
||||
value=list(prop_value),
|
||||
itemtype=value_type,
|
||||
)
|
||||
else:
|
||||
custom_prop = api_models.CustomPropertyV1(type=value_type, value=prop_value)
|
||||
|
||||
custom_props[prop_name] = custom_prop
|
||||
|
||||
if custom_props:
|
||||
meta.custom = custom_props
|
||||
|
||||
if meta == api_models.AssetMetadataV1():
|
||||
return None
|
||||
return meta
|
||||
|
||||
|
||||
def _save_thumbnail(datablock: bpy.types.ID, thumbnail_path: Path) -> None:
|
||||
"""Save the internal preview thumbnail as a WebP image."""
|
||||
|
||||
# Get the preview image size.
|
||||
width: int = datablock.preview.image_size[0]
|
||||
height: int = datablock.preview.image_size[1]
|
||||
|
||||
if not (width > 0 and height > 0):
|
||||
return
|
||||
|
||||
thumbnail_path.parent.mkdir(exist_ok=True, parents=True)
|
||||
|
||||
log.debug("Writing thumbnail: %s", thumbnail_path)
|
||||
try:
|
||||
# Create a new image in Blender to store the preview.
|
||||
image: bpy.types.Image = bpy.data.images.new(
|
||||
thumbnail_path.stem, width, height, alpha=True
|
||||
)
|
||||
|
||||
# Assign the pixel data from the preview to the new image.
|
||||
# image.pixels = [p for p in datablock.preview.image_pixels_float]
|
||||
image.pixels[:] = datablock.preview.image_pixels_float
|
||||
|
||||
# Save the image to disk.
|
||||
image.file_format = "WEBP"
|
||||
image.save(filepath=str(thumbnail_path), quality=80)
|
||||
|
||||
# Remove the image from Blender data after saving to free memory.
|
||||
bpy.data.images.remove(image)
|
||||
except Exception as e:
|
||||
print(f"Failed to save thumbnail for {datablock.name}: {e}")
|
||||
|
||||
|
||||
def _thumbnail_path(datablock: bpy.types.ID, thumbnail_dir: Path) -> Path | None:
|
||||
"""Return the path for this datablock's thumbnail, or None if it has none."""
|
||||
|
||||
if not datablock.preview:
|
||||
return None
|
||||
|
||||
datablock_safe = _name_to_filename(datablock.name)
|
||||
thumbnail_path: Path = (
|
||||
thumbnail_dir / datablock.id_type.title() / f"{datablock_safe}.webp"
|
||||
)
|
||||
|
||||
return thumbnail_path
|
||||
|
||||
|
||||
_re_safe_filename_nonword = re.compile(r'[^\w\s_-]')
|
||||
_re_safe_filename_dashspace = re.compile(r'[-\s]+')
|
||||
|
||||
|
||||
def _name_to_filename(value: str) -> str:
|
||||
"""Convert a string into something that should be safe as filename."""
|
||||
|
||||
value = unicodedata.normalize('NFKD', value).encode('ascii', 'ignore').decode('ascii')
|
||||
value = _re_safe_filename_nonword.sub('', value.lower())
|
||||
return _re_safe_filename_dashspace.sub('-', value).strip('-_')
|
||||
|
||||
|
||||
def _blendfile_info(filepath: Path, asset_library_root: Path) -> api_models.FileV1:
|
||||
stat = filepath.stat()
|
||||
|
||||
relative_posix = filepath.relative_to(asset_library_root).as_posix()
|
||||
file_url: str | None = urllib.parse.quote(relative_posix)
|
||||
|
||||
if file_url == relative_posix:
|
||||
# Optimization: if the file path is URL-safe, it can be used as the URL
|
||||
# and there is no need to include this URL explicitly.
|
||||
file_url = None
|
||||
|
||||
return api_models.FileV1(
|
||||
path=relative_posix,
|
||||
url=file_url,
|
||||
hash=hashing.hash_file(filepath),
|
||||
size_in_bytes=stat.st_size,
|
||||
blender_version="", # Determined later when the file is opened to find assets.
|
||||
)
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
# SPDX-FileCopyrightText: 2026 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from itertools import batched
|
||||
|
||||
from . import blender_asset_library_openapi as api_models
|
||||
|
||||
|
||||
def paginate_asset_list(
|
||||
assets: list[api_models.AssetV1],
|
||||
files: list[api_models.FileV1],
|
||||
num_assets_per_page: int = 0,
|
||||
) -> list[api_models.AssetLibraryIndexPageV1]:
|
||||
"""Return a list of asset pages.
|
||||
|
||||
Each page is no longer than `num_assets_per_page` long. If zero, all assets
|
||||
are put in the same page.
|
||||
|
||||
The files listed in each page are determined by the assets on that page.
|
||||
This means that it's possible for multiple pages to list the same file; this
|
||||
occurs when that file contains muliple assets, spread across multiple pages.
|
||||
"""
|
||||
|
||||
if not num_assets_per_page:
|
||||
return [api_models.AssetLibraryIndexPageV1(
|
||||
asset_count=len(assets),
|
||||
assets=assets,
|
||||
file_count=len(files),
|
||||
files=files,
|
||||
)]
|
||||
|
||||
pages = []
|
||||
for asset_batch in batched(assets, num_assets_per_page):
|
||||
used_file_paths = {
|
||||
file
|
||||
for asset in asset_batch
|
||||
for file in asset.files
|
||||
}
|
||||
file_batch = [file for file in files
|
||||
if file.path in used_file_paths]
|
||||
page = api_models.AssetLibraryIndexPageV1(
|
||||
asset_count=len(asset_batch),
|
||||
assets=list(asset_batch),
|
||||
file_count=len(file_batch),
|
||||
files=file_batch,
|
||||
)
|
||||
pages.append(page)
|
||||
return pages
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
# SPDX-FileCopyrightText: 2026 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
import functools
|
||||
import typing
|
||||
from pathlib import Path
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from _bpy_internal.assets.remote_library_listing.blender_asset_library_openapi import URLWithHash as _URLWithHash
|
||||
else:
|
||||
_URLWithHash = object
|
||||
|
||||
|
||||
def hash_file(filepath: Path) -> str:
|
||||
"""Computes and returns the hash of the file.
|
||||
|
||||
The returned string is prefixed with the hash type, like "{TYPE}:{HASH}".
|
||||
"""
|
||||
return 'SHA256:' + _sha256_file(filepath)
|
||||
|
||||
|
||||
@functools.lru_cache
|
||||
def _dfhs_storage_path() -> Path:
|
||||
"""Return the storage path of the disk file hash service."""
|
||||
import bpy
|
||||
|
||||
hashes_dir = Path(bpy.app.cachedir) / "{:d}.{:d}/file_hashes".format(*bpy.app.version)
|
||||
hashes_dir.mkdir(parents=True, exist_ok=True)
|
||||
return hashes_dir / "dfhs"
|
||||
|
||||
|
||||
def _sha256_file(filepath: Path) -> str:
|
||||
"""Computes and returns the SHA256 hash of the file."""
|
||||
from _bpy_internal import disk_file_hash_service
|
||||
|
||||
dfhs = disk_file_hash_service.get_service(_dfhs_storage_path())
|
||||
return dfhs.get_hash(filepath, 'sha256')
|
||||
|
||||
|
||||
def url(url_with_hash: _URLWithHash | tuple[str, str]) -> str:
|
||||
"""Return the url, with the hash on the query string.
|
||||
|
||||
>>> url(URLWithHash(url="http://localhost/", hash="sha256:the-hash"))
|
||||
'http://localhost/?hash=the-hash'
|
||||
>>> url(("http://localhost/", "sha256:the-hash"))
|
||||
'http://localhost/?hash=the-hash'
|
||||
"""
|
||||
|
||||
import urllib.parse
|
||||
|
||||
# Get the URL and the hash.
|
||||
if isinstance(url_with_hash, tuple):
|
||||
url, hash_with_type = url_with_hash
|
||||
else:
|
||||
url = url_with_hash.url
|
||||
hash_with_type = url_with_hash.hash
|
||||
|
||||
# Without a hash, it's simple.
|
||||
if not hash_with_type:
|
||||
return url
|
||||
|
||||
# Remove the hash type from the hash string.
|
||||
try:
|
||||
_, hash_value = hash_with_type.split(':', 1)
|
||||
except ValueError:
|
||||
# This means the hash is not in the form '{TYPE}:{HASH}'; just use it as-is.
|
||||
hash_value = hash_with_type
|
||||
|
||||
# Append to the URL with the correct separator.
|
||||
sep = '&' if '?' in url else '?'
|
||||
return url + sep + 'hash=' + urllib.parse.quote(hash_value)
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
# SPDX-FileCopyrightText: 2026 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from _bpy_internal.http import downloader as http_dl
|
||||
|
||||
|
||||
class ExtraFileMetadataProvider(http_dl.MetadataProvider):
|
||||
"""HTTP Metadata provider that can check an extra file.
|
||||
|
||||
This is to support the following file sets:
|
||||
|
||||
- `file.json`: Actual JSON file read by Blender. Is assumed to be validated.
|
||||
- `file-unsafe.json`: JSON file as downloaded. Must be validated before use.
|
||||
- `file-unsafe.json~`: The above file while it's being downloaded. Not yet
|
||||
complete JSON.
|
||||
|
||||
The downloader will get the request to download to `file-unsafe.json`.
|
||||
However, if `file.json` is still fresh (i.e. the HTTP metadata for the URL
|
||||
is appliccable to that file), the downloader should be able to do a
|
||||
conditional download (instead of an unconditional one).
|
||||
|
||||
This is implemented as a wrapper for any other MetadataProvider, rather than
|
||||
subclassing a specific one, so that it's independent of the underlying
|
||||
logic.
|
||||
"""
|
||||
|
||||
_wrapped: http_dl.MetadataProvider
|
||||
_logger: logging.Logger
|
||||
|
||||
def __init__(self, wrapped: http_dl.MetadataProvider) -> None:
|
||||
self._wrapped = wrapped
|
||||
self._logger = logging.getLogger(__name__ + ".ExtraFileMetadataProvider")
|
||||
|
||||
def save(self, http_req_descr: http_dl.RequestDescription, meta: http_dl.HTTPMetadata) -> None:
|
||||
self._wrapped.save(http_req_descr, meta)
|
||||
|
||||
def load(self, http_req_descr: http_dl.RequestDescription) -> http_dl.HTTPMetadata | None:
|
||||
return self._wrapped.load(http_req_descr)
|
||||
|
||||
def is_valid(
|
||||
self,
|
||||
meta: http_dl.HTTPMetadata,
|
||||
http_req_descr: http_dl.RequestDescription,
|
||||
local_path: Path) -> bool:
|
||||
# This assumes that the download is saved to the "unsafe" location, and
|
||||
# we have to check the metadata on the "safe" location as well.
|
||||
|
||||
if self._wrapped.is_valid(meta, http_req_descr, local_path):
|
||||
self._logger.info("HTTP metadata is valid for %s", local_path)
|
||||
return True
|
||||
|
||||
safe_filename = unsafe_to_safe_filename(local_path)
|
||||
if safe_filename == local_path:
|
||||
# There is no different filename to check, so let's stick to the
|
||||
# result of the first is_valid() call.
|
||||
self._logger.info("HTTP metadata is invalid for %s", local_path)
|
||||
return False
|
||||
|
||||
if self._wrapped.is_valid(meta, http_req_descr, safe_filename):
|
||||
self._logger.info("HTTP metadata is valid for %s", safe_filename)
|
||||
return True
|
||||
|
||||
self._logger.info("HTTP metadata is valid for neither %s nor %s", local_path, safe_filename)
|
||||
return False
|
||||
|
||||
def forget(self, http_req_descr: http_dl.RequestDescription) -> None:
|
||||
self._wrapped.forget(http_req_descr)
|
||||
|
||||
|
||||
def unsafe_to_safe_filename(unsafe_file_path: Path) -> Path:
|
||||
"""path/to/some_file.unsafe-json -> path/to/some_file.json"""
|
||||
# The suffix is changed, and not the stem, so that globs like "*.json" do not see the unsafe files.
|
||||
return unsafe_file_path.with_suffix(unsafe_file_path.suffix.replace('unsafe-', ''))
|
||||
|
||||
|
||||
def safe_to_unsafe_filename(safe_file_path: Path | str) -> Path:
|
||||
"""path/to/some_file.json -> path/to/some_file.unsafe-json"""
|
||||
if isinstance(safe_file_path, str):
|
||||
safe_file_path = Path(safe_file_path)
|
||||
|
||||
# path.suffix includes the leading period, so it's something like ".json".
|
||||
return safe_file_path.with_suffix('.unsafe-' + safe_file_path.suffix[1:])
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
# SPDX-FileCopyrightText: 2026 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
"""Wrapper around cattrs."""
|
||||
|
||||
__all__ = [
|
||||
"ValidatingParser",
|
||||
"APIModel",
|
||||
]
|
||||
|
||||
import dataclasses
|
||||
import json
|
||||
from typing import Any, Type, TypeVar
|
||||
|
||||
import cattrs
|
||||
import cattrs.preconf.json
|
||||
|
||||
from . import blender_asset_library_openapi as api_models
|
||||
|
||||
# There is no common base class for dataclasses, so this type variable will have to act as a stand-in.
|
||||
APIModel = TypeVar("APIModel")
|
||||
|
||||
|
||||
class ValidatingParser:
|
||||
"""Wrapper around cattrs, caching the cattrs converter."""
|
||||
|
||||
_converter: cattrs.preconf.json.JsonConverter
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._converter = cattrs.preconf.json.JsonConverter(omit_if_default=True)
|
||||
|
||||
# Register a custom unstructure hook for the type of `CustomPropertyV1.value`.
|
||||
#
|
||||
# NOTE: this MUST register the 'final' type, and cannot use
|
||||
# `CustomProperties` as an alias for `dict[str, CustomProperty]`. It
|
||||
# won't be found. It also has to include None in the union for some
|
||||
# reason, even though that's not declared in `CustomPropertyV1.value`.
|
||||
#
|
||||
# Basically cattrs told me to register a structure hook for this
|
||||
# specific type, and so that's what I (Sybren) did.
|
||||
self._converter.register_structure_hook(
|
||||
api_models.CustomPropertiesV1 | list[Any] | float | int | str | bool,
|
||||
lambda value, _: value,
|
||||
)
|
||||
|
||||
def parse_and_validate(self, model_class: Type[APIModel], json_payload: bytes | str) -> APIModel:
|
||||
"""Parse & validate the JSON data, returning an instance of the given model class.
|
||||
|
||||
:raises json.JSONDecodeError: if the payload is not formatted as JSON.
|
||||
:raises cattrs.errors.ClassValidationError: if the payload doesn't pass
|
||||
validation and can't be converted to the given model class.
|
||||
"""
|
||||
|
||||
json_doc = json.loads(json_payload)
|
||||
return self._converter.structure(json_doc, model_class)
|
||||
|
||||
def dumps(self, model_instance: Any) -> str:
|
||||
"""Convert the model instance to JSON, returning it as string."""
|
||||
assert dataclasses.is_dataclass(model_instance), f"{model_instance} is not a dataclass"
|
||||
return self._converter.dumps(model_instance, indent=2)
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
# SPDX-FileCopyrightText: 2026 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
"""Parser for Blender's asset catalog files.
|
||||
|
||||
It would be better if there was an RNA API for this, but for now this is faster
|
||||
to implement.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
from . import blender_asset_library_openapi as api_models
|
||||
|
||||
SUPPORTED_VERSION = 1
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class AssetCatalog:
|
||||
uuid: str
|
||||
path: PurePosixPath
|
||||
simple_name: str
|
||||
|
||||
|
||||
def parse_catalogs(library_path: Path) -> list[api_models.CatalogV1]:
|
||||
"""Parse all asset catalog files in the asset library.
|
||||
|
||||
Returns a collection of all asset catalogs in the library, as a mapping from
|
||||
UUID to the catalog.
|
||||
|
||||
If there are multiple catalog definition files, they will be merged
|
||||
together.
|
||||
"""
|
||||
# First use a mapping from UUID to the AssetCatalog, to ensure that each
|
||||
# UUID only maps to a single path.
|
||||
catalogs_by_uuid: dict[str, AssetCatalog] = {}
|
||||
|
||||
for file in library_path.rglob('*.cats.txt'):
|
||||
file_cats = _parse_catalog(file)
|
||||
catalogs_by_uuid.update(file_cats)
|
||||
|
||||
# Group catalogs by their path, to make the returned list compatible with
|
||||
# the API model.
|
||||
asset_cats_by_path: dict[PurePosixPath, api_models.CatalogV1] = {}
|
||||
for cat in catalogs_by_uuid.values():
|
||||
try:
|
||||
api_catalog = asset_cats_by_path[cat.path]
|
||||
except KeyError:
|
||||
asset_cats_by_path[cat.path] = api_models.CatalogV1(
|
||||
path=cat.path.as_posix(),
|
||||
uuids=[cat.uuid],
|
||||
simple_name=cat.simple_name,
|
||||
)
|
||||
else:
|
||||
api_catalog.uuids.append(cat.uuid)
|
||||
|
||||
return sorted(asset_cats_by_path.values(), key=lambda api_cat: api_cat.path)
|
||||
|
||||
|
||||
def _parse_catalog(catalog_filepath: Path) -> dict[str, AssetCatalog]:
|
||||
# Mapping from UUID to the AssetCatalog.
|
||||
catalogs: dict[str, AssetCatalog] = {}
|
||||
|
||||
with catalog_filepath.open('r', encoding='utf-8') as infile:
|
||||
for line in infile:
|
||||
line = line.strip()
|
||||
if not line or line.startswith('#'):
|
||||
continue
|
||||
|
||||
# Check the declared version, and simply ignore the file if it is
|
||||
# not supported.
|
||||
if line.startswith('VERSION '):
|
||||
_, version_as_str = line.split(maxsplit=1)
|
||||
if version_as_str != str(SUPPORTED_VERSION):
|
||||
msg = "{}: this version of Blender does not support catalog file version {!r}"
|
||||
print(msg.format(catalog_filepath, version_as_str))
|
||||
return {}
|
||||
continue
|
||||
|
||||
parts = line.split(':', maxsplit=2)
|
||||
if len(parts) < 2:
|
||||
# It's ok for the 'simple name' part to be missing, but if more is missing, this is not a valid file.
|
||||
msg = "{}: this does not seem to be an asset catalog file, ignoring it (line {!r} is not as expected)"
|
||||
print(msg.format(catalog_filepath, line))
|
||||
return {}
|
||||
|
||||
cat = AssetCatalog(
|
||||
uuid=parts[0],
|
||||
path=PurePosixPath(parts[1]),
|
||||
simple_name=parts[2] if len(parts) >= 3 else "",
|
||||
)
|
||||
catalogs[cat.uuid] = cat
|
||||
|
||||
return catalogs
|
||||
|
||||
|
||||
_ASSET_CATS_HEADER = """# This is an Asset Catalog Definition file for Blender.
|
||||
#
|
||||
# Empty lines and lines starting with `#` will be ignored.
|
||||
# The first non-ignored line should be the version indicator.
|
||||
# Other lines are of the format "UUID:catalog/path/for/assets:simple catalog name"
|
||||
#
|
||||
# Remote Asset Library: {library_name!s}
|
||||
|
||||
VERSION 1
|
||||
"""
|
||||
|
||||
|
||||
def write(catalogs: list[api_models.CatalogV1], catalog_filepath: Path,
|
||||
asset_library_meta: api_models.AssetLibraryMeta) -> None:
|
||||
"""Create a catalog file from the list of catalogs."""
|
||||
|
||||
# TODO: this really should be using an RNA API.
|
||||
|
||||
header = _ASSET_CATS_HEADER.format(library_name=asset_library_meta.name)
|
||||
|
||||
with catalog_filepath.open("w", encoding="utf8") as catfile:
|
||||
print(header, file=catfile)
|
||||
|
||||
for cat in sorted(catalogs, key=lambda cat: cat.path):
|
||||
for uuid in cat.uuids:
|
||||
print("{}:{}:{}".format(uuid, cat.path, cat.simple_name), file=catfile)
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
# SPDX-FileCopyrightText: 2026 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
"""Shared code for dealing with an asset library index.
|
||||
|
||||
Basically this is shared code between the index generator and index downloader.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
API_VERSION = 1
|
||||
"""The API version supported and produced by this version of Blender."""
|
||||
|
||||
API_VERSIONED_SUBDIR = f"_v{API_VERSION}"
|
||||
"""Sub-directory for all the asset index data except the top level metadata."""
|
||||
|
||||
ASSET_TOP_METADATA_FILENAME = "_asset-library-meta.json"
|
||||
"""Filename for the top-level asset index file.
|
||||
|
||||
This is the entry point for an asset library, and is expected to be at the root
|
||||
of the configured URL for the remote asset library.
|
||||
"""
|
||||
|
||||
ASSET_INDEX_JSON_FILENAME = "asset-index.json"
|
||||
"""Filename for the asset index.
|
||||
|
||||
This is expected to sit in the `API_VERSIONED_SUBDIR`, and reference other files
|
||||
in the same directory.
|
||||
"""
|
||||
|
||||
|
||||
def api_versioned(subpath: Path | str) -> Path:
|
||||
"Return the subpath, prefixed with API_VERSIONED_SUBDIR."
|
||||
return Path(API_VERSIONED_SUBDIR) / subpath
|
||||
|
||||
|
||||
API_VERSIONED_ASSET_INDEX_JSON_PATH = api_versioned(ASSET_INDEX_JSON_FILENAME).as_posix()
|
||||
|
|
@ -0,0 +1,789 @@
|
|||
# SPDX-FileCopyrightText: 2026 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
__all__ = (
|
||||
'RemoteAssetListingLocator',
|
||||
'DownloadStatus',
|
||||
'RemoteAssetListingDownloader',
|
||||
'is_more_recent_than',
|
||||
)
|
||||
|
||||
import copy
|
||||
import enum
|
||||
import functools
|
||||
import logging
|
||||
import unicodedata
|
||||
import urllib.parse
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Callable, TypeAlias, Type
|
||||
|
||||
import bpy
|
||||
|
||||
from _bpy_internal.http import downloader as http_dl
|
||||
from _bpy_internal.assets.remote_library_listing import blender_asset_library_openapi as api_models
|
||||
from _bpy_internal.assets.remote_library_listing import listing_common
|
||||
from _bpy_internal.assets.remote_library_listing import hashing
|
||||
from _bpy_internal.assets.remote_library_listing import http_metadata
|
||||
from _bpy_internal.assets.remote_library_listing import listing_asset_catalogs
|
||||
from _bpy_internal.assets.remote_library_listing import json_parsing
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# The resolution of the cache-busting 'stamp' used when fetching the top-level
|
||||
# JSON file of asset listings.
|
||||
#
|
||||
# All the links to other resources will contain a hash of their contents,
|
||||
# which is used in their URL to help HTTP caches. But since the hash of
|
||||
# the top-level file is not known a priori, a time-based 'stamp' is used
|
||||
# for this.
|
||||
#
|
||||
# The stamp is basically 'time in seconds since some point in time' divided by
|
||||
# the number below. So every HTTP_CACHEBUST_RESOLUTION_SEC seconds, a new stamp
|
||||
# is used.
|
||||
HTTP_CACHEBUST_RESOLUTION_SEC = 60
|
||||
|
||||
|
||||
class RemoteAssetListingLocator:
|
||||
"""Construct paths for various components of a remote asset library.
|
||||
|
||||
Basically this determines where assets are downloaded, what their filenames
|
||||
will be, and where the HTTP metadata cache is located.
|
||||
"""
|
||||
|
||||
_remote_url: str
|
||||
_local_path: Path
|
||||
|
||||
_remote_url_split: urllib.parse.SplitResult
|
||||
_remote_url_path: PurePosixPath
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
remote_url: str,
|
||||
local_path: Path | str,
|
||||
) -> None:
|
||||
self._remote_url = remote_url
|
||||
self._local_path = Path(local_path)
|
||||
|
||||
# Parse the remote URL. As this URL should never change, this can just
|
||||
# happen once here, instead of every time when this info is necessary.
|
||||
self._remote_url_split = urllib.parse.urlsplit(self._remote_url)
|
||||
self._remote_url_path = _sanitize_path_from_url(self._remote_url_split.path)
|
||||
|
||||
@property
|
||||
def remote_url(self) -> str:
|
||||
return self._remote_url
|
||||
|
||||
@property
|
||||
def local_path(self) -> Path:
|
||||
return self._local_path
|
||||
|
||||
@property
|
||||
@functools.lru_cache()
|
||||
def http_metadata_cache_location(self) -> Path:
|
||||
return self._local_path / "_local-meta-cache"
|
||||
|
||||
@property
|
||||
@functools.lru_cache()
|
||||
def catalogs_file(self) -> Path:
|
||||
return self._local_path / "blender_assets.cats.txt"
|
||||
|
||||
def asset_download_path(self, asset_file: api_models.FileV1) -> Path:
|
||||
"""Construct the absolute download path for this asset.
|
||||
|
||||
This can raise a ValueError if the file path is not suitable (either
|
||||
downright invalid, or not ending in `.blend`).
|
||||
|
||||
>>> loc = RemoteAssetListingLocator("https://localhost:8000/", Path("/tmp/dl"))
|
||||
>>> asset_file = api_models.FileV1(
|
||||
... path="monkeys/suzanne.blend",
|
||||
... url="http://localhost:8000/does/not/matter",
|
||||
... size_in_bytes=327,
|
||||
... hash='010203040506',
|
||||
... blender_version="1.2.3",
|
||||
... )
|
||||
>>> loc.asset_download_path(asset_file)
|
||||
PosixPath('/tmp/dl/monkeys/suzanne.blend')
|
||||
"""
|
||||
assert asset_file.path
|
||||
|
||||
relpath = Path(asset_file.path)
|
||||
|
||||
# TODO: support non-.blend downloads as well.
|
||||
path_suffix = relpath.suffix.lower()
|
||||
if path_suffix != '.blend':
|
||||
raise ValueError(
|
||||
"asset file path ({!s}) does not end in .blend (but in {!r})".format(
|
||||
relpath, path_suffix))
|
||||
|
||||
return self._local_path / relpath
|
||||
|
||||
|
||||
class DownloadStatus(enum.Enum):
|
||||
LOADING = 'loading'
|
||||
FINISHED_SUCCESSFULLY = 'finished successfully'
|
||||
FAILED = 'failed'
|
||||
|
||||
|
||||
class RemoteAssetListingDownloader:
|
||||
"""Download a remote asset listing.
|
||||
|
||||
Calling `downloader.download_and_process()` performs the following steps:
|
||||
|
||||
- Download the top metadata file, and validate+parse it.
|
||||
- Download the top asset listing index file, and validate+parse it.
|
||||
- For each page in that file, download the page, and validate+parse it.
|
||||
- Once the last page is downloaded and considered valid, touch the
|
||||
downloaded top metadata file. That is then an indicator of the last time
|
||||
the remote listing was downloaded.
|
||||
|
||||
The above steps always happen, even when the HTTP server returns a '304 Not
|
||||
Modified'.
|
||||
"""
|
||||
_locator: RemoteAssetListingLocator
|
||||
|
||||
OnUpdateCallback: TypeAlias = Callable[['RemoteAssetListingDownloader'], None]
|
||||
_on_update_callback: OnUpdateCallback
|
||||
OnDoneCallback: TypeAlias = Callable[['RemoteAssetListingDownloader'], None]
|
||||
_on_done_callback: OnDoneCallback
|
||||
OnMetafilesDoneCallback: TypeAlias = Callable[['RemoteAssetListingDownloader'], None]
|
||||
_on_metafiles_done_callback: OnMetafilesDoneCallback | None
|
||||
OnPageDoneCallback: TypeAlias = Callable[['RemoteAssetListingDownloader'], None]
|
||||
_on_page_done_callback: OnPageDoneCallback | None
|
||||
|
||||
_bgdownloader: http_dl.BackgroundDownloader
|
||||
_num_asset_pages_pending: int
|
||||
|
||||
_status: DownloadStatus
|
||||
_error_message: str
|
||||
"""An error message to show to the user.
|
||||
|
||||
Should be set on errors to communicate a message to users. Calling report()
|
||||
with 'ERROR' as the level will set this to the given message.
|
||||
"""
|
||||
|
||||
_DOWNLOAD_POLL_INTERVAL: float = 0.01
|
||||
"""How often the background download process is polled, in seconds.
|
||||
|
||||
Each 'poll' involves sending queued messages back & forth between the main
|
||||
Blender process and the background download process.
|
||||
"""
|
||||
|
||||
_referenced_local_files: list[Path]
|
||||
"""Paths of actually-referenced index page files.
|
||||
|
||||
This makes it possible to delete once-downloaded index pages that now no
|
||||
longer exist.
|
||||
"""
|
||||
|
||||
_library_meta: api_models.AssetLibraryMeta | None
|
||||
|
||||
_parser: json_parsing.ValidatingParser
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
remote_url: str,
|
||||
local_path: Path | str,
|
||||
on_update_callback: OnUpdateCallback,
|
||||
on_done_callback: OnDoneCallback,
|
||||
on_metafiles_done_callback: OnMetafilesDoneCallback | None = None,
|
||||
on_page_done_callback: OnPageDoneCallback | None = None,
|
||||
) -> None:
|
||||
"""Create a downloader for the remote index of this library.
|
||||
|
||||
:param remote_url: Base URL of the remote asset library server. See
|
||||
blender_asset_library_openapi.yaml for the files downloaded from
|
||||
there.
|
||||
|
||||
:param local_path: The directory to download the index files to.
|
||||
|
||||
:param on_update_callback: Called with one parameter (this
|
||||
RemoteAssetListingDownloader) in short, regular intervals
|
||||
(_DOWNLOAD_POLL_INTERVAL) while the download is ongoing, and once
|
||||
just after the download is done.
|
||||
|
||||
:param on_done_callback: called with one parameter (this
|
||||
RemoteAssetListingDownloader) whenever the downloader is "done".
|
||||
|
||||
Here "done" does not imply "successful", as cancellations, network
|
||||
errors, or other issues can cause things to abort. In that case,
|
||||
this function is still called.
|
||||
|
||||
:param on_metafiles_done_callback: called with one parameter (this
|
||||
RemoteAssetListingDownloader) whenever the meta files
|
||||
(ASSET_TOP_METADATA_FILENAME, ASSET_INDEX_JSON_FILENAME, and
|
||||
blender_assets.cats.txt) are in their final location and ready to
|
||||
be picked up by the asset system.
|
||||
|
||||
:param on_page_done_callback: called with one parameter (this
|
||||
RemoteAssetListingDownloader) when at least one new page of the
|
||||
asset listing finished downloading and verification, and was put in
|
||||
its final location, ready to be picked up by the asset system.
|
||||
"""
|
||||
|
||||
self._locator = RemoteAssetListingLocator(remote_url, local_path)
|
||||
|
||||
self._on_done_callback = on_done_callback
|
||||
self._on_update_callback = on_update_callback
|
||||
self._on_metafiles_done_callback = on_metafiles_done_callback
|
||||
self._on_page_done_callback = on_page_done_callback
|
||||
|
||||
self._num_asset_pages_pending = 0
|
||||
self._referenced_local_files = []
|
||||
self._library_meta = None
|
||||
|
||||
self._status = DownloadStatus.LOADING
|
||||
self._error_message = ""
|
||||
|
||||
self._parser = json_parsing.ValidatingParser()
|
||||
|
||||
# Work around a limitation of Blender, see bug report #139720 for details.
|
||||
self.on_timer_event = self.on_timer_event # type: ignore[method-assign]
|
||||
|
||||
self._http_metadata_provider = http_metadata.ExtraFileMetadataProvider(
|
||||
http_dl.MetadataProviderFilesystem(
|
||||
cache_location=self._locator.http_metadata_cache_location,
|
||||
))
|
||||
|
||||
# Create the background downloader object now, so that it
|
||||
# (hypothetically in some future) can be adjusted before the actual
|
||||
# downloading begins.
|
||||
self._bg_downloader = http_dl.BackgroundDownloader(
|
||||
options=http_dl.DownloaderOptions(
|
||||
metadata_provider=self._http_metadata_provider,
|
||||
http_headers={
|
||||
'Accept': 'application/json',
|
||||
'X-Blender': "{:d}.{:d}".format(*bpy.app.version),
|
||||
},
|
||||
timeout=300,
|
||||
),
|
||||
on_callback_error=self._on_callback_error,
|
||||
)
|
||||
self._bg_downloader.add_reporter(self)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return "{!s}(remote_url={!r}, local_path={!r})".format(
|
||||
type(self),
|
||||
self._locator.remote_url,
|
||||
self._locator.local_path,
|
||||
)
|
||||
|
||||
def download_and_process(self) -> None:
|
||||
"""Download and process the remote library index."""
|
||||
|
||||
self._bg_downloader.start()
|
||||
|
||||
# Register the timer for periodic message passing between the main and
|
||||
# background processes.
|
||||
if not bpy.app.timers.is_registered(self.on_timer_event):
|
||||
bpy.app.timers.register(
|
||||
self.on_timer_event,
|
||||
first_interval=self._DOWNLOAD_POLL_INTERVAL,
|
||||
persistent=True,
|
||||
)
|
||||
# Double-check the registration worked, see #139720 for details.
|
||||
assert bpy.app.timers.is_registered(self.on_timer_event)
|
||||
|
||||
# Kickstart the download process by downloading the remote asset meta file.
|
||||
top_meta_url = "{!s}?s={:d}".format(
|
||||
listing_common.ASSET_TOP_METADATA_FILENAME,
|
||||
self._cache_bust_stamp(),
|
||||
)
|
||||
self._queue_download(
|
||||
top_meta_url,
|
||||
http_metadata.safe_to_unsafe_filename(listing_common.ASSET_TOP_METADATA_FILENAME),
|
||||
self.parse_asset_lib_metadata,
|
||||
)
|
||||
|
||||
def parse_asset_lib_metadata(self,
|
||||
http_req_descr: http_dl.RequestDescription,
|
||||
unsafe_local_file: Path,
|
||||
) -> None:
|
||||
metadata, used_unsafe_file = self._parse_api_model(unsafe_local_file, api_models.AssetLibraryMeta)
|
||||
|
||||
# Show what we downloaded.
|
||||
logger.info(" API versions : %s", metadata.api_versions)
|
||||
logger.info(" Asset Library Name: %s", metadata.name)
|
||||
if metadata.contact:
|
||||
logger.info(
|
||||
" Contact : %s | %s | %s",
|
||||
metadata.contact.name,
|
||||
metadata.contact.url,
|
||||
metadata.contact.email,
|
||||
)
|
||||
|
||||
# Check API version.
|
||||
api_key = "v{:d}".format(listing_common.API_VERSION)
|
||||
try:
|
||||
main_index_url = metadata.api_versions[api_key]
|
||||
except KeyError:
|
||||
# Abort, the API version for this Blender is not supported by the library.
|
||||
library_versions = ", ".join(metadata.api_versions.keys())
|
||||
msg = "This asset library supports API versions {!s}, but this Blender uses version {!s}".format(
|
||||
library_versions,
|
||||
listing_common.API_VERSION,
|
||||
)
|
||||
self.report({'ERROR'}, msg)
|
||||
logger.error(msg)
|
||||
|
||||
self._status = DownloadStatus.FAILED
|
||||
self._bg_downloader.shutdown()
|
||||
return
|
||||
|
||||
# The file passed validation, so can be marked safe.
|
||||
if used_unsafe_file:
|
||||
self._rename_to_safe(unsafe_local_file)
|
||||
|
||||
self._library_meta = metadata
|
||||
|
||||
# Download the asset index.
|
||||
main_index_filepath = listing_common.api_versioned(listing_common.ASSET_INDEX_JSON_FILENAME)
|
||||
self._queue_download(
|
||||
main_index_url,
|
||||
http_metadata.safe_to_unsafe_filename(main_index_filepath),
|
||||
self.parse_asset_lib_index,
|
||||
)
|
||||
|
||||
def parse_asset_lib_index(self,
|
||||
http_req_descr: http_dl.RequestDescription,
|
||||
unsafe_local_file: Path,
|
||||
) -> None:
|
||||
asset_index, used_unsafe_file = self._parse_api_model(unsafe_local_file, api_models.AssetLibraryIndexV1)
|
||||
|
||||
pages = asset_index.pages or []
|
||||
logger.info(" Schema version : %s", asset_index.schema_version)
|
||||
logger.info(" Asset count : %d", asset_index.asset_count)
|
||||
logger.info(" Pages : %d", len(pages))
|
||||
|
||||
# The file passed validation, so can be marked safe.
|
||||
if used_unsafe_file:
|
||||
local_file = self._rename_to_safe(unsafe_local_file)
|
||||
else:
|
||||
local_file = unsafe_local_file
|
||||
|
||||
# Write the catalogs file. Even when there are no catalogs, this should
|
||||
# be done, because catalogs might have existed previously.
|
||||
assert self._library_meta, "By now the asset library metadata should be known"
|
||||
catalogs_file = self._locator.catalogs_file
|
||||
logger.info("Writing catalogs to %s", catalogs_file)
|
||||
listing_asset_catalogs.write(asset_index.catalogs or [], catalogs_file, self._library_meta)
|
||||
|
||||
# Construct a "processed" version of the asset index file. This will be
|
||||
# what Blender reads, and thus it should reference local files, and not
|
||||
# the URLs where they were downloaded from.
|
||||
processed_asset_index = copy.deepcopy(asset_index)
|
||||
# Catalogs are not read from here, but from the above-generated file. So
|
||||
# no need to store them again.
|
||||
processed_asset_index.catalogs = []
|
||||
# The code below will re-fill the list with the relative file paths.
|
||||
processed_asset_index.pages = []
|
||||
|
||||
# Download the asset pages.
|
||||
self._num_asset_pages_pending = len(pages)
|
||||
for page_index, page_url_w_hash in enumerate(pages):
|
||||
# These URLs may be absolute or they may be relative. In any case,
|
||||
# do not assume that they can be used direclty as local filesystem path.
|
||||
local_path = listing_common.api_versioned(f"assets-{page_index:05}.json")
|
||||
download_to = self._queue_download(
|
||||
page_url_w_hash,
|
||||
http_metadata.safe_to_unsafe_filename(local_path),
|
||||
self.on_asset_page_downloaded)
|
||||
|
||||
self._referenced_local_files.append(http_metadata.unsafe_to_safe_filename(download_to))
|
||||
|
||||
# Replace the URL with the local path.
|
||||
page_url_w_hash.url = local_path.as_posix()
|
||||
processed_asset_index.pages.append(page_url_w_hash)
|
||||
|
||||
# Save the processed index to a JSON file for Blender to pick up.
|
||||
json_path = local_file.with_suffix(".processed{!s}".format(local_file.suffix))
|
||||
|
||||
as_json = self._parser.dumps(processed_asset_index)
|
||||
json_path.parent.mkdir(exist_ok=True, parents=True)
|
||||
with json_path.open("w") as json_file:
|
||||
json_file.write(as_json)
|
||||
|
||||
# Meta files are ready to be picked up by the asset system.
|
||||
if self._on_metafiles_done_callback:
|
||||
self._on_metafiles_done_callback(self)
|
||||
|
||||
def on_asset_page_downloaded(self,
|
||||
http_req_descr: http_dl.RequestDescription,
|
||||
unsafe_local_file: Path,
|
||||
) -> None:
|
||||
_, used_unsafe_file = self._parse_api_model(unsafe_local_file, api_models.AssetLibraryIndexPageV1)
|
||||
|
||||
# The file passed validation, so can be marked safe.
|
||||
if used_unsafe_file:
|
||||
local_file = self._rename_to_safe(unsafe_local_file)
|
||||
else:
|
||||
local_file = http_metadata.unsafe_to_safe_filename(unsafe_local_file)
|
||||
|
||||
self._num_asset_pages_pending -= 1
|
||||
assert self._num_asset_pages_pending >= 0
|
||||
|
||||
if self._on_page_done_callback:
|
||||
self._on_page_done_callback(self)
|
||||
|
||||
logger.debug("Asset index page downloaded: %s", local_file)
|
||||
|
||||
if self._num_asset_pages_pending > 0:
|
||||
# Wait until all files have downloaded.
|
||||
self.report(
|
||||
{'INFO'},
|
||||
"Asset library index page downloaded; needs {:d} more".format(
|
||||
self._num_asset_pages_pending))
|
||||
return
|
||||
|
||||
# Remove any dangling pages of assets (downloaded before, no longer referenced).
|
||||
asset_page_dir = self._locator.local_path / listing_common.API_VERSIONED_SUBDIR
|
||||
# TODO: when upgrading to Python 3.12+, add `case_sensitive=False` to the glob() call.
|
||||
for asset_page_file in asset_page_dir.glob("assets-*.json"):
|
||||
abs_path = asset_page_dir / asset_page_file
|
||||
if abs_path in self._referenced_local_files:
|
||||
continue
|
||||
abs_path.unlink()
|
||||
|
||||
self.report({'INFO'}, "Asset library index downloaded")
|
||||
|
||||
# Update the mtime of the top metadata file, so that that can be used as
|
||||
# an indicator of how new the files are. This is only done after the
|
||||
# last page has been downloaded.
|
||||
#
|
||||
# See is_more_recent_than() below.
|
||||
top_metadata = self._locator.local_path / listing_common.ASSET_TOP_METADATA_FILENAME
|
||||
assert top_metadata.exists(), "Expecting top metadata file to exist after downloading"
|
||||
top_metadata.touch()
|
||||
|
||||
self._shutdown_if_done()
|
||||
|
||||
def _shutdown_if_done(self) -> None:
|
||||
if self._num_asset_pages_pending == 0 and self._bg_downloader.all_downloads_done:
|
||||
# Done downloading everything, let's shut down.
|
||||
self.shutdown(DownloadStatus.FINISHED_SUCCESSFULLY)
|
||||
|
||||
@staticmethod
|
||||
def _cache_bust_stamp(*, _mocked_now=None) -> int:
|
||||
"""Construct a cache-busting number for the top-level JSON file.
|
||||
|
||||
This is based on the time since the first commit in Blender's Git
|
||||
history (it's arbitrary anyway).
|
||||
|
||||
:param _mocked_now: UTC timestamp representing 'now', for testing only.
|
||||
|
||||
>>> from datetime import datetime, timezone
|
||||
>>> now = datetime(2026, 1, 29, 16, 57, 47, tzinfo=timezone.utc)
|
||||
>>> RemoteAssetListingDownloader._cache_bust_stamp(_mocked_now=now)
|
||||
12254720
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
if _mocked_now is None:
|
||||
now = datetime.now(timezone.utc)
|
||||
else:
|
||||
now = _mocked_now
|
||||
|
||||
epoch_utc_time = datetime(2002, 10, 12, 11, 37, 38, tzinfo=timezone.utc)
|
||||
time_delta = now - epoch_utc_time
|
||||
cache_bust_stamp = int(time_delta.total_seconds()) // HTTP_CACHEBUST_RESOLUTION_SEC
|
||||
return cache_bust_stamp
|
||||
|
||||
def _parse_api_model(self, unsafe_local_file: Path,
|
||||
api_model: Type[json_parsing.APIModel]) -> tuple[json_parsing.APIModel, bool]:
|
||||
"""Use the OpenAPI schema to parse & validate a JSON file.
|
||||
|
||||
:param unsafe_local_file: Path to load, parse, and validate. If this
|
||||
file does not exist, the 'safe' version of the filepath is tried. If
|
||||
that doesn't exist either, a FileNotFoundError is raised.
|
||||
:param api_model: the data class itself, to use for parsing & validating.
|
||||
|
||||
:returns: the parsed+validated data, and a boolean that indicates
|
||||
whether the input file was used directly (True), or its 'safe'
|
||||
version (False).
|
||||
"""
|
||||
if unsafe_local_file.exists():
|
||||
path_to_load = unsafe_local_file
|
||||
used_unsafe_file = True
|
||||
else:
|
||||
safe_local_file = http_metadata.unsafe_to_safe_filename(unsafe_local_file)
|
||||
if safe_local_file == unsafe_local_file:
|
||||
# There is no 'safe' version of this path.
|
||||
raise FileNotFoundError(unsafe_local_file)
|
||||
if not safe_local_file.exists():
|
||||
raise ValueError("Both {!s} and {!s} do not exist, what was downloaded?".format(
|
||||
unsafe_local_file, safe_local_file))
|
||||
path_to_load = safe_local_file
|
||||
used_unsafe_file = False
|
||||
|
||||
logger.info("Validating %s", path_to_load)
|
||||
json_data = path_to_load.read_bytes()
|
||||
parsed_data = self._parser.parse_and_validate(api_model, json_data)
|
||||
|
||||
# The file has been parsed & validated, so 'touch' it to let other
|
||||
# Blender processes know when this was last downloaded/validated.
|
||||
path_to_load.touch()
|
||||
|
||||
return parsed_data, used_unsafe_file
|
||||
|
||||
def _rename_to_safe(self, unsafe_filepath: Path) -> Path:
|
||||
safe_filepath = http_metadata.unsafe_to_safe_filename(unsafe_filepath)
|
||||
|
||||
if safe_filepath == unsafe_filepath:
|
||||
raise ValueError("filepath cannot be transformed to 'safe' filepath: {!s}".format(unsafe_filepath))
|
||||
|
||||
# AFAIK on Windows you cannot atomically overwrite a file by renaming.
|
||||
safe_filepath.unlink(missing_ok=True)
|
||||
unsafe_filepath.rename(safe_filepath)
|
||||
return safe_filepath
|
||||
|
||||
def _on_callback_error(
|
||||
self,
|
||||
http_req_descr: http_dl.RequestDescription,
|
||||
local_file: Path,
|
||||
exception: Exception) -> None:
|
||||
logger.exception(
|
||||
"exception while handling downloaded file ({!r}, saved to {!r})".format(
|
||||
http_req_descr, local_file))
|
||||
self.report({'ERROR'}, "Asset library index had an issue, download aborted")
|
||||
self.shutdown(DownloadStatus.FAILED)
|
||||
|
||||
def _queue_download(
|
||||
self,
|
||||
relative_url: str | api_models.URLWithHash,
|
||||
download_to_path: Path | str,
|
||||
on_done: Callable[[http_dl.RequestDescription, Path], None],
|
||||
) -> Path:
|
||||
"""Queue up this download, returning the path to which it will be downloaded."""
|
||||
assert isinstance(relative_url, (str, api_models.URLWithHash)), "value is {!r}".format(relative_url)
|
||||
assert isinstance(download_to_path, (str, Path)), "value is {!r}".format(download_to_path)
|
||||
|
||||
# If a hash is known, append it to the query string.
|
||||
if isinstance(relative_url, api_models.URLWithHash):
|
||||
relative_url = hashing.url(relative_url)
|
||||
|
||||
remote_url = urllib.parse.urljoin(self._locator.remote_url, relative_url)
|
||||
download_to_path = self._locator.local_path / download_to_path
|
||||
|
||||
self._bg_downloader.queue_download(remote_url, download_to_path, on_done)
|
||||
|
||||
return download_to_path
|
||||
|
||||
# TODO: implement this in a more useful way:
|
||||
def report(self, level: set[str], message: str) -> None:
|
||||
# logger.info("Report: {:s}: {:s}".format("/".join(level), message))
|
||||
if 'ERROR' in level:
|
||||
self._error_message = message
|
||||
|
||||
def shutdown(self, status: DownloadStatus) -> None:
|
||||
"""Stop the background downloader, update the status and call the 'done' callback."""
|
||||
|
||||
self._status = status
|
||||
|
||||
# The timer is no longer necessary, the bg_downloader.shutdown() call
|
||||
# takes care of the last queued messages.
|
||||
if bpy.app.timers.is_registered(self.on_timer_event):
|
||||
bpy.app.timers.unregister(self.on_timer_event)
|
||||
|
||||
try:
|
||||
# Only report if this is actually triggering a shutdown. If that was
|
||||
# already triggered somehow, don't bother.
|
||||
if not self._bg_downloader.is_shutdown_requested:
|
||||
# It may be tempting to call self.report(...) here, and report on the
|
||||
# cancellation. However, this should be done by the caller, when they know
|
||||
# of the reason of the cancellation and thus can provide more info.
|
||||
num_pending = self._bg_downloader.num_pending_downloads
|
||||
if num_pending:
|
||||
logger.warning("Shutting down background downloader, %d downloads pending", num_pending)
|
||||
|
||||
self._bg_downloader.shutdown()
|
||||
finally:
|
||||
# Regardless of whether the shutdown had some issues, the timer has
|
||||
# been unregistered, so there will be no more message handling, and
|
||||
# so for all intents and purposes, the downloader is done.
|
||||
self._on_done_callback(self)
|
||||
|
||||
def on_timer_event(self) -> float:
|
||||
try:
|
||||
self._bg_downloader.update()
|
||||
except http_dl.BackgroundProcessNotRunningError:
|
||||
logger.error("Background downloader subprocess died, aborting.")
|
||||
self.shutdown(DownloadStatus.FAILED)
|
||||
return 0 # Deactivate the timer.
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Unexpected error downloading remote asset library ilisting from %s to %s",
|
||||
self._locator.remote_url,
|
||||
self._locator.local_path)
|
||||
|
||||
self._on_update_callback(self)
|
||||
|
||||
return self._DOWNLOAD_POLL_INTERVAL
|
||||
|
||||
@property
|
||||
def remote_url(self) -> str:
|
||||
return self._locator.remote_url
|
||||
|
||||
@property
|
||||
def local_path(self) -> Path:
|
||||
return self._locator.local_path
|
||||
|
||||
@property
|
||||
def status(self) -> DownloadStatus:
|
||||
return self._status
|
||||
|
||||
@property
|
||||
def error_message(self) -> str:
|
||||
return self._error_message
|
||||
|
||||
# Below here: CachingDownloadReporter functions:
|
||||
|
||||
def download_starts(self, http_req_descr: http_dl.RequestDescription) -> None:
|
||||
self.report({'INFO'}, "Download starting: {}".format(http_req_descr.url))
|
||||
logger.debug("Download starting: %s", http_req_descr)
|
||||
|
||||
def already_downloaded(
|
||||
self,
|
||||
http_req_descr: http_dl.RequestDescription,
|
||||
local_file: Path,
|
||||
) -> None:
|
||||
logger.debug("Download unnecessary, file already downloaded: %s", http_req_descr.url)
|
||||
|
||||
def download_error(
|
||||
self,
|
||||
http_req_descr: http_dl.RequestDescription,
|
||||
local_file: Path,
|
||||
error: Exception,
|
||||
) -> None:
|
||||
if isinstance(error, http_dl.DownloadCancelled):
|
||||
if self._num_asset_pages_pending:
|
||||
self.report({'WARNING'}, "Cancelled {} pending download".format(self._num_asset_pages_pending))
|
||||
logger.warning("Download cancelled: %s", http_req_descr)
|
||||
self.shutdown(DownloadStatus.FAILED)
|
||||
return
|
||||
|
||||
self.report({'ERROR'}, "Error downloading {}: {}".format(http_req_descr.url, error))
|
||||
logger.error("Error downloading %s: %s", http_req_descr, error)
|
||||
self.shutdown(DownloadStatus.FAILED)
|
||||
|
||||
def download_progress(
|
||||
self,
|
||||
http_req_descr: http_dl.RequestDescription,
|
||||
content_length_bytes: int,
|
||||
downloaded_bytes: int,
|
||||
) -> None:
|
||||
percentage = downloaded_bytes / content_length_bytes * 100
|
||||
self.report({'INFO'}, "File download progress: {:.0f}%".format(percentage))
|
||||
# logger.info("File download progress: %.0f%%", percentage)
|
||||
|
||||
def download_finished(
|
||||
self,
|
||||
http_req_descr: http_dl.RequestDescription,
|
||||
local_file: Path,
|
||||
) -> None:
|
||||
self.report({'INFO'}, "Download finished: {}".format(http_req_descr.url))
|
||||
logger.info("Download finished: %s", http_req_descr)
|
||||
|
||||
|
||||
def _sanitize_path_from_url(urlpath: PurePosixPath | str) -> PurePosixPath:
|
||||
"""Safely convert some path (assumed from a URL) to a relative path.
|
||||
|
||||
Directory up-references ('/../') are removed.
|
||||
|
||||
>>> _sanitize_path_from_url(PurePosixPath('/normal/path/as/expected.blend'))
|
||||
PurePosixPath('normal/path/as/expected.blend')
|
||||
|
||||
>>> _sanitize_path_from_url(PurePosixPath(''))
|
||||
PurePosixPath('.')
|
||||
|
||||
>>> _sanitize_path_from_url(PurePosixPath('/path/sub/../filename.blend'))
|
||||
PurePosixPath('path/filename.blend')
|
||||
|
||||
>>> _sanitize_path_from_url('/path/sub%2F%2E%2e/filename.blend')
|
||||
PurePosixPath('path/filename.blend')
|
||||
|
||||
>>> _sanitize_path_from_url('path/filename.blend')
|
||||
PurePosixPath('path/filename.blend')
|
||||
|
||||
>>> _sanitize_path_from_url(PurePosixPath('/longer/faster/path/../../filename.blend'))
|
||||
PurePosixPath('longer/filename.blend')
|
||||
|
||||
>>> _sanitize_path_from_url(PurePosixPath('/faster/path/../../filename.blend'))
|
||||
PurePosixPath('filename.blend')
|
||||
|
||||
>>> _sanitize_path_from_url('/faster/path/../../filename.blend')
|
||||
PurePosixPath('filename.blend')
|
||||
|
||||
>>> _sanitize_path_from_url(PurePosixPath('/../../../../../etc/passwd'))
|
||||
PurePosixPath('etc/passwd')
|
||||
"""
|
||||
|
||||
if isinstance(urlpath, str):
|
||||
# Assumption: this string comes directly from urllib.parse.urlsplit(url).path
|
||||
unquoted = urllib.parse.unquote(urlpath)
|
||||
normalized = unicodedata.normalize('NFKC', unquoted)
|
||||
urlpath = PurePosixPath(normalized)
|
||||
|
||||
# The URL could have entries like `..` in there, which should be removed.
|
||||
# However, PurePosixPath does not have functionality for this (for good
|
||||
# reason), but since this is about URL paths and not real filesystem paths
|
||||
# (yet) we can just go ahead and do this ourselves.
|
||||
|
||||
parts = list(urlpath.parts)
|
||||
|
||||
if urlpath.is_absolute():
|
||||
parts = parts[1:]
|
||||
|
||||
i = 0
|
||||
while i < len(parts):
|
||||
if parts[i] != '..':
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if i == 0:
|
||||
parts = parts[1:]
|
||||
continue
|
||||
|
||||
parts = parts[:i - 1] + parts[i + 1:]
|
||||
i -= 1
|
||||
|
||||
return PurePosixPath(*parts)
|
||||
|
||||
|
||||
def is_more_recent_than(library_path: Path, max_age_sec: float | int) -> bool:
|
||||
"""Return whether the remote asset library listing is more recent than the given age.
|
||||
|
||||
If the listing hasn't been downloaded, return False.
|
||||
"""
|
||||
import time
|
||||
|
||||
top_metadata_path = library_path / listing_common.ASSET_TOP_METADATA_FILENAME
|
||||
|
||||
if not top_metadata_path.exists():
|
||||
# If the metadata does not exist, it's certainly not new enough.
|
||||
return False
|
||||
|
||||
try:
|
||||
stat = top_metadata_path.stat()
|
||||
except OSError as ex:
|
||||
print("Could not stat {!s}: {!s}".format(top_metadata_path, ex))
|
||||
return False
|
||||
|
||||
file_age_sec = time.time() - stat.st_mtime
|
||||
|
||||
# Note that the age can be negative, when the local clock changed. Since
|
||||
# that's usually measured in the order of minutes/hours, and the refresh
|
||||
# period of remote asset libraries is measured in days, we can consider it
|
||||
# "fresh" in those cases.
|
||||
return file_age_sec < max_age_sec
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import doctest
|
||||
|
||||
# Run the doctests. Note: these only work on Posix systems for now, due to
|
||||
# how pathlib.Path is an alias for either PosixPath or WindowsPath,
|
||||
# depending on the platform.
|
||||
doctest.testmod(optionflags=doctest.ELLIPSIS)
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
# SPDX-FileCopyrightText: 2026 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
import io
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
__all__ = (
|
||||
'mutex_lock',
|
||||
'mutex_unlock',
|
||||
)
|
||||
|
||||
|
||||
# Dictionary of local library path to a tuple with:
|
||||
# - lock file handle
|
||||
# - path of the lock file
|
||||
# - unlock function
|
||||
_mutex_locks: dict[Path, tuple[io.IOBase, Path, Callable[[io.IOBase], None]]] = {}
|
||||
|
||||
|
||||
def mutex_lock(local_library_path: Path) -> bool:
|
||||
"""Lock the library for syncing.
|
||||
|
||||
Create a file on disk that signals to other Blender instances that this
|
||||
remote asset library is being synced by this Blender.
|
||||
|
||||
This uses approaches from:
|
||||
- https://www.pythontutorials.net/blog/make-sure-only-a-single-instance-of-a-program-is-running/
|
||||
- https://yakking.branchable.com/posts/procrun-2-pidfiles/
|
||||
|
||||
:returns: true if the lock was created succesfully, false if some other
|
||||
Blender already locked this library.
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
# Choose platform-dependent _obtain_lock(file) and _release_lock() functions.
|
||||
if sys.platform == "win32":
|
||||
import msvcrt
|
||||
|
||||
def _obtain_lock(file: io.IOBase) -> None:
|
||||
# Lock the first byte of the file (arbitrary choice)
|
||||
msvcrt.locking(file.fileno(), msvcrt.LK_NBLCK, 1)
|
||||
|
||||
def _release_lock(file: io.IOBase) -> None:
|
||||
msvcrt.locking(file.fileno(), msvcrt.LK_UNLCK, 1)
|
||||
else:
|
||||
import fcntl
|
||||
|
||||
def _obtain_lock(file: io.IOBase) -> None:
|
||||
fcntl.flock(file, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
|
||||
def _release_lock(file: io.IOBase) -> None:
|
||||
# Closing the file automatically releases the lock.
|
||||
pass
|
||||
|
||||
assert isinstance(local_library_path, Path)
|
||||
assert local_library_path not in _mutex_locks, "Locks are not reentrant"
|
||||
|
||||
lockfile_path = local_library_path / "_sync.lock"
|
||||
|
||||
# It is not suitable here to use an 'exclusive create' ('x' option) here.
|
||||
# That will still create a race condition, with the space between creation
|
||||
# of the file and locking it. So, better to make the existence of the file
|
||||
# meaningless, and only communicate the lock state with an actual filesystem
|
||||
# lock.
|
||||
try:
|
||||
# Binary mode (`wb`) is required on Windows, for the locking.
|
||||
lockfile = lockfile_path.open('wb')
|
||||
except OSError:
|
||||
# on Windows, opening a file for writing, while another process already has it open, can fail.
|
||||
# That just means somebody else has ownership of it.
|
||||
return False
|
||||
try:
|
||||
_obtain_lock(lockfile)
|
||||
except OSError:
|
||||
# Lock is already held by another Blender.
|
||||
lockfile.close()
|
||||
return False
|
||||
|
||||
# We have obtained an exclusive lock, which the OS will release when this
|
||||
# process is killed.
|
||||
_mutex_locks[local_library_path] = (lockfile, lockfile_path, _release_lock)
|
||||
return True
|
||||
|
||||
|
||||
def mutex_unlock(local_library_path: Path) -> None:
|
||||
"""Remove the lock created by mutex_lock(local_library_path)."""
|
||||
|
||||
assert isinstance(local_library_path, Path)
|
||||
assert local_library_path in _mutex_locks, "library was not locked"
|
||||
|
||||
lockfile, lockfile_path, release_lock = _mutex_locks[local_library_path]
|
||||
release_lock(lockfile)
|
||||
lockfile.close()
|
||||
|
||||
del _mutex_locks[local_library_path]
|
||||
|
||||
try:
|
||||
lockfile_path.unlink(missing_ok=True)
|
||||
except IOError:
|
||||
# Ignore errors when deleting the file. By now another process may have
|
||||
# recreated it and locked it again.
|
||||
pass
|
||||
|
|
@ -1523,6 +1523,15 @@ if(TEST_SRC_DIR_EXISTS)
|
|||
# add_subdirectory(view_layer)
|
||||
endif()
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Remote Asset Library tests
|
||||
# ------------------------------------------------------------------------------
|
||||
if(TEST_SRC_DIR_EXISTS)
|
||||
add_subdirectory(assets)
|
||||
endif()
|
||||
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Linux Release sanity checks
|
||||
# ------------------------------------------------------------------------------
|
||||
|
|
|
|||
5
tests/python/assets/CMakeLists.txt
Normal file
5
tests/python/assets/CMakeLists.txt
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# SPDX-FileCopyrightText: 2025 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
add_subdirectory(remote_library_listing)
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
# SPDX-FileCopyrightText: 2025 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
add_blender_test(
|
||||
assets_remote_library_listing_generator_test
|
||||
--python ${CMAKE_CURRENT_LIST_DIR}/listing_generator_test.py
|
||||
)
|
||||
|
|
@ -0,0 +1,145 @@
|
|||
# SPDX-FileCopyrightText: 2025 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
from _bpy_internal.assets.remote_library_listing import json_parsing, hashing
|
||||
from _bpy_internal.assets.remote_library_listing import blender_asset_library_openapi as api_models
|
||||
from _bpy_internal.assets.remote_library_listing import cli_listing_generator_asset_finder as asset_finder
|
||||
|
||||
import bpy
|
||||
|
||||
|
||||
"""
|
||||
blender -b --factory-startup --python tests/python/assets/remote_library_listing/listing_generator_test.py
|
||||
"""
|
||||
|
||||
|
||||
class CustomPropertiesTest(unittest.TestCase):
|
||||
cube: bpy.types.Object
|
||||
|
||||
def setUp(self) -> None:
|
||||
bpy.ops.wm.read_homefile(use_factory_startup=True)
|
||||
|
||||
self.cube = bpy.data.objects['Cube']
|
||||
self.cube.asset_mark()
|
||||
self.maxDiff = 100000
|
||||
|
||||
def test_empty_metadata(self) -> None:
|
||||
del self.cube.asset_data["dimensions"]
|
||||
meta = asset_finder._get_asset_meta(self.cube.asset_data)
|
||||
self.assertIsNone(meta)
|
||||
|
||||
def test_plain_properties(self) -> None:
|
||||
asset_data = self.cube.asset_data
|
||||
|
||||
asset_data["barcode"] = "155366" # Integer-like, should be stored as string.
|
||||
asset_data["location"] = "café" # Non-ASCII string.
|
||||
asset_data["size"] = 32.7 # FLOAT
|
||||
asset_data["count"] = 47 # INT
|
||||
asset_data["amazing"] = True # BOOL
|
||||
|
||||
meta = asset_finder._get_asset_meta(asset_data)
|
||||
|
||||
Types = api_models.CustomPropertyTypeV1
|
||||
Prop = api_models.CustomPropertyV1
|
||||
expected_custom = {
|
||||
'amazing': Prop(type=Types.BOOLEAN, value=True),
|
||||
'barcode': Prop(type=Types.STRING, value='155366'),
|
||||
'count': Prop(type=Types.INT, value=47),
|
||||
'location': Prop(type=Types.STRING, value='café'),
|
||||
'size': Prop(type=Types.FLOAT, value=32.7),
|
||||
'dimensions': Prop(type=Types.ARRAY,
|
||||
value=[2.0, 2.0, 2.0],
|
||||
itemtype=Types.FLOAT),
|
||||
}
|
||||
|
||||
assert meta is not None
|
||||
self.assertEqual(expected_custom, meta.custom)
|
||||
|
||||
def test_array_properties(self) -> None:
|
||||
asset_data = self.cube.asset_data
|
||||
|
||||
asset_data["agents"] = ["007", "47", "327"]
|
||||
asset_data["locations"] = ["Hokkaido", "Santa Fortuna", "Sapienza"]
|
||||
asset_data["boundingbox"] = [-3.0, -4.0, -0.1, 1, 2, 3]
|
||||
|
||||
meta = asset_finder._get_asset_meta(asset_data)
|
||||
|
||||
Types = api_models.CustomPropertyTypeV1
|
||||
Prop = api_models.CustomPropertyV1
|
||||
expected_custom = {
|
||||
'agents': Prop(type=Types.ARRAY, value=["007", "47", "327"], itemtype=Types.STRING),
|
||||
'locations': Prop(type=Types.ARRAY, value=["Hokkaido", "Santa Fortuna", "Sapienza"], itemtype=Types.STRING),
|
||||
'boundingbox': Prop(type=Types.ARRAY, value=[-3.0, -4.0, -0.1, 1.0, 2.0, 3.0], itemtype=Types.FLOAT),
|
||||
'dimensions': Prop(type=Types.ARRAY,
|
||||
value=[2.0, 2.0, 2.0],
|
||||
itemtype=Types.FLOAT),
|
||||
}
|
||||
|
||||
assert meta is not None
|
||||
self.assertEqual(expected_custom, meta.custom)
|
||||
|
||||
def test_serialize_to_json(self) -> None:
|
||||
meta = asset_finder._get_asset_meta(self.cube.asset_data)
|
||||
|
||||
# The asset metadata should be convertable to JSON.
|
||||
parser = json_parsing.ValidatingParser()
|
||||
as_json = parser.dumps(meta)
|
||||
self.assertIsNotNone(as_json)
|
||||
|
||||
# The JSON should also be deserializable as well, and produce the same data.
|
||||
roundtripped = parser.parse_and_validate(api_models.AssetMetadataV1, as_json)
|
||||
self.assertEqual(meta, roundtripped)
|
||||
|
||||
|
||||
class HashingTest(unittest.TestCase):
|
||||
def test_url_function(self) -> None:
|
||||
# No hash.
|
||||
url_with_hash = api_models.URLWithHash(
|
||||
url="http://localhost:8080/_v1/asset-index.json",
|
||||
hash=""
|
||||
)
|
||||
self.assertEqual("http://localhost:8080/_v1/asset-index.json", hashing.url(url_with_hash))
|
||||
|
||||
# Hash without type, and to-be-quoted characters.
|
||||
url_with_hash.hash = "this is a weird häsh"
|
||||
self.assertEqual(
|
||||
"http://localhost:8080/_v1/asset-index.json?hash=this%20is%20a%20weird%20h%C3%A4sh",
|
||||
hashing.url(url_with_hash))
|
||||
|
||||
# Hash with a type prefix, should be stripped.
|
||||
url_with_hash.hash = "sha1:2cafc9d388fb8c2d0b6ca9780d6b75963587916d"
|
||||
self.assertEqual(
|
||||
"http://localhost:8080/_v1/asset-index.json?hash=2cafc9d388fb8c2d0b6ca9780d6b75963587916d",
|
||||
hashing.url(url_with_hash))
|
||||
|
||||
# Existing query string, should be correctly appended to.
|
||||
url_with_hash.url = "http://localhost:8080/_v1/asset-index.json?auth=none"
|
||||
self.assertEqual(
|
||||
"http://localhost:8080/_v1/asset-index.json?auth=none&hash=2cafc9d388fb8c2d0b6ca9780d6b75963587916d",
|
||||
hashing.url(url_with_hash))
|
||||
|
||||
# Using a tuple instead of an URLWithHash object.
|
||||
self.assertEqual(
|
||||
"http://localhost:8080/_v1/asset-index.json?auth=none&hash=2cafc9d388fb8c2d0b6ca9780d6b75963587916d",
|
||||
hashing.url((
|
||||
"http://localhost:8080/_v1/asset-index.json?auth=none",
|
||||
"sha1:2cafc9d388fb8c2d0b6ca9780d6b75963587916d"
|
||||
)))
|
||||
|
||||
|
||||
def main():
|
||||
global args
|
||||
|
||||
argv = [sys.argv[0]]
|
||||
if '--' in sys.argv:
|
||||
argv += sys.argv[sys.argv.index('--') + 1:]
|
||||
|
||||
unittest.main(argv=argv)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -92,9 +92,6 @@ CUSTOM_FILE_HEADER = """
|
|||
def main() -> None:
|
||||
"""Run the datamodel code generator."""
|
||||
|
||||
# Late import, as this is only available once inside the virtualenv.
|
||||
import yaml
|
||||
|
||||
argparser = argparse.ArgumentParser(description="Run the datamodel code generator.")
|
||||
argparser.add_argument('source_root', type=Path, help="The root of Blender's source directory")
|
||||
args = argparser.parse_args(sys.argv[1:])
|
||||
|
|
@ -119,17 +116,6 @@ def main() -> None:
|
|||
out_path=py_path,
|
||||
)
|
||||
|
||||
# Append the OpenAPI specification as Python code. This is necessary to
|
||||
# reference for runtime validation. Having it available as Python
|
||||
# dictionary is easier than having to parse it from YAML or JSON later.
|
||||
with yaml_path.open() as yamlfile:
|
||||
openapi_spec = yaml.safe_load(yamlfile)
|
||||
with py_path.open("a") as outfile:
|
||||
print(file=outfile)
|
||||
print("# This OpenAPI specification was used to generate the above code.", file=outfile)
|
||||
print("# It is here so that Blender does not have to parse the YAML file.", file=outfile)
|
||||
print("OPENAPI_SPEC = {!r}".format(openapi_spec), file=outfile)
|
||||
|
||||
# Make sure that output from subprocesses is flushed, before outputting more
|
||||
# below. This prevents stderr and stdout going out of sync, ensuring things
|
||||
# are shown in chronological order (i.e. generating files before
|
||||
|
|
@ -137,9 +123,7 @@ def main() -> None:
|
|||
sys.stderr.flush()
|
||||
sys.stdout.flush()
|
||||
|
||||
# Format the generated Python code. Autopep8 (used by Blender) does not
|
||||
# seem to re-wrap long lines, so that's why this script relies on running
|
||||
# ruff first.
|
||||
# Format the generated Python code.
|
||||
print("Formatting Python files")
|
||||
py_paths_as_str = [str(path) for path in py_paths]
|
||||
subprocess.run(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue