Add Chromium-only Blender WebEngine parity work

This commit is contained in:
mes123456
2026-08-12 04:47:48 -04:00
commit 9fd26010f6
18225 changed files with 11622124 additions and 0 deletions

View File

@@ -0,0 +1,3 @@
# SPDX-FileCopyrightText: 2017-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later

View File

@@ -0,0 +1,3 @@
# SPDX-FileCopyrightText: 2024 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later

View File

@@ -0,0 +1,42 @@
# SPDX-FileCopyrightText: 2017-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
"""
Implementation of blender's command line ``--addons`` argument,
e.g. ``--addons a,b,c`` to enable add-ons.
"""
__all__ = (
"set_from_cli",
)
def set_from_cli(addons_as_string):
from addon_utils import (
check,
check_extension,
enable,
extensions_refresh,
)
addon_modules = addons_as_string.split(",")
addon_modules_extensions = [m for m in addon_modules if check_extension(m)]
addon_modules_extensions_has_failure = False
if addon_modules_extensions:
extensions_refresh(
ensure_wheels=True,
addon_modules_pending=addon_modules_extensions,
)
for m in addon_modules:
if check(m)[1] is False:
if enable(m, persistent=True, refresh_handled=True) is None:
if check_extension(m):
addon_modules_extensions_has_failure = True
# Re-calculate wheels if any extensions failed to be enabled.
if addon_modules_extensions_has_failure:
extensions_refresh(
ensure_wheels=True,
)

View File

@@ -0,0 +1,3 @@
# SPDX-FileCopyrightText: 2025 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later

View File

