Add Chromium-only Blender WebEngine parity work
This commit is contained in:
3
blender-5.2.0/scripts/modules/_bpy_internal/__init__.py
Normal file
3
blender-5.2.0/scripts/modules/_bpy_internal/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
# SPDX-FileCopyrightText: 2017-2023 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
@@ -0,0 +1,3 @@
|
||||
# SPDX-FileCopyrightText: 2024 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
42
blender-5.2.0/scripts/modules/_bpy_internal/addons/cli.py
Normal file
42
blender-5.2.0/scripts/modules/_bpy_internal/addons/cli.py
Normal 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,
|
||||
)
|
||||
@@ -0,0 +1,3 @@
|
||||
# SPDX-FileCopyrightText: 2025 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -0,0 +1,75 @@
|
||||
# SPDX-FileCopyrightText: 2026 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime
|
||||
import logging
|
||||
import time
|
||||
|
||||
|
||||
def main(cli_args: list[str]) -> None:
|
||||
"""CLI entry point for the 'asset_listing' CLI commands."""
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="blender -c asset_listing",
|
||||
description="Manage asset library index files.",
|
||||
)
|
||||
|
||||
# func is set by subparsers to indicate which function to run.
|
||||
parser.set_defaults(func=None, loglevel=logging.INFO)
|
||||
|
||||
loggroup = parser.add_mutually_exclusive_group()
|
||||
loggroup.add_argument(
|
||||
"-v",
|
||||
"--verbose",
|
||||
dest="loglevel",
|
||||
action="store_const",
|
||||
const=logging.DEBUG,
|
||||
help="Log DEBUG level and higher",
|
||||
)
|
||||
loggroup.add_argument(
|
||||
"-q",
|
||||
"--quiet",
|
||||
dest="loglevel",
|
||||
action="store_const",
|
||||
const=logging.WARNING,
|
||||
help="Log at WARNING level and higher",
|
||||
)
|
||||
|
||||
subparsers = parser.add_subparsers(
|
||||
help="Choose a subcommand to actually make Blender do something. "
|
||||
"Global options go before the subcommand, "
|
||||
"whereas subcommand-specific options go after it. "
|
||||
"Use --help after the subcommand to get more info."
|
||||
)
|
||||
|
||||
from . import cli_listing_generator, cli_listing_downloader
|
||||
|
||||
cli_listing_generator.add_cli_parser(subparsers)
|
||||
cli_listing_downloader.add_cli_parser(subparsers)
|
||||
|
||||
args = parser.parse_args(cli_args)
|
||||
|
||||
config_logging(args)
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
if not args.func:
|
||||
parser.error("No subcommand was given")
|
||||
|
||||
start_time = time.monotonic()
|
||||
args.func(args)
|
||||
|
||||
duration = datetime.timedelta(seconds=time.monotonic() - start_time)
|
||||
log.info("Command took %s to complete", duration)
|
||||
|
||||
|
||||
def config_logging(args) -> None: # type: ignore
|
||||
"""Configures the logging system based on CLI arguments."""
|
||||
|
||||
logging.basicConfig(
|
||||
level=args.loglevel,
|
||||
format="%(asctime)-15s %(levelname)8s %(threadName)10s %(name)16s %(message)s",
|
||||
)
|
||||
@@ -0,0 +1,92 @@
|
||||
# SPDX-FileCopyrightText: 2026 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import dataclasses
|
||||
import logging
|
||||
import time
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
|
||||
from . import listing_downloader
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class CLIArguments:
|
||||
"""Parsed 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."""
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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:])
|
||||
@@ -0,0 +1,61 @@
|
||||
# SPDX-FileCopyrightText: 2026 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
"""Wrapper around cattrs."""
|
||||
|
||||
__all__ = [
|
||||
"ValidatingParser",
|
||||
"APIModel",
|
||||
]
|
||||
|
||||
import dataclasses
|
||||
import json
|
||||
from typing import Any, Type, TypeVar
|
||||
|
||||
import cattrs
|
||||
import cattrs.preconf.json
|
||||
|
||||
from . import blender_asset_library_openapi as api_models
|
||||
|
||||
# There is no common base class for dataclasses, so this type variable will have to act as a stand-in.
|
||||
APIModel = TypeVar("APIModel")
|
||||
|
||||
|
||||
class ValidatingParser:
|
||||
"""Wrapper around cattrs, caching the cattrs converter."""
|
||||
|
||||
_converter: cattrs.preconf.json.JsonConverter
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._converter = cattrs.preconf.json.JsonConverter(omit_if_default=True)
|
||||
|
||||
# Register a custom unstructure hook for the type of `CustomPropertyV1.value`.
|
||||
#
|
||||
# NOTE: this MUST register the 'final' type, and cannot use
|
||||
# `CustomProperties` as an alias for `dict[str, CustomProperty]`. It
|
||||
# won't be found. It also has to include None in the union for some
|
||||
# reason, even though that's not declared in `CustomPropertyV1.value`.
|
||||
#
|
||||
# Basically cattrs told me to register a structure hook for this
|
||||
# specific type, and so that's what I (Sybren) did.
|
||||
self._converter.register_structure_hook(
|
||||
api_models.CustomPropertiesV1 | list[Any] | float | int | str | bool,
|
||||
lambda value, _: value,
|
||||
)
|
||||
|
||||
def parse_and_validate(self, model_class: Type[APIModel], json_payload: bytes | str) -> APIModel:
|
||||
"""Parse & validate the JSON data, returning an instance of the given model class.
|
||||
|
||||
:raises json.JSONDecodeError: if the payload is not formatted as JSON.
|
||||
:raises cattrs.errors.ClassValidationError: if the payload doesn't pass
|
||||
validation and can't be converted to the given model class.
|
||||
"""
|
||||
|
||||
json_doc = json.loads(json_payload)
|
||||
return self._converter.structure(json_doc, model_class)
|
||||
|
||||
def dumps(self, model_instance: Any) -> str:
|
||||
"""Convert the model instance to JSON, returning it as string."""
|
||||
assert dataclasses.is_dataclass(model_instance), f"{model_instance} is not a dataclass"
|
||||
return self._converter.dumps(model_instance, indent=2)
|
||||
@@ -0,0 +1,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)
|
||||
@@ -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()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
"""
|
||||
@@ -0,0 +1,3 @@
|
||||
# SPDX-FileCopyrightText: 2024 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
@@ -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]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
# SPDX-FileCopyrightText: 2024 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
@@ -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)
|
||||
@@ -0,0 +1,3 @@
|
||||
# SPDX-FileCopyrightText: 2025 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
1683
blender-5.2.0/scripts/modules/_bpy_internal/http/downloader.py
Normal file
1683
blender-5.2.0/scripts/modules/_bpy_internal/http/downloader.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
# SPDX-FileCopyrightText: 2026 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
@@ -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())
|
||||
@@ -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__,
|
||||
)
|
||||
)
|
||||
@@ -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
|
||||
107
blender-5.2.0/scripts/modules/_bpy_internal/system_info/url_prefill_startup.py
Executable file
107
blender-5.2.0/scripts/modules/_bpy_internal/system_info/url_prefill_startup.py
Executable 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())
|
||||
Reference in New Issue
Block a user