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:
Sybren A. Stüvel 2026-01-26 16:48:41 +01:00
parent 8f504c35d1
commit ce34c5de34
22 changed files with 3117 additions and 104 deletions

View file

@ -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:

View file

@ -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

View file

@ -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()

View file

@ -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

View file

@ -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"

View file

@ -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",
)

View file

@ -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."""

View file

@ -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)

View file

@ -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.
)

View file

@ -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

View file

@ -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)

View file

@ -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:])

View file

@ -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)

View file

@ -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)

View file

@ -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()

View file

@ -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)

View file

@ -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

View file

@ -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
# ------------------------------------------------------------------------------

View file

@ -0,0 +1,5 @@
# SPDX-FileCopyrightText: 2025 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
add_subdirectory(remote_library_listing)

View file

@ -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
)

View file

@ -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()

View file

@ -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(