@@ -0,0 +1,27 @@
# 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,671 @@
# SPDX-FileCopyrightText: 2026 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
from __future__ import annotations
__all__ = [
"download_asset_file",
"downloader_status",
"DownloadStatus",
]
from collections.abc import Callable
import dataclasses
import enum
import logging
import urllib.parse
from pathlib import Path
import bpy
from _bpy_internal.http import downloader as http_dl
from _bpy_internal.assets.remote_library.listing_downloader import RemoteAssetListingLocator
from _bpy_internal.assets.remote_library 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) -> str:
"""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 successfully, it is renamed to the final path.
:returns: the final URL that was queued for downloading.
"""
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,
reporter=AssetReporter(asset_library_url=asset_library_url),
on_queue_empty_callback=on_asset_download_queue_empty,
)
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))
full_url = downloader.download_asset_file(download_url, save_to)
return full_url
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 dst_filepath: the path on disk where to download to. While the
download is pending, ".part" will be appended to the filename. When the
download finishes successfully, 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.
bpy.types.WindowManager.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,
reporter=PreviewReporter(),
on_queue_empty_callback=None,
)
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 cancel_download(asset_library_url: str, full_asset_url: str) -> None:
"""Cancel a running/queued asset download.
Cancelling a URL that has already been fully downloaded, or one that was never
queued is a no-op.
:param asset_library_url: Root URL of the remote asset library. Used as an
identifier of this library (to create a downloader per library).
Contrary to the download function, this is NOT used to resolve relative
URLs.
:param full_asset_url: the URL that's queued for download. MUST be the final
URL as returned by download_asset_file().
"""
try:
downloader = _asset_downloaders[asset_library_url]
except KeyError:
# No downloader could mean that the cancel came in just a millisecond
# too late, and the download was already finished.
return
downloader.cancel_download(full_asset_url)
def cancel_download_all_assets() -> None:
"""Cancel all active/queued downloads of all assets.
This shuts down all asset downloaders, effectively cancelling all their downloads.
"""
for downloader in _asset_downloaders.values():
downloader.cancel_and_shutdown()
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
def on_asset_download_queue_empty() -> None:
"""Called by the asset downloader when its download queue emptied."""
if any_asset_downloading():
return
bpy.types.WindowManager.asset_library_status_ping_finished_download_queue()
def any_asset_downloading() -> bool:
"""Returns true if there is any downloader currently downloading assets."""
return any(
downloader.status == DownloadStatus.DOWNLOADING
for downloader in _asset_downloaders.values()
)
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."""
CANCELLED = 'cancelled'
"""There still were pending downloads when the downloader shut down."""
class AssetDownloader:
"""Downloader for asset files & their thumbnails."""
_locator: RemoteAssetListingLocator
_bg_downloader: http_dl.BackgroundDownloader | None
_reporter: http_dl.DownloadReporter
_num_assets_pending: int
type QueueEmptyCallback = Callable[[], None]
_on_queue_empty_callback: QueueEmptyCallback | None
"""Called when the download queue became empty."""
_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.
"""
_HTTP_METHOD = "GET"
def __init__(
self,
remote_url: str,
local_path: Path | str,
*,
reporter: http_dl.DownloadReporter,
on_queue_empty_callback: QueueEmptyCallback | None,
) -> 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_download_done_callback: called with one parameter (this
AssetDownloader) when a file 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._num_assets_pending = 0
self._reporter = reporter
self._on_queue_empty_callback = on_queue_empty_callback
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,
)
# These are called in order. Doing things this way ensures that self._reporter.download_finished() is called for
# every individual download, and after that our own function is called. That means that the
# self._on_queue_empty_callback() function is called _after_ the individual downloads.
#
# Swapping this order would mean self._on_queue_empty_callback() is called _before_ the last call to
# self._reporter.download_finished(), which would be confusing.
self._bg_downloader.add_reporter(self._reporter)
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) -> str:
"""Download an asset or preview file to a local file.
Returns the URL that was queued. This is different than the given URL
when the latter is relative.
"""
# If the downloader was shut down, start it up again.
if not self._bg_downloader:
self.start()
self._status = DownloadStatus.DOWNLOADING
url = self._queue_download(asset_url, save_to)
return url
def cancel_download(self, full_asset_url: str) -> None:
"""Cancel downloading a URL.
If the URL was never queued, or it has already been downloaded,
this is a no-op.
"""
if not self._bg_downloader:
return
logger.info("cancelling download of %s", full_asset_url)
http_req_descr = http_dl.RequestDescription(self._HTTP_METHOD, full_asset_url)
self._bg_downloader.cancel_download(http_req_descr)
def _shutdown_if_done(self) -> None:
if self._num_assets_pending > 0:
return
is_done = self._bg_downloader is None or self._bg_downloader.all_downloads_done
if not is_done:
return
# Done downloading everything, let's shut down.
self._status = DownloadStatus.FINISHED
if self._on_queue_empty_callback is not None:
# Call the callback _after_ setting the status, so that when
# Blender is pinged about this, it can see it's finished.
self._on_queue_empty_callback()
# TODO: delay this for a few minutes, so that we don't need a new
# background process for every asset.
self.shutdown()
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._status = DownloadStatus.FAILED
self.shutdown()
def _queue_download(self, asset_url: str, download_to_path: Path | str) -> str:
"""Queue up this download.
Returns the URL of the download, and 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
# Safety measure: refuse to download a file into the listing directory.
if self._locator.is_system_path(download_to_path):
raise ValueError(
("Asset at {!s} wants to be downloaded to {!s}, which would overwrite local asset system files. " +
"Notify the owner of the asset library about this.").format(
remote_url,
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"
request_descr = self._bg_downloader.queue_download(
remote_url,
download_to_path,
http_method=self._HTTP_METHOD,
)
return request_descr.url
# 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 cancel_and_shutdown(self) -> None:
"""Cancel all downloads and shut down the background downloader."""
# Only set to 'Cancelled' if the downloader was still downloading.
if self._status == DownloadStatus.DOWNLOADING:
if self._bg_downloader and self._bg_downloader.num_pending_downloads > 0:
self._status = DownloadStatus.CANCELLED
else:
self._status = DownloadStatus.FINISHED
# The downloads themselves don't have to be explicitly cancelled,
# shutting down the downloader will do that implicitly.
self.shutdown()
# By now there is no more queue, so just treat it as 'empty' and let Blender know no downloads will happen any
# more (at least not by this downloader).
if self._on_queue_empty_callback is not None:
# Call the callback _after_ setting the status, so that when
# Blender is pinged about this, it can see it's finished.
self._on_queue_empty_callback()
def shutdown(self) -> None:
"""Stop the background downloader and call the 'done' callback."""
# 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 not self._bg_downloader:
return
# 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
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._status = DownloadStatus.FAILED
self.shutdown()
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
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: http_dl.DownloadReporter protocol functions:
def download_starts(self, http_req_descr: http_dl.RequestDescription) -> None:
pass
def already_downloaded(
self,
http_req_descr: http_dl.RequestDescription,
local_file: Path,
) -> None:
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):
# Cancelling a download should cancel all queued-up downloads.
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._status = DownloadStatus.FAILED
self.shutdown()
return
self._shutdown_if_done()
def download_progress(
self,
http_req_descr: http_dl.RequestDescription,
progress: http_dl.DownloadProgress,
) -> None:
pass
def download_finished(
self,
http_req_descr: http_dl.RequestDescription,
local_file: Path,
) -> None:
self._shutdown_if_done()
@dataclasses.dataclass
class AssetReporter:
"""Implementation of the http_dl.DownloadReporter protocol."""
asset_library_url: str
def download_starts(self, http_req_descr: http_dl.RequestDescription) -> None:
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)
bpy.types.WindowManager.asset_library_status_ping_asset_file_succeeded(
self.asset_library_url, http_req_descr.url, str(local_file))
def download_error(
self,
http_req_descr: http_dl.RequestDescription,
local_file: Path,
error: Exception,
) -> None:
logger.warning("Could not download file %s: %s", http_req_descr, error)
bpy.types.WindowManager.asset_library_status_ping_asset_file_failed(
self.asset_library_url, http_req_descr.url, str(local_file))
def download_progress(
self,
http_req_descr: http_dl.RequestDescription,
progress: http_dl.DownloadProgress,
) -> None:
bpy.types.WindowManager.asset_library_status_ping_asset_file_progress(
http_req_descr.url, progress.disk_bytes_written)
def download_finished(
self,
http_req_descr: http_dl.RequestDescription,
local_file: Path,
) -> None:
logger.info("Download finished: %s to %s", http_req_descr, local_file)
bpy.types.WindowManager.asset_library_status_ping_asset_file_succeeded(
self.asset_library_url, http_req_descr.url, str(local_file))
@dataclasses.dataclass
class PreviewReporter:
"""Implementation of the http_dl.DownloadReporter protocol."""
def download_starts(self, http_req_descr: http_dl.RequestDescription) -> None:
logger.debug("Download starting: %s", http_req_descr)
def already_downloaded(
self,
http_req_descr: http_dl.RequestDescription,
local_file: Path,
) -> None:
# This cannot check the content-type header (like download_finished() does), since
# there likely is none in a '304 Not Modified' response.
# Indicate to a future run that we just confirmed this file is still fresh.
local_file.touch()
# Poke Blender so it knows there's a thumbnail update. It shouldn't be necessary, but since it requested the
# file for downloading, it may not have been aware it already existed. Better let it know.
bpy.types.WindowManager.asset_library_status_ping_loaded_new_preview(str(local_file))
def download_error(
self,
http_req_descr: http_dl.RequestDescription,
local_file: Path,
error: Exception,
) -> None:
# TODO: create an empty file in the correct `.../_thumbs/failed` directory.
self.download_finished(http_req_descr, local_file)
def download_progress(
self,
http_req_descr: http_dl.RequestDescription,
progress: http_dl.DownloadProgress,
) -> None:
pass
def download_finished(
self,
http_req_descr: http_dl.RequestDescription,
local_file: 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 time-stamping works to prevent
# hammering the server.
# Indicate to a future run that we just confirmed this file is still fresh.
local_file.touch()
# Poke Blender so it knows there's a thumbnail update.
bpy.types.WindowManager.asset_library_status_ping_loaded_new_preview(str(local_file))

View File

@@ -0,0 +1,294 @@
# SPDX-FileCopyrightText: 2026 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
#
# Generated by datamodel-codegen:
# source filename: blender_asset_library_openapi.yaml
from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum
from typing import Any
@dataclass
class Contact:
"""Owner / publisher of this asset library."""
name: str
url: str | None = None
email: str | None = None
@dataclass
class URLWithHash:
"""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.
"""
url: str
"""URL of the page file."""
hash: str
"""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.
"""
type AssetIDTypeV1 = str
"""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.
"""
class CustomPropertyTypeV1(StrEnum):
"""Type of IDProperty, see `eIDPropertyType` in `DNA_ID_enumms.h`.
For now, type `ID` and `IDPARRAY` are not supported.
"""
IDP_STRING = "IDP_STRING"
IDP_INT = "IDP_INT"
IDP_FLOAT = "IDP_FLOAT"
IDP_ARRAY = "IDP_ARRAY"
IDP_GROUP = "IDP_GROUP"
IDP_DOUBLE = "IDP_DOUBLE"
IDP_BOOL = "IDP_BOOL"
@dataclass
class AssetBlenderVersionsV1:
"""Minimum and (optionally) maximum versions of Blender that this asset
should be shown in.
This is a half-open interval: Blender shows the asset if `min <= blender < until`.
"""
min: str
"""Minimum version of Blender that should show this asset."""
until: str | None = None
"""First version of Blender that should NOT show this asset."""
@dataclass
class CatalogV1:
"""An asset catalog, which can be represented by one or more UUIDs."""
path: str
uuids: list[str]
simple_name: str | None = None
@dataclass
class FileV1:
"""Single file in the asset library.
Identified by its relative path in that library.
"""
path: str
"""Relative path of where this file is located in the asset library."""
size_in_bytes: int
hash: str
"""Hash of the file.
This should be in the format "HASHTYPE:HASH-AS-HEX". Currently only
the "SHA256" hash type is supported.
"""
blender_version: str
"""Version of Blender used to write this file.
Only contains the major and minor version, no patch version ("5.2",
"6.3", etc. but not "5.2.1").
"""
url: str | None = None
"""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'.
"""
@dataclass
class AssetLibraryMeta:
"""Meta-data of this asset library."""
api_versions: dict[str, URLWithHash]
"""API versions of this asset library.
This is reflected in the URLs of all OpenAPI operations except the
one to get this metadata. A single asset library can expose multiple
versions, in order to be backward-compatible with older versions of
Blender. Keys should be "v1", "v2", etc. and their values should be
a URLWithHash that points to each version's index file.
"""
name: str
"""Name of this asset library."""
contact: Contact
@dataclass
class AssetLibraryIndexV1:
"""The available assets at this library."""
schema_version: str
"""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: int
asset_count: int
"""Total number of assets in this index.
This is the sum of all `asset_count` fields of each page.
"""
file_count: int
"""Total number of files in this index.
This is the sum of all `file_count` fields of each page (after
deduplication).
"""
pages: list[URLWithHash]
"""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: list[CatalogV1] | None = None
@dataclass
class AssetLibraryIndexPageV1:
"""Any number of assets."""
asset_count: int
"""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: int
"""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: list[AssetV1]
files: list[FileV1]
"""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.
"""
@dataclass
class AssetV1:
"""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.
"""
name: str
"""Name of the Blender data-block."""
id_type: AssetIDTypeV1
files: list[str]
"""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.
"""
bl_versions: AssetBlenderVersionsV1
thumbnail: URLWithHash | None = None
meta: AssetMetadataV1 | None = None
@dataclass
class AssetMetadataV1:
"""Metadata of an asset, as defined by Blender's `AssetMeta` DNA struct.
Fields should either be non-empty or absent.
"""
catalog_id: str | None = None
"""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.
"""
preferred_import_method: str | None = None
"""The import method preferred by this asset.
For example, base meshes for sculpting can declare they should
always be appended, making them instantly usable for sculpting.
Supports values APPEND, APPEND_REUSE, and ASSET_IMPORT_PACK. These
are not modeled here as an enum, to aid in forward compatibility of
this Blender version with future import methods (it'll just ignore
unsupported methods, instead of rejecting the file as invalid).
"""
tags: list[str] | None = None
author: str | None = None
description: str | None = None
license: str | None = None
copyright: str | None = None
properties: CustomPropertiesV1 | None = None
type CustomPropertiesV1 = list[CustomPropertyV1]
"""Arbitrary custom properties of the asset.
Keys are the property names.
"""
@dataclass
class CustomPropertyV1:
"""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`.
"""
name: str
type: CustomPropertyTypeV1
value: CustomPropertiesV1 | list[Any] | float | int | str | bool
itemtype: CustomPropertyTypeV1 | None = None

View File

@@ -0,0 +1,411 @@
# SPDX-FileCopyrightText: 2026 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
# This is the OpenAPI specification for Blender's Remote Assets 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 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:
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:
## 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
content:
application/json:
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."
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.
A single asset library can expose multiple versions, in order to be
backward-compatible with older versions of Blender.
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.
"contact": { $ref: "#/components/schemas/Contact" }
required: [api_versions, name, contact]
example:
api_versions:
v1:
url: _v1/asset-index.json
hash: "SHA256:22c9d2d5e9fe119b43fb8437df06c88e61d3bbad315690284b9eece66641c1e9"
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]
## 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" }
"bl_versions":
$ref: "#/components/schemas/AssetBlenderVersionsV1"
required:
- "name"
- "id_type"
- "files"
- "bl_versions"
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.
"preferred_import_method":
type: string
description: >
The import method preferred by this asset. For example, base meshes for
sculpting can declare they should always be appended, making them
instantly usable for sculpting.
Supports values APPEND, APPEND_REUSE, and ASSET_IMPORT_PACK.
These are not modeled here as an enum, to aid in forward compatibility
of this Blender version with future import methods (it'll just ignore
unsupported methods, instead of rejecting the file as invalid).
"tags":
type: array
items: { type: string }
minItems: 1
"author": { type: string }
"description": { type: string }
"license": { type: string }
"copyright": { type: string }
"properties": { $ref: "#/components/schemas/CustomPropertiesV1" }
CustomPropertiesV1:
type: array
items:
$ref: "#/components/schemas/CustomPropertyV1"
description: >
Arbitrary custom properties of the asset. Keys are the property names.
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:
"name": { type: string }
"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: [name, type, value]
CustomPropertyTypeV1:
type: string
description: >
Type of IDProperty, see `eIDPropertyType` in `DNA_ID_enumms.h`. For now,
type `ID` and `IDPARRAY` are not supported.
enum:
[
IDP_STRING,
IDP_INT,
IDP_FLOAT,
IDP_ARRAY,
IDP_GROUP,
IDP_DOUBLE,
IDP_BOOL,
]
AssetBlenderVersionsV1:
type: object
description: >
Minimum and (optionally) maximum versions of Blender that this asset should be shown in.
This is a half-open interval: Blender shows the asset if `min <= blender < until`.
properties:
"min":
type: string
description: Minimum version of Blender that should show this asset.
"until":
type: string
description: First version of Blender that should NOT show this asset.
required:
- "min"
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 write this file. Only contains the major and
minor version, no patch version ("5.2", "6.3", etc. but not "5.2.1").
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 command-line 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,283 @@
# 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)
_sort_assets(assets)
# 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 _sort_assets(assets: list[api_models.AssetV1]) -> None:
"""Sorts the assets in-place.
Sorting helps to get the generated listing stable, so that a diff between
two runs of the generator is as clean as possible.
"""
# Sort assets by their primary filename first. This places related assets together, and minimizes the repeats of the
# same file across multiple listing pages.
def sort_key(asset: api_models.AssetV1) -> tuple[str, str, str]:
if asset.files:
first_file = asset.files[0].lower()
else:
first_file = ""
return (first_file, asset.id_type.lower(), asset.name.lower())
assets.sort(key=sort_key)
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")
page_abspath = outdir_root / page_relpath
_save_json(page, page_abspath)
page_infos.append(api_models.URLWithHash(
url=urllib.parse.quote(page_relpath.as_posix()),
hash=hashing.hash_file(page_abspath),
))
# 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,272 @@
# 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)
# 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))
# Convert the Blender version to a string.
blend_version = ".".join(map(str, data_from.version))
blendfile_info.blender_version = blend_version
# 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_dir.mkdir(exist_ok=True, parents=True)
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]:
# TODO: when multiple files are supported, take the maximum of the files.
bl_versions = api_models.AssetBlenderVersionsV1(
min='.'.join(file.blender_version.split('.')[:2]),
)
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 and thumbnail_path.exists():
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,
bl_versions=bl_versions,
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
if asset_data.use_preferred_import_method:
meta.preferred_import_method = asset_data.preferred_import_method
# 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.IDP_BOOL
case int():
value_type = api_models.CustomPropertyTypeV1.IDP_INT
case str():
value_type = api_models.CustomPropertyTypeV1.IDP_STRING
case float():
value_type = api_models.CustomPropertyTypeV1.IDP_FLOAT
case _:
# Unsupported type, just ignore it.
continue
if is_array:
custom_prop = api_models.CustomPropertyV1(
name=prop_name,
type=api_models.CustomPropertyTypeV1.IDP_ARRAY,
value=list(prop_value),
itemtype=value_type,
)
else:
custom_prop = api_models.CustomPropertyV1(
name=prop_name, type=value_type, value=prop_value
)
custom_props.append(custom_prop)
if custom_props:
meta.properties = 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,58 @@
# 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 multiple assets, spread across multiple pages.
"""
# Files are sorted to ensure the generated file is stable (i.e. regenerating produces the same file, and
# inserting/removing files produce a small diff).
def file_sort_key(file: api_models.FileV1) -> str:
return file.path
if not num_assets_per_page:
return [api_models.AssetLibraryIndexPageV1(
asset_count=len(assets),
assets=assets,
file_count=len(files),
files=sorted(files, key=file_sort_key),
)]
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]
file_batch.sort(key=file_sort_key)
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.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 applicable 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,148 @@
# 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
import uuid
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."""
import re
# TODO: this really should be using an RNA API.
# Sanitize the library name, as it should not contain any newlines for the Asset Catalog Definition File to be
# valid. To be on the safe side, just collapse all white-space to spaces. Same for colons, those are used as field
# separators and shouldn't be included in any of the fields themselves.
unwanted_chars_re = re.compile(r'[\s:]+')
lib_name = unwanted_chars_re.sub(' ', asset_library_meta.name)
header = _ASSET_CATS_HEADER.format(library_name=lib_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 cat_uuid_str in cat.uuids:
# Sanitize the catalogs before writing them.
try:
cat_uuid = uuid.UUID(cat_uuid_str)
except ValueError:
print("Asset Library has invalid UUID ({uuid!r}) for catalog {path!r}, skipping".format(
uuid=cat_uuid_str, path=cat.path))
continue
cat_path = unwanted_chars_re.sub(' ', cat.path)
if isinstance(cat.simple_name, str):
cat_simple_name = unwanted_chars_re.sub(' ', cat.simple_name)
else:
cat_simple_name = ""
print("{!s}:{!s}:{!s}".format(cat_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,127 @@
# 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]]] = {}
_registered_atexit = False
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 successfully, false if some other
Blender already locked this library.
"""
global _registered_atexit
import atexit
import sys
if not _registered_atexit:
atexit.register(_unlock_all)
_registered_atexit = True
# 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 file-system
# 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
def _unlock_all() -> None:
"""Unlock all file mutexes.
This is automatically called when the Python interpreter exits.
From the OS perspective it's not necessary, as all locks are automatically
released when the process stops. However, Python will complain with a
ResourceWarning if any open files are not closed.
"""
for local_library_path in list(_mutex_locks.keys()):
mutex_unlock(local_library_path)

View File

@@ -0,0 +1,144 @@
# SPDX-FileCopyrightText: 2025 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
"""Service for computing hashes of files on disk.
The hashes are cached using a storage back-end (currently the SQLite back-end is
the only available one). The back-end manages concurrent access, so that
multiple Blender instances can use the same cache without conflict.
Service instances are obtained via `get_service(storage_path)`. They are cached
until a new blend file is loaded or Blender exits.
"""
__all__ = (
'get_service',
)
import atexit
import threading
from typing import TYPE_CHECKING
import bpy
if TYPE_CHECKING:
from pathlib import Path as _Path
from _bpy_internal.disk_file_hash_service.hash_service import DiskFileHashService as _DiskFileHashService
else:
_Path = object
_DiskFileHashService = object
# Mapping from storage path + thread ID to the service instance.
_services: dict[tuple[_Path, int], _DiskFileHashService] = {}
_services_mutex = threading.Lock()
def get_service(storage_path: _Path) -> _DiskFileHashService:
"""Get a disk file hash service that stores its cache on the given path.
Depending on the back-end (currently there is only the SQLite back-end, and
thus there is no choice in which one is used), the storage_path can be used
as directory or as file prefix. The SQLite back-end uses
`{storage_path}_v{schema_version}.sqlite` as storage.
Once a DiskFileHashService is constructed, it is cached for future
invocations. These cached services are cleaned up when Blender loads another
file or when it exits.
NOTE: DiskFileHashService instances should _NOT_ be used by different
threads. When this function is used from a thread other than the main
thread, it MUST use `release_service(storage_path)` once the work is done.
"""
map_key = _map_key(storage_path)
with _services_mutex:
try:
return _services[map_key]
except KeyError:
pass
from _bpy_internal.disk_file_hash_service import backend_sqlite, hash_service
# Construct the service.
backend = backend_sqlite.SQLiteBackend(storage_path)
service = hash_service.DiskFileHashService(backend)
# Register cleanup app handlers, if they haven't been registered yet.
if _on_file_load_pre not in bpy.app.handlers.load_pre:
bpy.app.handlers.load_pre.append(_on_file_load_pre)
service.open()
_services[map_key] = service
return service
def release_service(storage_path: _Path) -> None:
"""Close a DiskFileHashService and release its resources.
Since DiskFileHashService instances should not be shared across threads,
when your thread is done with the service, call this function. This is
mandatory, as thread IDs can be reused; not releasing the service when
your thread is done with it can cause hard-to-diagnose corruptions when
the thread ID is reused by another thread.
If your DFHS is only ever used from the main thread, it is not mandatory to
release it, as that'll automatically happen when a new blend file loads or
when Blender exits.
When there is no known service for the given storage path, this is a no-op.
"""
map_key = _map_key(storage_path)
with _services_mutex:
try:
service = _services.pop(map_key)
except KeyError:
return
service.close()
def _map_key(storage_path: _Path) -> tuple[_Path, int]:
thread_id = threading.current_thread().ident
assert thread_id is not None, "current thread should be running"
return (storage_path, thread_id)
@bpy.app.handlers.persistent
def _on_file_load_pre(_filename: str) -> None:
_cleanup_all_services()
@atexit.register
def on_blender_exit() -> None:
# Named without an underscore, to prevent code checkers from (incorrectly)
# thinking this function is never used. VSCode/Pylance needs this.
_cleanup_all_services()
def _cleanup_all_services() -> None:
"""Close & delete all known services."""
current_thread_id = threading.current_thread().ident
if current_thread_id != threading.main_thread().ident:
raise RuntimeError("this function MUST be run from the main thread")
with _services_mutex:
while _services:
(_, thread_id), service = _services.popitem()
# DFHS instances created in a thread MUST be freed by that thread.
if thread_id != current_thread_id:
print(
"WARNING: Disk File Hash Service was created on thread {:d} but not released by that thread".format(thread_id))
# Keep running, maybe it can still be freed from this thread, and then we don't leak instances.
try:
service.close()
except Exception:
# Print the exception, but keep running so that the next service can
# be closed.
import traceback
traceback.print_exc()

View File

@@ -0,0 +1,271 @@
# SPDX-FileCopyrightText: 2025 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
from __future__ import annotations
__all__ = (
'SQLiteBackend',
)
import contextlib
import datetime
import sqlite3
from pathlib import Path
from typing import Iterator, Callable
from . import types
DB_TIMEOUT_MSEC = 5000 # SQLite busy timeout in milliseconds.
DB_SCHEMA_VERSION = 1
CREATE_SCHEMA_V1 = """
BEGIN EXCLUSIVE;
CREATE TABLE IF NOT EXISTS files (
file_id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
path TEXT UNIQUE NOT NULL
);
CREATE TABLE IF NOT EXISTS hashes (
file_id INTEGER NOT NULL,
hash_algo VARCHAR(10) NOT NULL,
hexdigest TEXT NOT NULL,
size_in_bytes BIGINT NOT NULL,
file_stat_mtime FLOAT NOT NULL,
last_checked DATETIME NOT NULL,
PRIMARY KEY(file_id, hash_algo)
FOREIGN KEY(file_id) REFERENCES files(file_id) ON DELETE CASCADE ON UPDATE CASCADE
);
COMMIT;
"""
# Set to True to print all SQL queries.
_DEBUG_QUERIES = False
class SQLiteBackend:
"""DiskFileHashBackend implementation using SQLite as storage engine."""
dbfile_path: Path # Path of the .sqlite file to use.
_storage_path: Path # The original storage path, only for the '__repr__' function.
db_conn_rw: sqlite3.Connection | None = None
db_conn_ro: sqlite3.Connection | None = None
def __init__(self, storage_path: Path) -> None:
assert not storage_path.is_dir(), "SQLite back-end expects a directory + file prefix as storage path"
assert storage_path.is_absolute(), "SQLite back-end needs an absolute storage path"
self._storage_path = storage_path
self.dbfile_path = storage_path.with_name("{}_v{}.sqlite".format(storage_path.stem, DB_SCHEMA_VERSION))
self.db_conn_rw = None
self.db_conn_ro = None
def __repr__(self) -> str:
return "{!s}({!r})".format(self.__class__.__qualname__, self._storage_path)
def open(self) -> None:
"""Prepare the back-end for use.
Create the directory structure & database file, and ensure the schema is as expected.
"""
import sqlite3
self.dbfile_path.parent.mkdir(parents=True, exist_ok=True)
# Open a read-write connection.
# Once we upgrade to Python 3.12+, pass `autocommit=False` instead of `isolation_level=None`.
self.db_conn_rw = sqlite3.connect(self.dbfile_path, timeout=DB_TIMEOUT_MSEC / 1000, isolation_level=None)
if _DEBUG_QUERIES:
def callback_rw(query: str) -> None:
query = query.replace("\n", "\n ")
print(f"SQL/RW: {query}")
self.db_conn_rw.set_trace_callback(callback_rw)
self._execute_pragmas_on_connect(self.db_conn_rw)
# Open a read-only connection.
try:
uri = self.dbfile_path.as_uri() + "?mode=ro"
except ValueError as ex:
# The ValueError from as_uri() doesn't contain the actual path. Note that
# this shouldn't happen, unless the assert from the __init__ function was
# disabled (which is possible via a Python CLI argument).
raise ValueError("{!s}: {!s}".format(ex, self.dbfile_path))
# Once we upgrade to Python 3.12+, pass `autocommit=False` instead of `isolation_level=None`.
self.db_conn_ro = sqlite3.connect(uri, uri=True, timeout=DB_TIMEOUT_MSEC / 1000, isolation_level=None)
if _DEBUG_QUERIES:
def callback_ro(query: str) -> None:
query = query.replace("\n", "\n ")
print(f"SQL/RO: {query}")
self.db_conn_ro.set_trace_callback(callback_ro)
self._execute_pragmas_on_connect(self.db_conn_ro)
# Assumption: if the table exists, it should be in the right shape. If
# that's not the case, the DB_SCHEMA_VERSION class variable should have
# been incremented, and we'd be accessing another database file.
#
# This does not use our _transaction_rw() function, as the executescript()
# function expects the transaction management to be included in the script
# itself. It will auto-commit any already-opened transaction, before
# running the script.
self.db_conn_rw.executescript(CREATE_SCHEMA_V1)
def close(self) -> None:
"""Close the database connection."""
if self.db_conn_ro:
self.db_conn_ro.close()
self.db_conn_ro = None
# Close the read-write connection last, otherwise the WAL journal files
# will not be check-pointed and removed.
if self.db_conn_rw:
self.db_conn_rw.close()
self.db_conn_rw = None
def fetch_hash(self, filepath: Path, hash_algorithm: str) -> types.FileHashInfo | None:
"""Return the cached hash info of a given file.
Returns a tuple (hexdigest, file size in bytes, last file mtime).
"""
with self._transaction_ro() as db:
cursor = db.execute(
"SELECT h.size_in_bytes, h.hexdigest, h.file_stat_mtime " +
"FROM files f INNER JOIN hashes h USING (file_id) " +
"WHERE f.path=? AND h.hash_algo=?",
(str(filepath), hash_algorithm))
# The uniqueness constraints ensure there is at most one row.
row = cursor.fetchone()
if row is None:
return None
size, hex, mtime = row
return types.FileHashInfo(
hexhash=hex,
file_size_bytes=size,
file_stat_mtime=mtime,
)
def store_hash(
self,
filepath: Path,
hash_algorithm: str,
hash_info: types.FileHashInfo,
pre_write_callback: Callable[[], None] | None = None,
) -> None:
"""Store a pre-computed hash for the given file path. The path has to exist."""
now = self._now_string()
with self._transaction_rw() as db:
if pre_write_callback is not None:
pre_write_callback()
# The 'RETURNING file_id' ensures that we know which file ID was
# referenced. We can't rely on last_insert_rowid() or
# cursor.lastrowid, as that only works on actual INSERT and not on
# the 'ON CONFLICT' part. The 'DO UPDATE SET file_id=file_id' is
# senseless, but an update is necessary to get the `RETURNING
# file_id` to work (it won't return with `ON CONFLICT DO NOTHING`).
cursor = db.execute(
"INSERT INTO files (path) values (?) ON CONFLICT DO UPDATE SET file_id=file_id RETURNING file_id",
(str(filepath),),
)
file_id = cursor.fetchone()[0]
assert file_id, "file_id={!r}".format(file_id)
db.execute(
"INSERT INTO hashes " +
"(file_id, hash_algo, hexdigest, size_in_bytes, file_stat_mtime, last_checked) " +
"VALUES (:file_id, :hash_algo, :hex, :size, :mtime, :now) ON CONFLICT DO UPDATE " +
"SET hexdigest=:hex, size_in_bytes=:size, file_stat_mtime=:mtime, last_checked=:now", {
"file_id": file_id,
"hash_algo": hash_algorithm,
"hex": hash_info.hexhash,
"size": hash_info.file_size_bytes,
"mtime": hash_info.file_stat_mtime,
"now": now,
},
)
def mark_hash_as_fresh(self, filepath: Path, hash_algorithm: str) -> None:
"""Store that the hash is still considered 'fresh'.
See `remove_older_than()`.
"""
now = self._now_string()
with self._transaction_rw() as db:
db.execute(
"UPDATE hashes SET last_checked=? " +
"WHERE file_id = (SELECT file_id FROM files WHERE path=?) AND hash_algo=?",
(now, str(filepath), hash_algorithm))
def remove_older_than(self, *, days: int) -> None:
"""Remove all hash entries that are older than this many days.
When this removes all known hashes for a file, the file entry itself is
also removed.
"""
older_than = self._now() - datetime.timedelta(days=days)
with self._transaction_rw() as db:
# Delete all old hashes.
db.execute("DELETE FROM hashes WHERE last_checked<?",
(older_than.isoformat(),))
# Delete file entries for which there are no hashes known.
db.execute(
"DELETE FROM files WHERE file_id IN (" +
"SELECT f.file_id FROM files f " +
"LEFT JOIN hashes h USING (file_id) " +
"GROUP BY f.file_id "
"HAVING count(h.file_id) == 0" +
")")
def _now(self) -> datetime.datetime:
"""Current time, as UTC, in a timezone-aware object."""
return datetime.datetime.now(tz=datetime.timezone.utc)
def _now_string(self) -> str:
"""Current time, as UTC, in ISO 6801 notation."""
return self._now().isoformat()
@contextlib.contextmanager
def _transaction_rw(self) -> Iterator[sqlite3.Connection]:
"""Start a read-write transaction.
The transaction is rolled back when an exception is raised, and
committed otherwise.
"""
assert self.db_conn_rw is not None, "Open the back-end before trying to use it"
self.db_conn_rw.execute("BEGIN EXCLUSIVE")
try:
yield self.db_conn_rw
except BaseException:
self.db_conn_rw.rollback()
raise
else:
self.db_conn_rw.commit()
@contextlib.contextmanager
def _transaction_ro(self) -> Iterator[sqlite3.Connection]:
"""Start a read-write transaction.
The transaction is always rolled back, because it shouldn't write
anything anyway.
"""
assert self.db_conn_ro is not None, "Open the back-end before trying to use it"
self.db_conn_ro.execute("BEGIN IMMEDIATE")
try:
yield self.db_conn_ro
finally:
self.db_conn_ro.rollback()
def _execute_pragmas_on_connect(self, db_conn: sqlite3.Connection) -> None:
db_conn.execute("PRAGMA busy_timeout = {:d}".format(DB_TIMEOUT_MSEC))
db_conn.execute("PRAGMA foreign_keys = 1")
db_conn.execute("PRAGMA journal_mode = WAL")
db_conn.execute("PRAGMA synchronous = normal")

View File

@@ -0,0 +1,159 @@
# SPDX-FileCopyrightText: 2025 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
from __future__ import annotations
import hashlib
from pathlib import Path
from typing import Callable
from . import types
# Chunk size of the hashing process, in bytes.
HASH_BLOCK_SIZE = 1024 * 1024
# Hashes that have not been 'used' in this many days are removed from the database.
# A 'use' means actually storing/updating the hash itself, or seeing that the
# stats (file size & mtime) still match the file on disk.
HASH_RETAIN_AGE_DAYS = 180
class DiskFileHashService:
backend: types.DiskFileHashBackend
def __init__(self, backend: types.DiskFileHashBackend) -> None:
self.backend = backend
self._is_open = False
def open(self) -> None:
"""Prepare the service for use."""
self.backend.open()
self._is_open = True
def close(self) -> None:
"""Close the service."""
if not self._is_open:
# Support closing of a never-opened service.
return
# Remove (potentially) outdated hashes. This is done on close, and not
# on open, to give Blender the time to query files it needs.
#
# TODO: as a future improvement, we could investigate (instead of
# delete) hashes that are older than X days. If they reference files
# that still exist on disk, for which the cached entry is still valid
# (given size in bytes & mtime), the cache entry could be marked as
# 'freshly checked' instead of removed.
self.backend.remove_older_than(days=HASH_RETAIN_AGE_DAYS)
self.backend.close()
self._is_open = False
def get_hash(self, filepath: Path, hash_algorithm: str) -> str:
"""Return the hash of a file on disk."""
cached_info = self.backend.fetch_hash(filepath, hash_algorithm)
if cached_info:
if self._file_stat_matches(filepath, cached_info.file_size_bytes, cached_info.file_stat_mtime):
# Cached hash is still fresh.
self.backend.mark_hash_as_fresh(filepath, hash_algorithm)
return cached_info.hexhash
# Hash the actual file on disk & store in the back-end.
fresh_info = self._hash_file(filepath, hash_algorithm)
self.backend.store_hash(filepath, hash_algorithm, fresh_info)
return fresh_info.hexhash
def store_hash(
self,
filepath: Path,
hash_algorithm: str,
hash_info: types.FileHashInfo,
pre_write_callback: Callable[[], None] | None = None,
) -> None:
"""Store a pre-computed hash for the given file path.
:param filepath: the file whose hash should be stored. It does not have
to exist on disk yet at the moment of calling this function. If the
file does not exist, a pre_write_callback function should be given
that ensures the file does exist after it has been called.
:param hash_info: the file's hash, size in bytes, and last-modified
timestamp. When pre_write_callback is not None, the caller is
trusted to provide the correct information. Otherwise the file size
and last-modification timestamp are checked against the file on
disk. If they mis-match, a ValueError is raised.
:param pre_write_callback: if given, the function is called after any
lock on the storage back-end has been obtained, and before it is
updated. Any exception raised by this callback will abort the
storage of the hash.
This callback function can be used to implement the following:
- Download a file to a temp location.
- Compute its hash while downloading.
- After downloading is complete, get the file size & modification time.
- Store the hash.
- In the pre-write callback function, move the file to its final location.
- The Disk File Hashing Service unlocks the back-end.
This ensures the hash and file on disk are consistent.
"""
# Sanity check: this function accepts not-currently-valid values, but
# only if the callback ensures that they become valid.
if pre_write_callback is None and not self._file_stat_matches(
filepath, hash_info.file_size_bytes, hash_info.file_stat_mtime):
raise ValueError(
"to store a hash that does NOT match the file on disk, a pre_write_callback function " +
"that ensures the file matches the to-be-stored info, MUST be passed")
self.backend.store_hash(filepath, hash_algorithm, hash_info, pre_write_callback)
def file_matches(self, filepath: Path, hash_algorithm: str, hexhash: str, size_in_byes: int) -> bool:
"""Check the file on disk, to see if it matches the given properties."""
# Check the file size first, if it doesn't match we don't have to bother with the hash.
stat = filepath.stat()
if stat.st_size != size_in_byes:
return False
actual_hash = self.get_hash(filepath, hash_algorithm)
# The hash value in hex notation is case-insensitive.
return actual_hash.lower() == hexhash.lower()
def _file_stat_matches(self, filepath: Path, size_in_bytes: int, file_stat_mtime: float) -> bool:
"""Check whether the file on disk matches this size & timestamp."""
try:
stat = filepath.stat()
except FileNotFoundError:
return False
return stat.st_size == size_in_bytes and stat.st_mtime == file_stat_mtime
def _hash_file(self, filepath: Path, hash_algorithm: str) -> types.FileHashInfo:
stat = filepath.stat()
hasher = self._get_hasher(hash_algorithm)
with filepath.open(mode="rb") as infile:
while block := infile.read(HASH_BLOCK_SIZE):
hasher.update(block)
return types.FileHashInfo(
hexhash=hasher.hexdigest(),
file_size_bytes=stat.st_size,
file_stat_mtime=stat.st_mtime,
)
def _get_hasher(self, algorithm: str) -> hashlib._Hash:
"""Construct a hasher for the given hash algorithm.
The algorithm should be chosen from hashlib.algorithms_available.
"""
if algorithm not in hashlib.algorithms_available:
available = ", ".join(sorted(hashlib.algorithms_available))
raise ValueError("Hash algorithm {!r} not available ({!r})".format(
algorithm, available))
return hashlib.new(algorithm, usedforsecurity=False)

View File

@@ -0,0 +1,62 @@
# SPDX-FileCopyrightText: 2025 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
from pathlib import Path
from typing import Protocol, Callable
import dataclasses
__all__ = (
'DiskFileHashBackend',
'FileHashInfo',
)
@dataclasses.dataclass
class FileHashInfo:
hexhash: str
file_size_bytes: int
file_stat_mtime: float
class DiskFileHashBackend(Protocol):
def open(self) -> None:
"""Prepare the back-end for use."""
def close(self) -> None:
"""Close the back-end.
After calling this, the back-end is not expected to work any more.
"""
def fetch_hash(self, filepath: Path, hash_algorithm: str) -> FileHashInfo | None:
"""Return the cached hash info of a given file.
If no info is cached for this path/algorithm combo, returns None.
"""
def store_hash(
self,
filepath: Path,
hash_algorithm: str,
hash_info: FileHashInfo,
pre_write_callback: Callable[[], None] | None = None,
) -> None:
"""Store a pre-computed hash for the given file path.
See DiskFileHashService.store_hash() for an explanation of the parameters.
"""
def mark_hash_as_fresh(self, filepath: Path, hash_algorithm: str) -> None:
"""Store that the hash is still considered 'fresh'.
See `remove_older_than()`.
"""
def remove_older_than(self, *, days: int) -> None:
"""Remove all hash entries that are older than this many days.
When this removes all known hashes for a file, the file entry itself is
also removed.
"""

View File

@@ -0,0 +1,3 @@
# SPDX-FileCopyrightText: 2024 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later

View File

@@ -0,0 +1,165 @@
# SPDX-FileCopyrightText: 2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
"""
JunctionModuleHandle creates a module whose sub-modules are not located
in the same directory on the file-system as usual. Instead the sub-modules are
added into the package from different locations on the file-system.
The ``JunctionModuleHandle`` class is used to manipulate sub-modules at run-time.
This is needed to implement package management functionality, repositories can be added/removed at run-time.
"""
__all__ = (
"JunctionModuleHandle",
)
import sys
from types import ModuleType
from collections.abc import (
Sequence,
)
def _module_file_set(module: ModuleType, name_full: str) -> None:
# File is just an identifier, as this doesn't reference an actual file,
# it just needs to be descriptive.
module.__name__ = name_full
module.__package__ = name_full
module.__file__ = "[{:s}]".format(name_full)
def _module_create(
name: str,
*,
parent: ModuleType | None = None,
doc: str | None = None,
) -> ModuleType:
if parent is not None:
name_full = parent.__name__ + "." + name
else:
name_full = name
module = ModuleType(name, doc)
_module_file_set(module, name_full)
if parent is not None:
setattr(parent, name, module)
return module
class JunctionModuleHandle:
__slots__ = (
"_module_name",
"_module",
"_submodules",
)
def __init__(self, module_name: str):
self._module_name: str = module_name
self._module: ModuleType | None = None
self._submodules: dict[str, ModuleType] = {}
def submodule_items(self) -> Sequence[tuple[str, ModuleType]]:
return tuple(self._submodules.items())
def register_module(self) -> ModuleType:
"""
Register the base module in ``sys.modules``.
"""
if self._module is not None:
raise Exception("Module {!r} already registered!".format(self._module))
if self._module_name in sys.modules:
raise Exception("Module {:s} already in 'sys.modules'!".format(self._module_name))
module = _module_create(self._module_name)
sys.modules[self._module_name] = module
# Differentiate this, and allow access to the factory (may be useful).
# `module.__module_factory__ = self`
self._module = module
return module
def unregister_module(self) -> None:
"""
Unregister the base module in ``sys.modules``.
Keep everything except the modules name (allowing re-registration).
"""
# Cleanup `sys.modules`.
sys.modules.pop(self._module_name, None)
for submodule_name in self._submodules.keys():
sys.modules.pop("{:s}.{:s}".format(self._module_name, submodule_name), None)
# Remove from self.
self._submodules.clear()
self._module = None
def register_submodule(self, submodule_name: str, dirpath: str) -> ModuleType:
name_full = self._module_name + "." + submodule_name
if self._module is None:
raise Exception("Module not registered, cannot register a submodule!")
if submodule_name in self._submodules:
raise Exception("Module \"{:s}\" already registered!".format(submodule_name))
# Register.
submodule = _module_create(submodule_name, parent=self._module)
sys.modules[name_full] = submodule
submodule.__path__ = [dirpath]
setattr(self._module, submodule_name, submodule)
self._submodules[submodule_name] = submodule
return submodule
def unregister_submodule(self, submodule_name: str) -> None:
name_full = self._module_name + "." + submodule_name
if self._module is None:
raise Exception("Module not registered, cannot register a submodule!")
# Unregister.
submodule = self._submodules.pop(submodule_name, None)
if submodule is None:
raise Exception("Module \"{:s}\" not registered!".format(submodule_name))
delattr(self._module, submodule_name)
del sys.modules[name_full]
# Remove all sub-modules, to prevent them being reused in the future.
#
# While it might not seem like a problem to keep these around it means if a module
# with the same name is registered later, importing sub-modules uses the cached values
# from `sys.modules` and does *not* assign the module to the name-space of the new `submodule`.
# This isn't exactly a bug, it's often assumed that inspecting a module
# is a way to find its sub-modules, using `dir(submodule)` for example.
# For more technical example `sys.modules["foo.bar"] == sys.modules["foo"].bar`
# which can fail with and attribute error unless the modules are cleared here.
#
# An alternative solution could be re-attach sub-modules to the modules name-space when its re-registered.
# This has some advantages since the module doesn't have to be re-imported however it has the down
# side that stale data would be kept in `sys.modules` unnecessarily in many cases.
name_full_prefix = name_full + "."
submodule_name_list = [
submodule_name for submodule_name in sys.modules.keys()
if submodule_name.startswith(name_full_prefix)
]
for submodule_name in submodule_name_list:
del sys.modules[submodule_name]
def rename_submodule(self, submodule_name_src: str, submodule_name_dst: str) -> None:
name_full_prev = self._module_name + "." + submodule_name_src
name_full_next = self._module_name + "." + submodule_name_dst
submodule = self._submodules.pop(submodule_name_src)
self._submodules[submodule_name_dst] = submodule
delattr(self._module, submodule_name_src)
setattr(self._module, submodule_name_dst, submodule)
_module_file_set(submodule, name_full_next)
del sys.modules[name_full_prev]
sys.modules[name_full_next] = submodule
def rename_directory(self, submodule_name: str, dirpath: str) -> None:
# TODO: how to deal with existing loaded modules?
# In practice this is mostly users setting up directories for the first time.
submodule = self._submodules[submodule_name]
submodule.__path__ = [dirpath]

View File

@@ -0,0 +1,16 @@
# SPDX-FileCopyrightText: 2024 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
# The purpose of this list is to present the permissions to be picked up by translation.
# The initial list of permissions is the one defined in the manifest schema
# (https://developer.blender.org/docs/features/extensions/schema/).
permissions = [
"camera",
"clipboard",
"files",
"microphone",
"network",
]

View File

@@ -0,0 +1,417 @@
# SPDX-FileCopyrightText: 2024 Blender Foundation
#
# SPDX-License-Identifier: GPL-2.0-or-later
# Schedule files for later removal, needed for situations where files are locked.
#
# This is mainly a workaround for WIN32 error where an add-on DLL
# is considered *used* making it impossible to remove.
#
# This is also used on other systems as permissions can also prevent sub-directories from removed.
# In this case renaming can make way the path to be replaced however it doesn't address
# the problem of the "stale" path failing to be removed.
# The user would need to change the permissions in this case (although this really a corner case).
__all__ = (
"StaleFiles",
)
from collections.abc import (
Sequence,
)
# The stale file-format is very simple and works as follows.
#
# - Every line references a path relative to the stale file.
# - Paths must always references files within this directory
# (anything else must be ignored).
# - Paths must always use forward slashes (even on WIN32).
# This is done since a repository may be accessed from different systems.
# - Paths must end with a newline `\n`.
#
# Further notes:
# - Corrupted "stale" files must be handled gracefully (it may be random bytes).
# - Non UTF8 characters in paths are supported via `surrogateescape`.
# - File names containing newlines are *not* supported.
class StaleFiles:
__slots__ = (
# Files outside of this directory must *never* be removed.
"_base_directory",
# The name (within `_base_directory`) to load/store paths.
"_stale_filename",
# Stale paths relative to `_base_directory`.
"_paths",
# When true, print extra debug output.
"_debug",
# Store the cache index per-directory, avoids looking up an index every time a stale name needs to be created.
"_index_cache",
# True when the run-time state is different to the on-disk state.
"_is_modified",
)
def __init__(
self,
base_directory: str,
*,
stale_filename: str,
debug: bool = False,
):
import os
from os import sep
assert base_directory not in ("", ".", "..")
# NOTE: on WIN32 `normpath` won't remove the trailing `sep`,
# it's important to add only if it's not there.
base_directory = os.path.normpath(base_directory)
self._base_directory = base_directory if base_directory.endswith(sep) else (base_directory + sep)
self._stale_filename = stale_filename
self._paths: list[str] = []
self._debug: bool = debug
self._index_cache: dict[str, int] = {}
self._is_modified: bool = True
def is_empty(self) -> bool:
return not bool(self._paths)
def is_modified(self) -> bool:
return self._is_modified
def state_load(self, *, check_exists: bool) -> None:
import contextlib
import os
from os import sep
base_directory = self._base_directory
paths = self._paths
debug = self._debug
assert base_directory.endswith(sep)
# Don't support loading multiple times or running again after adding files.
assert len(paths) == 0
stale_filepath = os.path.join(base_directory, self._stale_filename)
line_count = 0
# Set here before early exit.
# Assume modified so any corrupt causes a re-write.
self._is_modified = True
try:
# pylint: disable-next=consider-using-with
fh_context = open(stale_filepath, "r", encoding="utf8", errors="surrogateescape")
except FileNotFoundError:
self._is_modified = False
return
except Exception as ex:
if debug:
print(base_directory, "error opening file for read", str(ex))
return
with contextlib.closing(fh_context) as fh:
fh_iter = iter(fh)
while True:
try:
path = next(fh_iter)
except StopIteration:
break
except Exception as ex:
if debug:
print(base_directory, "error reading line", str(ex))
break
line_count += 1
# Not expected, file may be truncated.
if not path.endswith("\n"):
if debug:
print(base_directory, "expected line endings on each line")
continue
path = path[:-1]
# Not expected but harmless, ignore if it does.
if not path:
if debug:
print(base_directory, "expected line not to be empty")
continue
path_abs = base_directory + (path if sep == "/" else path.replace("/", "\\"))
if check_exists:
# Harmless, somehow the file was removed.
if not os.path.exists(path_abs):
continue
path_abs = os.path.normpath(path_abs)
# Not expected, ensure under *no* conditions paths outside this directory are removed.
if not path_abs.startswith(base_directory):
if debug:
print(base_directory, "stale file points to parent path (unexpected but harmless)", repr(path))
continue
# Ensure the `base_directory` & `path_abs` they are not the same.
# One could be forgiven for thinking they must never be the same since `path`
# is known not be an empty string, one would be mistaken!
# WIN32 which considers `C:\path\` the same as `C:\path\. ` to be the same.
# Therefor, literal lines containing any combination of trailing full-stop
# or space characters would be considered files that cannot be removed.
# While this should never under normal conditions happen,
# guarantee that stale file removal *never* removes anything it should not,
# including situations when random bytes are written into this file
# (except in the case the random bytes happen to match a patch - which can't be avoided).
#
# If this ever did happen besides potentially trying to remove `base_directory`,
# this path could be treated as a file which could not be removed and queued for
# removal again causing a single space (for example) to be left in the stale file,
# trying to be removed every startup and failing.
# Avoid all these issues by checking the path doesn't resolve to being the same path as it's parent.
is_same = False
try:
is_same = os.path.samefile(base_directory, path_abs)
except FileNotFoundError:
pass
except Exception as ex:
if debug:
print(base_directory, "error checking the same path", str(ex))
if is_same:
if debug:
print(base_directory, "path results to it's parent", repr(path))
continue
# NOTE: duplicates are not checked, while they aren't expected, duplicates won't cause errors.
paths.append(path)
self._is_modified = len(paths) != line_count
def state_store(self, *, check_exists: bool) -> None:
import contextlib
import os
from os import sep
base_directory = self._base_directory
debug = self._debug
stale_filepath = os.path.join(base_directory, self._stale_filename)
if not self._paths:
self._is_modified = False
try:
os.remove(stale_filepath)
except FileNotFoundError:
pass
except Exception as ex:
if debug:
print(base_directory, "failed to remove!", str(ex))
self._is_modified = True
return
try:
# pylint: disable-next=consider-using-with
fh_context = open(stale_filepath, "w", encoding="utf8", errors="surrogateescape")
except Exception as ex:
if debug:
print(base_directory, "error opening file for write", str(ex))
self._is_modified = True
return
# Assume success, any errors can set to true.
is_modified = False
with contextlib.closing(fh_context) as fh:
for path in self._paths:
if check_exists:
path_abs = base_directory + (path if sep == "/" else path.replace("/", "\\"))
# Harmless, somehow the file was removed.
if not os.path.exists(path_abs):
continue
try:
fh.write(path + "\n")
except Exception as ex:
if debug:
print(base_directory, "failed to write path", str(ex))
is_modified = True
break
self._is_modified = is_modified
def state_remove_all(self) -> bool:
import stat
import shutil
import os
from os import sep
base_directory = self._base_directory
debug = self._debug
paths_next = []
for path in self._paths:
path_abs = base_directory + (path if sep == "/" else path.replace("/", "\\"))
path_abs = os.path.normpath(path_abs)
# Should be unreachable, extra paranoid check so we *never*
# recursively remove anything outside of the base directory.
if not path_abs.startswith(base_directory):
print("Internal error detected attempting to remove file outside of:", base_directory)
continue
try:
st = os.stat(path_abs)
except FileNotFoundError:
# Not a problem if it's already removed.
continue
except Exception as ex:
if debug:
print(base_directory, "failed to stat file", path, str(ex))
continue
if stat.S_ISDIR(st.st_mode):
try:
shutil.rmtree(path_abs)
except Exception as ex:
# May be necessary with links.
try:
os.remove(path_abs)
except Exception:
if debug:
print(base_directory, "failed to remove dir", path, str(ex))
else:
try:
os.remove(path_abs)
except Exception as ex:
if debug:
print(base_directory, "failed to remove file", path, str(ex))
# Failed to remove, add back to the list.
if os.path.exists(path_abs):
paths_next.append(path)
if len(self._paths) == len(paths_next):
return False
self._is_modified = True
self._paths[:] = paths_next
return True
def state_load_add_and_store(
self,
*,
# A sequence of absolute paths within `_base_directory`.
paths: Sequence[str],
) -> bool:
# Convenience function for a common operation.
# Return true when one or more items from "paths" were added to the "state".
self.state_load(check_exists=True)
if not self.is_empty():
self.state_remove_all()
result = False
for path_abs in paths:
self.filepath_add(path_abs, rename=True)
result = True
if self.is_modified():
self.state_store(check_exists=False)
return result
def state_load_remove_and_store(
self,
*,
# A sequence of absolute paths within `_base_directory`.
paths: Sequence[str],
) -> bool:
# Convenience function for a common operation.
# Return true when one or more items from "paths" were removed from the "state".
self.state_load(check_exists=False)
# Accounts for the common case where nothing has been marked for removal.
if not self._paths:
return False
paths_remove_canonical = {
self._filepath_relative_and_canonicalize(path_abs) for path_abs in paths
if self._filepath_relative_test(path_abs)
}
paths_next = [path for path in self._paths if path not in paths_remove_canonical]
if len(self._paths) == len(paths_next):
return False
self._paths[:] = paths_next
self._is_modified = True
self.state_store(check_exists=False)
return True
def _filepath_relative_test(self, path_abs: str) -> bool:
debug = self._debug
base_directory = self._base_directory
if not path_abs.startswith(base_directory):
if debug:
print(base_directory, "is not a sub-directory", path_abs)
return False
return True
def _filepath_relative_and_canonicalize(self, path_abs: str) -> str:
from os import sep
assert self._filepath_relative_test(path_abs)
path = path_abs[len(self._base_directory):].lstrip(sep)
if sep == "\\":
path = path.replace("\\", "/")
return path
def _filepath_rename_to_stale(self, path_abs: str) -> str:
import os
base_directory = self._base_directory
debug = self._debug
# These need not necessarily match, it could be optional.
prefix = self._stale_filename
dirpath = os.path.dirname(path_abs)
stale_index = self._index_cache.get(dirpath, 1)
while True:
path_abs_stale = os.path.join(dirpath, "{:s}{:04x}".format(prefix, stale_index))
if not os.path.exists(path_abs_stale):
break
stale_index += 1
rename_ok = False
try:
os.rename(path_abs, path_abs_stale)
rename_ok = True
except Exception as ex:
if debug:
print(base_directory, "failed to rename path", str(ex))
if rename_ok:
self._index_cache[dirpath] = stale_index + 1
else:
# Failed to rename, make the previous name stale as we have no better options.
path_abs_stale = path_abs
if debug:
print("failed to rename:", path_abs)
return path_abs_stale
def filepath_add(self, path_abs: str, *, rename: bool) -> bool:
if not self._filepath_relative_test(path_abs):
return False
if rename:
path_abs = self._filepath_rename_to_stale(path_abs)
path = self._filepath_relative_and_canonicalize(path_abs)
self._is_modified = True
self._paths.append(path)
return True

View File

@@ -0,0 +1,54 @@
# SPDX-FileCopyrightText: 2024 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
# The purpose of this list is to present the tags to be picked up by translation.
# Blender itself will show all the available tags from all the servers.
# The initial list of tags are the ones used by Blender Extensions Platforms (https://extensions.blender.org).
# Other platforms can send PRs to extend this list further.
addons = {
"All", # Added automatically for legacy add-ons without a category.
"3D View",
"Add Curve",
"Add Mesh",
"Animation",
"Bake",
"Camera",
"Compositing",
"Development",
"Game Engine",
"Geometry Nodes",
"Grease Pencil",
"Import-Export",
"Lighting",
"Material",
"Mesh",
"Modeling",
"Node",
"Object",
"Paint",
"Physics",
"Pipeline",
"Render",
"Rigging",
"Scene",
"Sculpt",
"Sequencer",
"System",
"Text Editor",
"Tracking",
"User Interface",
"UV",
}
themes = {
"Accessibility",
"Colorful",
"Dark",
"High Contrast",
"Inspired By",
"Light",
"Print",
}

View File

@@ -0,0 +1,677 @@
# SPDX-FileCopyrightText: 2024 Blender Foundation
#
# SPDX-License-Identifier: GPL-2.0-or-later
# Ref: https://peps.python.org/pep-0491/
# Deferred but seems to include valid info for existing wheels.
"""
This module takes wheels and applies them to a "managed" destination directory.
"""
__all__ = (
"apply_action",
)
import contextlib
import os
import re
import shutil
import zipfile
from collections.abc import (
Callable,
Iterator,
)
WheelSource = tuple[
# Key - doesn't matter what this is... it's just a handle.
str,
# A list of absolute wheel file-paths.
list[str],
]
def _read_records_csv(filepath: str) -> list[list[str]]:
import csv
with open(filepath, encoding="utf8", errors="surrogateescape") as fh:
return list(csv.reader(fh.read().splitlines()))
def _wheels_from_dir(dirpath: str) -> tuple[
# The key is:
# wheel_id
# The values are:
# Top level directories.
dict[str, list[str]],
# Unknown paths.
list[str],
]:
result: dict[str, list[str]] = {}
paths_unused: set[str] = set()
if not os.path.exists(dirpath):
return result, list(paths_unused)
for entry in os.scandir(dirpath):
name = entry.name
paths_unused.add(name)
if not entry.is_dir():
continue
# TODO: is this part of the spec?
name = entry.name
if not name.endswith("-info"):
continue
filepath_record = os.path.join(entry.path, "RECORD")
if not os.path.exists(filepath_record):
continue
record_rows = _read_records_csv(filepath_record)
# Build top-level paths.
toplevel_paths_set: set[str] = set()
for row in record_rows:
if not row:
continue
path_text = row[0]
# Ensure paths separator is compatible.
path_text = path_text.replace("\\", "/")
# Ensure double slashes don't cause issues or "/./" doesn't complicate checking the head of the path.
path_split = [
elem for elem in path_text.split("/")
if elem not in {"", "."}
]
if not path_split:
continue
# These wont have been extracted.
if path_split[0] in {"..", name}:
continue
toplevel_paths_set.add(path_split[0])
# Some wheels contain `{name}.libs` which are *not* listed in `RECORD`.
# Always add the path, the value will be skipped if it's missing.
toplevel_paths_set.add(os.path.join(dirpath, name.partition("-")[0] + ".libs"))
result[name] = list(sorted(toplevel_paths_set))
del toplevel_paths_set
for wheel_name, toplevel_paths in result.items():
paths_unused.discard(wheel_name)
for name in toplevel_paths:
paths_unused.discard(name)
paths_unused_list = list(sorted(paths_unused))
return result, paths_unused_list
def _wheel_info_dir_from_zip(filepath_wheel: str) -> tuple[str, list[str]] | None:
"""
Return:
- The "*-info" directory name which contains meta-data.
- The top-level path list (excluding "..").
"""
dir_info = ""
toplevel_paths: set[str] = set()
with zipfile.ZipFile(filepath_wheel, mode="r") as zip_fh:
# This file will always exist.
for filepath_rel in zip_fh.namelist():
path_split = [
elem for elem in filepath_rel.split("/")
if elem not in {"", "."}
]
if not path_split:
continue
if path_split[0] == "..":
continue
if len(path_split) == 2:
if path_split[1].upper() == "RECORD":
if path_split[0].endswith("-info"):
dir_info = path_split[0]
toplevel_paths.add(path_split[0])
if dir_info == "":
return None
toplevel_paths.discard(dir_info)
toplevel_paths_list = list(sorted(toplevel_paths))
return dir_info, toplevel_paths_list
def _rmtree_safe(dir_remove: str, expected_root: str) -> Exception | None:
if not dir_remove.startswith(expected_root):
raise Exception("Expected prefix not found")
ex_result = None
def on_exc(*args) -> None: # type: ignore
nonlocal ex_result
print("Failed to remove:", args)
ex_result = args[2]
shutil.rmtree(dir_remove, onexc=on_exc)
return ex_result
def _remove_safe(file_remove: str) -> Exception | None:
ex_result = None
try:
os.remove(file_remove)
except Exception as ex:
ex_result = ex
return ex_result
# -----------------------------------------------------------------------------
# Support for Wheel: Binary distribution format
def _wheel_parse_key_value(data: bytes) -> dict[bytes, bytes]:
# Parse: `{module}.dist-info/WHEEL` format, parse it inline as
# this doesn't seem to use an existing specification, it's simply key/value pairs.
result = {}
for line in data.split(b"\n"):
key, sep, value = line.partition(b":")
if not sep:
continue
if not key:
continue
result[key.strip()] = value.strip()
return result
def _wheel_record_csv_remap(record_data: str, record_path_map: dict[str, str]) -> bytes:
import csv
from io import StringIO
lines_remap = []
for line in csv.reader(StringIO(record_data, newline="")):
# It's expected to be 3, in this case we only care about the first element (the path),
# however, if there are fewer items, this may be malformed or some unknown future format.
# - Only handle lines containing 3 elements.
# - Only manipulate the first element.
if len(line) < 3:
continue
# Items 1 and 2 are hash_sum & size respectively.
# If the files need to be modified these will need to be updated.
path = line[0]
if (path_remap := record_path_map.get(path)) is not None:
print(path_remap)
line = [path_remap, *line[0]]
lines_remap.append(line)
data = StringIO()
writer = csv.writer(data, delimiter=",", quotechar='"', lineterminator="\n")
writer.writerows(lines_remap)
return data.getvalue().encode("utf8")
def _wheel_zipfile_normalize(
zip_fh: zipfile.ZipFile,
error_fn: Callable[[Exception], None],
) -> dict[str, bytes] | None:
"""
Modify the ZIP file to account for Python's binary format.
"""
member_dict = {}
files_to_find = (".dist-info/WHEEL", ".dist-info/RECORD")
for member in zip_fh.infolist():
filename_orig = member.filename
if (
filename_orig.endswith(files_to_find) and
# Unlikely but possible the names also exist in nested directories.
(filename_orig.count("/") == 1)
):
member_dict[os.path.basename(filename_orig)] = member
if len(member_dict) == len(files_to_find):
break
if (
((member_wheel := member_dict.get("WHEEL")) is None) or
((member_record := member_dict.get("RECORD")) is None)
):
return None
try:
wheel_data = zip_fh.read(member_wheel.filename)
except Exception as ex:
error_fn(ex)
return None
wheel_key_values = _wheel_parse_key_value(wheel_data)
if wheel_key_values.get(b"Root-Is-Purelib", b"true").lower() != b"false":
return None
del wheel_key_values
# The setting has been found: `Root-Is-Purelib: false`.
# This requires the wheel to be mangled.
#
# - `{module-XXX}.dist-info/*` will have a:
# `{module-XXX}.data/purelib/`
# - For a full list see:
# https://docs.python.org/3/library/sysconfig.html#installation-paths
#
# Note that PIP's `wheel` package has a `wheel/wheelfile.py` file which is a useful reference.
assert member_wheel.filename.endswith("/WHEEL")
dirpath_dist_info = member_wheel.filename.removesuffix("/WHEEL")
assert dirpath_dist_info.endswith(".dist-info")
dirpath_data = dirpath_dist_info.removesuffix("dist-info") + "data"
dirpath_data_with_slash = dirpath_data + "/"
# https://docs.python.org/3/library/sysconfig.html#user-scheme
user_scheme_map = {}
data_map = {}
record_path_map = {}
# Simply strip the prefix in the case of `purelib` & `platlib`
# so the modules are found in the expected directory.
#
# Note that we could support a "bin" and other directories however
# for the purpose of Blender scripts, installing command line programs
# for Blender's add-ons to access via `bin` is quite niche (although not impossible).
#
# For the time being this is *not* full support Python's "User scheme"
# just enough to import modules.
#
# Omitting other directories such as "includes" & "scripts" means these will remain in the
# `{module-XXX}.data/includes` sub-directory, support for them can always be added if needed.
user_scheme_map["purelib"] = ""
user_scheme_map["platlib"] = ""
for member in zip_fh.infolist():
filepath_orig = member.filename
if not filepath_orig.startswith(dirpath_data_with_slash):
continue
path_base, path_tail = filepath_orig[len(dirpath_data_with_slash):].partition("/")[0::2]
# The path may not contain a tail, skip these cases.
if not path_tail:
continue
if (path_base_remap := user_scheme_map.get(path_base)) is None:
continue
if path_base_remap:
filepath_remap = "{:s}/{:s}".format(path_base_remap, path_tail)
else:
filepath_remap = path_tail
member.filename = filepath_remap
record_path_map[filepath_orig] = filepath_remap
try:
data_map[member_record.filename] = _wheel_record_csv_remap(
zip_fh.read(member_record.filename).decode("utf8"),
record_path_map,
)
except Exception as ex:
error_fn(ex)
return None
# Nothing to remap.
if not record_path_map:
return None
return data_map
# -----------------------------------------------------------------------------
# Generic ZIP File Extractions
def _zipfile_extractall_safe(
zip_fh: zipfile.ZipFile,
path: str,
path_restrict: str,
*,
error_fn: Callable[[Exception], None],
remove_error_fn: Callable[[str, Exception], None],
# Map zip-file data to bytes.
# Only for small files as the mapped data needs to be held in memory.
# As it happens for this use case, it's only needed for the CSV file listing.
data_map: dict[str, bytes] | None,
) -> None:
"""
A version of ``ZipFile.extractall`` that wont write to paths outside ``path_restrict``.
Avoids writing this:
``zip_fh.extractall(zip_fh, path)``
"""
sep = os.sep
path_restrict = path_restrict.rstrip(sep)
if sep == "\\":
path_restrict = path_restrict.rstrip("/")
path_restrict_with_slash = path_restrict + sep
# Strip is probably not needed (only if multiple slashes exist).
path_prefix = path[len(path_restrict_with_slash):].lstrip(sep)
# Switch slashes forward.
if sep == "\\":
path_prefix = path_prefix.replace("\\", "/").rstrip("/") + "/"
else:
path_prefix = path_prefix + "/"
path_restrict_with_slash = path_restrict + sep
assert len(path) >= len(path_restrict_with_slash)
if not path.startswith(path_restrict_with_slash):
# This is an internal error if it ever happens.
raise Exception("Expected the restricted directory to start with \"{:s}\"".format(path_restrict_with_slash))
has_error = False
member_index = 0
# Use an iterator to avoid duplicating the checks (for the cleanup pass).
def zip_iter_filtered(*, verbose: bool) -> Iterator[tuple[zipfile.ZipInfo, str, str]]:
for member in zip_fh.infolist():
filename_orig = member.filename
filename_next = path_prefix + filename_orig
# This isn't likely to happen so accept a noisy print here.
# If this ends up happening more often, it could be suppressed.
# (although this hints at bigger problems because we might be excluding necessary files).
if os.path.normpath(filename_next).startswith(".." + sep):
if verbose:
print("Skipping path:", filename_next, "that escapes:", path_restrict)
continue
yield member, filename_orig, filename_next
for member, filename_orig, filename_next in zip_iter_filtered(verbose=True):
# Increment before extracting, so a potential cleanup will a file that failed to extract.
member_index += 1
member.filename = filename_next
data_transform = None if data_map is None else data_map.get(filename_orig)
filepath_native = path_restrict + sep + filename_next.replace("/", sep)
# Extraction can fail for many reasons, see: #132924.
try:
if data_transform is not None:
with open(filepath_native, "wb") as fh:
fh.write(data_transform)
else:
zip_fh.extract(member, path_restrict)
except Exception as ex:
error_fn(ex)
print("Failed to extract path:", filepath_native, "error", str(ex))
remove_error_fn(filepath_native, ex)
has_error = True
member.filename = filename_orig
if has_error:
break
# If the zip-file failed to extract, remove all files that were extracted.
# This is done so failure to extract a file never results in a partially-working
# state which can cause confusing situations for users.
if has_error:
# NOTE: this currently leaves empty directories which is not ideal.
# It's possible to calculate directories created by this extraction but more involved.
member_cleanup_len = member_index + 1
member_index = 0
for member, filename_orig, filename_next in zip_iter_filtered(verbose=False):
member_index += 1
if member_index >= member_cleanup_len:
break
filepath_native = path_restrict + sep + filename_next.replace("/", sep)
try:
os.unlink(filepath_native)
except Exception as ex:
remove_error_fn(filepath_native, ex)
# -----------------------------------------------------------------------------
# Wheel Utilities
WHEEL_VERSION_RE = re.compile(r"(\d+)?(?:\.(\d+))?(?:\.(\d+))")
def wheel_version_from_filename_for_cmp(
filename: str,
) -> tuple[int, int, int, str]:
"""
Extract the version number for comparison.
Note that this only handled the first 3 numbers,
the trailing text is compared as a string which is not technically correct
however this is not a priority to support since scripts should only be including stable releases,
so comparing the first 3 numbers is sufficient. The trailing string is just a tie breaker in the
unlikely event it differs.
If supporting the full spec, comparing: "1.1.dev6" with "1.1.6rc6" for example
we could support this doesn't seem especially important as extensions should use major releases.
"""
filename_split = filename.split("-")
if len(filename_split) >= 2:
version = filename.split("-")[1]
if (version_match := WHEEL_VERSION_RE.match(version)) is not None:
groups = version_match.groups()
# print(groups)
return (
int(groups[0]) if groups[0] is not None else 0,
int(groups[1]) if groups[1] is not None else 0,
int(groups[2]) if groups[2] is not None else 0,
version[version_match.end():],
)
return (0, 0, 0, "")
def wheel_list_deduplicate_as_skip_set(
wheel_list: list[WheelSource],
) -> set[str]:
"""
Return all wheel paths to skip.
"""
wheels_to_skip: set[str] = set()
all_wheels: set[str] = {
filepath
for _, wheels in wheel_list
for filepath in wheels
}
# NOTE: this is not optimized.
# Probably speed is never an issue here, but this could be sped up.
# Keep a map from the base name to the "best" wheel,
# the other wheels get added to `wheels_to_skip` to be ignored.
all_wheels_by_base: dict[str, str] = {}
for wheel in all_wheels:
wheel_filename = os.path.basename(wheel)
wheel_base = wheel_filename.partition("-")[0]
wheel_exists = all_wheels_by_base.get(wheel_base)
if wheel_exists is None:
all_wheels_by_base[wheel_base] = wheel
continue
wheel_exists_filename = os.path.basename(wheel_exists)
if wheel_exists_filename == wheel_filename:
# Should never happen because they are converted into a set before looping.
assert wheel_exists != wheel
# The same wheel is used in two different locations, use a tie breaker for predictability
# although the result should be the same.
if wheel_exists_filename < wheel_filename:
all_wheels_by_base[wheel_base] = wheel
wheels_to_skip.add(wheel_exists)
else:
wheels_to_skip.add(wheel)
else:
wheel_version = wheel_version_from_filename_for_cmp(wheel_filename)
wheel_exists_version = wheel_version_from_filename_for_cmp(wheel_exists_filename)
if (
(wheel_exists_version < wheel_version) or
# Tie breaker for predictability.
((wheel_exists_version == wheel_version) and (wheel_exists_filename < wheel_filename))
):
all_wheels_by_base[wheel_base] = wheel
wheels_to_skip.add(wheel_exists)
else:
wheels_to_skip.add(wheel)
return wheels_to_skip
# -----------------------------------------------------------------------------
# Public Function to Apply Wheels
def apply_action(
*,
local_dir: str,
local_dir_site_packages: str,
wheel_list: list[WheelSource],
error_fn: Callable[[Exception], None],
remove_error_fn: Callable[[str, Exception], None],
debug: bool,
) -> None:
"""
:param local_dir:
The location wheels are stored.
Typically: ``~/.config/blender/4.2/extensions/.local``.
WARNING: files under this directory may be removed.
:param local_dir_site_packages:
The path which wheels are extracted into.
Typically: ``~/.config/blender/4.2/extensions/.local/lib/python3.11/site-packages``.
"""
# NOTE: we could avoid scanning the wheel directories however:
# Recursively removing all paths on the users system can be considered relatively risky
# even if this is located in a known location under the users home directory - better avoid.
# So build a list of wheel paths and only remove the unused paths from this list.
wheels_installed, _paths_unknown = _wheels_from_dir(local_dir_site_packages)
# Wheels and their top level directories (which would be installed).
wheels_packages: dict[str, list[str]] = {}
# Map the wheel ID to path.
wheels_dir_info_to_filepath_map: dict[str, str] = {}
# NOTE(@ideasman42): the wheels skip-set only de-duplicates at the level of the base-name of the wheels filename.
# So the wheel file-paths:
# - `pip-24.0-py3-none-any.whl`
# - `pip-22.1-py2-none-any.whl`
# Will both extract the *base* name `pip`, de-duplicating by skipping the wheels with an older version number.
# This is not fool-proof, because it is possible files inside the `.whl` conflict upon extraction.
# In practice I consider this fairly unlikely because:
# - Practically all wheels extract to their top-level module names.
# - Modules are mainly downloaded from the Python package index.
#
# Having two modules conflict is possible but this is an issue outside of Blender,
# as it's most likely quite rare and generally avoided with unique module names,
# this is not considered a problem to "solve" at the moment.
#
# The one exception to this assumption is any extensions that bundle `.whl` files that aren't
# available on the Python package index. In this case naming collisions are more likely.
# This probably needs to be handled on a policy level - if the `.whl` author also maintains
# the extension they can in all likelihood make the module a sub-module of the extension
# without the need to use `.whl` files.
wheels_to_skip = wheel_list_deduplicate_as_skip_set(wheel_list)
for _key, wheels in wheel_list:
for wheel in wheels:
if wheel in wheels_to_skip:
continue
if (wheel_info := _wheel_info_dir_from_zip(wheel)) is None:
continue
dir_info, toplevel_paths_list = wheel_info
wheels_packages[dir_info] = toplevel_paths_list
wheels_dir_info_to_filepath_map[dir_info] = wheel
# Now there is two sets of packages, the ones we need and the ones we have.
# -----
# Clear
# First remove installed packages no longer needed:
for dir_info, toplevel_paths_list in wheels_installed.items():
if dir_info in wheels_packages:
continue
# Remove installed packages which aren't needed any longer.
for filepath_rel in (dir_info, *toplevel_paths_list):
filepath_abs = os.path.join(local_dir_site_packages, filepath_rel)
if not os.path.exists(filepath_abs):
continue
if debug:
print("removing wheel:", filepath_rel)
ex: Exception | None = None
if os.path.isdir(filepath_abs):
ex = _rmtree_safe(filepath_abs, local_dir)
# For symbolic-links, use remove as a fallback.
if ex is not None:
if _remove_safe(filepath_abs) is None:
ex = None
else:
ex = _remove_safe(filepath_abs)
if ex:
if debug:
print("failed to remove:", filepath_rel, str(ex), "setting stale")
# If the directory (or file) can't be removed, make it stale and try to remove it later.
remove_error_fn(filepath_abs, ex)
# -----
# Setup
# Install packages that need to be installed:
for dir_info, toplevel_paths_list in wheels_packages.items():
if dir_info in wheels_installed:
continue
if debug:
for filepath_rel in toplevel_paths_list:
print("adding wheel:", filepath_rel)
filepath = wheels_dir_info_to_filepath_map[dir_info]
# `ZipFile.extractall` is needed because some wheels contain paths that point to parent directories.
# Handle this *safely* by allowing extracting to parent directories but limit this to the `local_dir`.
try:
# pylint: disable-next=consider-using-with
zip_fh_context = zipfile.ZipFile(filepath, mode="r")
except Exception as ex:
print("Error ({:s}) opening zip-file: {:s}".format(str(ex), filepath))
error_fn(ex)
continue
with contextlib.closing(zip_fh_context) as zip_fh:
# Support non `Root-is-purelib` wheels, where the data needs to be remapped, see: .
# Typically `data_map` will be none, see: #132843 for the use case that requires this functionality.
#
# NOTE: these wheels should be included in tests (generated and checked to properly install).
# Unfortunately there doesn't seem to a be practical way to generate them using the `wheel` module.
data_map = _wheel_zipfile_normalize(
zip_fh,
error_fn=error_fn,
)
_zipfile_extractall_safe(
zip_fh,
local_dir_site_packages,
local_dir,
error_fn=error_fn,
remove_error_fn=remove_error_fn,
data_map=data_map,
)

View File

@@ -0,0 +1,130 @@
# SPDX-FileCopyrightText: 2026 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
import io
import time
from pathlib import Path
from typing import Callable
__all__ = (
'mutex_lock_and_open',
'mutex_lock_and_open_with_retry',
'MutexAcquisitionError',
)
class MutexAcquisitionError(Exception):
"""Raised when `mutex_lock_and_open_with_retry()` cannot obtain a lock."""
pass
def mutex_lock_and_open_with_retry(file_path: Path,
mode: str,
*,
max_tries: int,
wait_time_sec: float) -> tuple[io.IOBase, Callable[[io.IOBase], None]]:
"""Obtain an exclusive lock on a file, retrying when that fails.
See `mutex_lock_and_open()` for the lock semantics, and the first two parameters.
:param max_tries: number of times the code attempts to acquire the lock.
:param wait_time: amount of time (in seconds) to wait between tries.
:returns: A tuple (file, unlocker) is returned. The caller should call
`unlocker(file)` to unlock the mutex.
:raises MutexAcquisitionError: when the lock cannot be acquired within the
given number of tries.
"""
if 'r' in mode and not file_path.exists():
# Opening a non-existent file for read is not going to work. The retry
# logic is meant for the locking, and not to wait for the file's
# existence.
raise FileNotFoundError(file_path)
for _ in range(max_tries):
meta_file, unlocker = mutex_lock_and_open(file_path, mode)
if meta_file is not None:
assert unlocker is not None
return meta_file, unlocker
time.sleep(wait_time_sec)
raise MutexAcquisitionError("could not open & lock file {!s}".format(file_path))
def mutex_lock_and_open(file_path: Path, mode: str) -> tuple[io.IOBase | None, Callable[[io.IOBase], None] | None]:
"""Obtain an exclusive lock on a file.
Create a file on disk, and immediately lock it for exclusive use by this
process.
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/
:param: mode MUST be a binary mode, to be compatible with the file locking
on Windows. So either 'rb' or 'wb'.
:returns: If the file was opened & locked successfully, a tuple (file,
unlocker) is returned. Otherwise returns None. The caller should call
`unlocker(file)` to unlock the mutex.
"""
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. This is an arbitrary choice, but
# MUST be mirrored in the unlock function below as well.
file.seek(0)
msvcrt.locking(file.fileno(), msvcrt.LK_NBLCK, 1)
def _unlock_and_close(file: io.IOBase) -> None:
# Ensure the same byte is unlocked as was locked in the function above.
file.seek(0)
msvcrt.locking(file.fileno(), msvcrt.LK_UNLCK, 1)
file.close()
else:
import fcntl
def _obtain_lock(file: io.IOBase) -> None:
fcntl.flock(file, fcntl.LOCK_EX | fcntl.LOCK_NB)
def _unlock_and_close(file: io.IOBase) -> None:
# Closing the file automatically releases the lock.
file.close()
assert isinstance(file_path, Path)
assert 'b' in mode, "mode must include 'b' for binary"
# 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 file-system
# lock.
try:
# Type is ignored here, because the type checker doesn't realize that
# the above assert ensures the file is opened in a binary mode.
lockfile: io.IOBase
lockfile = file_path.open(mode) # type: ignore
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 None, None
try:
_obtain_lock(lockfile)
except OSError:
# Lock is already held by another Blender.
lockfile.close()
return None, None
# We have obtained an exclusive lock, which the OS will release when this
# process is killed.
return lockfile, _unlock_and_close

View File

@@ -0,0 +1,3 @@
# SPDX-FileCopyrightText: 2024 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later

View File

@@ -0,0 +1,408 @@
# SPDX-FileCopyrightText: 2024 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
from enum import Enum
class BezierHandle(Enum):
LEFT = 1
RIGHT = 2
class AttributeGetterSetter:
"""
Helper class to get and set attributes at an index for a domain.
"""
__slots__ = ("_attributes", "_index", "_domain")
def __init__(self, attributes, index, domain):
self._attributes = attributes
self._index = index
self._domain = domain
def _get_attribute(self, name, type, default):
if attribute := self._attributes.get(name):
if type in {'FLOAT', 'INT', 'STRING', 'BOOLEAN', 'INT8', 'INT32_2D', 'QUATERNION', 'FLOAT4X4'}:
return attribute.data[self._index].value
elif type == 'FLOAT_VECTOR':
return attribute.data[self._index].vector
elif type in {'FLOAT_COLOR', 'BYTE_COLOR'}:
return attribute.data[self._index].color
else:
raise Exception("Unknown type {!r}".format(type))
return default
def _set_attribute_value(self, attribute, index, type, value):
if type in {'FLOAT', 'INT', 'STRING', 'BOOLEAN', 'INT8', 'INT32_2D', 'QUATERNION', 'FLOAT4X4'}:
attribute.data[index].value = value
elif type == 'FLOAT_VECTOR':
attribute.data[index].vector = value
elif type in {'FLOAT_COLOR', 'BYTE_COLOR'}:
attribute.data[index].color = value
else:
raise Exception("Unknown type {!r}".format(type))
def _set_attribute(self, name, type, value, default):
if attribute := self._attributes.get(name):
self._set_attribute_value(attribute, self._index, type, value)
elif attribute := self._attributes.new(name, type, self._domain):
# Fill attribute with default value
num = self._attributes.domain_size(self._domain)
for i in range(num):
self._set_attribute_value(attribute, i, type, default)
self._set_attribute_value(attribute, self._index, type, value)
else:
raise Exception(
"Could not create attribute {:s} of type {!r}".format(name, type))
class SliceHelper:
"""
Helper class to handle custom slicing.
"""
__slots__ = ("_start", "_stop", "_size")
def __init__(self, start: int, stop: int):
self._start = start
self._stop = stop
self._size = stop - start
def __len__(self):
return self._size
def _is_valid_index(self, key: int):
if self._size <= 0:
return False
if key < 0:
# Support indexing from the end.
return abs(key) <= self._size
return abs(key) < self._size
def _getitem_helper(self, key):
if isinstance(key, int):
if not self._is_valid_index(key):
raise IndexError("Key {:d} is out of range".format(key))
# Turn the key into an index.
return self._start + (key % self._size)
elif isinstance(key, slice):
if key.step is not None and key.step != 1:
raise ValueError("Step values != 1 not supported")
# Default to 0 and size for the start and stop values.
start = key.start if key.start is not None else 0
stop = key.stop if key.stop is not None else self._size
# Wrap negative indices.
start = self._size + start if start < 0 else start
stop = self._size + stop if stop < 0 else stop
# Clamp start and stop.
start = max(0, min(start, self._size))
stop = max(0, min(stop, self._size))
return (self._start + start, self._start + stop)
else:
raise TypeError("Unexpected index of type {!r}".format(type(key)))
def def_prop_for_attribute(attr_name, type, default, doc):
"""
Creates a property that can read and write an attribute.
"""
def fget(self):
# Define `getter` callback for property.
return self._get_attribute(attr_name, type, default)
def fset(self, value):
# Define `setter` callback for property.
self._set_attribute(attr_name, type, value, default)
prop = property(fget=fget, fset=fset, doc=doc)
return prop
def DefAttributeGetterSetters(attributes_list):
"""
A class decorator that reads a list of attribute information &
creates properties on the class with ``getters`` & ``setters``.
"""
def wrapper(cls):
for prop_name, attr_name, type, default, doc in attributes_list:
prop = def_prop_for_attribute(attr_name, type, default, doc)
setattr(cls, prop_name, prop)
return cls
return wrapper
class GreasePencilStrokePointHandle:
"""Proxy giving read-only/write access to Bézier handle data."""
__slots__ = ("_point", "_handle")
def __init__(self, point, handle: BezierHandle):
self._point = point
self._handle = handle
@property
def position(self):
attribute_name = f"handle_{self._handle.name.lower()}"
return self._point._get_attribute(attribute_name, "FLOAT_VECTOR", (0.0, 0.0, 0.0))
@position.setter
def position(self, value):
attribute_name = f"handle_{self._handle.name.lower()}"
self._point._set_attribute(attribute_name, "FLOAT_VECTOR", value, (0.0, 0.0, 0.0))
@property
def type(self):
attribute_name = f"handle_type_{self._handle.name.lower()}"
return self._point._get_attribute(attribute_name, "INT", 0)
# Note: Setting the handle type is not allowed because recomputing the handle types isn't exposed to Python yet.
@property
def select(self):
attribute_name = f".selection_handle_{self._handle.name.lower()}"
return self._point._get_attribute(attribute_name, "BOOLEAN", True)
@select.setter
def select(self, value):
attribute_name = f".selection_handle_{self._handle.name.lower()}"
self._point._set_attribute(attribute_name, 'BOOLEAN', value, True)
# Define the list of attributes that should be exposed as read/write properties on the class.
@DefAttributeGetterSetters([
# Property Name, Attribute Name, Type, Default Value, Docstring.
("radius", "radius", 'FLOAT', 0.01, "The radius of the point."),
("opacity", "opacity", 'FLOAT', 1.0, "The opacity of the point."),
("vertex_color", "vertex_color", 'FLOAT_COLOR', (0.0, 0.0, 0.0, 0.0),
"The color for this point. The alpha value is used as a mix factor with the base color of the stroke."),
("rotation", "rotation", 'FLOAT', 0.0,
"The rotation for this point. Used to rotate textures."),
("delta_time", "delta_time", 'FLOAT', 0.0,
"The time delta in seconds since the start of the stroke."),
])
class GreasePencilStrokePoint(AttributeGetterSetter):
"""
A helper class to get access to stroke point data.
"""
__slots__ = ("_drawing", "_curve_index", "_point_index")
def __init__(self, drawing, curve_index, point_index):
super().__init__(drawing.attributes, point_index, 'POINT')
self._drawing = drawing
self._curve_index = curve_index
self._point_index = point_index
@property
def position(self):
"""
The position of the point (in local space).
"""
if attribute := self._attributes.get("position"):
return attribute.data[self._point_index].vector
# Position attribute should always exist, but return default just in case.
return (0.0, 0.0, 0.0)
@position.setter
def position(self, value):
# Position attribute should always exist
if attribute := self._attributes.get("position"):
attribute.data[self._point_index].vector = value
# Tag the positions of the drawing.
self._drawing.tag_positions_changed()
@property
def select(self):
"""
The selection state for this point.
"""
if attribute := self._attributes.get(".selection"):
if attribute.domain == 'CURVE':
return attribute.data[self._curve_index].value
elif attribute.domain == 'POINT':
return attribute.data[self._point_index].value
# If the attribute doesn't exist, everything is selected.
return True
@select.setter
def select(self, value):
if attribute := self._attributes.get(".selection"):
if attribute.domain == 'CURVE':
attribute.data[self._curve_index].value = value
elif attribute.domain == 'POINT':
attribute.data[self._point_index].value = value
elif attribute := self._attributes.new(".selection", 'BOOLEAN', 'POINT'):
attribute.data[self._point_index].value = value
@property
def handle_left(self):
"""
Return the left Bézier handle proxy, or None if this point's stroke isn't Bézier.
"""
stroke_curve_type = self._drawing.strokes[self._curve_index].curve_type
if stroke_curve_type == 2: # 2 == Bézier (enum value in Blender)
return GreasePencilStrokePointHandle(self, BezierHandle.LEFT)
return None
@property
def handle_right(self):
"""
Return the right Bézier handle proxy, or None if this point's stroke isn't Bézier.
"""
stroke_curve_type = self._drawing.strokes[self._curve_index].curve_type
if stroke_curve_type == 2:
return GreasePencilStrokePointHandle(self, BezierHandle.RIGHT)
return None
class GreasePencilStrokePointSlice(SliceHelper):
"""
A helper class that represents a slice of GreasePencilStrokePoint's.
"""
__slots__ = ("_drawing", "_curve_index")
def __init__(self, drawing, curve_index: int, start: int, stop: int):
super().__init__(start, stop)
self._drawing = drawing
self._curve_index = curve_index
def __len__(self):
return super().__len__()
def __getitem__(self, key):
key = super()._getitem_helper(key)
if isinstance(key, int):
return GreasePencilStrokePoint(self._drawing, self._curve_index, key)
elif isinstance(key, tuple):
start, stop = key
return GreasePencilStrokePointSlice(self._drawing, self._curve_index, start, stop)
# Define the list of attributes that should be exposed as read/write properties on the class.
@DefAttributeGetterSetters([
# Property Name, Attribute Name, Type, Default Value, Docstring.
("cyclic", "cyclic", 'BOOLEAN', False, "The closed state for this stroke."),
("material_index", "material_index", 'INT', 0,
"The index of the material for this stroke."),
("fill_id", "fill_id", 'INT', 0, "The fill id of this stroke."),
("hide_stroke", "hide_stroke", 'BOOLEAN', False, "The stroke visibility state."),
("softness", "softness", 'FLOAT', 0.0,
"Used by the renderer to generate a soft gradient from the stroke center line to the edges."),
("start_cap", "start_cap", 'INT8', 0, "The type of start cap of this stroke."),
("end_cap", "end_cap", 'INT8', 0, "The type of end cap of this stroke."),
("aspect_ratio", "aspect_ratio", 'FLOAT', 1.0,
"The aspect ratio (x/y) used for textures. "),
("fill_opacity", "fill_opacity", 'FLOAT', 1.0, "The opacity of the fill."),
("fill_color", "fill_color", 'FLOAT_COLOR',
(0.0, 0.0, 0.0, 0.0), "The color of the fill."),
("time_start", "init_time", 'FLOAT', 0.0,
"A time value for when the stroke was created."),
])
class GreasePencilStroke(AttributeGetterSetter):
"""
A helper class to get access to stroke data.
"""
__slots__ = ("_drawing", "_curve_index", "_points_start_index", "_points_end_index")
def __init__(self, drawing, curve_index: int, points_start_index: int, points_end_index: int):
super().__init__(drawing.attributes, curve_index, 'CURVE')
self._drawing = drawing
self._curve_index = curve_index
self._points_start_index = points_start_index
self._points_end_index = points_end_index
@property
def points(self):
"""
Return a slice of points in the stroke.
"""
return GreasePencilStrokePointSlice(
self._drawing,
self._curve_index,
self._points_start_index,
self._points_end_index)
def add_points(self, count: int):
"""
Add new points at the end of the stroke and returns the new points as a list.
"""
previous_end = self._points_end_index
new_size = self._points_end_index - self._points_start_index + count
self._drawing.resize_strokes(
sizes=[new_size],
indices=[self._curve_index],
)
self._points_end_index = self._points_start_index + new_size
return GreasePencilStrokePointSlice(self._drawing, self._curve_index, previous_end, self._points_end_index)
def remove_points(self, count: int):
"""
Remove points at the end of the stroke.
"""
new_size = self._points_end_index - self._points_start_index - count
# A stroke need to have at least one point.
if new_size < 1:
new_size = 1
self._drawing.resize_strokes(
sizes=[new_size],
indices=[self._curve_index],
)
self._points_end_index = self._points_start_index + new_size
@property
def curve_type(self):
"""
The curve type of this stroke.
"""
# Note: This is read-only which is why it is not part of the AttributeGetterSetters.
return super()._get_attribute("curve_type", 'INT8', 0)
@property
def select(self):
"""
The selection state for this stroke.
"""
if attribute := self._attributes.get(".selection"):
if attribute.domain == 'CURVE':
return attribute.data[self._curve_index].value
elif attribute.domain == 'POINT':
return any([attribute.data[point_index].value for point_index in range(
self._points_start_index, self._points_end_index)])
# If the attribute doesn't exist, everything is selected.
return True
@select.setter
def select(self, value):
if attribute := self._attributes.get(".selection"):
if attribute.domain == 'CURVE':
attribute.data[self._curve_index].value = value
elif attribute.domain == 'POINT':
for point_index in range(self._points_start_index, self._points_end_index):
attribute.data[point_index].value = value
elif attribute := self._attributes.new(".selection", 'BOOLEAN', 'CURVE'):
attribute.data[self._curve_index].value = value
class GreasePencilStrokeSlice(SliceHelper):
"""
A helper class that represents a slice of GreasePencilStroke's.
"""
__slots__ = ("_drawing", "_curve_offsets")
def __init__(self, drawing, start: int, stop: int):
super().__init__(start, stop)
self._drawing = drawing
self._curve_offsets = drawing.curve_offsets
def __len__(self):
return super().__len__()
def __getitem__(self, key):
key = super()._getitem_helper(key)
if isinstance(key, int):
offsets = self._curve_offsets
return GreasePencilStroke(self._drawing, key, offsets[key].value, offsets[key + 1].value)
elif isinstance(key, tuple):
start, stop = key
return GreasePencilStrokeSlice(self._drawing, start, stop)

View File

@@ -0,0 +1,3 @@
# SPDX-FileCopyrightText: 2025 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,3 @@
# SPDX-FileCopyrightText: 2026 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later

View File

@@ -0,0 +1,598 @@
# SPDX-FileCopyrightText: 2017-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
# TODO: file-type icons are currently not setup.
# Currently `xdg-icon-resource` doesn't support SVG's, so we would need to generate PNG's.
# Or wait until SVG's are supported, see: https://gitlab.freedesktop.org/xdg/xdg-utils/-/merge_requests/41
#
# NOTE: Typically this will run from Blender, you may also run this directly from Python
# which can be useful for testing.
__all__ = (
"register",
"unregister",
)
import argparse
import os
import shlex
import shutil
import subprocess
import sys
import tempfile
from collections.abc import (
Callable,
)
VERBOSE = True
# -----------------------------------------------------------------------------
# Environment
HOME_DIR = os.path.normpath(os.path.expanduser("~"))
# https://wiki.archlinux.org/title/XDG_Base_Directory
# Typically: `~/.local/share`.
XDG_DATA_HOME = os.environ.get("XDG_DATA_HOME") or os.path.join(HOME_DIR, ".local", "share")
HOMEDIR_LOCAL_BIN = os.path.join(HOME_DIR, ".local", "bin")
BLENDER_ENV = "bpy" in sys.modules
# -----------------------------------------------------------------------------
# Programs
# The command `xdg-mime` handles most of the file association actions.
XDG_MIME_PROG = shutil.which("xdg-mime") or ""
# Initialize by `bpy` or command line arguments.
BLENDER_BIN = ""
# Set to `os.path.dirname(BLENDER_BIN)`.
BLENDER_DIR = ""
# -----------------------------------------------------------------------------
# Path Constants
# These files are included along side a portable Blender installation.
BLENDER_DESKTOP = "blender.desktop"
# The target binary.
BLENDER_FILENAME = "blender"
# The target binary (thumbnailer).
BLENDER_THUMBNAILER_FILENAME = "blender-thumbnailer"
# -----------------------------------------------------------------------------
# Other Constants
# The mime type Blender users.
BLENDER_MIME = "application/x-blender"
# Use `/usr/local` because this is not managed by the systems package manager.
SYSTEM_PREFIX = "/usr/local"
# -----------------------------------------------------------------------------
# Utility Functions
# Display a short path, for nicer display only.
def filepath_repr(filepath: str) -> str:
if filepath.startswith(HOME_DIR):
return "~" + filepath[len(HOME_DIR):]
return filepath
def system_path_contains(dirpath: str) -> bool:
dirpath = os.path.normpath(dirpath)
for path in os.environ.get("PATH", "").split(os.pathsep):
# `$PATH` can include relative locations.
path = os.path.normpath(os.path.abspath(path))
if path == dirpath:
return True
return False
def filepath_ensure_removed(path: str) -> bool:
# When removing files to make way for newly copied file an `os.path.exists`
# check isn't sufficient as the path may be a broken symbolic-link.
if os.path.lexists(path):
os.remove(path)
return True
return False
# -----------------------------------------------------------------------------
# Handle Associations
#
# On registration when handlers return False this causes registration to fail and unregister to be called.
# Non fatal errors should print a message and return True instead.
def handle_bin(do_register: bool, all_users: bool) -> str | None:
if all_users:
dirpath_dst = os.path.join(SYSTEM_PREFIX, "bin")
else:
dirpath_dst = HOMEDIR_LOCAL_BIN
if VERBOSE:
sys.stdout.write("- {:s} symbolic-links in: {:s}\n".format(
("Setup" if do_register else "Remove"),
filepath_repr(dirpath_dst),
))
if do_register:
if not all_users:
if not system_path_contains(dirpath_dst):
sys.stdout.write(
"The PATH environment variable doesn't contain \"{:s}\", not creating symbolic-links\n".format(
dirpath_dst,
))
# NOTE: this is not an error, don't consider it a failure.
return None
os.makedirs(dirpath_dst, exist_ok=True)
# Full path, then name to create at the destination.
files_to_link = [
(BLENDER_BIN, BLENDER_FILENAME, False),
]
blender_thumbnailer_src = os.path.join(BLENDER_DIR, BLENDER_THUMBNAILER_FILENAME)
if os.path.exists(blender_thumbnailer_src):
# Unfortunately the thumbnailer must be copied for `bwrap` to find it.
files_to_link.append((blender_thumbnailer_src, BLENDER_THUMBNAILER_FILENAME, True))
else:
sys.stdout.write(" Thumbnailer not found, skipping: \"{:s}\"\n".format(blender_thumbnailer_src))
for filepath_src, filename, do_full_copy in files_to_link:
filepath_dst = os.path.join(dirpath_dst, filename)
filepath_ensure_removed(filepath_dst)
if not do_register:
continue
if not os.path.exists(filepath_src):
sys.stderr.write("File not found, skipping link: \"{:s}\" -> \"{:s}\"\n".format(
filepath_src, filepath_dst,
))
if do_full_copy:
shutil.copyfile(filepath_src, filepath_dst)
os.chmod(filepath_dst, 0o755)
else:
os.symlink(filepath_src, filepath_dst)
return None
def handle_desktop_file(do_register: bool, all_users: bool) -> str | None:
# `cp ./blender.desktop ~/.local/share/applications/`
filename = BLENDER_DESKTOP
if all_users:
base_dir = os.path.join(SYSTEM_PREFIX, "share")
else:
base_dir = XDG_DATA_HOME
dirpath_dst = os.path.join(base_dir, "applications")
filepath_desktop_src = os.path.join(BLENDER_DIR, filename)
filepath_desktop_dst = os.path.join(dirpath_dst, filename)
if VERBOSE:
sys.stdout.write("- {:s} desktop-file: {:s}\n".format(
("Setup" if do_register else "Remove"),
filepath_repr(filepath_desktop_dst),
))
filepath_ensure_removed(filepath_desktop_dst)
if not do_register:
return None
if not os.path.exists(filepath_desktop_src):
# Unlike other missing things, this must be an error otherwise
# the MIME association fails which is the main purpose of registering types.
return "Error: desktop file not found: {:s}".format(filepath_desktop_src)
os.makedirs(dirpath_dst, exist_ok=True)
with open(filepath_desktop_src, "r", encoding="utf-8") as fh:
data = fh.read()
data = data.replace("\nExec=blender %f\n", "\nExec={:s} %f\n".format(BLENDER_BIN))
with open(filepath_desktop_dst, "w", encoding="utf-8") as fh:
fh.write(data)
return None
def handle_thumbnailer(do_register: bool, all_users: bool) -> str | None:
filename = "blender.thumbnailer"
if all_users:
base_dir = os.path.join(SYSTEM_PREFIX, "share")
else:
base_dir = XDG_DATA_HOME
dirpath_dst = os.path.join(base_dir, "thumbnailers")
filepath_thumbnailer_dst = os.path.join(dirpath_dst, filename)
if VERBOSE:
sys.stdout.write("- {:s} thumbnailer: {:s}\n".format(
("Setup" if do_register else "Remove"),
filepath_repr(filepath_thumbnailer_dst),
))
filepath_ensure_removed(filepath_thumbnailer_dst)
if not do_register:
return None
blender_thumbnailer_bin = os.path.join(BLENDER_DIR, BLENDER_THUMBNAILER_FILENAME)
if not os.path.exists(blender_thumbnailer_bin):
sys.stderr.write("Thumbnailer not found, this may not be a portable installation: {:s}\n".format(
blender_thumbnailer_bin,
))
return None
os.makedirs(dirpath_dst, exist_ok=True)
# NOTE: unfortunately this can't be `blender_thumbnailer_bin` because GNOME calls the command
# with wrapper that means the command *must* be in the users `$PATH`.
# and it cannot be a SYMLINK.
if shutil.which("bwrap") is not None:
command = BLENDER_THUMBNAILER_FILENAME
else:
command = blender_thumbnailer_bin
with open(filepath_thumbnailer_dst, "w", encoding="utf-8") as fh:
fh.write("[Thumbnailer Entry]\n")
fh.write("TryExec={:s}\n".format(command))
fh.write("Exec={:s} %i %o\n".format(command))
fh.write("MimeType={:s};\n".format(BLENDER_MIME))
return None
def handle_mime_association_xml(do_register: bool, all_users: bool) -> str | None:
# `xdg-mime install x-blender.xml`
filename = "x-blender.xml"
if all_users:
base_dir = os.path.join(SYSTEM_PREFIX, "share")
else:
base_dir = XDG_DATA_HOME
# Ensure directories exist `xdg-mime` will fail with an error if these don't exist.
for dirpath_dst in (
os.path.join(base_dir, "mime", "application"),
os.path.join(base_dir, "mime", "packages")
):
os.makedirs(dirpath_dst, exist_ok=True)
del dirpath_dst
# Unfortunately there doesn't seem to be a way to know the installed location.
# Use hard-coded location.
package_xml_dst = os.path.join(base_dir, "mime", "application", filename)
if VERBOSE:
sys.stdout.write("- {:s} mime type: {:s}\n".format(
("Setup" if do_register else "Remove"),
filepath_repr(package_xml_dst),
))
env = {
**os.environ,
"XDG_DATA_DIRS": os.path.join(SYSTEM_PREFIX, "share")
}
if not do_register:
if not os.path.exists(package_xml_dst):
return None
# NOTE: `xdg-mime query default application/x-blender` could be used to check
# if the XML is installed, however there is some slim chance the XML is installed
# but the default doesn't point to Blender, just uninstall as it's harmless.
cmd = (
XDG_MIME_PROG,
"uninstall",
"--mode", "system" if all_users else "user",
package_xml_dst,
)
subprocess.check_output(cmd, env=env)
return None
with tempfile.TemporaryDirectory() as tempdir:
package_xml_src = os.path.join(tempdir, filename)
with open(package_xml_src, mode="w", encoding="utf-8") as fh:
fh.write("""<?xml version="1.0" encoding="UTF-8"?>\n""")
fh.write("""<mime-info xmlns="http://www.freedesktop.org/standards/shared-mime-info">\n""")
fh.write(""" <mime-type type="{:s}">\n""".format(BLENDER_MIME))
# NOTE: not using a trailing full-stop seems to be the convention here.
fh.write(""" <comment>Blender scene</comment>\n""")
fh.write(""" <glob pattern="*.blend"/>\n""")
# TODO: this doesn't seem to work, GNOME's Nautilus & KDE's Dolphin
# already have a file-type icon for this so we might consider this low priority.
if False:
fh.write(""" <icon name="application-x-blender"/>\n""")
fh.write(""" </mime-type>\n""")
fh.write("""</mime-info>\n""")
cmd = (
XDG_MIME_PROG,
"install",
"--mode", "system" if all_users else "user",
package_xml_src,
)
subprocess.check_output(cmd, env=env)
return None
def handle_mime_association_default(do_register: bool, all_users: bool) -> str | None:
# `xdg-mime default blender.desktop application/x-blender`
if VERBOSE:
sys.stdout.write("- {:s} mime type as default\n".format(
("Setup" if do_register else "Remove"),
))
# NOTE: there doesn't seem to be a way to reverse this action.
if not do_register:
return None
cmd = (
XDG_MIME_PROG,
"default",
BLENDER_DESKTOP,
BLENDER_MIME,
)
subprocess.check_output(cmd)
return None
def handle_icon(do_register: bool, all_users: bool) -> str | None:
filename = "blender.svg"
if all_users:
base_dir = os.path.join(SYSTEM_PREFIX, "share")
else:
base_dir = XDG_DATA_HOME
dirpath_dst = os.path.join(base_dir, "icons", "hicolor", "scalable", "apps")
filepath_desktop_src = os.path.join(BLENDER_DIR, filename)
filepath_desktop_dst = os.path.join(dirpath_dst, filename)
if VERBOSE:
sys.stdout.write("- {:s} icon: {:s}\n".format(
("Setup" if do_register else "Remove"),
filepath_repr(filepath_desktop_dst),
))
filepath_ensure_removed(filepath_desktop_dst)
if not do_register:
return None
if not os.path.exists(filepath_desktop_src):
sys.stderr.write(" Icon file not found, skipping: \"{:s}\"\n".format(filepath_desktop_src))
# Not an error.
return None
os.makedirs(dirpath_dst, exist_ok=True)
with open(filepath_desktop_src, "rb") as fh:
data = fh.read()
with open(filepath_desktop_dst, "wb") as fh:
fh.write(data)
return None
# -----------------------------------------------------------------------------
# Escalate Privileges
def main_run_as_root(
do_register: bool,
*,
python_args: tuple[str, ...],
) -> str | None:
# If the system prefix doesn't exist, fail with an error because it's highly likely that the
# system won't use this when it has not been created.
if not os.path.exists(SYSTEM_PREFIX):
return "Error: system path does not exist {!r}".format(SYSTEM_PREFIX)
prog: str | None = shutil.which("pkexec")
if prog is None:
return "Error: command \"pkexec\" not found"
python_args_extra = (
# Skips users `site-packages` because they are only additional overhead for running this script.
"-s",
)
python_args = (
*python_args,
*(arg for arg in python_args_extra if arg not in python_args)
)
cmd = [
prog,
sys.executable,
*python_args,
__file__,
BLENDER_BIN,
"--action={:s}".format("register-allusers" if do_register else "unregister-allusers"),
]
if VERBOSE:
sys.stdout.write("Executing: {:s}\n".format(shlex.join(cmd)))
proc = subprocess.run(cmd, stderr=subprocess.PIPE)
if proc.returncode != 0:
if proc.stderr:
return proc.stderr.decode("utf-8", errors="surrogateescape")
return "Error: pkexec returned non-zero returncode"
return None
# -----------------------------------------------------------------------------
# Checked Call
#
# While exceptions should not happen, we can't entirely prevent this as it's always possible
# a file write fails or a command doesn't work as expected anymore.
# Handle these cases gracefully.
def call_handle_checked(
fn: Callable[[bool, bool], str | None],
*,
do_register: bool,
all_users: bool,
) -> str | None:
try:
result = fn(do_register, all_users)
except Exception as ex:
# This should never happen.
result = "Internal Error: {!r}".format(ex)
return result
# -----------------------------------------------------------------------------
# Main Registration Functions
def register_impl(do_register: bool, all_users: bool) -> str | None:
# A non-empty string indicates an error (which is forwarded to the user), otherwise None for success.
global BLENDER_BIN
global BLENDER_DIR
if BLENDER_ENV:
# File association expects a "portable" build (see `WITH_INSTALL_PORTABLE` CMake option),
# while it's possible support registering a "system" installation, the paths aren't located
# relative to the blender binary and in general it's not needed because system installations
# are used by package managers which can handle file association themselves.
# The Linux builds provided by https://blender.org are portable, register is intended to be used for these.
if not __import__("bpy").app.portable:
return "System Installation, registration is handled by the package manager"
# While snap builds are portable, the snap system handled file associations.
# Blender is also launched via a wrapper, again, we could support this if it were
# important but we can rely on the snap packaging in this case.
if os.environ.get("SNAP"):
return "Snap Package Installation, registration is handled by the package manager"
if BLENDER_ENV:
# Only use of `bpy`.
BLENDER_BIN = os.path.normpath(__import__("bpy").app.binary_path)
# Running inside Blender, detect the need for privilege escalation (which will run outside of Blender).
if all_users:
if os.geteuid() != 0:
# Run this script with escalated privileges.
return main_run_as_root(
do_register,
python_args=__import__("bpy").app.python_args,
)
else:
assert BLENDER_BIN != ""
BLENDER_DIR = os.path.dirname(BLENDER_BIN)
if all_users:
if not os.access(SYSTEM_PREFIX, os.W_OK):
return "Error: {:s} not writable, this command may need to run as a superuser!".format(SYSTEM_PREFIX)
if VERBOSE:
sys.stdout.write("{:s}: {:s}\n".format("Register" if do_register else "Unregister", BLENDER_BIN))
if XDG_MIME_PROG == "":
return "Could not find \"xdg-mime\", unable to associate mime-types"
handlers = (
handle_bin,
handle_icon,
handle_desktop_file,
handle_mime_association_xml,
# This only makes sense for users, although there may be a way to do this for all users.
*(() if all_users else (handle_mime_association_default,)),
# The thumbnailer only works when installed for all users.
*((handle_thumbnailer,) if all_users else ()),
)
error_or_none = None
for i, fn in enumerate(handlers):
if (error_or_none := call_handle_checked(fn, do_register=do_register, all_users=all_users)) is not None:
break
if error_or_none is not None:
# Roll back registration on failure.
if do_register:
for fn in reversed(handlers[:i + 1]):
error_or_none_reverse = call_handle_checked(fn, do_register=False, all_users=all_users)
if error_or_none_reverse is not None:
sys.stdout.write("Error reverting action: {:s}\n".format(error_or_none_reverse))
# Print to the `stderr`, in case the user has a console open, it can be helpful
# especially if it's multi-line.
sys.stdout.write("{:s}\n".format(error_or_none))
return error_or_none
def register(all_users: bool = False) -> str | None:
# Return an empty string for success.
return register_impl(True, all_users)
def unregister(all_users: bool = False) -> str | None:
# Return an empty string for success.
return register_impl(False, all_users)
# -----------------------------------------------------------------------------
# Running directly (Escalated Privileges)
#
# Needed when running as an administer.
register_actions = {
"register": (True, False),
"unregister": (False, False),
"register-allusers": (True, True),
"unregister-allusers": (False, True),
}
def argparse_create() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser()
parser.add_argument(
"blender_bin",
metavar="BLENDER_BIN",
type=str,
help="The location of Blender's binary",
)
parser.add_argument(
"--action",
choices=register_actions.keys(),
dest="register_action",
required=True,
)
return parser
def main() -> int:
global BLENDER_BIN
assert BLENDER_BIN == ""
args = argparse_create().parse_args()
BLENDER_BIN = args.blender_bin
do_register, all_users = register_actions[args.register_action]
if do_register:
result = register(all_users=all_users)
else:
result = unregister(all_users=all_users)
if result:
sys.stderr.write("{:s}\n".format(result))
return 1
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,258 @@
# SPDX-FileCopyrightText: 2010-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
# Function for extracting info from Blenders system information
# (sometimes useful to include in bug reports).
# Called by the operator `WM_OT_sysinfo`.
__all__ = (
"write",
)
def write(output):
# Writes into `output`, a file-like object.
import sys
import platform
import subprocess
import bpy
import gpu
# pretty repr
def prepr(v):
r = repr(v)
vt = type(v)
if vt is bytes:
r = r[2:-1]
elif vt is list or vt is tuple:
r = r[1:-1]
return r
header = "= Blender {:s} System Information =\n".format(bpy.app.version_string)
lilies = "{:s}\n\n".format((len(header) - 1) * "=")
output.write(lilies[:-1])
output.write(header)
output.write(lilies)
def title(text):
return "\n{:s}:\n{:s}".format(text, lilies)
# build info
output.write(title("Blender"))
output.write(
"version: {:s}, branch: {:s}, commit date: {:s} {:s}, hash: {:s}, type: {:s}\n".format(
bpy.app.version_string,
prepr(bpy.app.build_branch),
prepr(bpy.app.build_commit_date),
prepr(bpy.app.build_commit_time),
prepr(bpy.app.build_hash),
prepr(bpy.app.build_type),
)
)
output.write("build date: {:s}, {:s}\n".format(prepr(bpy.app.build_date), prepr(bpy.app.build_time)))
output.write("platform: {:s}\n".format(prepr(platform.platform())))
output.write("binary path: {:s}\n".format(prepr(bpy.app.binary_path)))
output.write("build cflags: {:s}\n".format(prepr(bpy.app.build_cflags)))
output.write("build cxxflags: {:s}\n".format(prepr(bpy.app.build_cxxflags)))
output.write("build linkflags: {:s}\n".format(prepr(bpy.app.build_linkflags)))
output.write("build system: {:s}\n".format(prepr(bpy.app.build_system)))
# Windowing Environment (include when dynamically selectable).
from _bpy import _ghost_backend
ghost_backend = _ghost_backend()
if ghost_backend not in {'NONE', 'DEFAULT'}:
output.write("windowing environment: {:s}\n".format(prepr(ghost_backend)))
del _ghost_backend, ghost_backend
# Python info.
output.write(title("Python"))
output.write("version: {:s}\n".format(sys.version.replace("\n", " ")))
output.write("file system encoding: {:s}:{:s}\n".format(
sys.getfilesystemencoding(),
sys.getfilesystemencodeerrors(),
))
output.write("paths:\n")
for p in sys.path:
output.write("\t{!r}\n".format(p))
output.write(title("Python (External Binary)"))
output.write("binary path: {:s}\n".format(prepr(sys.executable)))
try:
py_ver = prepr(subprocess.check_output([
sys.executable,
"--version",
]).strip())
except Exception as ex:
py_ver = str(ex)
output.write("version: {:s}\n".format(py_ver))
del py_ver
output.write(title("Directories"))
output.write("scripts:\n")
for p in bpy.utils.script_paths():
output.write("\t{!r}\n".format(p))
output.write("user scripts: {!r}\n".format(bpy.utils.script_path_user()))
output.write("pref scripts:\n")
for p in bpy.utils.script_paths_pref():
output.write("\t{!r}\n".format(p))
output.write("datafiles: {!r}\n".format(bpy.utils.user_resource('DATAFILES')))
output.write("config: {!r}\n".format(bpy.utils.user_resource('CONFIG')))
output.write("scripts: {!r}\n".format(bpy.utils.user_resource('SCRIPTS')))
output.write("extensions: {!r}\n".format(bpy.utils.user_resource('EXTENSIONS')))
output.write("tempdir: {!r}\n".format(bpy.app.tempdir))
output.write(title("FFmpeg"))
ffmpeg = bpy.app.ffmpeg
if ffmpeg.supported:
for lib in ("avcodec", "avdevice", "avformat", "avutil", "swscale"):
output.write(
"{:s}:{:s}{!r}\n".format(
lib,
" " * (10 - len(lib)),
getattr(ffmpeg, lib + "_version_string"),
)
)
else:
output.write("Blender was built without FFmpeg support\n")
if bpy.app.build_options.sdl:
output.write(title("SDL"))
output.write("Version: {:s}\n".format(bpy.app.sdl.version_string))
output.write(title("Other Libraries"))
ocio = bpy.app.ocio
output.write("OpenColorIO: ")
if ocio.supported:
if ocio.version_string == "fallback":
output.write(
"Blender was built with OpenColorIO, "
"but it currently uses fallback color management.\n"
)
else:
output.write("{:s}\n".format(ocio.version_string))
else:
output.write("Blender was built without OpenColorIO support\n")
oiio = bpy.app.oiio
output.write("OpenImageIO: ")
if ocio.supported:
output.write("{:s}\n".format(oiio.version_string))
else:
output.write("Blender was built without OpenImageIO support\n")
output.write("OpenShadingLanguage: ")
if bpy.app.build_options.cycles:
if bpy.app.build_options.cycles_osl:
from _cycles import osl_version_string
output.write("{:s}\n".format(osl_version_string))
else:
output.write("Blender was built without OpenShadingLanguage support in Cycles\n")
else:
output.write("Blender was built without Cycles support\n")
opensubdiv = bpy.app.opensubdiv
output.write("OpenSubdiv: ")
if opensubdiv.supported:
output.write("{:s}\n".format(opensubdiv.version_string))
else:
output.write("Blender was built without OpenSubdiv support\n")
openvdb = bpy.app.openvdb
output.write("OpenVDB: ")
if openvdb.supported:
output.write("{:s}\n".format(openvdb.version_string))
else:
output.write("Blender was built without OpenVDB support\n")
alembic = bpy.app.alembic
output.write("Alembic: ")
if alembic.supported:
output.write("{:s}\n".format(alembic.version_string))
else:
output.write("Blender was built without Alembic support\n")
usd = bpy.app.usd
output.write("USD: ")
if usd.supported:
output.write("{:s}\n".format(usd.version_string))
else:
output.write("Blender was built without USD support\n")
if not bpy.app.build_options.sdl:
output.write("SDL: Blender was built without SDL support\n")
if bpy.app.background:
output.write("\nGPU: missing, background mode\n")
else:
output.write(title("GPU"))
output.write("renderer:\t{!r}\n".format(gpu.platform.renderer_get()))
output.write("vendor:\t\t{!r}\n".format(gpu.platform.vendor_get()))
output.write("version:\t{!r}\n".format(gpu.platform.version_get()))
output.write("device type:\t{!r}\n".format(gpu.platform.device_type_get()))
output.write("backend type:\t{!r}\n".format(gpu.platform.backend_type_get()))
output.write("extensions:\n")
glext = sorted(gpu.capabilities.extensions_get())
for line in glext:
output.write("\t{:s}\n".format(line))
output.write(title("Implementation Dependent GPU Limits"))
output.write("Maximum Batch Vertices:\t{:d}\n".format(
gpu.capabilities.max_batch_vertices_get(),
))
output.write("Maximum Batch Indices:\t{:d}\n".format(
gpu.capabilities.max_batch_indices_get(),
))
output.write("\nGLSL:\n")
output.write("Maximum Varying Floats:\t{:d}\n".format(
gpu.capabilities.max_varying_floats_get(),
))
output.write("Maximum Vertex Attributes:\t{:d}\n".format(
gpu.capabilities.max_vertex_attribs_get(),
))
output.write("Maximum Vertex Uniform Components:\t{:d}\n".format(
gpu.capabilities.max_uniforms_vert_get(),
))
output.write("Maximum Fragment Uniform Components:\t{:d}\n".format(
gpu.capabilities.max_uniforms_frag_get(),
))
output.write("Maximum Vertex Image Units:\t{:d}\n".format(
gpu.capabilities.max_textures_vert_get(),
))
output.write("Maximum Fragment Image Units:\t{:d}\n".format(
gpu.capabilities.max_textures_frag_get(),
))
output.write("Maximum Pipeline Image Units:\t{:d}\n".format(
gpu.capabilities.max_textures_get(),
))
output.write("Maximum Image Units:\t{:d}\n".format(
gpu.capabilities.max_images_get(),
))
if bpy.app.build_options.cycles:
import cycles
output.write(title("Cycles"))
output.write(cycles.engine.system_info())
import addon_utils
addon_utils.modules()
output.write(title("Enabled add-ons"))
for addon in bpy.context.preferences.addons.keys():
addon_mod = addon_utils.addons_fake_modules.get(addon, None)
if addon_mod is None:
output.write("{:s} (MISSING)\n".format(addon))
else:
output.write(
"{:s} (version: {:s}, path: {!r})\n".format(
addon,
str(addon_mod.bl_info.get("version", "UNKNOWN")),
addon_mod.__file__,
)
)

View File

@@ -0,0 +1,56 @@
# SPDX-FileCopyrightText: 2019-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
# Keep the information collected in this script synchronized with `startup.py`.
__all__ = (
"url_from_blender",
)
def url_from_blender():
import bpy
import gpu
import struct
import platform
import urllib.parse
query_params = {
"type": "bug_report",
"project": "blender",
}
query_params["os"] = "{:s} {:d} Bits".format(
platform.platform(),
struct.calcsize("P") * 8,
)
# Windowing Environment (include when dynamically selectable).
# This lets us know if WAYLAND/X11 is in use.
from _bpy import _ghost_backend
ghost_backend = _ghost_backend()
if ghost_backend not in {'NONE', 'DEFAULT'}:
query_params["os"] += (", {:s} UI".format(ghost_backend))
del _ghost_backend, ghost_backend
query_params["gpu"] = "{:s} {:s} {:s}".format(
gpu.platform.renderer_get(),
gpu.platform.vendor_get(),
gpu.platform.version_get(),
)
gpu_backend = gpu.platform.backend_type_get()
if gpu_backend not in {'NONE', 'UNKNOWN', 'METAL'}:
query_params["gpu"] += (" {:s} Backend".format(gpu_backend.title()))
query_params["broken_version"] = "{:s}, branch: {:s}, commit date: {:s} {:s}, hash: `{:s}`".format(
bpy.app.version_string,
bpy.app.build_branch.decode('utf-8', 'replace'),
bpy.app.build_commit_date.decode('utf-8', 'replace'),
bpy.app.build_commit_time.decode('utf-8', 'replace'),
bpy.app.build_hash.decode('ascii'),
)
query_str = urllib.parse.urlencode(query_params)
return "https://redirect.blender.org/?" + query_str

View File

@@ -0,0 +1,107 @@
#!/usr/bin/env python3
# SPDX-FileCopyrightText: 2024 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
# Keep the information collected in this script synchronized with `runtime.py`.
# NOTE: this can run as a standalone script, called directly from Python
# (even though it's located inside a package).
__all__ = (
"url_from_blender",
)
def url_from_blender() -> str:
import re
import struct
import platform
import subprocess
import sys
import urllib.parse
from pathlib import Path
print("Collecting system information...")
query_params = {"type": "bug_report", "project": "blender"}
query_params["os"] = "{:s} {:d} Bits".format(
platform.platform(),
struct.calcsize("P") * 8,
)
# There doesn't appear to be a easy way to collect GPU information in Python
# if Blender isn't opening and we can't import the GPU module.
# So just tell users to follow a written guide.
query_params["gpu"] = (
"Follow our guide to collect this information:\n"
"https://developer.blender.org/docs/handbook/bug_reports/making_good_bug_reports/collect_system_information/"
)
os_type = platform.system()
script_directory = Path(__file__).parent.resolve()
if os_type == "Darwin": # macOS appears as Darwin.
blender_bin = script_directory.joinpath("../../../../../../MacOS/Blender")
elif os_type == "Windows":
blender_bin = script_directory.joinpath("../../../../../Blender.exe")
else: # Linux and other Unix systems.
blender_bin = script_directory.joinpath("../../../../../blender")
try:
blender_output = subprocess.run(
(blender_bin, "--version"),
stdout=subprocess.PIPE,
encoding="utf-8",
errors="surrogateescape",
)
except Exception as ex:
sys.stderr.write("{:s}\n".format(str(ex)))
return ""
text = blender_output.stdout
unknown_string = "<unknown>"
def re_group_or_unknown(m: re.Match[str] | None) -> str:
if m is None:
return unknown_string
return m.group(1)
# Gather Blender version information.
values: dict[str, str] = {
"version": re_group_or_unknown(re.search(r"^Blender (.*)", text, flags=re.MULTILINE)),
"branch": re_group_or_unknown(re.search(r"^\s+build branch: (.*)", text, flags=re.MULTILINE)),
"commit_date": re_group_or_unknown(re.search(r"^\s+build commit date: (.*)", text, flags=re.MULTILINE)),
"commit_time": re_group_or_unknown(re.search(r"^\s+build commit time: (.*)", text, flags=re.MULTILINE)),
"build_hash": re_group_or_unknown(re.search(r"^\s+build hash: (.*)", text, flags=re.MULTILINE)),
}
if not (set(values.values()) - {unknown_string}):
# No valid Blender info could be found.
print("Blender did not provide any build information. Blender may be corrupt or blocked from running.")
print("Please try reinstalling Blender and double check your anti-virus isn't blocking it from running.")
return ""
query_params["broken_version"] = (
"{version:s}, branch: {branch:s}, commit date: {commit_date:s} {commit_time:s}, hash `{build_hash:s}`".format(
**values,
)
)
return "https://redirect.blender.org/?{:s}".format(urllib.parse.urlencode(query_params))
def main() -> int:
import webbrowser
if not (url := url_from_blender()):
return 1
webbrowser.open(url)
return 0
if __name__ == "__main__":
import sys
sys.exit(main())