Add Chromium-only Blender WebEngine parity work

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

View File

@@ -0,0 +1,230 @@
# SPDX-FileCopyrightText: 2010-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
"""
This module has utility functions for renaming
rna values in fcurves and drivers.
Currently unused, but might become useful later again.
"""
__all__ = (
"update_data_paths",
)
import sys
import bpy
IS_TESTING = False
def classes_recursive(base_type, clss=None):
if clss is None:
clss = [base_type]
else:
clss.append(base_type)
for base_type_iter in base_type.__bases__:
if base_type_iter is not object:
classes_recursive(base_type_iter, clss)
return clss
class DataPathBuilder:
"""Dummy class used to parse fcurve and driver data paths."""
__slots__ = ("data_path", )
def __init__(self, attrs):
self.data_path = attrs
def __getattr__(self, attr):
str_value = ".{:s}".format(attr)
return DataPathBuilder(self.data_path + (str_value, ))
def __getitem__(self, key):
if type(key) is int:
str_value = '[{:d}]'.format(key)
elif type(key) is str:
str_value = '["{:s}"]'.format(bpy.utils.escape_identifier(key))
else:
raise Exception("unsupported accessor {!r} of type {!r} (internal error)".format(key, type(key)))
return DataPathBuilder(self.data_path + (str_value, ))
def resolve(self, real_base, rna_update_from_map, fcurve, log):
"""Return (attribute, value) pairs."""
pairs = []
base = real_base
for item in self.data_path:
if base is not Ellipsis:
base_new = Ellipsis
# find the new name
if item.startswith("."):
for class_name, item_new, options in (
rna_update_from_map.get(item[1:], []) +
[(None, item[1:], None)]
):
if callable(item_new):
# No type check here, callback is assumed to know what it's doing.
base_new, item_new = item_new(base, class_name, item[1:], fcurve, options)
if base_new is not Ellipsis:
break # found, don't keep looking
else:
# Type check!
type_ok = True
if class_name is not None:
type_ok = False
for base_type in classes_recursive(type(base)):
if base_type.__name__ == class_name:
type_ok = True
break
if type_ok:
try:
# print("base." + item_new)
base_new = eval("base." + item_new)
break # found, don't keep looking
except Exception:
pass
item_new = "." + item_new
else:
item_new = item
try:
base_new = eval("base" + item_new)
except Exception:
pass
if base_new is Ellipsis:
print("Failed to resolve data path:", self.data_path, file=log)
base = base_new
else:
item_new = item
pairs.append((item_new, base))
return pairs
def id_iter():
from bpy.types import bpy_prop_collection
assert isinstance(bpy.data.objects, bpy_prop_collection)
for attr in dir(bpy.data):
data_iter = getattr(bpy.data, attr, None)
if isinstance(data_iter, bpy_prop_collection):
for id_data in data_iter:
if id_data.library is None:
yield id_data
def anim_data_actions(anim_data) -> list[tuple[bpy.types.Action, bpy.types.ActionSlot]]:
actions = []
actions.append((anim_data.action, anim_data.action_slot))
for track in anim_data.nla_tracks:
for strip in track.strips:
actions.append((strip.action, strip.action_slot))
# Filter out None actions/slots, because if either is None, there is no animation.
return [(act, slot) for (act, slot) in actions if act and slot]
def find_path_new(id_data, data_path, rna_update_from_map, fcurve, log):
# note!, id_data can be ID type or a node tree
# ignore ID props for now
if data_path.startswith("["):
return data_path
# recursive path fixing, likely will be one in most cases.
data_path_builder = eval("DataPathBuilder(tuple())." + data_path)
data_resolve = data_path_builder.resolve(id_data, rna_update_from_map, fcurve, log)
path_new = [pair[0] for pair in data_resolve]
return "".join(path_new)[1:] # skip the first "."
def update_data_paths(rna_update, log=sys.stdout):
"""
rna_update triple [(class_name, from, to or to_callback, callback options), ...]
to_callback is a function with this signature: update_cb(base, class_name, old_path, fcurve, options)
where base is current object, class_name is the expected type name of base (callback has to handle
this), old_path it the org name of base's property, fcurve is the affected fcurve (!),
and options is an opaque data.
class_name, fcurve and options may be None!
"""
from bpy_extras import anim_utils
rna_update_from_map = {}
for ren_class, ren_from, ren_to, options in rna_update:
rna_update_from_map.setdefault(ren_from, []).append((ren_class, ren_to, options))
for id_data in id_iter():
anim_data_ls: list[tuple[bpy.types.ID, bpy.types.AnimData | None]] = [
(id_data, getattr(id_data, "animation_data", None))]
# check node-trees too
node_tree = getattr(id_data, "node_tree", None)
if node_tree:
anim_data_ls.append((node_tree, node_tree.animation_data))
for anim_data_base, anim_data in anim_data_ls:
if anim_data is None:
continue
for fcurve in anim_data.drivers:
data_path = fcurve.data_path
data_path_new = find_path_new(anim_data_base, data_path, rna_update_from_map, fcurve, log)
# print(data_path_new)
if data_path_new != data_path:
if not IS_TESTING:
fcurve.data_path = data_path_new
fcurve.driver.is_valid = True # reset to allow this to work again
print(
"driver-fcurve ({:s}): {:s} -> {:s}".format(id_data.name, data_path, data_path_new),
file=log,
)
for var in fcurve.driver.variables:
if var.type == 'SINGLE_PROP':
for tar in var.targets:
id_data_other = tar.id
data_path = tar.data_path
if id_data_other and data_path:
data_path_new = find_path_new(id_data_other, data_path, rna_update_from_map, None, log)
# print(data_path_new)
if data_path_new != data_path:
if not IS_TESTING:
tar.data_path = data_path_new
print(
"driver ({:s}): {:s} -> {:s}".format(
id_data_other.name,
data_path,
data_path_new,
),
file=log,
)
for action, action_slot in anim_data_actions(anim_data):
channelbag = anim_utils.action_get_channelbag_for_slot(action, action_slot)
if not channelbag:
continue
for fcu in channelbag.fcurves:
data_path = fcu.data_path
data_path_new = find_path_new(anim_data_base, data_path, rna_update_from_map, fcu, log)
# print(data_path_new)
if data_path_new != data_path:
if not IS_TESTING:
fcu.data_path = data_path_new
print("fcurve ({:s}): {:s} -> {:s}".format(id_data.name, data_path, data_path_new), file=log)
if __name__ == "__main__":
# Example, should be called externally
# (class, from, to or to_callback, callback_options)
replace_ls = [
("AnimVizMotionPaths", "frame_after", "frame_after", None),
("AnimVizMotionPaths", "frame_before", "frame_before", None),
("AnimVizOnionSkinning", "frame_after", "frame_after", None),
]
update_data_paths(replace_ls)

View File

@@ -0,0 +1,7 @@
# SPDX-FileCopyrightText: 2022-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
"""
Utilities relating to text mode console interactions.
"""

View File

@@ -0,0 +1,7 @@
# SPDX-FileCopyrightText: 2009-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright (c) 2009 https://www.stani.be
"""Package for console specific modules."""

View File

@@ -0,0 +1,174 @@
# SPDX-FileCopyrightText: 2009-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright (c) 2009 https://www.stani.be
import inspect
import re
# regular expression constants
DEF_DOC = r'{:s}\s*(\(.*?\))'
DEF_SOURCE = r'def\s+{:s}\s*(\(.*?\)):'
RE_EMPTY_LINE = re.compile(r'^\s*\n')
RE_FLAG = re.MULTILINE | re.DOTALL
RE_NEWLINE = re.compile('\n+')
RE_SPACE = re.compile(r'\s+')
RE_DEF_COMPLETE = re.compile(
# don't start with a quote
'''(?:^|[^"'a-zA-Z0-9_])'''
# start with a \w = [a-zA-Z0-9_]
r'''((\w+'''
# allow also dots and closed bracket pairs []
r'''(?:\w|[.]|\[.+?\])*'''
# allow empty string
'''|)'''
# allow opening bracket(s)
r'''(?:\(|\s)*)$''')
def reduce_newlines(text):
"""Reduces multiple newlines to a single newline.
:param text: text with multiple newlines
:type text: str
:returns: text with single newlines
:rtype: str
>>> reduce_newlines('hello\\n\\nworld')
'hello\\nworld'
"""
return RE_NEWLINE.sub('\n', text)
def reduce_spaces(text):
"""Reduces multiple white-spaces to a single space.
:param text: text with multiple spaces
:type text: str
:returns: text with single spaces
:rtype: str
>>> reduce_spaces('hello \\nworld')
'hello world'
"""
return RE_SPACE.sub(' ', text)
def get_doc(obj):
"""Get the doc string or comments for an object.
:param object: object
:returns: doc string
:rtype: str
>>> get_doc(abs)
'abs(number) -> number\\n\\nReturn the absolute value of the argument.'
"""
result = inspect.getdoc(obj) or inspect.getcomments(obj)
return result and RE_EMPTY_LINE.sub('', result.rstrip()) or ''
def get_argspec(func, *, strip_self=True, doc=None, source=None):
"""Get argument specifications.
:param strip_self: strip ``self`` from argspec
:type strip_self: bool
:param doc: doc string of func (optional)
:type doc: str
:param source: source code of func (optional)
:type source: str
:returns: argument specification
:rtype: str
>>> get_argspec(inspect.getclasstree)
'(classes, unique=0)'
>>> get_argspec(abs)
'(number)'
"""
# get the function object of the class
try:
func = func.__func__
except AttributeError:
pass
# is callable?
if not hasattr(func, '__call__'):
return ''
# func should have a name
try:
func_name = func.__name__
except AttributeError:
return ''
# From docstring.
if doc is None:
doc = get_doc(func)
match = re.search(DEF_DOC.format(func_name), doc, RE_FLAG)
# from source code
if not match:
if source is None:
try:
source = inspect.getsource(func)
except (TypeError, IOError):
source = ''
if source:
match = re.search(DEF_SOURCE.format(func_name), source, RE_FLAG)
if match:
argspec = reduce_spaces(match.group(1))
else:
# try with the inspect.getarg* functions
try:
argspec = inspect.formatargspec(*inspect.getfullargspec(func))
except:
try:
argspec = inspect.formatargvalues(
*inspect.getargvalues(func))
except:
argspec = ''
if strip_self:
argspec = argspec.replace('self, ', '')
return argspec
def complete(line, cursor, namespace):
"""Complete callable with call-tip.
:param line: incomplete text line
:type line: str
:param cursor: current character position
:type cursor: int
:param namespace: namespace
:type namespace: dict[str, Any]
:returns: (matches, world, scrollback)
:rtype: tuple[str, str, str]
>>> import os
>>> complete('os.path.isdir(', 14, {'os': os})[-1]
'isdir(s)\\nReturn true if the pathname refers to an existing directory.'
>>> complete('abs(', 4, {})[-1]
'abs(number) -> number\\nReturn the absolute value of the argument.'
"""
matches = []
word = ''
scrollback = ''
match = RE_DEF_COMPLETE.search(line[:cursor])
if match:
word = match.group(1)
func_word = match.group(2)
try:
func = eval(func_word, namespace)
except BaseException:
func = None
if func:
doc = get_doc(func)
argspec = get_argspec(func, doc=doc)
scrollback = func_word.split('.')[-1] + (argspec or '()')
if doc.startswith(scrollback):
scrollback = doc
elif doc:
scrollback += '\n' + doc
scrollback = reduce_newlines(scrollback)
return matches, word, scrollback

View File

@@ -0,0 +1,180 @@
# SPDX-FileCopyrightText: 2009-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright (c) 2009 Fernando Perez, https://www.stani.be
# Original copyright (see docstring):
# ****************************************************************************
# Copyright (C) 2001-2006 Fernando Perez <fperez@colorado.edu>
#
# Distributed under the terms of the BSD License. The full license is in
# the file COPYING, distributed as part of this software.
# ****************************************************************************
"""Completer for import statements
Original code was from IPython/Extensions/ipy_completers.py. The following
changes have been made:
- ported to python3
- pep8 polishing
- limit list of modules to prefix in case of "from w"
- sorted modules
- added sphinx documentation
- complete() returns a blank list of the module isn't found
"""
import os
import sys
TIMEOUT_STORAGE = 3 # Time in secs after which the root-modules will be stored
TIMEOUT_GIVEUP = 20 # Time in secs after which we give up
ROOT_MODULES = None
def get_root_modules():
"""
Returns a list containing the names of all the modules available in the
folders of the python-path.
:returns: modules
:rtype: list[ModuleType]
"""
global ROOT_MODULES
modules = []
if not (ROOT_MODULES is None):
return ROOT_MODULES
from time import time
t = time()
store = False
for path in sys.path:
modules += module_list(path)
if time() - t >= TIMEOUT_STORAGE and not store:
# Caching the list of root modules, please wait!
store = True
if time() - t > TIMEOUT_GIVEUP:
# This is taking too long, we give up.
ROOT_MODULES = []
return []
modules += sys.builtin_module_names
# needed for modules defined in C
modules += sys.modules.keys()
modules = set(modules)
modules.discard("__init__")
modules = sorted(list(modules))
if store:
ROOT_MODULES = modules
return modules
def module_list(path):
"""
Return the list containing the names of the modules available in
the given folder.
:param path: folder path
:type path: str
:returns: modules
:rtype: list[ModuleType]
"""
if os.path.isdir(path):
folder_list = os.listdir(path)
elif path.endswith('.egg'):
from zipimport import zipimporter
try:
folder_list = [f for f in zipimporter(path)._files]
except:
folder_list = []
else:
folder_list = []
# folder_list = glob.glob(os.path.join(path,'*'))
folder_list = [
p for p in folder_list
if (os.path.exists(os.path.join(path, p, '__init__.py')) or
p[-3:] in {'.py', '.so'} or
p[-4:] in {'.pyc', '.pyo', '.pyd'})]
folder_list = [os.path.basename(p).split('.')[0] for p in folder_list]
return folder_list
def complete(line):
"""
Returns a list containing the completion possibilities for an import line.
:param line:
incomplete line which contains an import statement::
import xml.d
from xml.dom import
:type line: str
:returns: list of completion possibilities
:rtype: list[str]
>>> complete('import weak')
['weakref']
>>> complete('from weakref import C')
['CallableProxyType']
"""
import inspect
def try_import(mod, *, only_modules=False):
def is_importable(module, attr):
if only_modules:
return inspect.ismodule(getattr(module, attr))
else:
return not (attr[:2] == '__' and attr[-2:] == '__')
try:
m = __import__(mod)
except:
return []
mods = mod.split('.')
for module in mods[1:]:
m = getattr(m, module)
if (not hasattr(m, '__file__')) or (not only_modules) or\
(hasattr(m, '__file__') and '__init__' in m.__file__):
completion_list = [attr for attr in dir(m)
if is_importable(m, attr)]
else:
completion_list = []
completion_list.extend(getattr(m, '__all__', []))
if hasattr(m, '__file__') and '__init__' in m.__file__:
completion_list.extend(module_list(os.path.dirname(m.__file__)))
completion_list = list(set(completion_list))
if '__init__' in completion_list:
completion_list.remove('__init__')
return completion_list
def filter_prefix(names, prefix):
return [name for name in names if name.startswith(prefix)]
words = line.split(' ')
if len(words) == 3 and words[0] == 'from':
return ['import ']
if len(words) < 3 and (words[0] in {'import', 'from'}):
if len(words) == 1:
return get_root_modules()
mod = words[1].split('.')
if len(mod) < 2:
return filter_prefix(get_root_modules(), words[-1])
completion_list = try_import('.'.join(mod[:-1]), only_modules=True)
completion_list = ['.'.join(mod[:-1] + [el]) for el in completion_list]
return filter_prefix(completion_list, words[-1])
if len(words) >= 3 and words[0] == 'from':
mod = words[1]
return filter_prefix(try_import(mod), words[-1])
# get here if the import is not found
# import invalid_module
# ^, in this case return nothing
return []

View File

@@ -0,0 +1,192 @@
# SPDX-FileCopyrightText: 2009-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright (c) 2009 https://www.stani.be
"""Autocomplete with the standard library"""
import re
import rlcompleter
RE_INCOMPLETE_INDEX = re.compile(r'(.*?)\[[^\]]+$')
TEMP = '__tEmP__' # only \w characters are allowed!
TEMP_N = len(TEMP)
def is_dict(obj):
"""Returns whether obj is a dictionary"""
return hasattr(obj, "keys") and hasattr(getattr(obj, "keys"), "__call__")
def is_struct_seq(obj):
"""Returns whether obj is a structured sequence subclass: sys.float_info"""
return isinstance(obj, tuple) and hasattr(obj, "n_fields")
def complete_names(word, namespace):
"""Complete variable names or attributes
:param word: word to be completed
:type word: str
:param namespace: namespace
:type namespace: dict[str, Any]
:returns: completion matches
:rtype: list of str
>>> complete_names('fo', {'foo': 'bar'})
['foo', 'for', 'format(']
"""
# start completer
completer = rlcompleter.Completer(namespace)
# find matches with std library (don't try to implement this yourself)
completer.complete(word, 0)
return sorted(set(completer.matches))
def complete_indices(word, namespace, *, obj=None, base=None):
"""Complete a list or dictionary with its indices:
* integer numbers for list
* any keys for dictionary
:param word: word to be completed
:type word: str
:param namespace: namespace
:type namespace: dict
:param obj: object evaluated from base
:param base: sub-string which can be evaluated into an object.
:type base: str
:returns: completion matches
:rtype: list of str
>>> complete_indices('foo', {'foo': range(5)})
['foo[0]', 'foo[1]', 'foo[2]', 'foo[3]', 'foo[4]']
>>> complete_indices('foo', {'foo': {'bar':0, 1:2}})
['foo[1]', "foo['bar']"]
>>> complete_indices("foo['b", {'foo': {'bar':0, 1:2}}, base='foo')
["foo['bar']"]
"""
# FIXME: 'foo["b'
if base is None:
base = word
if obj is None:
try:
obj = eval(base, namespace)
except BaseException:
return []
if not hasattr(obj, '__getitem__'):
# obj is not a list or dictionary
return []
obj_is_dict = is_dict(obj)
# rare objects have a __getitem__ but no __len__ (eg. BMEdge)
if not obj_is_dict:
try:
obj_len = len(obj)
except TypeError:
return []
if obj_is_dict:
# dictionary type
matches = ['{:s}[{!r}]'.format(base, key) for key in sorted(obj.keys())]
else:
# list type
matches = ['{:s}[{:d}]'.format(base, idx) for idx in range(obj_len)]
if word != base:
matches = [match for match in matches if match.startswith(word)]
return matches
def complete(word, namespace, *, private=True):
"""Complete word within a namespace with the standard rlcompleter
module. Also supports index or key access [].
:param word: word to be completed
:type word: str
:param namespace: namespace
:type namespace: dict
:param private: whether private attribute/methods should be returned
:type private: bool
:returns: completion matches
:rtype: list of str
>>> complete('foo[1', {'foo': range(14)})
['foo[1]', 'foo[10]', 'foo[11]', 'foo[12]', 'foo[13]']
>>> complete('foo[0]', {'foo': [range(5)]})
['foo[0][0]', 'foo[0][1]', 'foo[0][2]', 'foo[0][3]', 'foo[0][4]']
>>> complete('foo[0].i', {'foo': [range(5)]})
['foo[0].index(', 'foo[0].insert(']
>>> complete('rlcompleter', {'rlcompleter': rlcompleter})
['rlcompleter.']
"""
#
# if word is empty -> nothing to complete
if not word:
return []
re_incomplete_index = RE_INCOMPLETE_INDEX.search(word)
if re_incomplete_index:
# ignore incomplete index at the end, e.g 'a[1' -> 'a'
matches = complete_indices(word, namespace,
base=re_incomplete_index.group(1))
elif not ('[' in word):
matches = complete_names(word, namespace)
elif word[-1] == ']':
matches = [word]
elif '.' in word:
# brackets are normally not allowed -> work around
# remove brackets by using a temp var without brackets
obj, attr = word.rsplit('.', 1)
try:
# do not run the obj expression in the console
namespace[TEMP] = eval(obj, namespace)
except BaseException:
return []
matches = complete_names(TEMP + '.' + attr, namespace)
matches = [obj + match[TEMP_N:] for match in matches]
del namespace[TEMP]
else:
# safety net, but when would this occur?
return []
if not matches:
return []
# add '.', '(' or '[' if no match has been found
elif len(matches) == 1 and matches[0] == word:
# try to retrieve the object
try:
obj = eval(word, namespace)
except BaseException:
return []
# ignore basic types
if type(obj) in {bool, float, int, str}:
return []
# an extra char '[', '(' or '.' will be added
if hasattr(obj, '__getitem__') and not is_struct_seq(obj):
# list or dictionary
matches = complete_indices(word, namespace, obj=obj)
elif hasattr(obj, '__call__'):
# callables
matches = [word + '(']
else:
# any other type
matches = [word + '.']
# separate public from private
public_matches = [match for match in matches if not ('._' in match)]
if private:
private_matches = [match for match in matches if '._' in match]
return public_matches + private_matches
else:
return public_matches

View File

@@ -0,0 +1,138 @@
# SPDX-FileCopyrightText: 2009-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright (c) 2009 https://www.stani.be
"""This module provides intellisense features such as:
* autocompletion
* calltips
It unifies all completion plugins and only loads them on demand.
"""
# TODO: file complete if startswith quotes
import os
import re
# regular expressions to find out which completer we need
# line which starts with an import statement
RE_MODULE = re.compile(r'''^import(\s|$)|from.+''')
# The following regular expression means an 'unquoted' word
RE_UNQUOTED_WORD = re.compile(
# don't start with a quote
r'''(?:^|[^"'a-zA-Z0-9_])'''
# start with a \w = [a-zA-Z0-9_]
r'''((?:\w+'''
# allow also dots and closed bracket pairs []
r'''(?:\w|[.]|\[.+?\])*'''
# allow empty string
r'''|)'''
# allow an unfinished index at the end (including quotes)
r'''(?:\[[^\]]*$)?)$''',
# allow unicode as theoretically this is possible
re.UNICODE)
def complete(line, cursor, namespace, private):
"""Returns a list of possible completions:
* name completion
* attribute completion (obj.attr)
* index completion for lists and dictionaries
* module completion (from/import)
:param line: incomplete text line
:type line: str
:param cursor: current character position
:type cursor: int
:param namespace: namespace
:type namespace: dict
:param private: whether private variables should be listed
:type private: bool
:returns: list of completions, word
:rtype: tuple[list[str], str]
>>> complete('re.sr', 5, {'re': re})
(['re.sre_compile', 're.sre_parse'], 're.sr')
"""
re_unquoted_word = RE_UNQUOTED_WORD.search(line[:cursor])
if re_unquoted_word:
# unquoted word -> module or attribute completion
word = re_unquoted_word.group(1)
if RE_MODULE.match(line):
from . import complete_import
matches = complete_import.complete(line)
if not private:
matches[:] = [m for m in matches if m[:1] != "_"]
matches.sort()
else:
from . import complete_namespace
matches = complete_namespace.complete(word, namespace, private=private)
else:
# for now we don't have completers for strings
# TODO: add file auto completer for strings
word = ''
matches = []
return matches, word
def expand(line, cursor, namespace, *, private=True):
"""This method is invoked when the user asks auto-completion,
e.g. when Ctrl+Space is clicked.
:param line: incomplete text line
:type line: str
:param cursor: current character position
:type cursor: int
:param namespace: namespace
:type namespace: dict[str, Any]
:param private: whether private variables should be listed
:type private: bool
:returns:
current expanded line, updated cursor position and scrollback
:rtype: str, int, str
>>> expand('os.path.isdir(', 14, {'os': os})[-1]
'isdir(s)\\nReturn true if the pathname refers to an existing directory.'
>>> expand('abs(', 4, {})[-1]
'abs(number) -> number\\nReturn the absolute value of the argument.'
"""
if line[:cursor].strip().endswith('('):
from . import complete_calltip
matches, word, scrollback = complete_calltip.complete(
line, cursor, namespace)
prefix = os.path.commonprefix(matches)[len(word):]
no_calltip = False
else:
matches, word = complete(line, cursor, namespace, private)
prefix = os.path.commonprefix(matches)[len(word):]
if len(matches) == 1:
scrollback = ''
else:
# causes blender bug #27495 since string keys may contain '.'
# scrollback = ' '.join([m.split('.')[-1] for m in matches])
# add white space to align with the cursor
white_space = " " + (" " * (cursor + len(prefix)))
word_prefix = word + prefix
scrollback = '\n'.join(
[white_space + m[len(word_prefix):]
if (word_prefix and m.startswith(word_prefix))
else
white_space + m.rsplit('.', 1)[-1]
for m in matches])
no_calltip = True
if prefix:
line = line[:cursor] + prefix + line[cursor:]
cursor += len(prefix.encode('utf-8'))
if no_calltip and prefix.endswith('('):
return expand(line, cursor, namespace, private=private)
return line, cursor, scrollback

View File

@@ -0,0 +1,5 @@
# SPDX-FileCopyrightText: 2009-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
"""Package for translation (i18n) tools."""

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,132 @@
#!/usr/bin/env python3
# SPDX-FileCopyrightText: 2012-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
# Merge one or more .po files into the first dest one.
# If a msgkey is present in more than one merged po, the one in the first file wins, unless
# its marked as fuzzy and one later is not.
# The fuzzy flag is removed if necessary.
# All other comments are never modified.
# However, commented messages in dst will always remain commented, and commented messages are
# never merged from sources.
import sys
if __package__ is None:
import settings
import utils
else:
from . import (
settings,
utils,
)
# XXX This is a quick hack to make it work with new I18n... objects! To be reworked!
def main():
import argparse
parser = argparse.ArgumentParser(
description=(
"Merge one or more .po files into the first dest one.\n"
"If a msgkey (msgctxt, msgid) is present in more than one merged po, the one in the first file "
"wins, unless its marked as fuzzy and one later is not.\n"
"The fuzzy flag is removed if necessary.\n"
"All other comments are never modified.\n"
"Commented messages in dst will always remain commented, and commented messages are never merged "
"from sources."
),
)
parser.add_argument('-s', '--stats', action="store_true", help="Show statistics info.")
parser.add_argument('-r', '--replace', action="store_true",
help="Replace existing messages of same \"level\" already in dest po.")
parser.add_argument('dst', metavar='dst.po', help="The dest po into which merge the others.")
parser.add_argument('src', metavar='src.po', nargs='+', help="The po's to merge into the dst.po one.")
args = parser.parse_args()
ret = 0
done_msgkeys = set()
done_fuzzy_msgkeys = set()
nbr_merged = 0
nbr_replaced = 0
nbr_added = 0
nbr_unfuzzied = 0
dst_msgs = utils.I18nMessages(kind='PO', src=args.dst)
if dst_msgs.parsing_errors:
print("Dest po is BROKEN, aborting.")
return 1
if args.stats:
print("Dest po, before merging:")
dst_msgs.print_stats(prefix="\t")
# If we dont want to replace existing valid translations, pre-populate done_msgkeys and done_fuzzy_msgkeys.
if not args.replace:
done_msgkeys = dst_msgs.trans_msgs.copy()
done_fuzzy_msgkeys = dst_msgs.fuzzy_msgs.copy()
for po in args.src:
msgs = utils.I18nMessages(kind='PO', src=po)
if msgs.parsing_errors:
print("\tSrc po {} is BROKEN, skipping.".format(po))
ret = 1
continue
print("\tMerging {}...".format(po))
if args.stats:
print("\t\tMerged po stats:")
msgs.print_stats(prefix="\t\t\t")
for msgkey, msg in msgs.msgs.items():
msgctxt, msgid = msgkey
# This msgkey has already been completely merged, or is a commented one,
# or the new message is commented, skip it.
if msgkey in (done_msgkeys | dst_msgs.comm_msgs | msgs.comm_msgs):
continue
is_ttip = msg.is_tooltip
# New messages does not yet exists in dest.
if msgkey not in dst_msgs.msgs:
dst_msgs[msgkey] = msgs.msgs[msgkey]
if msgkey in msgs.fuzzy_msgs:
done_fuzzy_msgkeys.add(msgkey)
dst_msgs.fuzzy_msgs.add(msgkey)
elif msgkey in msgs.trans_msgs:
done_msgkeys.add(msgkey)
dst_msgs.trans_msgs.add(msgkey)
nbr_added += 1
# From now on, the new messages is already in dst.
# New message is neither translated nor fuzzy, skip it.
elif msgkey not in (msgs.trans_msgs | msgs.fuzzy_msgs):
continue
# From now on, the new message is either translated or fuzzy!
# The new message is translated.
elif msgkey in msgs.trans_msgs:
dst_msgs.msgs[msgkey].msgstr = msg.msgstr
done_msgkeys.add(msgkey)
done_fuzzy_msgkeys.discard(msgkey)
if msgkey in dst_msgs.fuzzy_msgs:
dst_msgs.fuzzy_msgs.remove(msgkey)
nbr_unfuzzied += 1
if msgkey not in dst_msgs.trans_msgs:
dst_msgs.trans_msgs.add(msgkey)
else:
nbr_replaced += 1
nbr_merged += 1
# The new message is fuzzy, org one is fuzzy too, and this msgkey has not yet been merged.
elif msgkey not in (dst_msgs.trans_msgs | done_fuzzy_msgkeys):
dst_msgs[msgkey].msgstr = msg.msgstr
done_fuzzy_msgkeys.add(msgkey)
dst_msgs.fuzzy_msgs.add(msgkey)
nbr_merged += 1
nbr_replaced += 1
dst_msgs.write(kind='PO', dest=args.dst)
print("Merged completed. {} messages were merged (among which {} were replaced), {} were added, "
"{} were \"un-fuzzied\".".format(nbr_merged, nbr_replaced, nbr_added, nbr_unfuzzied))
if args.stats:
dst_msgs.update_info()
print("Final merged po stats:")
dst_msgs.print_stats(prefix="\t")
return ret
if __name__ == "__main__":
print("\n\n *** Running {} *** \n".format(__file__))
sys.exit(main())

View File

@@ -0,0 +1,824 @@
# SPDX-FileCopyrightText: 2012-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
# Global settings used by all scripts in this directory.
# XXX Before any use of the tools in this directory, please make a copy of this file
# named "setting.py"
# XXX This is a template, most values should be OK, but some youll have to
# edit (most probably, BLENDER_EXEC and SOURCE_DIR).
import json
import os
import re
import sys
import types
# Only do soft-dependency on `bpy` module, not real strong need for it currently.
try:
import bpy
except ModuleNotFoundError:
bpy = None
###############################################################################
# MISC
###############################################################################
# The languages defined in Blender.
LANGUAGES = (
# ID, UI English label, ISO code.
(0, "Automatic", "DEFAULT"),
(1, "English (US)", "en_US"),
(2, "Japanese - 日本語", "ja_JP"),
(3, "Dutch - Nederlands", "nl_NL"),
(4, "Italian - Italiano", "it_IT"),
(5, "German - Deutsch", "de_DE"),
(6, "Finnish - Suomi", "fi_FI"),
(7, "Swedish - Svenska", "sv_SE"),
(8, "French - Français", "fr_FR"),
(9, "Spanish - Español", "es"),
(10, "Catalan - Català", "ca_AD"),
(11, "Czech - Čeština", "cs_CZ"),
(12, "Portuguese (Portugal) - Português europeu", "pt_PT"),
(13, "Chinese (Simplified) - 简体中文", "zh_HANS"),
(14, "Chinese (Traditional) - 繁體中文", "zh_HANT"),
(15, "Russian - Русский", "ru_RU"),
(16, "Croatian - Hrvatski", "hr"),
(17, "Serbian (Cyrillic) - Српски", "sr_RS"),
(18, "Ukrainian - Українська", "uk_UA"),
(19, "Polish - Polski", "pl_PL"),
(20, "Romanian - Român", "ro_RO"),
# Using the utf8 flipped form of Arabic (العربية).
(21, "Arabic - ﺔﻴﺑﺮﻌﻟﺍ", "ar_EG"),
(22, "Bulgarian - Български", "bg_BG"),
(23, "Greek - Ελληνικά", "el_GR"),
(24, "Korean - 한국어", "ko_KR"),
# 25 is free, used to be "Nepali - नेपाली", ("ne_NP").
# Using the utf8 flipped form of Persian (فارسی).
(26, "Persian - ﯽﺳﺭﺎﻓ", "fa_IR"),
(27, "Indonesian - Bahasa indonesia", "id_ID"),
(28, "Serbian (Latin) - Srpski latinica", "sr_RS@latin"),
(29, "Kyrgyz - Кыргыз тили", "ky_KG"),
(30, "Turkish - Türkçe", "tr_TR"),
(31, "Hungarian - Magyar", "hu_HU"),
(32, "Portuguese (Brazil) - Português brasileiro", "pt_BR"),
# Using the utf8 flipped form of Hebrew (עִבְרִית)).
(33, "Hebrew - תירִבְעִ", "he_IL"),
# 34 is free, used to be "Estonian - Eesti keel" ("et_EE").
(35, "Esperanto - Esperanto", "eo"),
# 36 is free, used to be "Spanish from Spain" ("es_ES").
# 37 is free, used to be "Amharic - አማርኛ" ("am_ET").
# 38 is free, used to be "Uzbek (Latin) - Oʻzbek" ("uz_UZ@latin").
# 39 is free, used to be "Uzbek (Cyrillic) - Ўзбек" ("uz_UZ@cyrillic").
(40, "Hindi - हिन्दी", "hi_IN"),
(41, "Vietnamese - Tiếng Việt", "vi_VN"),
(42, "Basque - Euskara", "eu_EU"),
# 43 is free, used to be "Hausa - Hausa" ("ha").
# 44 is free, used to be "Kazakh - Қазақша" ("kk_KZ").
(45, "Abkhaz - Аԥсуа бызшәа", "ab"),
(46, "Thai - ภาษาไทย", "th_TH"),
(47, "Slovak - Slovenčina", "sk_SK"),
(48, "Georgian - ქართული", "ka"),
(49, "Tamil - தமிழ்", "ta"),
# 50 is free, used to be "Khmer - ខ្មែរ" ("km").
(51, "Swahili - Kiswahili", "sw"),
(52, "Belarusian - Беларуская", "be"),
(53, "Danish - Dansk", "da"),
(54, "Slovenian - Slovenščina", "sl"),
# Using the utf8 flipped form of Urdu (اُردُو).
(55, "Urdu - وُدرُا", "ur"),
(56, "Lithuanian - Lietuviškai", "lt"),
(57, "English (UK)", "en_GB"),
(58, "Malayalam - മലയാളം", "ml"),
(59, "Norwegian (Bokmål) - Norsk bokmål", "nb"),
)
# Default context, in py (keep in sync with `BLT_translation.hh`)!
if bpy is not None:
assert bpy.app.translations.contexts.default == "*"
DEFAULT_CONTEXT = "*"
# Name of language file used by Blender to generate translations' menu.
LANGUAGES_FILE = "languages"
# The minimum level of completeness for a `.po` file to be imported from
# the working repository to the Blender one, as a percentage.
IMPORT_MIN_LEVEL = 0.0
# Languages in the working repository that should not be imported in the Blender one currently...
IMPORT_LANGUAGES_SKIP = set()
# Languages that need RTL pre-processing.
IMPORT_LANGUAGES_RTL = {
'ar_EG', 'fa_IR', 'he_IL', 'ur',
}
# The comment prefix used in generated `messages.txt` file.
MSG_COMMENT_PREFIX = "#~ "
# The comment prefix used in generated `messages.txt` file.
MSG_CONTEXT_PREFIX = "MSGCTXT:"
# The default comment prefix used in po's.
PO_COMMENT_PREFIX = "# "
# The comment prefix used to mark sources of msgids, in po's.
PO_COMMENT_PREFIX_SOURCE = "#: "
# The comment prefix used to mark sources of msgids, in po's.
PO_COMMENT_PREFIX_SOURCE_CUSTOM = "#. :src: "
# The general "generated" comment prefix, in po's.
PO_COMMENT_PREFIX_GENERATED = "#. "
# The comment prefix used to comment entries in po's.
PO_COMMENT_PREFIX_MSG = "#~ "
# The comment prefix used to mark fuzzy msgids, in po's.
PO_COMMENT_FUZZY = "#, fuzzy"
# The prefix used to define context, in po's.
PO_MSGCTXT = "msgctxt "
# The prefix used to define msgid, in po's.
PO_MSGID = "msgid "
# The prefix used to define msgstr, in po's.
PO_MSGSTR = "msgstr "
# The 'header' key of po files.
PO_HEADER_KEY = (DEFAULT_CONTEXT, "")
PO_HEADER_MSGSTR = (
"Project-Id-Version: {blender_ver} ({blender_hash})\\n\n"
"Report-Msgid-Bugs-To: \\n\n"
"POT-Creation-Date: {time}\\n\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\\n\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\\n\n"
"Language-Team: LANGUAGE <LL@li.org>\\n\n"
"Language: {uid}\\n\n"
"MIME-Version: 1.0\\n\n"
"Content-Type: text/plain; charset=UTF-8\\n\n"
"Content-Transfer-Encoding: 8bit\n"
)
PO_HEADER_COMMENT_COPYRIGHT = (
"# Blender's translation file (po format).\n"
"# Copyright (C) {year} The Blender Authors.\n"
"# This file is distributed under the same license as the Blender package.\n"
"#\n"
)
PO_HEADER_COMMENT = (
"# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.\n"
"#"
)
TEMPLATE_ISO_ID = "__TEMPLATE__"
# Num buttons report their label with a trailing ': '...
NUM_BUTTON_SUFFIX = ": "
# Undocumented operator placeholder string.
UNDOC_OPS_STR = "(undocumented operator)"
# The gettext domain.
DOMAIN = "blender"
# Our own "gettext" stuff.
# File type (ext) to parse.
PYGETTEXT_ALLOWED_EXTS = {".c", ".cc", ".cpp", ".cxx", ".hh", ".hpp", ".hxx", ".h"}
# Max number of contexts into a BLT_I18N_MSGID_MULTI_CTXT macro...
PYGETTEXT_MAX_MULTI_CTXT = 16
# Where to search contexts definitions, relative to SOURCE_DIR (defined below).
PYGETTEXT_CONTEXTS_DEFSRC = os.path.join("source", "blender", "blentranslation", "BLT_translation.h")
# Regex to extract contexts defined in BLT_translation.h
# XXX Not full-proof, but should be enough here!
PYGETTEXT_CONTEXTS = "#define\\s+(BLT_I18NCONTEXT_[A-Z_0-9]+)\\s+\"([^\"]*)\""
# autopep8: off
# Keywords' regex.
# XXX Most unfortunately, we can't use named back-references inside character sets,
# which makes the REGEXES even more twisty... :/
_str_base = (
# Match void string
"(?P<{_}1>[\"'])(?P={_}1)" # Get opening quote (' or "), and closing immediately.
"|"
# Or match non-void string
"(?P<{_}2>[\"'])" # Get opening quote (' or ").
"(?{capt}(?:"
# This one is for crazy things like "hi \\\\\" folks!"...
r"(?:(?!<\\)(?:\\\\)*\\(?=(?P={_}2)))|"
# The most common case.
".(?!(?P={_}2))"
")*.)" # Don't forget the last char!
"(?P={_}2)" # And closing quote.
"(?:_ustr)?" # Optional trailing _ustr.
)
str_clean_re = _str_base.format(_="g", capt="P<clean>")
_inbetween_str_re = (
# XXX Strings may have comments between their pieces too, not only spaces!
r"(?:\s*(?:"
# A C comment
r"/\*.*(?!\*/).\*/|"
# Or a C++ one!
r"//[^\n]*\n|"
# Or some #defined value (like `BLI_STR_UTF8_BLACK_RIGHT_POINTING_SMALL_TRIANGLE`)
# NOTE: This should be avoided at all cost, as it will simply make translation lookup fail.
r"[ a-zA-Z0-9_]*"
# And we are done!
r")?)*"
)
# Here we have to consider two different cases (empty string and other).
_str_whole_re = (
_str_base.format(_="{_}1_", capt=":") +
# Optional loop start, this handles "split" strings...
"(?:(?<=[\"'])" + _inbetween_str_re + "(?=[\"'])(?:"
+ _str_base.format(_="{_}2_", capt=":") +
# End of loop.
"))*"
)
_ctxt_re_gen = lambda uid: (
r"(?P<ctxt_raw{uid}>(?:".format(uid=uid) +
_str_whole_re.format(_="_ctxt{uid}".format(uid=uid)) +
r")|(?:[A-Z_0-9]+))"
)
_ctxt_re = _ctxt_re_gen("")
_msg_re = r"(?P<msg_raw>" + _str_whole_re.format(_="_msg") + r")"
class PyGettextKeyword:
def __init__(self, re_expr, context_override=None):
self.re_expr = re_expr
self.context_override = context_override
self.re = re.compile(re_expr)
self.search = self.re.search
PYGETTEXT_KEYWORDS = (() +
tuple(PyGettextKeyword((r"{}\(\s*" + _msg_re + r"\s*\)").format(it))
for it in ("IFACE_", "TIP_", "RPT_", "DATA_", "N_")) +
tuple(PyGettextKeyword((r"{}\(\s*" + _ctxt_re + r"\s*,\s*" + _msg_re + r"\s*\)").format(it))
for it in ("CTX_IFACE_", "CTX_TIP_", "CTX_RPT_", "CTX_DATA_", "CTX_N_")) +
tuple(PyGettextKeyword(("{}\\((?:[^\"',]+,){{1,2}}\\s*" + _msg_re + r"\s*(?:\)|,)").format(it))
for it in ("BKE_report", "BKE_reportf", "BKE_reports_prepend", "BKE_reports_prependf",
"CTX_wm_operator_poll_msg_set", "WM_global_report", "WM_global_reportf",
"button_disable")) +
# ED_undo_push() is used in Undo History menu and has the "Operator" context, same as operators.
tuple(PyGettextKeyword(("{}\\((?:[^\"',]+,)\\s*" + _msg_re + r"\s*(?:\))").format(it),
context_override='BLT_I18NCONTEXT_OPERATOR_DEFAULT')
for it in ("ED_undo_push", "ED_undo_grouped_push")) +
# bmesh operator errors
tuple(PyGettextKeyword(("{}\\((?:[^\"',]+,){{3}}\\s*" + _msg_re + r"\s*\)").format(it))
for it in ("BMO_error_raise",)) +
# Modifier errors
tuple(PyGettextKeyword(("{}\\((?:[^\"',]+,){{2}}\\s*" + _msg_re + r"\s*(?:\)|,)").format(it))
for it in ("BKE_modifier_set_error",)) +
# Window manager job names.
tuple(PyGettextKeyword(("{}\\((?:[^\"',]+,){{3}}\\s*" + _msg_re + r"\s*,").format(it))
for it in ("WM_jobs_get",)) +
# Compositor and EEVEE messages.
# Ends either with `)` (function call close), or `,` when there are extra formatting parameters.
tuple(PyGettextKeyword((r"{}\(\s*" + _msg_re + r"\s*(?:\)|,)").format(it))
for it in ("set_info_message", "info_append_i18n")) +
# This one is a tad more risky, but in practice would not expect a name/uid string parameter
# (the second one in those functions) to ever have a comma in it, so think this is fine.
tuple(PyGettextKeyword(("{}\\((?:[^,]+,){{2}}\\s*" + _msg_re + r"\s*(?:\)|,)").format(it))
for it in ("modifier_subpanel_register", "gpencil_modifier_subpanel_register")) +
# Node socket declarations: context-less names.
tuple(PyGettextKeyword((r"\.{}(?:<decl::.*?>\(|[^,]+,)\s*" + _msg_re + r"(?:,[^),]+)*\s*\)"
r"(?![^;]*\.translation_context\()").format(it))
for it in ("add_input", "add_output")) +
# Node socket declarations: names with contexts
tuple(PyGettextKeyword((r"\.{}(?:<decl::.*?>\(|[^,]+,)\s*" + _msg_re +
r"[^;]*\.translation_context\(\s*" + _ctxt_re + r"\s*\)").format(it))
for it in ("add_input", "add_output")) +
# Node socket declarations: description and error messages
tuple(PyGettextKeyword((r"\.{}\(\s*" + _msg_re + r"\s*\)").format(it))
for it in ("description", "error_message_add")) +
# Node socket panels and labels from declarations: context-less names
tuple(PyGettextKeyword((r"\.{}\(\s*" + _msg_re +
r"\s*\)(?![^;]*\.translation_context\()[^;]*;").format(it))
for it in ("short_label", "add_panel",)) +
# Node socket panels and labels from declarations: names with contexts
tuple(PyGettextKeyword((r"\.{}\(\s*" + _msg_re + r"[^;]*\.translation_context\(\s*" +
_ctxt_re + r"\s*\)").format(it))
for it in ("short_label", "add_panel",)) +
# Dynamic node socket labels
tuple(PyGettextKeyword((r"{}\(\s*[^,]+,\s*" + _msg_re + r"\s*\)").format(it))
for it in ("node_sock_label",)) +
# Geometry Nodes field inputs
(PyGettextKeyword(r"FieldInput\(CPPType::get<.*?>\(\),\s*" + _msg_re + r"\s*\)"),) +
# bUnitDef unit names
(PyGettextKeyword(r"/\*name_display\*/\s*" + _msg_re + r"\s*,"),) +
tuple(PyGettextKeyword(
(r"{}\(\s*"
+ _msg_re
+ r"\s*,\s*(?:"
+ r"\s*,\s*)?(?:".join(_ctxt_re_gen(i) for i in range(PYGETTEXT_MAX_MULTI_CTXT))
+ r")?\s*,?\s*\)"
).format(it)
) for it in ("BLT_I18N_MSGID_MULTI_CTXT",))
)
# autopep8: on
# Check printf mismatches between msgid and msgstr.
CHECK_PRINTF_FORMAT = (
r"(?!<%)(?:%%)*%" # Beginning, with handling for crazy things like '%%%%%s'
r"[-+#0]?" # Flags (note: do not add the ' ' (space) flag here, generates too much false positives!)
r"(?:\*|[0-9]+)?" # Width
r"(?:\.(?:\*|[0-9]+))?" # Precision
r"(?:[hljztL]|hh|ll)?" # Length
r"[tldiuoxXfFeEgGaAcspn]" # Specifiers (note we have Blender-specific %t and %l ones too)
)
# Should po parser warn when finding a first letter not capitalized?
WARN_MSGID_NOT_CAPITALIZED = True
# Strings that should not raise above warning!
WARN_MSGID_NOT_CAPITALIZED_ALLOWED = {
"", # Simplifies things... :p
"ac3",
"along X",
"along Y",
"along Z",
"along %s X",
"along %s Y",
"along %s Z",
"along local Z",
"arccos(A)",
"arcsin(A)",
"arctan(A)",
"ascii",
"author", # Addons' field. :/
"bItasc",
"blender.org",
"bytes",
"color_index is invalid",
"cos(A)",
"cosh(A)",
"dB", # dB audio power unit.
"dbl-", # Compacted for 'double', for keymap items.
"description", # Addons' field. :/
"dx",
"fBM",
"flac",
"fps: %.2f",
"fps: %i",
"gimbal",
"global",
"glTF 2.0 (.glb/.gltf)",
"glTF Binary (.glb)",
"glTF Embedded (.gltf)",
"glTF Material Output",
"glTF Original PBR data",
"glTF Separate (.gltf + .bin + textures)",
"gltfpack",
"glTFpack file path",
"invoke() needs to be called before execute()",
"iScale",
"iso-8859-15",
"iTaSC",
"iTaSC parameters",
"kb",
"local",
"location", # Addons' field. :/
"locking %s X",
"locking %s Y",
"locking %s Z",
"mkv",
"mm",
"mp2",
"mp3",
"normal",
"ogg",
"oneAPI",
"p0",
"parent_index should not be less than -1: %d",
"parent_index (%d) should be less than the number of bone collections (%d)",
"px",
"re",
"res",
"rv",
"seconds",
"sin(A)",
"sin(x) / x",
"sinh(A)",
"sqrt(x*x+y*y+z*z)",
"sRGB",
"sRGB display space",
"sRGB display space with Filmic view transform",
"sRGB IEC 61966-2-1 compound (piece-wise) encoding",
"tan(A)",
"tanh(A)",
"utf-8",
"uv_on_emitter() requires a modifier from an evaluated object",
"var",
"vBVH",
"view",
"wav",
"wmOwnerID '%s' not in workspace '%s'",
"y",
"y = (Ax + B)",
# ID plural names, defined in IDTypeInfo.
"armatures",
"brushes",
"cache_files",
"cameras",
"collections",
"curves",
"fonts",
"grease_pencils",
"hair_curves",
"ipos",
"lattices",
"libraries",
"lightprobes",
"lights",
"linestyles",
"link_placeholders",
"masks",
"metaballs",
"materials",
"meshes",
"movieclips",
"node_groups",
"objects",
"paint_curves",
"palettes",
"particles",
"pointclouds",
"screens",
"shape_keys",
"sounds",
"speakers",
"texts",
"textures",
"volumes",
"window_managers",
"workspaces",
"worlds",
# Sub-strings.
"all",
"all and invert unselected",
"and AMD driver version %s or newer",
"and AMD Radeon Pro %s driver or newer",
"and NVIDIA driver version %s or newer",
"and Windows driver version %s or newer",
"available with",
"brown fox",
"can't save image while rendering",
"category",
"constructive modifier",
"cursor",
"custom",
"custom matrix",
"custom orientation",
"drag-",
"edge data",
"exp(A)",
"expected a timeline/animation area to be active",
"expected a view3d region",
"expected a view3d region & editcurve",
"expected a view3d region & editmesh",
"face data",
"gimbal",
"global",
"glTF Settings",
"image file not found",
"image format is read-only",
"image path can't be written to",
"in %i days",
"in %i hours",
"in %i minutes",
"in memory to enable editing!",
"in the asset shelf.",
"insufficient content",
"into",
"jumps over",
"left",
"local",
"matrices", "no matrices",
"multi-res modifier",
"name",
"non-triangle face",
"normal",
"on {:%Y-%m-%d}",
"or AMD with macOS %s or newer",
"parent",
"performance impact!",
"positions", "no positions",
"read",
"remove",
"right",
"selected",
"selected and lock unselected",
"selected and unlock unselected",
"screen",
"the lazy dog",
"this legacy pose library to pose assets",
"to the top level of the tree",
"unable to load movie clip",
"unable to load text",
"unable to open the file",
"unknown error reading file",
"unknown error statting file",
"unknown error writing file",
"unselected",
"unsupported font format",
"unsupported format",
"unsupported image format",
"unsupported movie clip format",
"untitled",
"vertex data",
"verts only",
"view",
"virtual parents",
"which was replaced by the Asset Browser",
"within seconds",
"write",
}
WARN_MSGID_NOT_CAPITALIZED_ALLOWED |= set(lng[2] for lng in LANGUAGES)
WARN_MSGID_END_POINT_ALLOWED = {
"Cannot figure out which object this bone belongs to.",
"Circle|Alt .",
"Float Neg. Exp.",
"Max Ext.",
"Newer graphics drivers may be available to improve Blender support.",
"Not assigned to any bone collection.",
"Numpad .",
"Pad.",
"Please file a bug report.",
" RNA Path: bpy.types.",
"Temp. Diff.",
"Temperature Diff.",
"The program will now close.",
"Your graphics card or driver has limited support. It may work, but with issues.",
"Your graphics card or driver is not supported.",
"Invalid surface UVs on %d curves.",
"The pose library moved.",
"in the asset shelf.",
"Remove, local files not found.",
"Remove all files in \"{}\".",
"Remove, keeping local files.",
}
PARSER_CACHE_HASH = 'sha1'
PARSER_TEMPLATE_ID = "__POT__"
PARSER_PY_ID = "__PY__"
PARSER_PY_MARKER_BEGIN = "\n# ##### BEGIN AUTOGENERATED I18N SECTION #####\n"
PARSER_PY_MARKER_END = "\n# ##### END AUTOGENERATED I18N SECTION #####\n"
PARSER_MAX_FILE_SIZE = 2 ** 24 # in bytes, i.e. 16 Mb.
###############################################################################
# PATHS
###############################################################################
# The Python3 executable.Youll likely have to edit it in your user_settings.py
# if youre under Windows.
PYTHON3_EXEC = "python3"
# The Blender executable!
# This is just an example, youll have to edit it in your user_settings.py!
BLENDER_EXEC = os.path.abspath(os.path.join("foo", "bar", "blender"))
# check for blender.bin
if not os.path.exists(BLENDER_EXEC):
if os.path.exists(BLENDER_EXEC + ".bin"):
BLENDER_EXEC = BLENDER_EXEC + ".bin"
# The gettext msgfmt "compiler". Youll likely have to edit it in your user_settings.py if youre under Windows.
GETTEXT_MSGFMT_EXECUTABLE = "msgfmt"
# The FriBidi C compiled library (.so under Linux, `.dll` under windows...).
# Youll likely have to edit it in your `user_settings.py` if youre under Windows., e.g. using the included one:
# `FRIBIDI_LIB = os.path.join(TOOLS_DIR, "libfribidi.dll")`
FRIBIDI_LIB = "libfribidi.so.0"
# The name of the (currently empty) file that must be present in a po's directory to enable RTL-preprocess.
RTL_PREPROCESS_FILE = "is_rtl"
# The Blender source root path.
# This is just an example, youll have to override it in your user_settings.py!
SOURCE_DIR = os.path.abspath(os.path.join("blender"))
# The bf-translation repository (you'll have to override this in your user_settings.py).
I18N_DIR = os.path.abspath(os.path.join("i18n"))
# The 'work' path to PO files (relative to I18N_DIR).
REL_WORK_DIR = os.path.join("")
# The path to the Blender translation directory (relative to SOURCE_DIR).
REL_BLENDER_I18N_DIR = os.path.join("locale")
# The /po path of the Blender translation directory (relative to REL_BLENDER_I18N_DIR).
REL_BLENDER_I18N_PO_DIR = os.path.join("po")
# The Blender source path to check for i18n macros (relative to SOURCE_DIR).
REL_POTFILES_SOURCE_DIR = os.path.join("source")
# Where to search for preset names (relative to SOURCE_DIR).
REL_PRESETS_DIR = os.path.join("scripts", "presets")
# Where to search for templates (relative to SOURCE_DIR).
REL_TEMPLATES_DIR = os.path.join("scripts", "startup", "bl_app_templates_system")
# Name of the built-in asset catalog file.
ASSET_CATALOG_FILE = "blender_assets.cats.txt"
# The template messages file (relative to I18N_DIR).
REL_FILE_NAME_POT = os.path.join(REL_WORK_DIR, DOMAIN + ".pot")
# Mo path generator for a given language (relative to any "locale" directory).
MO_PATH_ROOT_RELATIVE = os.path.join("locale")
MO_PATH_TEMPLATE_RELATIVE = os.path.join(MO_PATH_ROOT_RELATIVE, "{}", "LC_MESSAGES")
# Mo file name.
MO_FILE_NAME = DOMAIN + ".mo"
# Where to search for py files that may contain ui strings (relative to one of the 'resource_path' of Blender).
CUSTOM_PY_UI_FILES = [
os.path.join("scripts", "startup", "bl_ui"),
os.path.join("scripts", "startup", "bl_operators"),
os.path.join("scripts", "modules", "rna_prop_ui.py"),
os.path.join("scripts", "modules", "rna_keymap_ui.py"),
os.path.join("scripts", "modules", "_bpy_types.py"),
os.path.join("scripts", "presets", "keyconfig"),
]
# An optional text file listing files to force include/exclude from py_xgettext process.
SRC_POTFILES = ""
# A cache storing validated msgids, to avoid re-spellchecking them.
SPELL_CACHE = os.path.join("/tmp", ".spell_cache")
# Threshold defining whether a new msgid is similar enough with an old one to reuse its translation...
SIMILAR_MSGID_THRESHOLD = 0.75
# Additional import paths to add to `sys.path` (';' separated)...
INTERN_PY_SYS_PATHS = ""
# Custom override settings must be one directory above i18n tools itself!
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
try:
from bl_i18n_settings_override import *
except ImportError: # If no i18n_override_settings available, its no error!
pass
# Override with custom user settings, if available.
try:
from settings_user import *
except ImportError: # If no user_settings available, its no error!
pass
for p in set(INTERN_PY_SYS_PATHS.split(";")):
if p:
sys.path.append(p)
# The settings class itself!
def _do_get(ref, path):
return os.path.normpath(os.path.join(ref, path))
def _do_set(ref, path):
path = os.path.normpath(path)
# If given path is absolute, make it relative to current ref one (else we consider it is already the case!)
if os.path.isabs(path):
# can't always find the relative path (between drive letters on windows)
try:
return os.path.relpath(path, ref)
except ValueError:
pass
return path
def _gen_get_set_path(ref, name):
def _get(self):
return _do_get(getattr(self, ref), getattr(self, name))
def _set(self, value):
setattr(self, name, _do_set(getattr(self, ref), value))
return _get, _set
def _check_valid_data(uid, val):
return not uid.startswith("_") and type(val) not in tuple(types.__dict__.values()) + (type,)
class I18nSettings:
"""
Class allowing persistence of our settings!
Saved in JSON format, so settings should be JSON'able objects!
"""
_settings = None
def __new__(cls, *args, **kwargs):
# Addon preferences are singleton by definition, so is this class!
if not I18nSettings._settings:
cls._settings = super(I18nSettings, cls).__new__(cls)
cls._settings.__dict__ = {uid: val for uid, val in globals().items() if _check_valid_data(uid, val)}
return I18nSettings._settings
def __getstate__(self):
return self.to_dict()
def __setstate__(self, mapping):
return self.from_dict(mapping)
def from_dict(self, mapping):
# Special case... :/
if "INTERN_PY_SYS_PATHS" in mapping:
self.PY_SYS_PATHS = mapping["INTERN_PY_SYS_PATHS"]
self.__dict__.update(mapping)
def to_dict(self):
glob = globals()
return {uid: val for uid, val in self.__dict__.items() if _check_valid_data(uid, val) and uid in glob}
def from_json(self, string):
self.from_dict(dict(json.loads(string)))
def to_json(self):
# Only save the diff from default i18n_settings!
glob = globals()
export_dict = {
uid: val for uid, val in self.__dict__.items()
if _check_valid_data(uid, val) and glob.get(uid) != val
}
return json.dumps(export_dict)
def load(self, fname, reset=False):
reset = reset or fname is None
if reset:
self.__dict__ = {uid: data for uid, data in globals().items() if not uid.startswith("_")}
if fname is None:
return
if isinstance(fname, str):
if not os.path.isfile(fname):
# Assume it is already real JSON string.
self.from_json(fname)
return
with open(fname, encoding="utf8") as f:
self.from_json(f.read())
# Else assume fname is already a file(like) object!
else:
self.from_json(fname.read())
def save(self, fname):
if isinstance(fname, str):
with open(fname, 'w', encoding="utf8") as f:
f.write(self.to_json())
# Else assume fname is already a file(like) object!
else:
fname.write(self.to_json())
WORK_DIR = property(*(_gen_get_set_path("I18N_DIR", "REL_WORK_DIR")))
BLENDER_I18N_ROOT = property(*(_gen_get_set_path("SOURCE_DIR", "REL_BLENDER_I18N_DIR")))
BLENDER_I18N_PO_DIR = property(*(_gen_get_set_path("BLENDER_I18N_ROOT", "REL_BLENDER_I18N_PO_DIR")))
POTFILES_SOURCE_DIR = property(*(_gen_get_set_path("SOURCE_DIR", "REL_POTFILES_SOURCE_DIR")))
PRESETS_DIR = property(*(_gen_get_set_path("SOURCE_DIR", "REL_PRESETS_DIR")))
TEMPLATES_DIR = property(*(_gen_get_set_path("SOURCE_DIR", "REL_TEMPLATES_DIR")))
FILE_NAME_POT = property(*(_gen_get_set_path("I18N_DIR", "REL_FILE_NAME_POT")))
def _get_py_sys_paths(self):
return self.INTERN_PY_SYS_PATHS
def _set_py_sys_paths(self, val):
old_paths = set(self.INTERN_PY_SYS_PATHS.split(";")) - {""}
new_paths = set(val.split(";")) - {""}
for p in old_paths - new_paths:
if p in sys.path:
sys.path.remove(p)
for p in new_paths - old_paths:
sys.path.append(p)
self.INTERN_PY_SYS_PATHS = val
PY_SYS_PATHS = property(_get_py_sys_paths, _set_py_sys_paths)

View File

@@ -0,0 +1,7 @@
# SPDX-FileCopyrightText: 2002-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
import os
import settings

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,144 @@
# SPDX-FileCopyrightText: 2020-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
# Some useful operations from utils' I18nMessages class exposed as a CLI.
import os
if __package__ is None:
import settings as settings_i18n
import utils as utils_i18n
import utils_languages_menu
else:
from . import settings as settings_i18n
from . import utils as utils_i18n
from . import utils_languages_menu
def update_po(args, settings):
pot = utils_i18n.I18nMessages(uid=None, kind='PO', src=args.template, settings=settings)
if os.path.isfile(args.dst):
uid = os.path.splitext(os.path.basename(args.dst))[0]
po = utils_i18n.I18nMessages(uid=uid, kind='PO', src=args.dst, settings=settings)
po.update(pot)
else:
po = pot
po.write(kind="PO", dest=args.dst)
def cleanup_po(args, settings):
uid = os.path.splitext(os.path.basename(args.src))[0]
if not args.dst:
args.dst = args.src
po = utils_i18n.I18nMessages(uid=uid, kind='PO', src=args.src, settings=settings)
po.check(fix=True)
po.clean_commented()
po.write(kind="PO", dest=args.dst)
def strip_po(args, settings):
uid = os.path.splitext(os.path.basename(args.src))[0]
if not args.dst:
args.dst = args.src
po = utils_i18n.I18nMessages(uid=uid, kind='PO', src=args.src, settings=settings)
po.clean_commented()
po.write(kind="PO_COMPACT", dest=args.dst)
def rtl_process_po(args, settings):
uid = os.path.splitext(os.path.basename(args.src))[0]
if not args.dst:
args.dst = args.src
po = utils_i18n.I18nMessages(uid=uid, kind='PO', src=args.src, settings=settings)
po.rtl_process()
po.write(kind="PO", dest=args.dst)
def language_menu(args, settings):
# 'DEFAULT' and en_US are always valid, fully-translated "languages"!
stats = {"DEFAULT": 1.0, "en_US": 1.0}
po_to_uid = {
os.path.basename(po_path_work): uid
for can_use, uid, _num_id, _name, _isocode, po_path_work
in utils_i18n.list_po_dir(settings.WORK_DIR, settings)
if can_use
}
for po_dir in os.listdir(settings.WORK_DIR):
po_dir = os.path.join(settings.WORK_DIR, po_dir)
if not os.path.isdir(po_dir):
continue
for po_path in os.listdir(po_dir):
uid = po_to_uid.get(po_path, None)
# print("Checking {:s}, found uid {:s}".format(po_path, uid))
po_path = os.path.join(po_dir, po_path)
if uid is not None:
po = utils_i18n.I18nMessages(uid=uid, kind='PO', src=po_path, settings=settings)
stats[uid] = po.nbr_trans_msgs / po.nbr_msgs if po.nbr_msgs > 0 else 0
utils_languages_menu.gen_menu_file(stats, settings)
def main():
import sys
import argparse
parser = argparse.ArgumentParser(description="Tool to perform common actions over PO/MO files.")
parser.add_argument(
'-s', '--settings', default=None,
help="Override (some) default settings. Either a JSON file name, or a JSON string.",
)
sub_parsers = parser.add_subparsers()
sub_parser = sub_parsers.add_parser('update_po', help="Update a PO file from a given POT template file")
sub_parser.add_argument(
'--template', metavar='template.pot', required=True,
help="The source pot file to use as template for the update.",
)
sub_parser.add_argument('--dst', metavar='dst.po', required=True, help="The destination po to update.")
sub_parser.set_defaults(func=update_po)
sub_parser = sub_parsers.add_parser(
'cleanup_po',
help="Cleanup a PO file (check for and fix some common errors, remove commented messages).",
)
sub_parser.add_argument('--src', metavar='src.po', required=True, help="The source po file to clean up.")
sub_parser.add_argument('--dst', metavar='dst.po', help="The destination po to write to.")
sub_parser.set_defaults(func=cleanup_po)
sub_parser = sub_parsers.add_parser(
'strip_po',
help="Reduce all non-essential data from given PO file (reduce its size).",
)
sub_parser.add_argument('--src', metavar='src.po', required=True, help="The source po file to strip.")
sub_parser.add_argument('--dst', metavar='dst.po', help="The destination po to write to.")
sub_parser.set_defaults(func=strip_po)
sub_parser = sub_parsers.add_parser(
'rtl_process_po',
help="Pre-process PO files for RTL languages.",
)
sub_parser.add_argument('--src', metavar='src.po', required=True, help="The source po file to process.")
sub_parser.add_argument('--dst', metavar='dst.po', help="The destination po to write to.")
sub_parser.set_defaults(func=rtl_process_po)
sub_parser = sub_parsers.add_parser(
'language_menu',
help="Generate the text file used by Blender to create its language menu.",
)
sub_parser.set_defaults(func=language_menu)
args = parser.parse_args(sys.argv[1:])
settings = settings_i18n.I18nSettings()
settings.load(args.settings)
if getattr(args, "template", None) is not None:
settings.FILE_NAME_POT = args.template
args.func(args=args, settings=settings)
if __name__ == "__main__":
print("\n\n *** Running {} *** \n".format(__file__))
main()

View File

@@ -0,0 +1,68 @@
# SPDX-FileCopyrightText: 2013-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
# Update "languages" text file used by Blender at runtime to build translations menu.
OK = 0
MISSING = 1
TOOLOW = 2
SKIPPED = 3
FLAG_MESSAGES = {
OK: "",
MISSING: "No translation yet.",
TOOLOW: "Not complete enough to be included.",
SKIPPED: "Skipped (see IMPORT_LANGUAGES_SKIP in settings.py).",
}
def gen_menu_file(stats, settings):
# Generate languages file content used by Blender's i18n system.
# First, match all entries in LANGUAGES to a `lang` in stats, if possible!
# Returns a iterable of text lines.
tmp = []
for uid_num, label, uid in settings.LANGUAGES:
if uid in stats:
if uid in settings.IMPORT_LANGUAGES_SKIP:
tmp.append((stats[uid], uid_num, label, uid, SKIPPED))
else:
tmp.append((stats[uid], uid_num, label, uid, OK))
else:
tmp.append((0.0, uid_num, label, uid, MISSING))
stats = tmp
stats = sorted(stats, key=lambda it: it[0], reverse=True)
langs = []
highest_uid = 0
for lvl, uid_num, label, uid, flag in stats:
if lvl < settings.IMPORT_MIN_LEVEL and flag == OK:
flag = TOOLOW
if uid_num == 0:
# Insert default language (Automatic) at index 0, after sorting.
default_lang = (uid_num, label, uid, flag, lvl)
continue
langs.append((uid_num, label, uid, flag, lvl))
if abs(uid_num) > highest_uid:
highest_uid = abs(uid_num)
# Sort languages by name.
langs.sort(key=lambda it: it[1])
langs.insert(0, default_lang)
data_lines = [
"# File used by Blender to know which languages (translations) are available, ",
"# and to generate translation menu.",
"#",
"# File format:",
"# ID:MENULABEL:ISOCODE",
"# ID must be unique, except for 0 value (marks categories for menu).",
"# Line starting with a # are comments!",
"#",
"# Automatically generated by _bl_i18n_utils/utils_languages_menu.py script.",
"# Highest ID currently in use: {}".format(highest_uid),
]
for uid_num, label, uid, flag, lvl in langs:
if flag == OK:
data_lines.append("{}:{}:{}:{}%".format(uid_num, label, uid, round(lvl * 100)))
else:
# Non-existing, commented entry!
data_lines.append("# {} #{}:{}:{}:{}%".format(FLAG_MESSAGES[flag], uid_num, label, uid, round(lvl * 100)))
return data_lines

View File

@@ -0,0 +1,228 @@
#!/usr/bin/env python3
# SPDX-FileCopyrightText: 2012-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
# Pre-process right-to-left languages.
# You can use it either standalone, or through import_po_from_branches or
# update_trunk.
#
# Notes: This has been tested on Linux, not 100% it will work nicely on
# Windows or OsX.
# This uses ctypes, as there is no py3 binding for fribidi currently.
# This implies you only need the compiled C library to run it.
# Finally, note that it handles some formatting/escape codes (like
# \", %s, %x12, %.4f, etc.), protecting them from ugly (evil) fribidi,
# which seems completely unaware of such things (as unicode is...).
import ctypes
import re
# define FRIBIDI_MASK_NEUTRAL 0x00000040L /* Is neutral */
FRIBIDI_PAR_ON = 0x00000040
# define FRIBIDI_FLAG_SHAPE_MIRRORING 0x00000001
# define FRIBIDI_FLAG_REORDER_NSM 0x00000002
# define FRIBIDI_FLAG_SHAPE_ARAB_PRES 0x00000100
# define FRIBIDI_FLAG_SHAPE_ARAB_LIGA 0x00000200
# define FRIBIDI_FLAG_SHAPE_ARAB_CONSOLE 0x00000400
# define FRIBIDI_FLAG_REMOVE_BIDI 0x00010000
# define FRIBIDI_FLAG_REMOVE_JOINING 0x00020000
# define FRIBIDI_FLAG_REMOVE_SPECIALS 0x00040000
# define FRIBIDI_FLAGS_DEFAULT ( \
# FRIBIDI_FLAG_SHAPE_MIRRORING | \
# FRIBIDI_FLAG_REORDER_NSM | \
# FRIBIDI_FLAG_REMOVE_SPECIALS )
# define FRIBIDI_FLAGS_ARABIC ( \
# FRIBIDI_FLAG_SHAPE_ARAB_PRES | \
# FRIBIDI_FLAG_SHAPE_ARAB_LIGA )
FRIBIDI_FLAG_SHAPE_MIRRORING = 0x00000001
FRIBIDI_FLAG_REORDER_NSM = 0x00000002
FRIBIDI_FLAG_REMOVE_SPECIALS = 0x00040000
FRIBIDI_FLAG_SHAPE_ARAB_PRES = 0x00000100
FRIBIDI_FLAG_SHAPE_ARAB_LIGA = 0x00000200
FRIBIDI_FLAGS_DEFAULT = FRIBIDI_FLAG_SHAPE_MIRRORING | FRIBIDI_FLAG_REORDER_NSM | FRIBIDI_FLAG_REMOVE_SPECIALS
FRIBIDI_FLAGS_ARABIC = FRIBIDI_FLAG_SHAPE_ARAB_PRES | FRIBIDI_FLAG_SHAPE_ARAB_LIGA
MENU_DETECT_REGEX = re.compile("%x\\d+\\|")
##### Kernel processing functions. #####
def protect_format_seq(msg):
"""
Find some specific escaping/formatting sequences (like \", %s, etc.,
and protect them from any modification!
NOTE: This is not covering all exotic 'printf' formatting cases!
It also only covers the minimal `{}` syntax for the modern `format` syntax.
"""
# LRM = "\u200E"
# RLM = "\u200F"
LRE = "\u202A"
# RLE = "\u202B"
PDF = "\u202C"
LRO = "\u202D"
# RLO = "\u202E"
# uctrl = {LRE, RLE, PDF, LRO, RLO}
# 'printf' format, from https://cplusplus.com/reference/cstdio/printf/
printf_format_flags = set("-+ #0")
printf_format_widthprec = set(".0123456789") # For width and precision.
printf_format_datasize = set("hljztL")
printf_format_codes = set("diuoxXfFeEgGaAcsp")
# 'fmt::format' (and Python 'format()'),
# see https://fmt.dev/12.0/syntax/ and https://docs.python.org/3.13/library/string.html#formatstrings
fmt_format_widthprec = set(".0123456789") # For width and precision.
fmt_format_codes = set("aAbBcdeEfFgGnopsxX?%")
if not msg:
return msg
elif MENU_DETECT_REGEX.search(msg):
# An ugly "menu" message, just force it whole LRE if not yet done.
if msg[0] not in {LRE, LRO}:
msg = LRE + msg
idx = 0
ret = []
ln = len(msg)
while idx < ln:
dlt = 1
# # If we find a control char, skip any additional protection!
# if msg[idx] in uctrl:
# ret.append(msg[idx:])
# break
# \", \' or \\
if idx < (ln - 1) and msg[idx] == '\\' and msg[idx + 1] in "\"\'\\":
dlt = 2
elif idx < (ln - 1) and msg[idx] == '{':
# The whole 'format' syntax...
# Coverage of this one is still fairly limited and basic currently.
# TODO: support more of the 'format' mini-language (and check how much fmt::format matches with Python's).
orig_dlt = dlt
valid_format = False
# {3}, {scale} (positional indicator or named reference)
while (idx + dlt) < ln and msg[idx + dlt].isalnum():
dlt += 1
if (idx + dlt) < ln and msg[idx + dlt] == ":":
dlt += 1
# {:.4}, {:6d}, ...
while (idx + dlt) < ln and msg[idx + dlt] in fmt_format_widthprec:
dlt += 1
# {:f}, {:s}, ...
while (idx + dlt) < ln and msg[idx + dlt] in fmt_format_codes:
dlt += 1
if (idx + dlt) < ln and msg[idx + dlt] == "}":
dlt += 1
valid_format = True
if not valid_format:
dlt = orig_dlt
# %%
elif idx < (ln - 1) and msg[idx] == '%' and msg[idx + 1] == '%':
dlt = 2
elif idx < (ln - 1) and msg[idx] == '%':
# The whole 'printf' syntax...
# Not fully covering the format, but most of it, and should cover all Blender usages.
orig_dlt = dlt
valid_format = False
# `%x12|` - What is this for actually?
if idx < (ln - 2) and msg[idx + 1] in "x" and msg[idx + 2] in printf_format_widthprec:
dlt = 2
while (idx + dlt) < ln and msg[idx + dlt] in printf_format_widthprec:
dlt += 1
if (idx + dlt) < ln and msg[idx + dlt] == '|':
dlt += 1
valid_format = True
else:
# `%+d, %-40s`, ...
while (idx + dlt) < ln and msg[idx + dlt] in printf_format_flags:
dlt += 1
# `%.4f, %6d`, ...
while (idx + dlt) < ln and msg[idx + dlt] in printf_format_widthprec:
dlt += 1
# `%lld, %zu`, ...
while (idx + dlt) < ln and msg[idx + dlt] in printf_format_datasize:
dlt += 1
# `%s, %d`, ...
if (idx + dlt) < ln and msg[idx + dlt] in printf_format_codes:
dlt += 1
valid_format = True
if not valid_format:
dlt = orig_dlt
if dlt > 1:
ret.append(LRE)
ret += msg[idx:idx + dlt]
idx += dlt
if dlt > 1:
ret.append(PDF)
return "".join(ret)
def log2vis(msgs, settings):
"""
Globally mimics deprecated fribidi_log2vis.
msgs should be an iterable of messages to RTL-process.
"""
fbd = ctypes.CDLL(settings.FRIBIDI_LIB)
for msg in msgs:
msg = protect_format_seq(msg)
fbc_str = ctypes.create_unicode_buffer(msg)
ln = len(fbc_str) - 1
# print(fbc_str.value, ln)
btypes = (ctypes.c_int * ln)()
embed_lvl = (ctypes.c_uint8 * ln)()
pbase_dir = ctypes.c_int(FRIBIDI_PAR_ON)
jtypes = (ctypes.c_uint8 * ln)()
flags = FRIBIDI_FLAGS_DEFAULT | FRIBIDI_FLAGS_ARABIC
# Find out direction of each char.
fbd.fribidi_get_bidi_types(fbc_str, ln, ctypes.byref(btypes))
# print(*btypes)
fbd.fribidi_get_par_embedding_levels(
btypes, ln,
ctypes.byref(pbase_dir),
embed_lvl,
)
# print(*embed_lvl)
# Joinings for arabic chars.
fbd.fribidi_get_joining_types(fbc_str, ln, jtypes)
# print(*jtypes)
fbd.fribidi_join_arabic(btypes, ln, embed_lvl, jtypes)
# print(*jtypes)
# Final Shaping!
fbd.fribidi_shape(flags, embed_lvl, ln, jtypes, fbc_str)
# print(fbc_str.value)
# print(*(ord(c) for c in fbc_str))
# And now, the reordering.
# Note that here, we expect a single line, so no need to do
# fancy things...
fbd.fribidi_reorder_line(flags, btypes, ln, 0, pbase_dir, embed_lvl,
fbc_str, None)
# print(fbc_str.value)
# print(*(ord(c) for c in fbc_str))
yield fbc_str.value

View File

@@ -0,0 +1,955 @@
# SPDX-FileCopyrightText: 2012-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
import os
import pickle
import re
try:
import enchant
except ModuleNotFoundError:
enchant = None
print("WARNING: No `enchant` python module found, no spell check will happen.")
class SpellChecker:
"""
A basic spell checker.
"""
# These must be all lower case for comparisons
uimsgs = {
# OK words
"accessor", "accessors",
"adaptively", "adaptivity",
"al", # et al.
"aren", # aren't
"betweens", # yuck! in-betweens!
"boolean", "booleans",
"chamfer",
"couldn", # couldn't
"customizable",
"decrement",
"derivate",
"deterministically",
"doesn", # doesn't
"duplications",
"effector",
"equi", # equi-angular, etc.
"eszett", # German Eszett
"et", # et al.
"fader",
"globbing",
"guillemet",
"gridded",
"haptics",
"hasn", # hasn't
"hetero",
"hoc", # ad-hoc
"incompressible",
"indices",
"instantiation",
"iridas",
"isn", # isn't
"iterable",
"kyrgyz",
"latin",
"merchantability",
"mille", # Per Mille
"mplayer",
"ons", # add-ons
"pong", # ping pong
"procedurals", # Used as noun
"recurse",
"resumable",
"runtimes",
"scalable",
"shadeless",
"shouldn", # shouldn't
"smoothen",
"spacings",
"teleport", "teleporting",
"tangency",
"vertices",
"wasn", # wasn't
"zig", "zag",
# Brands etc.
"htc",
"huawei",
"radeon",
"vive",
"xbox",
# Merged words
"antialiasing", "antialias",
"arcsine", "arccosine", "arctangent",
"autoclip",
"autocomplete",
"autoexec",
"autoexecution",
"autogenerated",
"autolock",
"automask", "automasking",
"automerge",
"autoname",
"autopack",
"autosave",
"autoscale",
"autosmooth",
"autosplit",
"backend", "backends",
"backface", "backfacing",
"backimage",
"backscattered",
"bandnoise",
"bindcode",
"bitdepth",
"bitflag", "bitflags",
"bitrate",
"blackbody",
"blendfile",
"blendin",
"bonesize",
"boundbox",
"boxpack",
"buffersize",
"builtin", "builtins",
"bytecode",
"chunksize",
"codebase",
"customdata",
"dataset", "datasets",
"de",
"deadzone",
"decomposable",
"deconstruct",
"defocus",
"denoise", "denoised", "denoising", "denoiser",
"deselect", "deselecting", "deselection",
"despill", "despilling",
"dirtree",
"editcurve",
"editmesh",
"faceforward",
"filebrowser",
"filelist",
"filename", "filenames",
"filepath", "filepaths",
"forcefield", "forcefields",
"framerange",
"frontmost",
"fulldome", "fulldomes",
"fullscreen",
"gamepad",
"gridline", "gridlines",
"hardlight",
"hemi",
"hostname",
"inbetween",
"inscatter", "inscattering",
"libdata",
"lightcache",
"lightgroup", "lightgroups",
"lightprobe", "lightprobes",
"lightless",
"lineset",
"linestyle", "linestyles",
"localview",
"lookup", "lookups",
"mathutils",
"micropolygon",
"midlevel",
"midground",
"mixdown",
"monospaced",
"multi",
"multifractal",
"multiframe",
"multilayer",
"multipaint",
"multires", "multiresolution",
"multisampling",
"multiscatter",
"multitexture",
"multithreaded",
"multiuser",
"multiview",
"namespace",
"nodetree", "nodetrees",
"keyconfig",
"offscreen",
"online",
"playhead",
"popup", "popups",
"pointcloud",
"pre",
"precache", "precaching",
"precalculate",
"precomputing",
"prefetch",
"prefilter", "prefiltering",
"preload",
"premultiply", "premultiplied",
"prepass",
"prepend",
"preprocess", "preprocessing", "preprocessor", "preprocessed",
"preseek",
"preselect", "preselected",
"promillage",
"pushdown",
"raytree",
"readonly",
"realtime",
"recompute", "recomputation",
"reinject", "reinjected",
"rekey",
"relink",
"remesh",
"reprojection", "reproject", "reprojecting",
"resample",
"rescale",
"resize",
"restpose",
"resync", "resynced",
"retarget", "retargets", "retargeting", "retargeted",
"retime", "retimed", "retiming",
"rigidbody",
"ringnoise",
"rolloff",
"runtime",
"scanline",
"screenshot", "screenshots",
"seekability",
"selfcollision",
"shadowbuffer", "shadowbuffers",
"singletexture",
"softbox",
"spellcheck", "spellchecking",
"startup",
"stateful",
"starfield",
"studiolight",
"subflare", "subflares",
"subframe", "subframes",
"subclass", "subclasses", "subclassing",
"subdirectory", "subdirectories", "subdir", "subdirs",
"subitem",
"submode",
"submodule", "submodules",
"subpath",
"subsample", "subsamples", "subsampling",
"subsize",
"substep", "substeps",
"substring",
"supercompress", "supercompression",
"targetless",
"textbox", "textboxes",
"tilemode",
"timestamp", "timestamps",
"timestep", "timesteps",
"todo",
"tradeoff",
"un",
"unadjust", "unadjusted",
"unassign",
"unassociate", "unassociated",
"unbake",
"uncheck",
"unclosed",
"uncomment",
"unculled",
"undeformed",
"undistort", "undistorted", "undistortion",
"uneditable",
"ungroup", "ungrouped",
"unhandled",
"unhide",
"unindent",
"unitless",
"unkeyed",
"unlink", "unlinked",
"unmute",
"unphysical",
"unpremultiply",
"unprojected",
"unprotect",
"unreacted",
"unreferenced",
"unregister", "unregistration", "unregistering",
"unselect", "unselected", "unselectable",
"unsets",
"unshadowed",
"unspill",
"unstitchable", "unstitch",
"unsubdivided", "unsubdivide",
"untrusted",
"vectorscope",
"whitespace", "whitespaces",
"worldspace",
"workflow",
"workspace", "workspaces",
# Neologisms, slangs
"affectable",
"animatable",
"automagic", "automagically",
"blobby",
"blockiness", "blocky",
"collider", "colliders",
"deformer", "deformers",
"determinator",
"editability",
"effectors",
"expander",
"instancer",
"keyer",
"lacunarity",
"linkable",
"numerics",
"occluder", "occluders",
"overridable",
"passepartout",
"perspectively",
"pixelate",
"pointiness",
"polycount",
"polygonization", "polygonalization", # yuck!
"scalings",
"selectable", "selectability",
"shaper",
"smoothen", "smoothening",
"spherize", "spherized",
"statting", # Running `stat` command, yuck!
"stitchable",
"symmetrize",
"trackability",
"transmissivity",
"rasterized", "rasterization", "rasterizer",
"renderer", "renderers", "renderable", "renderability",
# Really bad!!!
"convertor",
"fullscr",
# Abbreviations
"aero",
"amb",
"anim",
"aov",
"app",
"args", # Arguments
"bbox", "bboxes",
"bksp", # Backspace
"bool",
"calc",
"cfl",
"config", "configs",
"const",
"coord", "coords",
"degr",
"diff",
"dof",
"dupli", "duplis",
"eg",
"esc",
"expr",
"fac",
"fra",
"fract",
"frs",
"grless",
"http",
"init",
"irr", # Irradiance
"kbit", "kb",
"lang", "langs",
"lclick", "rclick",
"lensdist",
"loc", "rot", "pos",
"lorem",
"luma",
"mbs", # mouse button 'select'.
"mem",
"mul", # Multiplicative etc.
"multicam",
"num",
"ok",
"orco",
"ortho",
"pano",
"persp",
"pref", "prefs",
"prev",
"param",
"premul",
"quad", "quads",
"quat", "quats",
"recalc", "recalcs",
"refl",
"sce",
"sel",
"spec",
"struct", "structs",
"subdiv",
"sys",
"tex",
"texcoord",
"tmr", # timer
"tri", "tris",
"udim", "udims",
"upres", # Upresolution
"usd",
"uv", "uvs", "uvw", "uw", "uvmap",
"ve",
"vec",
"vel", # velocity!
"vert", "verts",
"vis",
"vram",
"xor",
"xyz", "xzy", "yxz", "yzx", "zxy", "zyx",
"xy", "xz", "yx", "yz", "zx", "zy",
# General computer/science terms
"affine",
"albedo",
"anamorphic",
"anisotropic", "anisotropy",
"arcminute", "arcminutes",
"arcsecond", "arcseconds",
"autokey",
"bimanual", # OpenXR?
"bitangent",
"boid", "boids",
"ceil",
"centum", # From 'centum weight'
"compressibility",
"coplanar",
"curvilinear",
"dekameter", "dekameters",
"equiangular",
"equisolid",
"euler", "eulers",
"eumelanin",
"fribidi",
"gettext",
"hashable",
"hotspot",
"hydrostatic",
"interocular",
"intrinsics",
"irradiance",
"isosurface",
"jitter", "jittering", "jittered",
"keymap", "keymaps",
"lambertian",
"laplacian",
"metadata",
"microwatt", "microwatts",
"microflake",
"milliwatt", "milliwatts",
"msgfmt",
"nand", "xnor",
"nanowatt", "nanowatts",
"normals",
"numpad",
"octahedral",
"octree",
"omnidirectional",
"opengl",
"openmp",
"parametrization",
"pheomelanin",
"photoreceptor",
"picometer", "picometers",
"poly",
"polyline", "polylines",
"probabilistically",
"pulldown", "pulldowns",
"quadratically",
"quantized",
"quartic",
"quaternion", "quaternions",
"quintic",
"reallocations",
"samplerate",
"sandboxed",
"sawtooth",
"scrollback",
"scrollbar",
"scroller",
"searchable",
"spacebar",
"subtractive",
"superellipse",
"thumbstick",
"tooltip", "tooltips",
"touchpad", "trackpad",
"trilinear",
"triquadratic",
"tuple",
"unicode",
"viewport", "viewports",
"viscoelastic",
"vorticity",
"waveform", "waveforms",
"wildcard", "wildcards",
"wintab", # Some Windows tablet API
# General computer graphics terms
"anaglyph",
"bezier", "beziers",
"bicubic",
"bilinear",
"bindpose",
"binormal",
"blackpoint", "whitepoint",
"blendshape", "blendshapes", # USD slang :(
"blinn",
"bokeh",
"catadioptric",
"centroid",
"chroma",
"chrominance",
"clearcoat",
"codec", "codecs",
"codepoint",
"colorspace",
"compositing",
"crossfade",
"cubemap", "cubemaps",
"cuda",
"deinterlace",
"dropoff",
"duotone",
"dv",
"eigenvectors",
"emissive",
"equirectangular",
"fader",
"filmlike",
"fisheye",
"framerate",
"gimbal",
"grayscale",
"icosahedron",
"icosphere",
"illuminant", # CIE illuminant D65
"inpaint",
"kerning",
"lightmap",
"linearlight",
"lossless", "lossy",
"luminance",
"mantaflow",
"matcap",
"microfacet",
"midtones",
"mipmap", "mipmaps", "mip",
"ngon", "ngons",
"ntsc",
"nurb", "nurbs",
"perlin",
"phong",
"photorealistic",
"pinlight",
"posterize",
"primvar", "primvars", # USD slang :(
"qi",
"radiosity",
"raycast", "raycasting",
"raymarching",
"raytrace", "raytracing", "raytraced",
"refractions",
"remesher", "remeshing", "remesh",
"renderfarm",
"retopology",
"scanfill",
"shader", "shaders",
"shadowmap", "shadowmaps",
"softlight",
"specular", "specularity",
"spillmap",
"sobel",
"stereoscopy",
"subpixel",
"surfel", "surfels", # Surface Element
"texel",
"timecode",
"tonemap",
"toon",
"transmissive",
"uvproject",
"uvtile", # Form UDIM
"vividlight",
"volumetrics",
"voronoi",
"voxel", "voxels",
"vsync",
"vulkan",
"wireframe", "wireframes",
"xforms", # USD slang :(
"zmask",
"ztransp",
# Blender terms
"audaspace",
"azone", # action zone
"backwire",
"bbone",
"bdata",
"bendy", # bones
"bmesh",
"breakdowner",
"bspline",
"bweight",
"colorband",
"crazyspace",
"datablock", "datablocks",
"despeckle",
"depsgraph",
"dopesheet",
"dupliface", "duplifaces",
"dupliframe", "dupliframes",
"dupliobject", "dupliob",
"dupligroup",
"duplivert",
"dyntopo",
"editbone",
"editmode",
"eevee",
"fcurve", "fcurves",
"fedge", "fedges",
"filmic",
"fluidsim",
"freestyle",
"enum", "enums",
"gizmogroup",
"gon", "gons", # N-GON(s)
"gpencil",
"idcol",
"ipos",
"keyframe", "keyframes", "keyframing", "keyframed",
"lookdev",
"luminocity",
"mathvis",
"metaball", "metaballs", "mball",
"metaelement", "metaelements",
"metastrip", "metastrips",
"movieclip", "movieclips",
"mpoly",
"mtex",
"nabla",
"navmesh",
"outliner",
"overscan",
"paintmap", "paintmaps",
"pointclouds",
"polygroup", "polygroups",
"poselib",
"pushpull",
"qe", # keys...
"shaderfx", "shaderfxs",
"shapekey", "shapekeys",
"shrinkfatten",
"shrinkwrap",
"softbody",
"srna",
"stucci",
"subdiv",
"subtype",
"sunsky",
"tessface", "tessfaces",
"texface",
"timeline", "timelines",
"tmpact", # sigh...
"tosphere",
"uilist",
"userpref",
"vcol", "vcols",
"vgroup", "vgroups",
"vinterlace",
"vse",
"wasd", "wasdqe", # keys...
"wetmap", "wetmaps",
"wpaint",
"uvwarp",
# UOC (Ugly Operator Categories)
"cachefile",
"paintcurve",
"ptcache",
"dpaint",
# Algorithm/library/tools names
"ashikhmin", # Ashikhmin-Shirley
"arsloe", # Texel-Marsen-Arsloe
"beckmann",
"blackman", # Blackman-Harris
"blosc",
"burley", # Christensen-Burley
"butterworth",
"catmull",
"catrom",
"chiang",
"chebychev",
"conrady", # Brown-Conrady
"courant",
"cryptomatte", "crypto",
"devlin",
"embree",
"gmp",
"gltfpack",
"hosek",
"kutta",
"kuwahara",
"lennard",
"marsen", # Texel-Marsen-Arsloe
"mikktspace",
"minkowski",
"minnaert",
"mises", # von Mises-Fisher
"moskowitz", # Pierson-Moskowitz
"musgrave",
"nayar",
"netravali",
"nishita",
"ogawa",
"oren",
"peucker", # Ramer-Douglas-Peucker
"pierson", # Pierson-Moskowitz
"preetham",
"prewitt",
"ramer", # Ramer-Douglas-Peucker
"reinhard",
"runge",
"sobol",
"verlet",
"von", # von Mises-Fisher
"wilkie",
"worley",
# Acronyms
"aa", "msaa",
"acescg", # ACEScg color space.
"ao",
"aov", "aovs",
"api",
"apic", # Affine Particle-In-Cell
"asc", "cdl",
"ascii",
"atrac",
"avx",
"bsdf", "bsdfs",
"bssrdf",
"bt", # BT.1886 2.4 Exponent EOTF
"bw",
"ccd",
"cie", # CIE XYZ color space
"cmd",
"cmos",
"cpus",
"ctrl",
"cw", "ccw",
"dci", # DCI-P3 D65
"dev",
"dls",
"djv",
"dpi",
"dvar",
"dx",
"eo",
"eotf", # BT.1886 2.4 Exponent EOTF
"ewa",
"fh",
"fk",
"fov",
"fft",
"futura",
"fx",
"gfx",
"ggx",
"gl",
"glsl",
"gpl",
"gpu", "gpus",
"hc",
"hdc",
"hdr", "hdri", "hdris",
"hh", "mm", "ss", "ff", # `hh:mm:ss:ff` time-code.
"hpg", # Intel Xe-HPG architecture
"hsv", "hsva", "hsl",
"id",
"iec", # sRGB IEC 61966-2-1
"ies",
"ior",
"itu",
"jonswap",
"lfe",
"lhs",
"lmb", "mmb", "rmb",
"lscm",
"lx", # Lux light unit
"kb",
"mis",
"mocap",
"msgid", "msgids",
"mux",
"ndof",
"pbr", # Physically Based Rendering
"ppc",
"precisa",
"px",
"qmc",
"rdna",
"rdp",
"rgb", "rgba",
"ris",
"rhs",
"rpp", # EEVEE ray-tracing?
"rv",
"sdf",
"sdl",
"sdls",
"sl",
"smpte",
"ssao",
"ssr",
"svn",
"tma",
"ui",
"unix",
"uuid", "uid",
"vbo", "vbos",
"vfx",
"vmm",
"vr",
"wxyz",
"xform",
"xr",
"ycc", "ycca",
"yrgb",
"yuv", "yuva",
# Blender acronyms
"bli",
"bpy",
"bvh",
"dbvt",
"dop", # BLI K-DOP BVH
"ik",
"nla",
"py",
"qbvh",
"rna",
"rvo",
"simd",
"sph",
"svbvh",
# Files types/formats
"aac",
"avi",
"attrac",
"autocad",
"autodesk",
"bmp",
"btx",
"cineon",
"dpx",
"dwaa",
"dwab",
"dxf",
"eps",
"exr",
"fbx",
"fbxnode",
"ffmpeg",
"flac",
"gltf",
"gprim", # From USD.
"gzip",
"ico",
"jpg", "jpeg", "jpegs",
"json",
"lightwave",
"lzw",
"matroska",
"mdd",
"mkv",
"mpeg", "mjpeg",
"mtl",
"ogg",
"openjpeg",
"osl",
"oso",
"pcm",
"piz",
"png", "pngs",
"po",
"quicktime",
"rle",
"sgi",
"stl",
"svg",
"targa", "tga",
"tiff",
"theora",
"usdz",
"vdb",
"vorbis",
"vp9",
"wav",
"webm",
"xiph",
"xml",
"xna",
"xvid",
}
_valid_before = "(?<=[\\s*'\"`])|(?<=[a-zA-Z][/-])|(?<=^)"
_valid_after = "(?=[\\s'\"`.!?,;:])|(?=[/-]\\s*[a-zA-Z])|(?=$)"
_valid_words = "(?:{})(?:(?:[A-Z]+[a-z]*)|[A-Z]*|[a-z]*)(?:{})".format(_valid_before, _valid_after)
_split_words = re.compile(_valid_words).findall
@classmethod
def split_words(cls, text):
return [w for w in cls._split_words(text) if w]
def __init__(self, settings, lang="en_US"):
self.settings = settings
self.dict_spelling = enchant.Dict(lang) if enchant else None
self.cache = set(self.uimsgs)
cache = self.settings.SPELL_CACHE
if cache and os.path.exists(cache):
with open(cache, 'rb') as f:
self.cache |= set(pickle.load(f))
def __del__(self):
cache = self.settings.SPELL_CACHE
if cache and os.path.exists(cache):
with open(cache, 'wb') as f:
pickle.dump(self.cache, f)
def check(self, txt):
ret = []
if txt in self.cache:
return ret
for w in self.split_words(txt):
w_lower = w.lower()
if w_lower in self.cache:
continue
if self.dict_spelling and not self.dict_spelling.check(w):
ret.append((w, self.dict_spelling.suggest(w)))
else:
self.cache.add(w_lower)
if not ret:
self.cache.add(txt)
return ret

View File

@@ -0,0 +1,538 @@
# SPDX-FileCopyrightText: 2015-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
# Populate a template file (POT format currently) from Blender RNA/py/C data.
# Note: This script is meant to be used from inside Blender!
import os
import bpy
from mathutils import (
Euler,
Matrix,
Vector,
)
OBJECT_TYPES_RENDER = {'MESH', 'CURVE', 'SURFACE', 'META', 'FONT'}
def ids_nolib(bids):
return (bid for bid in bids if not bid.library)
def ids_nolib_with_preview(bids):
return (bid for bid in bids if (not bid.library and bid.preview))
def rna_backup_gen(data, include_props=None, exclude_props=None, root=()):
# only writable properties...
for p in data.bl_rna.properties:
pid = p.identifier
if pid in {"rna_type", "original"}:
continue
path = root + (pid,)
if include_props is not None and path not in include_props:
continue
if exclude_props is not None and path in exclude_props:
continue
val = getattr(data, pid)
if val is not None and p.type == 'POINTER':
# recurse!
yield from rna_backup_gen(val, include_props, exclude_props, root=path)
elif data.is_property_readonly(pid):
continue
else:
yield path, val
def rna_backup_restore(data, backup):
for path, val in backup:
dt = data
for pid in path[:-1]:
dt = getattr(dt, pid)
setattr(dt, path[-1], val)
def do_previews(do_objects, do_collections, do_scenes, do_data_intern):
import collections
# Helpers.
RenderContext = collections.namedtuple("RenderContext", (
"scene", "world", "camera", "light", "camera_data", "light_data", "image", # All those are names!
"backup_scene", "backup_world", "backup_camera", "backup_light", "backup_camera_data", "backup_light_data",
))
RENDER_PREVIEW_SIZE = bpy.app.render_preview_size
def render_context_create(engine, objects_ignored):
if engine == '__SCENE':
backup_scene, backup_world, backup_camera, backup_light, backup_camera_data, backup_light_data = [()] * 6
scene = bpy.context.window.scene
exclude_props = {("world",), ("camera",), ("tool_settings",), ("preview",), ("asset_data",),
("collection", "asset_data",), ("render", "bake", "image_settings", "linear_colorspace_settings",)}
backup_scene = tuple(rna_backup_gen(scene, exclude_props=exclude_props))
world = scene.world
camera = scene.camera
if camera:
camera_data = camera.data
else:
backup_camera, backup_camera_data = [None] * 2
camera_data = bpy.data.cameras.new("TEMP_preview_render_camera")
camera = bpy.data.objects.new("TEMP_preview_render_camera", camera_data)
camera.rotation_euler = Euler((1.1635528802871704, 0.0, 0.7853981852531433), 'XYZ') # (66.67, 0, 45)
scene.camera = camera
scene.collection.objects.link(camera)
# TODO: add light if none found in scene?
light = None
light_data = None
else:
backup_scene, backup_world, backup_camera, backup_light, backup_camera_data, backup_light_data = [None] * 6
scene = bpy.data.scenes.new("TEMP_preview_render_scene")
world = bpy.data.worlds.new("TEMP_preview_render_world")
camera_data = bpy.data.cameras.new("TEMP_preview_render_camera")
camera = bpy.data.objects.new("TEMP_preview_render_camera", camera_data)
light_data = bpy.data.lights.new("TEMP_preview_render_light", 'SPOT')
light = bpy.data.objects.new("TEMP_preview_render_light", light_data)
objects_ignored.add((camera.name, light.name))
scene.world = world
camera.rotation_euler = Euler((1.1635528802871704, 0.0, 0.7853981852531433), 'XYZ') # (66.67, 0, 45)
scene.camera = camera
scene.collection.objects.link(camera)
light.rotation_euler = Euler((0.7853981852531433, 0.0, 1.7453292608261108), 'XYZ') # (45, 0, 100)
light_data.spot_size = 1.0471975803375244 # 60
scene.collection.objects.link(light)
scene.render.engine = 'CYCLES'
scene.render.film_transparent = True
# TODO: define Cycles world?
scene.render.image_settings.media_type = 'IMAGE'
scene.render.image_settings.file_format = 'PNG'
scene.render.image_settings.color_depth = '8'
scene.render.image_settings.color_mode = 'RGBA'
scene.render.image_settings.compression = 25
scene.render.resolution_x = RENDER_PREVIEW_SIZE
scene.render.resolution_y = RENDER_PREVIEW_SIZE
scene.render.resolution_percentage = 100
scene.render.filepath = os.path.join(bpy.app.tempdir, 'TEMP_preview_render.png')
scene.render.use_overwrite = True
scene.render.use_stamp = False
scene.render.threads_mode = 'AUTO'
image = bpy.data.images.new("TEMP_render_image", RENDER_PREVIEW_SIZE, RENDER_PREVIEW_SIZE, alpha=True)
image.source = 'FILE'
image.filepath = scene.render.filepath
return RenderContext(
scene.name, world.name if world else None, camera.name, light.name if light else None,
camera_data.name, light_data.name if light_data else None, image.name,
backup_scene, backup_world, backup_camera, backup_light, backup_camera_data, backup_light_data,
)
def render_context_delete(render_context):
# We use try/except blocks here to avoid crash, too much things can go wrong, and we want to leave the current
# .blend as clean as possible!
success = True
scene = bpy.data.scenes[render_context.scene, None]
try:
if render_context.backup_scene is None:
scene.world = None
scene.camera = None
if render_context.camera:
scene.collection.objects.unlink(bpy.data.objects[render_context.camera, None])
if render_context.light:
scene.collection.objects.unlink(bpy.data.objects[render_context.light, None])
bpy.data.scenes.remove(scene, do_unlink=True)
scene = None
else:
rna_backup_restore(scene, render_context.backup_scene)
except Exception as ex:
print("ERROR:", ex)
success = False
if render_context.world is not None:
try:
world = bpy.data.worlds[render_context.world, None]
if render_context.backup_world is None:
if scene is not None:
scene.world = None
world.user_clear()
bpy.data.worlds.remove(world)
else:
rna_backup_restore(world, render_context.backup_world)
except Exception as ex:
print("ERROR:", ex)
success = False
if render_context.camera:
try:
camera = bpy.data.objects[render_context.camera, None]
if render_context.backup_camera is None:
if scene is not None:
scene.camera = None
scene.collection.objects.unlink(camera)
camera.user_clear()
bpy.data.objects.remove(camera)
bpy.data.cameras.remove(bpy.data.cameras[render_context.camera_data, None])
else:
rna_backup_restore(camera, render_context.backup_camera)
rna_backup_restore(bpy.data.cameras[render_context.camera_data, None],
render_context.backup_camera_data)
except Exception as ex:
print("ERROR:", ex)
success = False
if render_context.light:
try:
light = bpy.data.objects[render_context.light, None]
if render_context.backup_light is None:
if scene is not None:
scene.collection.objects.unlink(light)
light.user_clear()
bpy.data.objects.remove(light)
bpy.data.lights.remove(bpy.data.lights[render_context.light_data, None])
else:
rna_backup_restore(light, render_context.backup_light)
rna_backup_restore(bpy.data.lights[render_context.light_data,
None], render_context.backup_light_data)
except Exception as ex:
print("ERROR:", ex)
success = False
try:
image = bpy.data.images[render_context.image, None]
image.user_clear()
bpy.data.images.remove(image)
except Exception as ex:
print("ERROR:", ex)
success = False
return success
def object_bbox_merge(bbox, ob, ob_space, offset_matrix):
# Take collections instances into account (including linked one in this case).
if ob.type == 'EMPTY' and ob.instance_type == 'COLLECTION':
grp_objects = tuple((ob.name, ob.library.filepath if ob.library else None)
for ob in ob.instance_collection.all_objects)
if (len(grp_objects) == 0):
ob_bbox = ob.bound_box
else:
coords = objects_bbox_calc(ob_space, grp_objects,
Matrix.Translation(ob.instance_collection.instance_offset).inverted())
ob_bbox = ((coords[0], coords[1], coords[2]), (coords[21], coords[22], coords[23]))
elif ob.bound_box:
ob_bbox = ob.bound_box
else:
ob_bbox = ((-ob.scale.x, -ob.scale.y, -ob.scale.z), (ob.scale.x, ob.scale.y, ob.scale.z))
for v in ob_bbox:
v = offset_matrix @ Vector(v) if offset_matrix is not None else Vector(v)
v = ob_space.matrix_world.inverted() @ ob.matrix_world @ v
if bbox[0].x > v.x:
bbox[0].x = v.x
if bbox[0].y > v.y:
bbox[0].y = v.y
if bbox[0].z > v.z:
bbox[0].z = v.z
if bbox[1].x < v.x:
bbox[1].x = v.x
if bbox[1].y < v.y:
bbox[1].y = v.y
if bbox[1].z < v.z:
bbox[1].z = v.z
def objects_bbox_calc(camera, objects, offset_matrix):
bbox = (Vector((1e24, 1e24, 1e24)), Vector((-1e24, -1e24, -1e24)))
for obname, libpath in objects:
ob = bpy.data.objects[obname, libpath]
object_bbox_merge(bbox, ob, camera, offset_matrix)
# Our bbox has been generated in camera local space, bring it back in world one
bbox[0][:] = camera.matrix_world @ bbox[0]
bbox[1][:] = camera.matrix_world @ bbox[1]
cos = (
bbox[0].x, bbox[0].y, bbox[0].z,
bbox[0].x, bbox[0].y, bbox[1].z,
bbox[0].x, bbox[1].y, bbox[0].z,
bbox[0].x, bbox[1].y, bbox[1].z,
bbox[1].x, bbox[0].y, bbox[0].z,
bbox[1].x, bbox[0].y, bbox[1].z,
bbox[1].x, bbox[1].y, bbox[0].z,
bbox[1].x, bbox[1].y, bbox[1].z,
)
return cos
def preview_render_do(render_context, item_container, item_name, objects, offset_matrix=None):
# Unused.
# scene = bpy.data.scenes[render_context.scene, None]
if objects is not None:
camera = bpy.data.objects[render_context.camera, None]
light = bpy.data.objects[render_context.light, None] if render_context.light is not None else None
cos = objects_bbox_calc(camera, objects, offset_matrix)
depsgraph = bpy.context.evaluated_depsgraph_get()
loc, _ortho_scale = camera.camera_fit_coords(depsgraph, cos)
camera.location = loc
# Set camera clipping accordingly to computed bbox.
min_dist = 1e24
max_dist = -1e24
for co in zip(*(iter(cos),) * 3):
dist = (Vector(co) - loc).length
if dist < min_dist:
min_dist = dist
if dist > max_dist:
max_dist = dist
camera.data.clip_start = min_dist / 2
camera.data.clip_end = max_dist * 2
if light:
loc, _ortho_scale = light.camera_fit_coords(depsgraph, cos)
light.location = loc
bpy.context.view_layer.update()
bpy.ops.render.render(write_still=True)
image = bpy.data.images[render_context.image, None]
item = getattr(bpy.data, item_container)[item_name, None]
image.reload()
preview = item.preview_ensure()
preview.image_size = (RENDER_PREVIEW_SIZE, RENDER_PREVIEW_SIZE)
preview.image_pixels_float[:] = image.pixels
# And now, main code!
do_save = True
if do_data_intern:
bpy.ops.wm.previews_clear(id_type={'SHADING'})
bpy.ops.wm.previews_ensure()
render_contexts = {}
objects_ignored = set()
collections_ignored = set()
prev_scenename = bpy.context.window.scene.name
if do_objects:
prev_shown = {ob.name: ob.hide_render for ob in ids_nolib(bpy.data.objects)}
for ob in ids_nolib(bpy.data.objects):
if ob in objects_ignored:
continue
ob.hide_render = True
for root in ids_nolib(bpy.data.objects):
if root.name in objects_ignored:
continue
if root.type not in OBJECT_TYPES_RENDER:
continue
objects = ((root.name, None),)
render_context = render_contexts.get('CYCLES', None)
if render_context is None:
render_context = render_context_create('CYCLES', objects_ignored)
render_contexts['CYCLES'] = render_context
scene = bpy.data.scenes[render_context.scene, None]
bpy.context.window.scene = scene
for obname, libpath in objects:
ob = bpy.data.objects[obname, libpath]
if obname not in scene.objects:
scene.collection.objects.link(ob)
ob.hide_render = False
bpy.context.view_layer.update()
preview_render_do(render_context, "objects", root.name, objects)
# XXX Hyper Super Uber Suspicious Hack!
# Without this, on windows build, script excepts with following message:
# Traceback (most recent call last):
# File "<string>", line 1, in <module>
# File "<string>", line 451, in <module>
# File "<string>", line 443, in main
# File "<string>", line 327, in do_previews
# OverflowError: Python int too large to convert to C long
# ... :(
scene = bpy.data.scenes[render_context.scene, None]
for obname, libpath in objects:
ob = bpy.data.objects[obname, libpath]
scene.collection.objects.unlink(ob)
ob.hide_render = True
for ob in ids_nolib(bpy.data.objects):
is_rendered = prev_shown.get(ob.name, ...)
if is_rendered is not ...:
ob.hide_render = is_rendered
if do_collections:
for grp in ids_nolib(bpy.data.collections):
if grp.name in collections_ignored:
continue
# Here too, we do want to keep linked objects members of local collection...
objects = tuple((ob.name, ob.library.filepath if ob.library else None) for ob in grp.objects)
render_context = render_contexts.get('CYCLES', None)
if render_context is None:
render_context = render_context_create('CYCLES', objects_ignored)
render_contexts['CYCLES'] = render_context
scene = bpy.data.scenes[render_context.scene, None]
bpy.context.window.scene = scene
bpy.ops.object.collection_instance_add(collection=grp.name)
grp_ob = next((
ob for ob in scene.objects
if ob.instance_collection and ob.instance_collection.name == grp.name
))
grp_obname = grp_ob.name
bpy.context.view_layer.update()
offset_matrix = Matrix.Translation(grp.instance_offset).inverted()
preview_render_do(render_context, "collections", grp.name, objects, offset_matrix)
scene = bpy.data.scenes[render_context.scene, None]
scene.collection.objects.unlink(bpy.data.objects[grp_obname, None])
bpy.context.window.scene = bpy.data.scenes[prev_scenename, None]
for render_context in render_contexts.values():
if not render_context_delete(render_context):
do_save = False # Do not save file if something went wrong here, we could 'pollute' it with temp data...
if do_scenes:
for scene in ids_nolib(bpy.data.scenes):
has_camera = scene.camera is not None
bpy.context.window.scene = scene
render_context = render_context_create('__SCENE', objects_ignored)
bpy.context.view_layer.update()
objects = None
if not has_camera:
# We had to add a temp camera, now we need to place it to see interesting objects!
objects = tuple((ob.name, ob.library.filepath if ob.library else None) for ob in scene.objects
if (not ob.hide_render) and (ob.type in OBJECT_TYPES_RENDER))
preview_render_do(render_context, "scenes", scene.name, objects)
if not render_context_delete(render_context):
do_save = False
bpy.context.window.scene = bpy.data.scenes[prev_scenename, None]
if do_save:
print("Saving {:s}...".format(bpy.data.filepath))
try:
bpy.ops.wm.save_mainfile()
except Exception as ex:
# Might fail in some odd cases, like e.g. in regression files we have `glsl/ram_glsl.blend` which
# references an nonexistent texture. Better not break in this case, just spit error to console.
print("ERROR:", ex)
else:
print(
"*NOT* Saving {:s}, because some error(s) happened while deleting temp render data...".format(
bpy.data.filepath,
)
)
def do_clear_previews(do_objects, do_collections, do_scenes, do_data_intern):
if do_data_intern:
bpy.ops.wm.previews_clear(id_type={'SHADING'})
if do_objects:
for ob in ids_nolib_with_preview(bpy.data.objects):
ob.preview.image_size = (0, 0)
if do_collections:
for grp in ids_nolib_with_preview(bpy.data.collections):
grp.preview.image_size = (0, 0)
if do_scenes:
for scene in ids_nolib_with_preview(bpy.data.scenes):
scene.preview.image_size = (0, 0)
print("Saving {:s}...".format(bpy.data.filepath))
bpy.ops.wm.save_mainfile()
def main():
try:
import bpy
except ImportError:
print("This script must run from inside blender")
return
import sys
import argparse
# Get rid of Blender args!
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
parser = argparse.ArgumentParser(
description="Use Blender to generate previews for currently open Blender file's items.",
)
parser.add_argument(
'--clear',
default=False,
action="store_true",
help="Clear previews instead of generating them.",
)
parser.add_argument(
'--no_backups',
default=False,
action="store_true",
help="Do not generate a backup .blend1 file when saving processed ones.",
)
parser.add_argument(
'--no_scenes',
default=True,
action="store_false",
help="Do not generate/clear previews for scene IDs.",
)
parser.add_argument(
'--no_collections',
default=True,
action="store_false",
help="Do not generate/clear previews for collection IDs.",
)
parser.add_argument(
'--no_objects',
default=True,
action="store_false",
help="Do not generate/clear previews for object IDs.",
)
parser.add_argument(
'--no_data_intern',
default=True,
action="store_false",
help="Do not generate/clear previews for mat/tex/image/etc. IDs (those handled by core Blender code).",
)
args = parser.parse_args(argv)
orig_save_version = bpy.context.preferences.filepaths.save_version
if args.no_backups:
bpy.context.preferences.filepaths.save_version = 0
elif orig_save_version < 1:
bpy.context.preferences.filepaths.save_version = 1
if args.clear:
print("clear!")
do_clear_previews(do_objects=args.no_objects, do_collections=args.no_collections, do_scenes=args.no_scenes,
do_data_intern=args.no_data_intern)
else:
print("render!")
do_previews(do_objects=args.no_objects, do_collections=args.no_collections, do_scenes=args.no_scenes,
do_data_intern=args.no_data_intern)
# Not really necessary, but better be consistent.
bpy.context.preferences.filepaths.save_version = orig_save_version
if __name__ == "__main__":
print("\n\n *** Running {:s} *** \n".format(__file__))
print(" *** Blend file {:s} *** \n".format(bpy.data.filepath))
main()
bpy.ops.wm.quit_blender()

View File

@@ -0,0 +1,76 @@
# SPDX-FileCopyrightText: 2021-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
__all__ = (
"property_definition_from_data_path",
"decompose_data_path",
)
class _TokenizeDataPath:
"""
Class to split up tokens of a data-path.
Note that almost all access generates new objects with additional paths,
with the exception of iteration which is the intended way to access the resulting data."""
__slots__ = (
"data_path",
)
def __init__(self, attrs):
self.data_path = attrs
def __getattr__(self, attr):
return _TokenizeDataPath(self.data_path + ((".{:s}".format(attr)),))
def __getitem__(self, key):
return _TokenizeDataPath(self.data_path + (("[{!r}]".format(key)),))
def __call__(self, *args, **kw):
value_str = ", ".join([
val for val in (
", ".join(repr(value) for value in args),
", ".join(["{:s}={!r}".format(key, value) for key, value in kw.items()]),
) if val])
return _TokenizeDataPath(self.data_path + ('({:s})'.format(value_str), ))
def __iter__(self):
return iter(self.data_path)
def decompose_data_path(data_path):
"""
Return the components of a data path split into a list.
"""
ns = {"base": _TokenizeDataPath(())}
return list(eval("base" + data_path, ns, ns))
def property_definition_from_data_path(base, data_path):
"""
Return an RNA property definition from an object and a data path.
In Blender this is often used with ``context`` as the base and a
path that it references, for example ``.space_data.lock_camera``.
"""
data = decompose_data_path(data_path)
while data and (not data[-1].startswith(".")):
data.pop()
if (not data) or (not data[-1].startswith(".")) or (len(data) < 2):
return None
data_path_head = "".join(data[:-1])
data_path_tail = data[-1]
value_head = eval("base" + data_path_head)
value_head_rna = getattr(value_head, "bl_rna", None)
if value_head_rna is None:
return None
value_tail = value_head.bl_rna.properties.get(data_path_tail[1:])
if not value_tail:
return None
return value_tail

View File

@@ -0,0 +1,54 @@
# SPDX-FileCopyrightText: 2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
__all__ = (
"open_external_editor",
)
def open_external_editor(filepath, line, column, /):
# Internal Python implementation for `TEXT_OT_jump_to_file_at_point`.
# Returning a non-empty string represents an error, an empty string for success.
import shlex
import subprocess
from string import Template
from bpy import context
from bpy.app.translations import pgettext_rpt as rpt_
text_editor = context.preferences.filepaths.text_editor
text_editor_args = context.preferences.filepaths.text_editor_args
# The caller should check this.
assert text_editor
if not text_editor_args:
return rpt_(
"Provide text editor argument format in File Paths/Applications Preferences, "
"see input field tool-tip for more information",
)
if "$filepath" not in text_editor_args:
return rpt_("Text Editor Args Format must contain $filepath")
args = [text_editor]
template_vars = {
"filepath": filepath,
"line": line + 1,
"column": column + 1,
"line0": line,
"column0": column,
}
try:
args.extend([Template(arg).substitute(**template_vars) for arg in shlex.split(text_editor_args)])
except Exception as ex:
return rpt_("Exception parsing template: {!r}").format(ex)
try:
# With `check=True` if `process.returncode != 0` an exception will be raised.
subprocess.run(args, check=True)
except Exception as ex:
return rpt_("Exception running external editor: {!r}").format(ex)
return ""

View File

@@ -0,0 +1,21 @@
# SPDX-FileCopyrightText: 2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
import contextlib
@contextlib.contextmanager
def operator_context(layout, op_context):
"""Context manager that temporarily overrides the operator context.
>>> with operator_context(layout, 'INVOKE_REGION_CHANNELS'):
... layout.operator("anim.channels_delete")
"""
orig_context = layout.operator_context
layout.operator_context = op_context
try:
yield
finally:
layout.operator_context = orig_context

View File

@@ -0,0 +1,234 @@
# SPDX-FileCopyrightText: 2025 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
'''
This module contains utility classes for reading headers in .blend files.
This is a pure Python implementation of the corresponding C++ code in Blender
in BLO_core_blend_header.hh and BLO_core_bhead.hh.
'''
import os
import struct
import typing
from dataclasses import dataclass
class BlendHeaderError(Exception):
pass
@dataclass
class BHead4:
code: bytes
len: int
old: int
SDNAnr: int
nr: int
@dataclass
class SmallBHead8:
code: bytes
len: int
old: int
SDNAnr: int
nr: int
@dataclass
class LargeBHead8:
code: bytes
SDNAnr: int
old: int
len: int
nr: int
@dataclass
class BlockHeaderStruct:
# Binary format of the encoded header.
struct: struct.Struct
# Corresponding Python type for retrieving block header values.
type: typing.Type[typing.Union[BHead4, SmallBHead8, LargeBHead8]]
@property
def size(self) -> int:
return self.struct.size
def parse(self, data: bytes) -> typing.Union[BHead4, SmallBHead8, LargeBHead8]:
return self.type(*self.struct.unpack(data))
class BlendFileHeader:
"""
BlendFileHeader represents the first 12-17 bytes of a blend file.
It contains information about the hardware architecture, which is relevant
to the structure of the rest of the file.
"""
# Always 'BLENDER'.
magic: bytes
# Currently always 0 or 1.
file_format_version: int
# Either 4 or 8.
pointer_size: int
# Endianness of values stored in the file.
is_little_endian: bool
# Blender version the file has been written with.
# The last two digits are the minor version. So 280 is 2.80.
version: int
def __init__(self, file: typing.IO[bytes]) -> None:
file.seek(0, os.SEEK_SET)
bytes_0_6 = file.read(7)
if bytes_0_6 != b'BLENDER':
raise BlendHeaderError("invalid first bytes {!r}".format(bytes_0_6))
self.magic = bytes_0_6
byte_7 = file.read(1)
is_legacy_header = byte_7 in (b'_', b'-')
if is_legacy_header:
self.file_format_version = 0
if byte_7 == b'_':
self.pointer_size = 4
elif byte_7 == b'-':
self.pointer_size = 8
else:
raise BlendHeaderError("invalid pointer size {!r}".format(byte_7))
byte_8 = file.read(1)
if byte_8 == b'v':
self.is_little_endian = True
elif byte_8 == b'V':
self.is_little_endian = False
else:
raise BlendHeaderError("invalid endian indicator {!r}".format(byte_8))
bytes_9_11 = file.read(3)
self.version = int(bytes_9_11)
else:
byte_8 = file.read(1)
header_size = int(byte_7 + byte_8)
if header_size != 17:
raise BlendHeaderError("unknown file header size {:d}".format(header_size))
byte_9 = file.read(1)
if byte_9 != b'-':
raise BlendHeaderError("invalid file header")
self.pointer_size = 8
byte_10_11 = file.read(2)
self.file_format_version = int(byte_10_11)
if self.file_format_version != 1:
raise BlendHeaderError("unsupported file format version {:d}".format(self.file_format_version))
byte_12 = file.read(1)
if byte_12 != b'v':
raise BlendHeaderError("invalid file header")
self.is_little_endian = True
byte_13_16 = file.read(4)
self.version = int(byte_13_16)
def create_block_header_struct(self) -> BlockHeaderStruct:
assert self.file_format_version in (0, 1)
endian_str = b'<' if self.is_little_endian else b'>'
if self.file_format_version == 1:
header_struct = struct.Struct(b''.join((
endian_str,
# LargeBHead8.code
b'4s',
# LargeBHead8.SDNAnr
b'i',
# LargeBHead8.old
b'Q',
# LargeBHead8.len
b'q',
# LargeBHead8.nr
b'q',
)))
return BlockHeaderStruct(header_struct, LargeBHead8)
if self.pointer_size == 4:
header_struct = struct.Struct(b''.join((
endian_str,
# BHead4.code
b'4s',
# BHead4.len
b'i',
# BHead4.old
b'I',
# BHead4.SDNAnr
b'i',
# BHead4.nr
b'i',
)))
return BlockHeaderStruct(header_struct, BHead4)
assert self.pointer_size == 8
header_struct = struct.Struct(b''.join((
endian_str,
# SmallBHead8.code
b'4s',
# SmallBHead8.len
b'i',
# SmallBHead8.old
b'Q',
# SmallBHead8.SDNAnr
b'i',
# SmallBHead8.nr
b'i',
)))
return BlockHeaderStruct(header_struct, SmallBHead8)
class BlockHeader:
"""
A .blend file consists of a sequence of blocks whereby each block has a header.
This class can parse a header block in a specific .blend file.
Note the binary representation of this header is different for different files.
This class provides a unified interface for these underlying representations.
"""
__slots__ = (
"code",
"size",
"addr_old",
"sdna_index",
"count",
)
# Indicates the type of the block. See BLO_CODE_* in BLO_core_bhead.hh.
code: bytes
# Number of bytes in the block.
size: int
# Old pointer/identifier of the block.
addr_old: int
# DNA struct index of the data in the block.
sdna_index: int
# Number of DNA structures in the block.
count: int
def __init__(self, file: typing.IO[bytes], block_header_struct: BlockHeaderStruct) -> None:
data = file.read(block_header_struct.size)
if len(data) != block_header_struct.size:
if len(data) != 8:
raise BlendHeaderError("invalid block header size")
legacy_endb = struct.Struct(b'4sI')
endb_header = legacy_endb.unpack(data)
if endb_header[0] != b'ENDB':
raise BlendHeaderError("invalid block header")
self.code = b'ENDB'
self.size = 0
self.addr_old = 0
self.sdna_index = 0
self.count = 0
return
header = block_header_struct.parse(data)
self.code = header.code.partition(b'\0')[0]
self.size = header.len
self.addr_old = header.old
self.sdna_index = header.SDNAnr
self.count = header.nr

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,27 @@
# SPDX-FileCopyrightText: 2026 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
from __future__ import annotations
# The function below is (un)registered from scripts/addons_core/bl_pkg/__init__.py:
def asset_listing_main(args: list[str]) -> int:
"""Run the `blender -c asset_listing` CLI command.
This is late-importing the cli module, so that it (and its
dependencies) are only imported when actually used.
"""
import traceback
from . import cli
try:
cli.main(args)
except SystemExit as ex:
if isinstance(ex.code, int):
return ex.code
return 2
except BaseException:
traceback.print_exc()
return 1
return 0

View File

@@ -0,0 +1,671 @@
# SPDX-FileCopyrightText: 2026 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
from __future__ import annotations
__all__ = [
"download_asset_file",
"downloader_status",
"DownloadStatus",
]
from collections.abc import Callable
import dataclasses
import enum
import logging
import urllib.parse
from pathlib import Path
import bpy
from _bpy_internal.http import downloader as http_dl
from _bpy_internal.assets.remote_library.listing_downloader import RemoteAssetListingLocator
from _bpy_internal.assets.remote_library import hashing
logger = logging.getLogger(__name__)
# Preview images will NOT be downloaded if they already exist on disk AND their
# timestamp is younger than this age.
PREVIEW_DOWNLOAD_AGE_THRESHOLD_SEC = 7 * 24 * 3600 # 1 week
_asset_downloaders: dict[str, AssetDownloader] = {}
_preview_downloaders: dict[str, AssetDownloader] = {}
def download_asset_file(
asset_library_url: str,
asset_library_local_path: Path,
asset_url: str,
asset_hash: str,
save_to: Path) -> str:
"""Download an asset file to a file on disk.
:param asset_library_url: Root URL of the remote asset library. Used as an
identifier of this library (to create a downloader per library), as well
as for resolving relative URLs.
:param asset_library_local_path: Root path of the local asset cache. Used to
resolve relative `save_to` paths, but also to find the HTTP metadata
cache for this asset library (for conditional downloads).
:param asset_url: the URL to download. Can be absolute or relative to the
asset library URL. If it is an empty string, the `save_to` path is used
as the URL.
:param asset_hash: the hash of the asset file, will be appended to the URL.
:param save_to: the path on disk where to download to. While the download is
pending, ".part" will be appended to the filename. When the download
finishes successfully, it is renamed to the final path.
:returns: the final URL that was queued for downloading.
"""
try:
downloader = _asset_downloaders[asset_library_url]
assert downloader.local_path == asset_library_local_path, (
"This code assumes that remote asset libraries do not move on the local disk"
)
except KeyError:
downloader = AssetDownloader(
asset_library_url,
asset_library_local_path,
reporter=AssetReporter(asset_library_url=asset_library_url),
on_queue_empty_callback=on_asset_download_queue_empty,
)
downloader.start()
_asset_downloaders[asset_library_url] = downloader
# Construct the URL if not given explicitly.
if not asset_url:
if save_to.is_absolute():
relative_path = save_to.relative_to(asset_library_local_path)
else:
relative_path = save_to
asset_url = urllib.parse.quote(relative_path.as_posix())
# Include the hash in the URL, and download the asset.
download_url = hashing.url((asset_url, asset_hash))
full_url = downloader.download_asset_file(download_url, save_to)
return full_url
def download_preview(
asset_library_url: str,
asset_library_local_path: Path,
preview_url: str,
preview_hash: str,
dst_filepath: Path) -> None:
"""Download an asset preview to a file on disk.
:param asset_library_url: Root URL of the remote asset library. Used as an
identifier of this library (to create a downloader per library), as well
as for resolving relative URLs.
:param asset_library_local_path: Root path of the local asset cache. Used to
resolve relative `save_to` paths, but also to find the HTTP metadata
cache for this asset library (for conditional downloads).
:param preview_url: the URL to download. Can be absolute or relative.
:param preview_hash: the hash of the thumbnail, will be appended to the URL.
:param dst_filepath: the path on disk where to download to. While the
download is pending, ".part" will be appended to the filename. When the
download finishes successfully, it is renamed to the final path.
"""
import time
# Check if the file exists and is new enough. If it is, don't bother the server.
try:
stat = dst_filepath.stat()
except FileNotFoundError:
pass # Fine, something new to download.
else:
# File exists, let's see if it's young enough to use as-is.
age_in_seconds = time.time() - stat.st_mtime
if age_in_seconds < PREVIEW_DOWNLOAD_AGE_THRESHOLD_SEC:
# The local file is still fresh, just pretend we just downloaded it.
bpy.types.WindowManager.asset_library_status_ping_loaded_new_preview(str(dst_filepath))
return
try:
downloader = _preview_downloaders[asset_library_url]
assert downloader.local_path == asset_library_local_path, (
"This code assumes that remote asset libraries do not move on the local disk"
)
except KeyError:
downloader = AssetDownloader(
asset_library_url,
asset_library_local_path,
reporter=PreviewReporter(),
on_queue_empty_callback=None,
)
downloader.start()
_preview_downloaders[asset_library_url] = downloader
# Include the hash in the URL, and download the preview.
download_url = hashing.url((preview_url, preview_hash))
downloader.download_asset_file(download_url, dst_filepath)
def cancel_download(asset_library_url: str, full_asset_url: str) -> None:
"""Cancel a running/queued asset download.
Cancelling a URL that has already been fully downloaded, or one that was never
queued is a no-op.
:param asset_library_url: Root URL of the remote asset library. Used as an
identifier of this library (to create a downloader per library).
Contrary to the download function, this is NOT used to resolve relative
URLs.
:param full_asset_url: the URL that's queued for download. MUST be the final
URL as returned by download_asset_file().
"""
try:
downloader = _asset_downloaders[asset_library_url]
except KeyError:
# No downloader could mean that the cancel came in just a millisecond
# too late, and the download was already finished.
return
downloader.cancel_download(full_asset_url)
def cancel_download_all_assets() -> None:
"""Cancel all active/queued downloads of all assets.
This shuts down all asset downloaders, effectively cancelling all their downloads.
"""
for downloader in _asset_downloaders.values():
downloader.cancel_and_shutdown()
def downloader_status(asset_library_url: str) -> DownloadStatus:
"""Returns the asset downloader status.
Raises a KeyError if there never was a downloader for this URL.
"""
return _asset_downloaders[asset_library_url].status
def on_asset_download_queue_empty() -> None:
"""Called by the asset downloader when its download queue emptied."""
if any_asset_downloading():
return
bpy.types.WindowManager.asset_library_status_ping_finished_download_queue()
def any_asset_downloading() -> bool:
"""Returns true if there is any downloader currently downloading assets."""
return any(
downloader.status == DownloadStatus.DOWNLOADING
for downloader in _asset_downloaders.values()
)
class DownloadStatus(enum.Enum):
IDLE = 'idle'
DOWNLOADING = 'downloading'
FINISHED = 'finished'
"""The downloader has downloaded everything that was queued.
Note: this does NOT mean that all downloads were perfect. It just means that
there were no exceptions raised.
"""
FAILED = 'failed'
"""Unexpected exceptions occurred."""
CANCELLED = 'cancelled'
"""There still were pending downloads when the downloader shut down."""
class AssetDownloader:
"""Downloader for asset files & their thumbnails."""
_locator: RemoteAssetListingLocator
_bg_downloader: http_dl.BackgroundDownloader | None
_reporter: http_dl.DownloadReporter
_num_assets_pending: int
type QueueEmptyCallback = Callable[[], None]
_on_queue_empty_callback: QueueEmptyCallback | None
"""Called when the download queue became empty."""
_status: DownloadStatus
_error_message: str
"""An error message to show to the user.
Should be set on errors to communicate a message to users. Calling report()
with 'ERROR' as the level will set this to the given message.
"""
_DOWNLOAD_POLL_INTERVAL: float = 0.01
"""How often the background download process is polled, in seconds.
Each 'poll' involves sending queued messages back & forth between the main
Blender process and the background download process.
"""
_HTTP_METHOD = "GET"
def __init__(
self,
remote_url: str,
local_path: Path | str,
*,
reporter: http_dl.DownloadReporter,
on_queue_empty_callback: QueueEmptyCallback | None,
) -> None:
"""Create a downloader for assets of a specific asset library.
:param remote_url: Base URL of the remote asset library server.
:param local_path: The directory to download the index files to.
:param on_download_done_callback: called with one parameter (this
AssetDownloader) when a file finished downloading and was put
in its final location, ready to be picked up by the asset system.
"""
self._locator = RemoteAssetListingLocator(remote_url, local_path)
self._num_assets_pending = 0
self._reporter = reporter
self._on_queue_empty_callback = on_queue_empty_callback
self._status = DownloadStatus.IDLE
self._error_message = ""
# Work around a limitation of Blender, see bug report #139720 for details.
self.on_timer_event = self.on_timer_event # type: ignore[method-assign]
self._http_metadata_provider = http_dl.MetadataProviderFilesystem(
cache_location=self._locator.http_metadata_cache_location,
)
self._bg_downloader = None
def _create_bg_downloader(self) -> None:
self._bg_downloader = http_dl.BackgroundDownloader(
options=http_dl.DownloaderOptions(
metadata_provider=self._http_metadata_provider,
timeout=300,
http_headers={
'X-Blender': "{:d}.{:d}".format(*bpy.app.version),
},
),
on_callback_error=self._on_callback_error,
)
# These are called in order. Doing things this way ensures that self._reporter.download_finished() is called for
# every individual download, and after that our own function is called. That means that the
# self._on_queue_empty_callback() function is called _after_ the individual downloads.
#
# Swapping this order would mean self._on_queue_empty_callback() is called _before_ the last call to
# self._reporter.download_finished(), which would be confusing.
self._bg_downloader.add_reporter(self._reporter)
self._bg_downloader.add_reporter(self)
def __repr__(self) -> str:
return "{!s}(remote_url={!r}, local_path={!r})".format(
type(self),
self._locator.remote_url,
self._locator.local_path,
)
def start(self) -> None:
"""Start the background process."""
if not self._bg_downloader:
self._create_bg_downloader()
assert self._bg_downloader
self._bg_downloader.start()
# Register the timer for periodic message passing between the main and
# background processes.
if not bpy.app.timers.is_registered(self.on_timer_event):
bpy.app.timers.register(
self.on_timer_event,
first_interval=self._DOWNLOAD_POLL_INTERVAL,
persistent=True,
)
# Double-check the registration worked, see #139720 for details.
assert bpy.app.timers.is_registered(self.on_timer_event)
def download_asset_file(self, asset_url: str, save_to: Path) -> str:
"""Download an asset or preview file to a local file.
Returns the URL that was queued. This is different than the given URL
when the latter is relative.
"""
# If the downloader was shut down, start it up again.
if not self._bg_downloader:
self.start()
self._status = DownloadStatus.DOWNLOADING
url = self._queue_download(asset_url, save_to)
return url
def cancel_download(self, full_asset_url: str) -> None:
"""Cancel downloading a URL.
If the URL was never queued, or it has already been downloaded,
this is a no-op.
"""
if not self._bg_downloader:
return
logger.info("cancelling download of %s", full_asset_url)
http_req_descr = http_dl.RequestDescription(self._HTTP_METHOD, full_asset_url)
self._bg_downloader.cancel_download(http_req_descr)
def _shutdown_if_done(self) -> None:
if self._num_assets_pending > 0:
return
is_done = self._bg_downloader is None or self._bg_downloader.all_downloads_done
if not is_done:
return
# Done downloading everything, let's shut down.
self._status = DownloadStatus.FINISHED
if self._on_queue_empty_callback is not None:
# Call the callback _after_ setting the status, so that when
# Blender is pinged about this, it can see it's finished.
self._on_queue_empty_callback()
# TODO: delay this for a few minutes, so that we don't need a new
# background process for every asset.
self.shutdown()
def _on_callback_error(
self,
http_req_descr: http_dl.RequestDescription,
local_file: Path,
exception: Exception) -> None:
logger.exception(
"exception while handling downloaded file ({!r}, saved to {!r})".format(
http_req_descr, local_file))
self.report({'ERROR'}, "Resource download had an issue, download aborted")
self._status = DownloadStatus.FAILED
self.shutdown()
def _queue_download(self, asset_url: str, download_to_path: Path | str) -> str:
"""Queue up this download.
Returns the URL of the download, and the path to which it will be downloaded.
"""
remote_url = urllib.parse.urljoin(self._locator.remote_url, asset_url)
download_to_path = self._locator.local_path / download_to_path
# Safety measure: refuse to download a file into the listing directory.
if self._locator.is_system_path(download_to_path):
raise ValueError(
("Asset at {!s} wants to be downloaded to {!s}, which would overwrite local asset system files. " +
"Notify the owner of the asset library about this.").format(
remote_url,
download_to_path))
logger.info("downloading %s to %s", remote_url, download_to_path)
assert self._bg_downloader, "downloads can only be queued when the bgdownloader is available"
request_descr = self._bg_downloader.queue_download(
remote_url,
download_to_path,
http_method=self._HTTP_METHOD,
)
return request_descr.url
# TODO: implement this in a more useful way:
def report(self, level: set[str], message: str) -> None:
# logger.info("Report: {:s}: {:s}".format("/".join(level), message))
if 'ERROR' in level:
self._error_message = message
def cancel_and_shutdown(self) -> None:
"""Cancel all downloads and shut down the background downloader."""
# Only set to 'Cancelled' if the downloader was still downloading.
if self._status == DownloadStatus.DOWNLOADING:
if self._bg_downloader and self._bg_downloader.num_pending_downloads > 0:
self._status = DownloadStatus.CANCELLED
else:
self._status = DownloadStatus.FINISHED
# The downloads themselves don't have to be explicitly cancelled,
# shutting down the downloader will do that implicitly.
self.shutdown()
# By now there is no more queue, so just treat it as 'empty' and let Blender know no downloads will happen any
# more (at least not by this downloader).
if self._on_queue_empty_callback is not None:
# Call the callback _after_ setting the status, so that when
# Blender is pinged about this, it can see it's finished.
self._on_queue_empty_callback()
def shutdown(self) -> None:
"""Stop the background downloader and call the 'done' callback."""
# The timer is no longer necessary, the bg_downloader.shutdown() call
# takes care of the last queued messages.
if bpy.app.timers.is_registered(self.on_timer_event):
bpy.app.timers.unregister(self.on_timer_event)
try:
if not self._bg_downloader:
return
# Only report if this is actually triggering a shutdown. If that was
# already triggered somehow, don't bother.
if not self._bg_downloader.is_shutdown_requested:
# It may be tempting to call self.report(...) here, and report on the
# cancellation. However, this should be done by the caller, when they know
# of the reason of the cancellation and thus can provide more info.
num_pending = self._bg_downloader.num_pending_downloads
if num_pending:
logger.warning("Shutting down background downloader, %d downloads pending", num_pending)
self._bg_downloader.shutdown()
finally:
# Regardless of whether the shutdown had some issues, the timer has
# been unregistered, so there will be no more message handling, and
# so for all intents and purposes, the downloader is done.
self._bg_downloader = None
def on_timer_event(self) -> float:
assert self._bg_downloader, "timer events should only come in while the bgdownloader is available"
try:
self._bg_downloader.update()
except http_dl.BackgroundProcessNotRunningError:
logger.error("Background downloader subprocess died, aborting.")
self._status = DownloadStatus.FAILED
self.shutdown()
return 0 # Deactivate the timer.
except Exception:
logger.exception(
"Unexpected error downloading remote asset library ilisting from %s to %s",
self._locator.remote_url,
self._locator.local_path)
# Automatically switch between IDLE and DOWNLOADING, but never overwrite
# FAILED or FINISHED_SUCCESFULLY.
if self._status in {DownloadStatus.DOWNLOADING, DownloadStatus.IDLE}:
if self._bg_downloader.num_pending_downloads > 0:
self._status = DownloadStatus.DOWNLOADING
else:
self._status = DownloadStatus.IDLE
return self._DOWNLOAD_POLL_INTERVAL
@property
def remote_url(self) -> str:
return self._locator.remote_url
@property
def local_path(self) -> Path:
return self._locator.local_path
@property
def status(self) -> DownloadStatus:
return self._status
@property
def error_message(self) -> str:
return self._error_message
# Below here: http_dl.DownloadReporter protocol functions:
def download_starts(self, http_req_descr: http_dl.RequestDescription) -> None:
pass
def already_downloaded(
self,
http_req_descr: http_dl.RequestDescription,
local_file: Path,
) -> None:
self._shutdown_if_done()
def download_error(
self,
http_req_descr: http_dl.RequestDescription,
local_file: Path,
error: Exception,
) -> None:
if isinstance(error, http_dl.DownloadCancelled):
# Cancelling a download should cancel all queued-up downloads.
if self._num_assets_pending:
self.report({'WARNING'}, "Cancelled {} pending download".format(self._num_assets_pending))
logger.warning("Download cancelled: %s", http_req_descr)
self._status = DownloadStatus.FAILED
self.shutdown()
return
self._shutdown_if_done()
def download_progress(
self,
http_req_descr: http_dl.RequestDescription,
progress: http_dl.DownloadProgress,
) -> None:
pass
def download_finished(
self,
http_req_descr: http_dl.RequestDescription,
local_file: Path,
) -> None:
self._shutdown_if_done()
@dataclasses.dataclass
class AssetReporter:
"""Implementation of the http_dl.DownloadReporter protocol."""
asset_library_url: str
def download_starts(self, http_req_descr: http_dl.RequestDescription) -> None:
logger.debug("Download starting: %s", http_req_descr)
def already_downloaded(
self,
http_req_descr: http_dl.RequestDescription,
local_file: Path,
) -> None:
logger.debug("Download unnecessary, file already downloaded: %s", http_req_descr.url)
bpy.types.WindowManager.asset_library_status_ping_asset_file_succeeded(
self.asset_library_url, http_req_descr.url, str(local_file))
def download_error(
self,
http_req_descr: http_dl.RequestDescription,
local_file: Path,
error: Exception,
) -> None:
logger.warning("Could not download file %s: %s", http_req_descr, error)
bpy.types.WindowManager.asset_library_status_ping_asset_file_failed(
self.asset_library_url, http_req_descr.url, str(local_file))
def download_progress(
self,
http_req_descr: http_dl.RequestDescription,
progress: http_dl.DownloadProgress,
) -> None:
bpy.types.WindowManager.asset_library_status_ping_asset_file_progress(
http_req_descr.url, progress.disk_bytes_written)
def download_finished(
self,
http_req_descr: http_dl.RequestDescription,
local_file: Path,
) -> None:
logger.info("Download finished: %s to %s", http_req_descr, local_file)
bpy.types.WindowManager.asset_library_status_ping_asset_file_succeeded(
self.asset_library_url, http_req_descr.url, str(local_file))
@dataclasses.dataclass
class PreviewReporter:
"""Implementation of the http_dl.DownloadReporter protocol."""
def download_starts(self, http_req_descr: http_dl.RequestDescription) -> None:
logger.debug("Download starting: %s", http_req_descr)
def already_downloaded(
self,
http_req_descr: http_dl.RequestDescription,
local_file: Path,
) -> None:
# This cannot check the content-type header (like download_finished() does), since
# there likely is none in a '304 Not Modified' response.
# Indicate to a future run that we just confirmed this file is still fresh.
local_file.touch()
# Poke Blender so it knows there's a thumbnail update. It shouldn't be necessary, but since it requested the
# file for downloading, it may not have been aware it already existed. Better let it know.
bpy.types.WindowManager.asset_library_status_ping_loaded_new_preview(str(local_file))
def download_error(
self,
http_req_descr: http_dl.RequestDescription,
local_file: Path,
error: Exception,
) -> None:
# TODO: create an empty file in the correct `.../_thumbs/failed` directory.
self.download_finished(http_req_descr, local_file)
def download_progress(
self,
http_req_descr: http_dl.RequestDescription,
progress: http_dl.DownloadProgress,
) -> None:
pass
def download_finished(
self,
http_req_descr: http_dl.RequestDescription,
local_file: Path,
) -> None:
# Check whether the file was actually an image.
assert http_req_descr.response_headers
content_type = http_req_descr.response_headers.get('content-type', "")
# Only check the content type if the server sends it back. Otherwise
# just trust that it's valid. For example, when sending a `304 Not
# Modified`, the server may actually skip the Content-Type header.
if content_type and not content_type.startswith('image/'):
logger.warning("Thumbnail URL %r has content type %r, expected an image",
http_req_descr.url, content_type)
# TODO: mark as 'failed' so that this file isn't repeatedly
# downloaded and rejected. For now I'll just keep the file
# around, so that at least the time-stamping works to prevent
# hammering the server.
# Indicate to a future run that we just confirmed this file is still fresh.
local_file.touch()
# Poke Blender so it knows there's a thumbnail update.
bpy.types.WindowManager.asset_library_status_ping_loaded_new_preview(str(local_file))

View File

@@ -0,0 +1,294 @@
# SPDX-FileCopyrightText: 2026 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
#
# Generated by datamodel-codegen:
# source filename: blender_asset_library_openapi.yaml
from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum
from typing import Any
@dataclass
class Contact:
"""Owner / publisher of this asset library."""
name: str
url: str | None = None
email: str | None = None
@dataclass
class URLWithHash:
"""Resource that's identified by a URL.
The resource should be fetched by including the hash in the query
string, like `GET {URL}?hash={HASH}`. Here `{HASH}` should _not_
include the hash type. The purpose of including this on the URL is
for cache busting, and thus the hash type is not relevant here.
"""
url: str
"""URL of the page file."""
hash: str
"""Hash of the resource obtained at that URL.
This should be in the format "HASHTYPE:HASH-AS-HEX". Currently only
the "SHA256" hash type is supported. Note that for dynamic API
servers, which may perform a server-side filter on the data, the
actual response may not have the same hash. Static servers send
content that matches the hash.
"""
type AssetIDTypeV1 = str
"""Type of the Blender data-block.
This can be obtained via BPY with `datablock.id_type`. Any comparisons
should be done in a case-insensitive manner. Note that this list is just
a list of data-block types in Blender. This type being in this list does
not mean that Blender supports making this data-block an asset. It's
just here to ensure that if that changes, and more data-block types can
become assets, this schema doesn't need updating.
"""
class CustomPropertyTypeV1(StrEnum):
"""Type of IDProperty, see `eIDPropertyType` in `DNA_ID_enumms.h`.
For now, type `ID` and `IDPARRAY` are not supported.
"""
IDP_STRING = "IDP_STRING"
IDP_INT = "IDP_INT"
IDP_FLOAT = "IDP_FLOAT"
IDP_ARRAY = "IDP_ARRAY"
IDP_GROUP = "IDP_GROUP"
IDP_DOUBLE = "IDP_DOUBLE"
IDP_BOOL = "IDP_BOOL"
@dataclass
class AssetBlenderVersionsV1:
"""Minimum and (optionally) maximum versions of Blender that this asset
should be shown in.
This is a half-open interval: Blender shows the asset if `min <= blender < until`.
"""
min: str
"""Minimum version of Blender that should show this asset."""
until: str | None = None
"""First version of Blender that should NOT show this asset."""
@dataclass
class CatalogV1:
"""An asset catalog, which can be represented by one or more UUIDs."""
path: str
uuids: list[str]
simple_name: str | None = None
@dataclass
class FileV1:
"""Single file in the asset library.
Identified by its relative path in that library.
"""
path: str
"""Relative path of where this file is located in the asset library."""
size_in_bytes: int
hash: str
"""Hash of the file.
This should be in the format "HASHTYPE:HASH-AS-HEX". Currently only
the "SHA256" hash type is supported.
"""
blender_version: str
"""Version of Blender used to write this file.
Only contains the major and minor version, no patch version ("5.2",
"6.3", etc. but not "5.2.1").
"""
url: str | None = None
"""URL where the file can be downloaded.
If the URL is relative, it is to be interpreted as relative to the
library's root URL. If the URL is not given, or an empty string, it
is assumed to be the same as 'path'.
"""
@dataclass
class AssetLibraryMeta:
"""Meta-data of this asset library."""
api_versions: dict[str, URLWithHash]
"""API versions of this asset library.
This is reflected in the URLs of all OpenAPI operations except the
one to get this metadata. A single asset library can expose multiple
versions, in order to be backward-compatible with older versions of
Blender. Keys should be "v1", "v2", etc. and their values should be
a URLWithHash that points to each version's index file.
"""
name: str
"""Name of this asset library."""
contact: Contact
@dataclass
class AssetLibraryIndexV1:
"""The available assets at this library."""
schema_version: str
"""Version number of the used schema.
This should be the same as the version of this OpenAPI definition,
as described in its 'info.version' field.
"""
asset_size_bytes: int
asset_count: int
"""Total number of assets in this index.
This is the sum of all `asset_count` fields of each page.
"""
file_count: int
"""Total number of files in this index.
This is the sum of all `file_count` fields of each page (after
deduplication).
"""
pages: list[URLWithHash]
"""URLs of the individual asset index pages.
When relative, these are taken as relative to the main server URL
(i.e. the root of all paths defined in this OpenAPI spec).
"""
catalogs: list[CatalogV1] | None = None
@dataclass
class AssetLibraryIndexPageV1:
"""Any number of assets."""
asset_count: int
"""Number of assets in this page.
This is declared separately, so that a partial JSON parser has this
information before the entire file is downloaded and parsed.
"""
file_count: int
"""Number of files in this page.
This is declared separately, so that a partial JSON parser has this
information before the entire file is downloaded and parsed.
"""
assets: list[AssetV1]
files: list[FileV1]
"""The files that are referenced by the above assets.
Note that there may be duplication of this information between asset
pages, as each file can contain multiple assets, and those assets
might be scattered across multiple pages.
"""
@dataclass
class AssetV1:
"""Representation of a single asset.
Assets are always Blender data-blocks in some blend file. This asset
may be stored in the same blend file as other assets, and so it does
_not_ represent a single downloadable item.
"""
name: str
"""Name of the Blender data-block."""
id_type: AssetIDTypeV1
files: list[str]
"""Relative paths of the files that contain this asset.
The first entry in the list MUST contain the asset data-block
itself, while the remaining entries can be in any order. These
relative paths are used to look up more file information in the
asset library's list of files.
"""
bl_versions: AssetBlenderVersionsV1
thumbnail: URLWithHash | None = None
meta: AssetMetadataV1 | None = None
@dataclass
class AssetMetadataV1:
"""Metadata of an asset, as defined by Blender's `AssetMeta` DNA struct.
Fields should either be non-empty or absent.
"""
catalog_id: str | None = None
"""The catalog UUID that contains this asset.
Having the UUID here makes it easier to create a per-blendfile
.cats.txt file, if that's ever necessary.
"""
preferred_import_method: str | None = None
"""The import method preferred by this asset.
For example, base meshes for sculpting can declare they should
always be appended, making them instantly usable for sculpting.
Supports values APPEND, APPEND_REUSE, and ASSET_IMPORT_PACK. These
are not modeled here as an enum, to aid in forward compatibility of
this Blender version with future import methods (it'll just ignore
unsupported methods, instead of rejecting the file as invalid).
"""
tags: list[str] | None = None
author: str | None = None
description: str | None = None
license: str | None = None
copyright: str | None = None
properties: CustomPropertiesV1 | None = None
type CustomPropertiesV1 = list[CustomPropertyV1]
"""Arbitrary custom properties of the asset.
Keys are the property names.
"""
@dataclass
class CustomPropertyV1:
"""Single 'custom property' value of the asset.
The value should be compatible with the given type; GROUP properties
should be represented as `CustomPropertiesV1` object again. Arrays
should specify an `itemtype`.
"""
name: str
type: CustomPropertyTypeV1
value: CustomPropertiesV1 | list[Any] | float | int | str | bool
itemtype: CustomPropertyTypeV1 | None = None

View File

@@ -0,0 +1,411 @@
# SPDX-FileCopyrightText: 2026 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
# This is the OpenAPI specification for Blender's Remote Assets system.
#
# At this moment, the `paths` section is not used by the Blender code, and is
# here just for referencing by humans. It is also still being designed, so don't
# take it as set in stone.
#
# The Python code generator just uses the data structures specified by the
# `components` section.
#
# --------------------------------------------------------------------------
# Run `ninja generate_datamodels` from the build directory to regenerate the
# Python code in blender_asset_library_openapi.py. Replace `ninja` with your
# build tool of choice.
# --------------------------------------------------------------------------
openapi: 3.0.0
info:
version: 1.0.0
title: Blender Asset Library API
description: Blender's API for describing and fetching assets from online libraries.
contact:
name: Blender
url: https://www.blender.org/
license:
name: GPLv3
url: https://www.gnu.org/licenses/gpl-3.0.en.html
servers:
- url: /
paths:
## Meta
/_asset-library-meta.json:
summary: Meta-information about this asset library.
get:
summary: Retrieve the asset library meta info.
operationId: getLibraryMeta
tags: [meta]
responses:
"200":
description: normal response
content:
application/json:
schema:
$ref: "#/components/schemas/AssetLibraryMeta"
## Index
/_v1/asset-index.json:
summary: The index of the asset library, containing the metadata of all available assets.
get:
summary: Get the asset library index.
operationId: getLibraryIndex
tags: [index]
responses:
"200":
description: normal response
content:
application/json:
schema:
$ref: "#/components/schemas/AssetLibraryIndexV1"
/_v1/assets-{page}.json:
summary: >
The index of the asset library, containing the metadata of all available assets.
Note that the actual URLs of these pages are listed in the `asset-index.json` above.
The path specified here is merely a suggestion.
get:
summary: Get the asset library index.
operationId: getLibraryIndexPage
tags: [index]
parameters:
- name: page
in: path
required: true
schema: { type: integer }
responses:
"200":
description: normal response
content:
application/json:
schema:
$ref: "#/components/schemas/AssetLibraryIndexPageV1"
tags:
- name: meta
description: Info about the asset library itself.
- name: index
description: Access to the asset library's list of assets.
components:
schemas:
## Meta
AssetLibraryMeta:
type: object
description: "Meta-data of this asset library."
properties:
"api_versions":
type: object
description: >
API versions of this asset library. This is reflected in the URLs of
all OpenAPI operations except the one to get this metadata.
A single asset library can expose multiple versions, in order to be
backward-compatible with older versions of Blender.
Keys should be "v1", "v2", etc. and their values should be a
URLWithHash that points to each version's index file.
additionalProperties: { $ref: "#/components/schemas/URLWithHash" }
"name":
type: string
description: Name of this asset library.
"contact": { $ref: "#/components/schemas/Contact" }
required: [api_versions, name, contact]
example:
api_versions:
v1:
url: _v1/asset-index.json
hash: "SHA256:22c9d2d5e9fe119b43fb8437df06c88e61d3bbad315690284b9eece66641c1e9"
name: Blender Essentials
contact:
name: Blender
url: https://www.blender.org/
Contact:
type: object
description: Owner / publisher of this asset library.
properties:
"name": { type: string }
"url": { type: string }
"email": { type: string }
required: [name]
## Index
AssetLibraryIndexV1:
type: object
description: The available assets at this library.
properties:
"schema_version":
type: string
description: >
Version number of the used schema. This should be the same as the
version of this OpenAPI definition, as described in its
'info.version' field.
"asset_size_bytes": { type: integer }
"asset_count":
type: integer
description: >
Total number of assets in this index. This is the sum of all
`asset_count` fields of each page.
"file_count":
type: integer
description: >
Total number of files in this index. This is the sum of all
`file_count` fields of each page (after deduplication).
"pages":
type: array
items: { $ref: "#/components/schemas/URLWithHash" }
description: >
URLs of the individual asset index pages. When relative, these are
taken as relative to the main server URL (i.e. the root of all paths
defined in this OpenAPI spec).
"catalogs":
type: array
items: { $ref: "#/components/schemas/CatalogV1" }
required:
[schema_version, asset_size_bytes, asset_count, file_count, pages]
URLWithHash:
type: object
description: >
Resource that's identified by a URL. The resource should be fetched by
including the hash in the query string, like `GET {URL}?hash={HASH}`.
Here `{HASH}` should _not_ include the hash type. The purpose of
including this on the URL is for cache busting, and thus the hash type
is not relevant here.
properties:
"url":
type: string
description: URL of the page file
"hash":
type: string
description: >
Hash of the resource obtained at that URL. This should be in the
format "HASHTYPE:HASH-AS-HEX". Currently only the "SHA256" hash type
is supported.
Note that for dynamic API servers, which may perform a server-side
filter on the data, the actual response may not have the same hash.
Static servers send content that matches the hash.
required: [url, hash]
AssetLibraryIndexPageV1:
type: object
description: Any number of assets.
properties:
"asset_count":
type: integer
description: >
Number of assets in this page. This is declared separately, so that
a partial JSON parser has this information before the entire file is
downloaded and parsed.
"file_count":
type: integer
description: >
Number of files in this page. This is declared separately, so that
a partial JSON parser has this information before the entire file is
downloaded and parsed.
"assets":
type: array
items: { $ref: "#/components/schemas/AssetV1" }
"files":
type: array
items: { $ref: "#/components/schemas/FileV1" }
description: >
The files that are referenced by the above assets. Note that there
may be duplication of this information between asset pages, as each
file can contain multiple assets, and those assets might be
scattered across multiple pages.
required: [asset_count, file_count, assets, files]
AssetV1:
type: object
description: >
Representation of a single asset. Assets are always Blender data-blocks
in some blend file.
This asset may be stored in the same blend file as other assets, and so
it does _not_ represent a single downloadable item.
properties:
"name":
type: string
description: Name of the Blender data-block.
"id_type": { $ref: "#/components/schemas/AssetIDTypeV1" }
"files":
type: array
items: { type: string }
minItems: 1
description: >
Relative paths of the files that contain this asset. The first entry
in the list MUST contain the asset data-block itself, while the
remaining entries can be in any order. These relative paths are used
to look up more file information in the asset library's list of
files.
"thumbnail": { $ref: "#/components/schemas/URLWithHash" }
"meta": { $ref: "#/components/schemas/AssetMetadataV1" }
"bl_versions":
$ref: "#/components/schemas/AssetBlenderVersionsV1"
required:
- "name"
- "id_type"
- "files"
- "bl_versions"
AssetIDTypeV1:
type: string
description: >
Type of the Blender data-block.
This can be obtained via BPY with `datablock.id_type`. Any comparisons
should be done in a case-insensitive manner.
Note that this list is just a list of data-block types in Blender. This
type being in this list does not mean that Blender supports making this
data-block an asset. It's just here to ensure that if that changes, and
more data-block types can become assets, this schema doesn't need
updating.
AssetMetadataV1:
type: object
description: >
Metadata of an asset, as defined by Blender's `AssetMeta` DNA struct.
Fields should either be non-empty or absent.
properties:
"catalog_id":
type: string
description: >
The catalog UUID that contains this asset. Having the UUID here
makes it easier to create a per-blendfile .cats.txt file, if that's
ever necessary.
"preferred_import_method":
type: string
description: >
The import method preferred by this asset. For example, base meshes for
sculpting can declare they should always be appended, making them
instantly usable for sculpting.
Supports values APPEND, APPEND_REUSE, and ASSET_IMPORT_PACK.
These are not modeled here as an enum, to aid in forward compatibility
of this Blender version with future import methods (it'll just ignore
unsupported methods, instead of rejecting the file as invalid).
"tags":
type: array
items: { type: string }
minItems: 1
"author": { type: string }
"description": { type: string }
"license": { type: string }
"copyright": { type: string }
"properties": { $ref: "#/components/schemas/CustomPropertiesV1" }
CustomPropertiesV1:
type: array
items:
$ref: "#/components/schemas/CustomPropertyV1"
description: >
Arbitrary custom properties of the asset. Keys are the property names.
CustomPropertyV1:
type: object
description: >
Single 'custom property' value of the asset. The value should be
compatible with the given type; GROUP properties should be represented
as `CustomPropertiesV1` object again. Arrays should specify an
`itemtype`.
properties:
"name": { type: string }
"type": { $ref: "#/components/schemas/CustomPropertyTypeV1" }
"itemtype": { $ref: "#/components/schemas/CustomPropertyTypeV1" }
"value":
oneOf:
- { $ref: "#/components/schemas/CustomPropertiesV1" }
- { type: array }
- { type: number }
- { type: integer }
- { type: string }
- { type: boolean }
required: [name, type, value]
CustomPropertyTypeV1:
type: string
description: >
Type of IDProperty, see `eIDPropertyType` in `DNA_ID_enumms.h`. For now,
type `ID` and `IDPARRAY` are not supported.
enum:
[
IDP_STRING,
IDP_INT,
IDP_FLOAT,
IDP_ARRAY,
IDP_GROUP,
IDP_DOUBLE,
IDP_BOOL,
]
AssetBlenderVersionsV1:
type: object
description: >
Minimum and (optionally) maximum versions of Blender that this asset should be shown in.
This is a half-open interval: Blender shows the asset if `min <= blender < until`.
properties:
"min":
type: string
description: Minimum version of Blender that should show this asset.
"until":
type: string
description: First version of Blender that should NOT show this asset.
required:
- "min"
CatalogV1:
type: object
description: An asset catalog, which can be represented by one or more UUIDs.
properties:
"path": { type: string }
"simple_name": { type: string }
"uuids":
type: array
items:
type: string
minItems: 1
required: [path, uuids]
FileV1:
type: object
description: >
Single file in the asset library. Identified by its relative path in that library.
properties:
"path":
type: string
description: >
Relative path of where this file is located in the asset library.
"url":
type: string
description: >
URL where the file can be downloaded. If the URL is relative, it is
to be interpreted as relative to the library's root URL.
If the URL is not given, or an empty string, it is assumed to be the
same as 'path'.
"size_in_bytes": { type: integer }
"hash":
type: string
description: >
Hash of the file. This should be in the format "HASHTYPE:HASH-AS-HEX".
Currently only the "SHA256" hash type is supported.
"blender_version":
type: string
description: >
Version of Blender used to write this file. Only contains the major and
minor version, no patch version ("5.2", "6.3", etc. but not "5.2.1").
required:
- "path"
- "size_in_bytes"
- "hash"
- "blender_version"

View File

@@ -0,0 +1,75 @@
# SPDX-FileCopyrightText: 2026 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
from __future__ import annotations
import argparse
import datetime
import logging
import time
def main(cli_args: list[str]) -> None:
"""CLI entry point for the 'asset_listing' CLI commands."""
parser = argparse.ArgumentParser(
prog="blender -c asset_listing",
description="Manage asset library index files.",
)
# func is set by subparsers to indicate which function to run.
parser.set_defaults(func=None, loglevel=logging.INFO)
loggroup = parser.add_mutually_exclusive_group()
loggroup.add_argument(
"-v",
"--verbose",
dest="loglevel",
action="store_const",
const=logging.DEBUG,
help="Log DEBUG level and higher",
)
loggroup.add_argument(
"-q",
"--quiet",
dest="loglevel",
action="store_const",
const=logging.WARNING,
help="Log at WARNING level and higher",
)
subparsers = parser.add_subparsers(
help="Choose a subcommand to actually make Blender do something. "
"Global options go before the subcommand, "
"whereas subcommand-specific options go after it. "
"Use --help after the subcommand to get more info."
)
from . import cli_listing_generator, cli_listing_downloader
cli_listing_generator.add_cli_parser(subparsers)
cli_listing_downloader.add_cli_parser(subparsers)
args = parser.parse_args(cli_args)
config_logging(args)
log = logging.getLogger(__name__)
if not args.func:
parser.error("No subcommand was given")
start_time = time.monotonic()
args.func(args)
duration = datetime.timedelta(seconds=time.monotonic() - start_time)
log.info("Command took %s to complete", duration)
def config_logging(args) -> None: # type: ignore
"""Configures the logging system based on CLI arguments."""
logging.basicConfig(
level=args.loglevel,
format="%(asctime)-15s %(levelname)8s %(threadName)10s %(name)16s %(message)s",
)

View File

@@ -0,0 +1,92 @@
# SPDX-FileCopyrightText: 2026 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
from __future__ import annotations
import argparse
import dataclasses
import logging
import time
import urllib.parse
from pathlib import Path
from . import listing_downloader
logger = logging.getLogger(__name__)
@dataclasses.dataclass
class CLIArguments:
"""Parsed command-line arguments."""
url: str
def cli_main(arguments_raw: argparse.Namespace) -> None:
"""Generate the index for the passed-on-the-CLI asset library path."""
# Parse CLI arguments.
arguments = _parse_cli_args(arguments_raw)
base_path = Path(".").resolve() / "_asset_download_location" # TODO: be sensible.
is_done = False
def on_done_callback(_: listing_downloader.RemoteAssetListingDownloader) -> None:
nonlocal is_done
is_done = True
downloader = listing_downloader.RemoteAssetListingDownloader(
arguments.url,
base_path,
lambda *args: None,
on_done_callback)
downloader.download_and_process()
while not is_done:
# Ordinarily Blender's timer system will call the right method. But
# because this is intended to run headless, and we're blocking the main
# thread here, that doesn't happen.
downloader.on_timer_event()
time.sleep(downloader._DOWNLOAD_POLL_INTERVAL)
print("Done!")
# Ignore the type of the `subparsers` argument, because there doesn't seem
# to be a way to make both static mypy and the runtime Python happy at the
# same time.
def add_cli_parser(subparsers: argparse._SubParsersAction) -> None: # type: ignore[type-arg]
"""Add argparser for this subcommand."""
parser = subparsers.add_parser("download", help="Download and parse a remote asset library index")
parser.set_defaults(func=cli_main)
parser.add_argument(
"url",
type=str,
help="""URL of the remote asset library""",
)
def _parse_cli_args(arguments_raw: argparse.Namespace) -> CLIArguments:
"""Make sure the passed arguments are valid."""
try:
urllib.parse.urlparse(arguments_raw.url)
except ValueError as ex:
logger.error("invalid URL specified: {}".format(ex))
arguments = CLIArguments(
url=arguments_raw.url,
)
return arguments
class APIVersionError(Exception):
"""Raised when none of the API versions declared by a remote asset library are supported by Blender."""

View File

@@ -0,0 +1,283 @@
# SPDX-FileCopyrightText: 2026 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
from __future__ import annotations
"""Blender Online Asset Repository Listing Generator."""
__all__ = (
'cli_main',
'SCHEMA_VERSION',
)
import argparse
import dataclasses
import json
import logging
import sys
import urllib.parse
from pathlib import Path
from typing import Any
import cattrs.preconf.json
from . import hashing, listing_asset_catalogs, listing_common, json_parsing
from . import cli_listing_generator_asset_finder as asset_finder
from . import cli_listing_generator_pagination as pagination
from . import blender_asset_library_openapi as api_models
SCHEMA_VERSION = "1.0.0"
DEFAULT_METADATA = api_models.AssetLibraryMeta(
api_versions={}, # Determined by cli_main().
name="Your Asset Library",
contact=api_models.Contact(
name="Your Name",
url="https://example.org/",
email="example@example.org",
),
)
logger = logging.getLogger(__name__)
_converter = cattrs.preconf.json.JsonConverter(omit_if_default=True)
@dataclasses.dataclass
class CLIArguments:
"""Parsed commandline arguments."""
repository: Path
limit: int
page_size: int
def cli_main(arguments_raw: argparse.Namespace) -> None:
"""Generate the index for the passed-on-the-CLI asset library path."""
# Parse CLI arguments.
arguments = _parse_cli_args(arguments_raw)
# Read the top-level meta file first. If this already exists, an attempt
# at parsing & upgrading it is performed. Better to do this (and stop on
# errors) before diving into the assets themselves.
meta_json_path = arguments.repository / listing_common.ASSET_TOP_METADATA_FILENAME
toplevel_meta = _toplevel_meta_read(meta_json_path)
# Find all .blend files.
filepaths: list[Path] = []
logger.info("Traversing %s", arguments.repository)
for filepath in arguments.repository.rglob("*.blend"):
filepaths.append(filepath)
files_total = len(filepaths)
logger.info(f"* {files_total} .blend files found.")
limit = _total_files_to_process(arguments, files_total)
# Find the assets in the blend files.
logger.info("Parsing the files...")
assets: list[api_models.AssetV1] = []
files: list[api_models.FileV1] = []
for i, filepath in enumerate(filepaths[:limit]):
logger.info(f"* {i + 1}/{limit}: {filepath.relative_to(arguments.repository)}")
bfile_info, assets_in_file = asset_finder.list_assets(filepath, arguments.repository)
if not assets_in_file:
continue
assets.extend(assets_in_file)
files.append(bfile_info)
_sort_assets(assets)
# Write the listing index and the pages:
asset_index_pages = pagination.paginate_asset_list(assets, files, arguments.page_size)
index_path = _write_json_files(arguments, asset_index_pages)
# Write the top-level meta file:
api_version_key = "v{:d}".format(listing_common.API_VERSION)
index_relpath: Path = index_path.relative_to(arguments.repository)
toplevel_meta.api_versions[api_version_key] = api_models.URLWithHash(
url=urllib.parse.quote(index_relpath.as_posix()),
hash=hashing.hash_file(index_path),
)
_save_json(toplevel_meta, meta_json_path)
def _toplevel_meta_read(meta_json_path: Path) -> api_models.AssetLibraryMeta:
try:
metadata = _toplevel_metadata(meta_json_path)
except (json.JSONDecodeError, cattrs.errors.ClassValidationError) as ex:
msg = "Metadata file {} could not be parsed: {}"
logger.error(msg.format(meta_json_path, ex))
raise SystemExit(1) from None
return metadata
def _sort_assets(assets: list[api_models.AssetV1]) -> None:
"""Sorts the assets in-place.
Sorting helps to get the generated listing stable, so that a diff between
two runs of the generator is as clean as possible.
"""
# Sort assets by their primary filename first. This places related assets together, and minimizes the repeats of the
# same file across multiple listing pages.
def sort_key(asset: api_models.AssetV1) -> tuple[str, str, str]:
if asset.files:
first_file = asset.files[0].lower()
else:
first_file = ""
return (first_file, asset.id_type.lower(), asset.name.lower())
assets.sort(key=sort_key)
def _write_json_files(
arguments: CLIArguments,
asset_index_pages: list[api_models.AssetLibraryIndexPageV1],
) -> Path:
"""Write the asset listing page files and the index file.
:returns: the path of the index file.
"""
outdir_root = arguments.repository
outdir_versioned = outdir_root / listing_common.API_VERSIONED_SUBDIR
# Remove old pages, in case the number of assets per page was increased and
# so less page files are needed.
existing_pages = outdir_versioned.glob("assets-*.json")
for filepath in existing_pages:
filepath.unlink()
# Library Index Page /_v1/assets-{page}.json
#
# Note that these paths are determined by the generator, and their URLs are
# listed explicitly in the index file, so there is no need to have those in
# the listing_common.py file.
page_infos: list[api_models.URLWithHash] = []
for page_index, page in enumerate(asset_index_pages):
page_relpath = listing_common.api_versioned(f"assets-{page_index:05}.json")
page_abspath = outdir_root / page_relpath
_save_json(page, page_abspath)
page_infos.append(api_models.URLWithHash(
url=urllib.parse.quote(page_relpath.as_posix()),
hash=hashing.hash_file(page_abspath),
))
# Library Index file /_v1/asset-index.json:
total_asset_count = sum(page.asset_count for page in asset_index_pages)
total_file_count = sum(page.file_count for page in asset_index_pages)
asset_size_bytes = sum(file.size_in_bytes
for page in asset_index_pages
for file in page.files)
asset_cats = listing_asset_catalogs.parse_catalogs(arguments.repository)
index = api_models.AssetLibraryIndexV1(
schema_version=SCHEMA_VERSION,
asset_size_bytes=asset_size_bytes,
asset_count=total_asset_count,
file_count=total_file_count,
pages=page_infos,
catalogs=asset_cats,
)
index_path = outdir_versioned / listing_common.ASSET_INDEX_JSON_FILENAME
_save_json(index, index_path)
return index_path
def _save_json(model: Any, json_path: Path) -> None:
as_json = _converter.dumps(model, indent=2)
json_path.parent.mkdir(exist_ok=True, parents=True)
logger.info("Writing %s", json_path)
with json_path.open("wt") as json_file:
json_file.write(as_json)
def _toplevel_metadata(json_path: Path) -> api_models.AssetLibraryMeta:
"""Construct the top-level metadata.
Returns the metadata, or raises an exception (see json_parsing.ValidatingParser)
if it is not valid JSON.
Writing is considered safe, except when the file exists but does not contain
valid JSON. In that case, it's better to warn about this and keep the file
as-is, so that the user can either delete or fix it.
"""
try:
json_data = json_path.read_bytes()
except IOError:
# Ignore any read errors, as this likely means the file simply doesn't exist.
return DEFAULT_METADATA
parser = json_parsing.ValidatingParser()
metadata = parser.parse_and_validate(api_models.AssetLibraryMeta, json_data)
# Update the metadata to declare the API version for which we're going to
# write the data.
metadata.api_versions = DEFAULT_METADATA.api_versions.copy()
return metadata
# Ignore the type of the `subparsers` argument, because there doesn't seem
# to be a way to make both static mypy and the runtime Python happy at the
# same time.
def add_cli_parser(subparsers: argparse._SubParsersAction) -> None: # type: ignore[type-arg]
"""Add argparser for this subcommand."""
parser = subparsers.add_parser("generate", help="Generate files necessary to serve an asset library")
parser.set_defaults(func=cli_main)
parser.add_argument(
"repository",
type=Path,
help="""Asset repository folder""",
)
parser.add_argument(
"--limit",
"-l",
metavar="NUM_BLEND_FILES",
type=int,
default=None,
help="Limit the number of files to process",
)
parser.add_argument(
"--page",
"-p",
metavar="ASSETS_PER_PAGE",
type=int,
default=1000,
help="Number of assets per JSON file, set to 0 to disable pagination",
)
def _parse_cli_args(arguments_raw: argparse.Namespace) -> CLIArguments:
"""Make sure the passed arguments are valid."""
repository = arguments_raw.repository.absolute()
if not repository.is_dir():
print(f"Error: Repository specified is not a folder: {repository}")
sys.exit(1)
arguments = CLIArguments(
repository=repository,
limit=arguments_raw.limit or 0,
page_size=arguments_raw.page or 0,
)
return arguments
def _total_files_to_process(arguments: CLIArguments, files_total: int) -> int:
if not arguments.limit:
return files_total
return min(arguments.limit, files_total)

View File

@@ -0,0 +1,272 @@
# SPDX-FileCopyrightText: 2026 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
from __future__ import annotations
import logging
import os
import re
import shutil
import unicodedata
import urllib.parse
from pathlib import Path
import bpy
from . import blender_asset_library_openapi as api_models
from . import hashing
log = logging.getLogger(__name__)
def list_assets(blendfile: Path, asset_library_root: Path) -> tuple[api_models.FileV1, list[api_models.AssetV1]]:
# Start by erasing everything from memory.
bpy.ops.wm.read_homefile(use_factory_startup=True, use_empty=True, load_ui=False)
blendfile_info = _blendfile_info(blendfile, asset_library_root)
# Tell Blender to only load asset data-blocks.
with bpy.data.libraries.load(str(blendfile), assets_only=True) as (
data_from,
data_to,
):
for attr in dir(data_to):
setattr(data_to, attr, getattr(data_from, attr))
# Convert the Blender version to a string.
blend_version = ".".join(map(str, data_from.version))
blendfile_info.blender_version = blend_version
# Get the last modification timestamp of the blend file, to compare against
# the thumbnails.
thumbnail_dir = blendfile.with_name(blendfile.stem + "_thumbnails")
blend_stat = blendfile.stat()
thumbnail_timestamper = thumbnail_dir / ".last_modified"
if thumbnail_timestamper.exists():
thumb_mtime = thumbnail_timestamper.stat().st_mtime
should_write_thumbnails = abs(blend_stat.st_mtime - thumb_mtime) > 0.001
else:
should_write_thumbnails = True
if should_write_thumbnails:
# Remove the entire thumbnail tree, so that thumbnails of deleted assets
# are also deleted. All thumbnails are going to be re-written anyway.
log.debug("thumbnails will be exported to %s", thumbnail_dir)
assert thumbnail_dir
if Path(thumbnail_dir.root) == thumbnail_dir:
raise RuntimeError(f"Refusing to remove a root directory: {thumbnail_dir}")
if thumbnail_dir.exists():
shutil.rmtree(thumbnail_dir)
# Collect the asset data.
assets: list[api_models.AssetV1] = []
for attr in dir(data_to):
if attr == 'version':
continue
datablocks = getattr(data_from, attr)
datablocks_assets = _find_assets(
asset_library_root,
blendfile_info,
datablocks,
thumbnail_dir,
should_write_thumbnails,
)
assets.extend(datablocks_assets)
# After processing is done, set the thumbnail dir mtime to that of the
# blendfile. By tracking the mtime of the directory itself, not every
# individual thumbnail needs to be time-checked.
thumbnail_dir.mkdir(exist_ok=True, parents=True)
thumbnail_timestamper.touch(exist_ok=True)
os.utime(thumbnail_timestamper, (blend_stat.st_atime, blend_stat.st_mtime))
return blendfile_info, assets
def _find_assets(
asset_library_root: Path,
file: api_models.FileV1,
datablocks: bpy.types.BlendData,
thumbnail_dir: Path,
should_write_thumbnails: bool,
) -> list[api_models.AssetV1]:
# TODO: when multiple files are supported, take the maximum of the files.
bl_versions = api_models.AssetBlenderVersionsV1(
min='.'.join(file.blender_version.split('.')[:2]),
)
assets = []
for datablock in datablocks:
asset_data: bpy.types.AssetData = datablock.asset_data
if not asset_data:
continue
thumbnail_path = _thumbnail_path(datablock, thumbnail_dir)
if thumbnail_path and should_write_thumbnails:
_save_thumbnail(datablock, thumbnail_path)
if thumbnail_path and thumbnail_path.exists():
as_posix = thumbnail_path.relative_to(asset_library_root).as_posix()
thumbnail = api_models.URLWithHash(
url=urllib.parse.quote(as_posix),
hash=hashing.hash_file(thumbnail_path),
)
else:
thumbnail = None
asset = api_models.AssetV1(
name=datablock.name,
id_type=datablock.id_type,
files=[file.path],
thumbnail=thumbnail,
bl_versions=bl_versions,
meta=_get_asset_meta(asset_data),
)
assets.append(asset)
return assets
def _get_asset_meta(asset_data: bpy.types.AssetData) -> api_models.AssetMetadataV1 | None:
# Only set the fields that have a value. That way we can detect whether
# none of them are set, and prevent the empty metadata from being
# included.
meta = api_models.AssetMetadataV1()
if asset_data.catalog_id and asset_data.catalog_id != "00000000-0000-0000-0000-000000000000":
meta.catalog_id = asset_data.catalog_id
if asset_data.tags:
meta.tags = [tag.name for tag in asset_data.tags]
if asset_data.author:
meta.author = asset_data.author
if asset_data.description:
meta.description = asset_data.description
if asset_data.license:
meta.license = asset_data.license
if asset_data.copyright:
meta.copyright = asset_data.copyright
if asset_data.use_preferred_import_method:
meta.preferred_import_method = asset_data.preferred_import_method
# Convert custom properties.
import rna_prop_ui
custom_props: api_models.CustomPropertiesV1 = []
for prop_name, prop_value in asset_data.items():
is_array = isinstance(prop_value, rna_prop_ui.ARRAY_TYPES) and len(prop_value) > 0
item_value = prop_value[0] if is_array else prop_value
match item_value:
case bool():
value_type = api_models.CustomPropertyTypeV1.IDP_BOOL
case int():
value_type = api_models.CustomPropertyTypeV1.IDP_INT
case str():
value_type = api_models.CustomPropertyTypeV1.IDP_STRING
case float():
value_type = api_models.CustomPropertyTypeV1.IDP_FLOAT
case _:
# Unsupported type, just ignore it.
continue
if is_array:
custom_prop = api_models.CustomPropertyV1(
name=prop_name,
type=api_models.CustomPropertyTypeV1.IDP_ARRAY,
value=list(prop_value),
itemtype=value_type,
)
else:
custom_prop = api_models.CustomPropertyV1(
name=prop_name, type=value_type, value=prop_value
)
custom_props.append(custom_prop)
if custom_props:
meta.properties = custom_props
if meta == api_models.AssetMetadataV1():
return None
return meta
def _save_thumbnail(datablock: bpy.types.ID, thumbnail_path: Path) -> None:
"""Save the internal preview thumbnail as a WebP image."""
# Get the preview image size.
width: int = datablock.preview.image_size[0]
height: int = datablock.preview.image_size[1]
if not (width > 0 and height > 0):
return
thumbnail_path.parent.mkdir(exist_ok=True, parents=True)
log.debug("Writing thumbnail: %s", thumbnail_path)
try:
# Create a new image in Blender to store the preview.
image: bpy.types.Image = bpy.data.images.new(
thumbnail_path.stem, width, height, alpha=True
)
# Assign the pixel data from the preview to the new image.
# image.pixels = [p for p in datablock.preview.image_pixels_float]
image.pixels[:] = datablock.preview.image_pixels_float
# Save the image to disk.
image.file_format = "WEBP"
image.save(filepath=str(thumbnail_path), quality=80)
# Remove the image from Blender data after saving to free memory.
bpy.data.images.remove(image)
except Exception as e:
print(f"Failed to save thumbnail for {datablock.name}: {e}")
def _thumbnail_path(datablock: bpy.types.ID, thumbnail_dir: Path) -> Path | None:
"""Return the path for this datablock's thumbnail, or None if it has none."""
if not datablock.preview:
return None
datablock_safe = _name_to_filename(datablock.name)
thumbnail_path: Path = (
thumbnail_dir / datablock.id_type.title() / f"{datablock_safe}.webp"
)
return thumbnail_path
_re_safe_filename_nonword = re.compile(r'[^\w\s_-]')
_re_safe_filename_dashspace = re.compile(r'[-\s]+')
def _name_to_filename(value: str) -> str:
"""Convert a string into something that should be safe as filename."""
value = unicodedata.normalize('NFKD', value).encode('ascii', 'ignore').decode('ascii')
value = _re_safe_filename_nonword.sub('', value.lower())
return _re_safe_filename_dashspace.sub('-', value).strip('-_')
def _blendfile_info(filepath: Path, asset_library_root: Path) -> api_models.FileV1:
stat = filepath.stat()
relative_posix = filepath.relative_to(asset_library_root).as_posix()
file_url: str | None = urllib.parse.quote(relative_posix)
if file_url == relative_posix:
# Optimization: if the file path is URL-safe, it can be used as the URL
# and there is no need to include this URL explicitly.
file_url = None
return api_models.FileV1(
path=relative_posix,
url=file_url,
hash=hashing.hash_file(filepath),
size_in_bytes=stat.st_size,
blender_version="", # Determined later when the file is opened to find assets.
)

View File

@@ -0,0 +1,58 @@
# SPDX-FileCopyrightText: 2026 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
from __future__ import annotations
from itertools import batched
from . import blender_asset_library_openapi as api_models
def paginate_asset_list(
assets: list[api_models.AssetV1],
files: list[api_models.FileV1],
num_assets_per_page: int = 0,
) -> list[api_models.AssetLibraryIndexPageV1]:
"""Return a list of asset pages.
Each page is no longer than `num_assets_per_page` long. If zero, all assets
are put in the same page.
The files listed in each page are determined by the assets on that page.
This means that it's possible for multiple pages to list the same file; this
occurs when that file contains multiple assets, spread across multiple pages.
"""
# Files are sorted to ensure the generated file is stable (i.e. regenerating produces the same file, and
# inserting/removing files produce a small diff).
def file_sort_key(file: api_models.FileV1) -> str:
return file.path
if not num_assets_per_page:
return [api_models.AssetLibraryIndexPageV1(
asset_count=len(assets),
assets=assets,
file_count=len(files),
files=sorted(files, key=file_sort_key),
)]
pages = []
for asset_batch in batched(assets, num_assets_per_page):
used_file_paths = {
file
for asset in asset_batch
for file in asset.files
}
file_batch = [file for file in files
if file.path in used_file_paths]
file_batch.sort(key=file_sort_key)
page = api_models.AssetLibraryIndexPageV1(
asset_count=len(asset_batch),
assets=list(asset_batch),
file_count=len(file_batch),
files=file_batch,
)
pages.append(page)
return pages

View File

@@ -0,0 +1,72 @@
# SPDX-FileCopyrightText: 2026 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
import functools
import typing
from pathlib import Path
if typing.TYPE_CHECKING:
from _bpy_internal.assets.remote_library.blender_asset_library_openapi import URLWithHash as _URLWithHash
else:
_URLWithHash = object
def hash_file(filepath: Path) -> str:
"""Computes and returns the hash of the file.
The returned string is prefixed with the hash type, like "{TYPE}:{HASH}".
"""
return 'SHA256:' + _sha256_file(filepath)
@functools.lru_cache
def _dfhs_storage_path() -> Path:
"""Return the storage path of the disk file hash service."""
import bpy
hashes_dir = Path(bpy.app.cachedir) / "{:d}.{:d}/file_hashes".format(*bpy.app.version)
hashes_dir.mkdir(parents=True, exist_ok=True)
return hashes_dir / "dfhs"
def _sha256_file(filepath: Path) -> str:
"""Computes and returns the SHA256 hash of the file."""
from _bpy_internal import disk_file_hash_service
dfhs = disk_file_hash_service.get_service(_dfhs_storage_path())
return dfhs.get_hash(filepath, 'sha256')
def url(url_with_hash: _URLWithHash | tuple[str, str]) -> str:
"""Return the url, with the hash on the query string.
>>> url(URLWithHash(url="http://localhost/", hash="sha256:the-hash"))
'http://localhost/?hash=the-hash'
>>> url(("http://localhost/", "sha256:the-hash"))
'http://localhost/?hash=the-hash'
"""
import urllib.parse
# Get the URL and the hash.
if isinstance(url_with_hash, tuple):
url, hash_with_type = url_with_hash
else:
url = url_with_hash.url
hash_with_type = url_with_hash.hash
# Without a hash, it's simple.
if not hash_with_type:
return url
# Remove the hash type from the hash string.
try:
_, hash_value = hash_with_type.split(':', 1)
except ValueError:
# This means the hash is not in the form '{TYPE}:{HASH}'; just use it as-is.
hash_value = hash_with_type
# Append to the URL with the correct separator.
sep = '&' if '?' in url else '?'
return url + sep + 'hash=' + urllib.parse.quote(hash_value)

View File

@@ -0,0 +1,88 @@
# SPDX-FileCopyrightText: 2026 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
from __future__ import annotations
import logging
from pathlib import Path
from _bpy_internal.http import downloader as http_dl
class ExtraFileMetadataProvider(http_dl.MetadataProvider):
"""HTTP Metadata provider that can check an extra file.
This is to support the following file sets:
- `file.json`: Actual JSON file read by Blender. Is assumed to be validated.
- `file-unsafe.json`: JSON file as downloaded. Must be validated before use.
- `file-unsafe.json~`: The above file while it's being downloaded. Not yet
complete JSON.
The downloader will get the request to download to `file-unsafe.json`.
However, if `file.json` is still fresh (i.e. the HTTP metadata for the URL
is applicable to that file), the downloader should be able to do a
conditional download (instead of an unconditional one).
This is implemented as a wrapper for any other MetadataProvider, rather than
subclassing a specific one, so that it's independent of the underlying
logic.
"""
_wrapped: http_dl.MetadataProvider
_logger: logging.Logger
def __init__(self, wrapped: http_dl.MetadataProvider) -> None:
self._wrapped = wrapped
self._logger = logging.getLogger(__name__ + ".ExtraFileMetadataProvider")
def save(self, http_req_descr: http_dl.RequestDescription, meta: http_dl.HTTPMetadata) -> None:
self._wrapped.save(http_req_descr, meta)
def load(self, http_req_descr: http_dl.RequestDescription) -> http_dl.HTTPMetadata | None:
return self._wrapped.load(http_req_descr)
def is_valid(
self,
meta: http_dl.HTTPMetadata,
http_req_descr: http_dl.RequestDescription,
local_path: Path) -> bool:
# This assumes that the download is saved to the "unsafe" location, and
# we have to check the metadata on the "safe" location as well.
if self._wrapped.is_valid(meta, http_req_descr, local_path):
self._logger.info("HTTP metadata is valid for %s", local_path)
return True
safe_filename = unsafe_to_safe_filename(local_path)
if safe_filename == local_path:
# There is no different filename to check, so let's stick to the
# result of the first is_valid() call.
self._logger.info("HTTP metadata is invalid for %s", local_path)
return False
if self._wrapped.is_valid(meta, http_req_descr, safe_filename):
self._logger.info("HTTP metadata is valid for %s", safe_filename)
return True
self._logger.info("HTTP metadata is valid for neither %s nor %s", local_path, safe_filename)
return False
def forget(self, http_req_descr: http_dl.RequestDescription) -> None:
self._wrapped.forget(http_req_descr)
def unsafe_to_safe_filename(unsafe_file_path: Path) -> Path:
"""path/to/some_file.unsafe-json -> path/to/some_file.json"""
# The suffix is changed, and not the stem, so that globs like "*.json" do not see the unsafe files.
return unsafe_file_path.with_suffix(unsafe_file_path.suffix.replace('unsafe-', ''))
def safe_to_unsafe_filename(safe_file_path: Path | str) -> Path:
"""path/to/some_file.json -> path/to/some_file.unsafe-json"""
if isinstance(safe_file_path, str):
safe_file_path = Path(safe_file_path)
# path.suffix includes the leading period, so it's something like ".json".
return safe_file_path.with_suffix('.unsafe-' + safe_file_path.suffix[1:])

View File

@@ -0,0 +1,61 @@
# SPDX-FileCopyrightText: 2026 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
"""Wrapper around cattrs."""
__all__ = [
"ValidatingParser",
"APIModel",
]
import dataclasses
import json
from typing import Any, Type, TypeVar
import cattrs
import cattrs.preconf.json
from . import blender_asset_library_openapi as api_models
# There is no common base class for dataclasses, so this type variable will have to act as a stand-in.
APIModel = TypeVar("APIModel")
class ValidatingParser:
"""Wrapper around cattrs, caching the cattrs converter."""
_converter: cattrs.preconf.json.JsonConverter
def __init__(self) -> None:
self._converter = cattrs.preconf.json.JsonConverter(omit_if_default=True)
# Register a custom unstructure hook for the type of `CustomPropertyV1.value`.
#
# NOTE: this MUST register the 'final' type, and cannot use
# `CustomProperties` as an alias for `dict[str, CustomProperty]`. It
# won't be found. It also has to include None in the union for some
# reason, even though that's not declared in `CustomPropertyV1.value`.
#
# Basically cattrs told me to register a structure hook for this
# specific type, and so that's what I (Sybren) did.
self._converter.register_structure_hook(
api_models.CustomPropertiesV1 | list[Any] | float | int | str | bool,
lambda value, _: value,
)
def parse_and_validate(self, model_class: Type[APIModel], json_payload: bytes | str) -> APIModel:
"""Parse & validate the JSON data, returning an instance of the given model class.
:raises json.JSONDecodeError: if the payload is not formatted as JSON.
:raises cattrs.errors.ClassValidationError: if the payload doesn't pass
validation and can't be converted to the given model class.
"""
json_doc = json.loads(json_payload)
return self._converter.structure(json_doc, model_class)
def dumps(self, model_instance: Any) -> str:
"""Convert the model instance to JSON, returning it as string."""
assert dataclasses.is_dataclass(model_instance), f"{model_instance} is not a dataclass"
return self._converter.dumps(model_instance, indent=2)

View File

@@ -0,0 +1,148 @@
# SPDX-FileCopyrightText: 2026 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
"""Parser for Blender's asset catalog files.
It would be better if there was an RNA API for this, but for now this is faster
to implement.
"""
from __future__ import annotations
import dataclasses
import uuid
from pathlib import Path, PurePosixPath
from . import blender_asset_library_openapi as api_models
SUPPORTED_VERSION = 1
@dataclasses.dataclass(frozen=True)
class AssetCatalog:
uuid: str
path: PurePosixPath
simple_name: str
def parse_catalogs(library_path: Path) -> list[api_models.CatalogV1]:
"""Parse all asset catalog files in the asset library.
Returns a collection of all asset catalogs in the library, as a mapping from
UUID to the catalog.
If there are multiple catalog definition files, they will be merged
together.
"""
# First use a mapping from UUID to the AssetCatalog, to ensure that each
# UUID only maps to a single path.
catalogs_by_uuid: dict[str, AssetCatalog] = {}
for file in library_path.rglob('*.cats.txt'):
file_cats = _parse_catalog(file)
catalogs_by_uuid.update(file_cats)
# Group catalogs by their path, to make the returned list compatible with
# the API model.
asset_cats_by_path: dict[PurePosixPath, api_models.CatalogV1] = {}
for cat in catalogs_by_uuid.values():
try:
api_catalog = asset_cats_by_path[cat.path]
except KeyError:
asset_cats_by_path[cat.path] = api_models.CatalogV1(
path=cat.path.as_posix(),
uuids=[cat.uuid],
simple_name=cat.simple_name,
)
else:
api_catalog.uuids.append(cat.uuid)
return sorted(asset_cats_by_path.values(), key=lambda api_cat: api_cat.path)
def _parse_catalog(catalog_filepath: Path) -> dict[str, AssetCatalog]:
# Mapping from UUID to the AssetCatalog.
catalogs: dict[str, AssetCatalog] = {}
with catalog_filepath.open('r', encoding='utf-8') as infile:
for line in infile:
line = line.strip()
if not line or line.startswith('#'):
continue
# Check the declared version, and simply ignore the file if it is
# not supported.
if line.startswith('VERSION '):
_, version_as_str = line.split(maxsplit=1)
if version_as_str != str(SUPPORTED_VERSION):
msg = "{}: this version of Blender does not support catalog file version {!r}"
print(msg.format(catalog_filepath, version_as_str))
return {}
continue
parts = line.split(':', maxsplit=2)
if len(parts) < 2:
# It's ok for the 'simple name' part to be missing, but if more is missing, this is not a valid file.
msg = "{}: this does not seem to be an asset catalog file, ignoring it (line {!r} is not as expected)"
print(msg.format(catalog_filepath, line))
return {}
cat = AssetCatalog(
uuid=parts[0],
path=PurePosixPath(parts[1]),
simple_name=parts[2] if len(parts) >= 3 else "",
)
catalogs[cat.uuid] = cat
return catalogs
_ASSET_CATS_HEADER = """# This is an Asset Catalog Definition file for Blender.
#
# Empty lines and lines starting with `#` will be ignored.
# The first non-ignored line should be the version indicator.
# Other lines are of the format "UUID:catalog/path/for/assets:simple catalog name"
#
# Remote Asset Library: {library_name!s}
VERSION 1
"""
def write(catalogs: list[api_models.CatalogV1], catalog_filepath: Path,
asset_library_meta: api_models.AssetLibraryMeta) -> None:
"""Create a catalog file from the list of catalogs."""
import re
# TODO: this really should be using an RNA API.
# Sanitize the library name, as it should not contain any newlines for the Asset Catalog Definition File to be
# valid. To be on the safe side, just collapse all white-space to spaces. Same for colons, those are used as field
# separators and shouldn't be included in any of the fields themselves.
unwanted_chars_re = re.compile(r'[\s:]+')
lib_name = unwanted_chars_re.sub(' ', asset_library_meta.name)
header = _ASSET_CATS_HEADER.format(library_name=lib_name)
with catalog_filepath.open("w", encoding="utf8") as catfile:
print(header, file=catfile)
for cat in sorted(catalogs, key=lambda cat: cat.path):
for cat_uuid_str in cat.uuids:
# Sanitize the catalogs before writing them.
try:
cat_uuid = uuid.UUID(cat_uuid_str)
except ValueError:
print("Asset Library has invalid UUID ({uuid!r}) for catalog {path!r}, skipping".format(
uuid=cat_uuid_str, path=cat.path))
continue
cat_path = unwanted_chars_re.sub(' ', cat.path)
if isinstance(cat.simple_name, str):
cat_simple_name = unwanted_chars_re.sub(' ', cat.simple_name)
else:
cat_simple_name = ""
print("{!s}:{!s}:{!s}".format(cat_uuid, cat_path, cat_simple_name), file=catfile)

View File

@@ -0,0 +1,40 @@
# SPDX-FileCopyrightText: 2026 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
from __future__ import annotations
"""Shared code for dealing with an asset library index.
Basically this is shared code between the index generator and index downloader.
"""
from pathlib import Path
API_VERSION = 1
"""The API version supported and produced by this version of Blender."""
API_VERSIONED_SUBDIR = f"_v{API_VERSION}"
"""Sub-directory for all the asset index data except the top level metadata."""
ASSET_TOP_METADATA_FILENAME = "_asset-library-meta.json"
"""Filename for the top-level asset index file.
This is the entry point for an asset library, and is expected to be at the root
of the configured URL for the remote asset library.
"""
ASSET_INDEX_JSON_FILENAME = "asset-index.json"
"""Filename for the asset index.
This is expected to sit in the `API_VERSIONED_SUBDIR`, and reference other files
in the same directory.
"""
def api_versioned(subpath: Path | str) -> Path:
"Return the subpath, prefixed with API_VERSIONED_SUBDIR."
return Path(API_VERSIONED_SUBDIR) / subpath
API_VERSIONED_ASSET_INDEX_JSON_PATH = api_versioned(ASSET_INDEX_JSON_FILENAME).as_posix()

View File

@@ -0,0 +1,127 @@
# SPDX-FileCopyrightText: 2026 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
import io
from pathlib import Path
from typing import Callable
__all__ = (
'mutex_lock',
'mutex_unlock',
)
# Dictionary of local library path to a tuple with:
# - lock file handle
# - path of the lock file
# - unlock function
_mutex_locks: dict[Path, tuple[io.IOBase, Path, Callable[[io.IOBase], None]]] = {}
_registered_atexit = False
def mutex_lock(local_library_path: Path) -> bool:
"""Lock the library for syncing.
Create a file on disk that signals to other Blender instances that this
remote asset library is being synced by this Blender.
This uses approaches from:
- https://www.pythontutorials.net/blog/make-sure-only-a-single-instance-of-a-program-is-running/
- https://yakking.branchable.com/posts/procrun-2-pidfiles/
:returns: true if the lock was created successfully, false if some other
Blender already locked this library.
"""
global _registered_atexit
import atexit
import sys
if not _registered_atexit:
atexit.register(_unlock_all)
_registered_atexit = True
# Choose platform-dependent _obtain_lock(file) and _release_lock() functions.
if sys.platform == "win32":
import msvcrt
def _obtain_lock(file: io.IOBase) -> None:
# Lock the first byte of the file (arbitrary choice)
msvcrt.locking(file.fileno(), msvcrt.LK_NBLCK, 1)
def _release_lock(file: io.IOBase) -> None:
msvcrt.locking(file.fileno(), msvcrt.LK_UNLCK, 1)
else:
import fcntl
def _obtain_lock(file: io.IOBase) -> None:
fcntl.flock(file, fcntl.LOCK_EX | fcntl.LOCK_NB)
def _release_lock(file: io.IOBase) -> None:
# Closing the file automatically releases the lock.
pass
assert isinstance(local_library_path, Path)
assert local_library_path not in _mutex_locks, "Locks are not reentrant"
lockfile_path = local_library_path / "_sync.lock"
# It is not suitable here to use an 'exclusive create' ('x' option) here.
# That will still create a race condition, with the space between creation
# of the file and locking it. So, better to make the existence of the file
# meaningless, and only communicate the lock state with an actual file-system
# lock.
try:
# Binary mode (`wb`) is required on Windows, for the locking.
lockfile = lockfile_path.open('wb')
except OSError:
# on Windows, opening a file for writing, while another process already has it open, can fail.
# That just means somebody else has ownership of it.
return False
try:
_obtain_lock(lockfile)
except OSError:
# Lock is already held by another Blender.
lockfile.close()
return False
# We have obtained an exclusive lock, which the OS will release when this
# process is killed.
_mutex_locks[local_library_path] = (lockfile, lockfile_path, _release_lock)
return True
def mutex_unlock(local_library_path: Path) -> None:
"""Remove the lock created by mutex_lock(local_library_path)."""
assert isinstance(local_library_path, Path)
assert local_library_path in _mutex_locks, "library was not locked"
lockfile, lockfile_path, release_lock = _mutex_locks[local_library_path]
release_lock(lockfile)
lockfile.close()
del _mutex_locks[local_library_path]
try:
lockfile_path.unlink(missing_ok=True)
except IOError:
# Ignore errors when deleting the file. By now another process may have
# recreated it and locked it again.
pass
def _unlock_all() -> None:
"""Unlock all file mutexes.
This is automatically called when the Python interpreter exits.
From the OS perspective it's not necessary, as all locks are automatically
released when the process stops. However, Python will complain with a
ResourceWarning if any open files are not closed.
"""
for local_library_path in list(_mutex_locks.keys()):
mutex_unlock(local_library_path)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,130 @@
# SPDX-FileCopyrightText: 2026 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
import io
import time
from pathlib import Path
from typing import Callable
__all__ = (
'mutex_lock_and_open',
'mutex_lock_and_open_with_retry',
'MutexAcquisitionError',
)
class MutexAcquisitionError(Exception):
"""Raised when `mutex_lock_and_open_with_retry()` cannot obtain a lock."""
pass
def mutex_lock_and_open_with_retry(file_path: Path,
mode: str,
*,
max_tries: int,
wait_time_sec: float) -> tuple[io.IOBase, Callable[[io.IOBase], None]]:
"""Obtain an exclusive lock on a file, retrying when that fails.
See `mutex_lock_and_open()` for the lock semantics, and the first two parameters.
:param max_tries: number of times the code attempts to acquire the lock.
:param wait_time: amount of time (in seconds) to wait between tries.
:returns: A tuple (file, unlocker) is returned. The caller should call
`unlocker(file)` to unlock the mutex.
:raises MutexAcquisitionError: when the lock cannot be acquired within the
given number of tries.
"""
if 'r' in mode and not file_path.exists():
# Opening a non-existent file for read is not going to work. The retry
# logic is meant for the locking, and not to wait for the file's
# existence.
raise FileNotFoundError(file_path)
for _ in range(max_tries):
meta_file, unlocker = mutex_lock_and_open(file_path, mode)
if meta_file is not None:
assert unlocker is not None
return meta_file, unlocker
time.sleep(wait_time_sec)
raise MutexAcquisitionError("could not open & lock file {!s}".format(file_path))
def mutex_lock_and_open(file_path: Path, mode: str) -> tuple[io.IOBase | None, Callable[[io.IOBase], None] | None]:
"""Obtain an exclusive lock on a file.
Create a file on disk, and immediately lock it for exclusive use by this
process.
This uses approaches from:
- https://www.pythontutorials.net/blog/make-sure-only-a-single-instance-of-a-program-is-running/
- https://yakking.branchable.com/posts/procrun-2-pidfiles/
:param: mode MUST be a binary mode, to be compatible with the file locking
on Windows. So either 'rb' or 'wb'.
:returns: If the file was opened & locked successfully, a tuple (file,
unlocker) is returned. Otherwise returns None. The caller should call
`unlocker(file)` to unlock the mutex.
"""
import sys
# Choose platform-dependent _obtain_lock(file) and _release_lock() functions.
if sys.platform == "win32":
import msvcrt
def _obtain_lock(file: io.IOBase) -> None:
# Lock the first byte of the file. This is an arbitrary choice, but
# MUST be mirrored in the unlock function below as well.
file.seek(0)
msvcrt.locking(file.fileno(), msvcrt.LK_NBLCK, 1)
def _unlock_and_close(file: io.IOBase) -> None:
# Ensure the same byte is unlocked as was locked in the function above.
file.seek(0)
msvcrt.locking(file.fileno(), msvcrt.LK_UNLCK, 1)
file.close()
else:
import fcntl
def _obtain_lock(file: io.IOBase) -> None:
fcntl.flock(file, fcntl.LOCK_EX | fcntl.LOCK_NB)
def _unlock_and_close(file: io.IOBase) -> None:
# Closing the file automatically releases the lock.
file.close()
assert isinstance(file_path, Path)
assert 'b' in mode, "mode must include 'b' for binary"
# It is not suitable here to use an 'exclusive create' ('x' option) here.
# That will still create a race condition, with the space between creation
# of the file and locking it. So, better to make the existence of the file
# meaningless, and only communicate the lock state with an actual file-system
# lock.
try:
# Type is ignored here, because the type checker doesn't realize that
# the above assert ensures the file is opened in a binary mode.
lockfile: io.IOBase
lockfile = file_path.open(mode) # type: ignore
except OSError:
# On Windows, opening a file for writing, while another process already
# has it open, can fail. That just means somebody else has ownership of
# it.
return None, None
try:
_obtain_lock(lockfile)
except OSError:
# Lock is already held by another Blender.
lockfile.close()
return None, None
# We have obtained an exclusive lock, which the OS will release when this
# process is killed.
return lockfile, _unlock_and_close

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,50 @@
# SPDX-FileCopyrightText: 2009-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
"""
This module contains RestrictBlend context manager.
"""
__all__ = (
"RestrictBlend",
)
import bpy as _bpy
class _RestrictContext:
__slots__ = ()
_real_data = _bpy.data
# safe, the pointer never changes
_real_pref = _bpy.context.preferences
@property
def window_manager(self):
return self._real_data.window_managers[0]
@property
def preferences(self):
return self._real_pref
class _RestrictData:
__slots__ = ()
_context_restrict = _RestrictContext()
_data_restrict = _RestrictData()
class RestrictBlend:
__slots__ = ("context", "data")
def __enter__(self):
self.data = _bpy.data
self.context = _bpy.context
_bpy.data = _data_restrict
_bpy.context = _context_restrict
def __exit__(self, _type, _value, _traceback):
_bpy.data = self.data
_bpy.context = self.context

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,368 @@
# SPDX-FileCopyrightText: 2009-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
__all__ = (
"autocomplete",
"banner",
"execute",
"language_id",
)
import sys
import bpy
language_id = "python"
class _TempModuleOverride:
__slots__ = (
"module_name",
"module",
"module_override",
)
def __init__(self, module_name, module_override):
self.module_name = module_name
self.module = None
self.module_override = module_override
def __enter__(self):
self.module = sys.modules.get(self.module_name)
sys.modules[self.module_name] = self.module_override
def __exit__(self, _type, _value, _traceback):
if self.module is None:
# Account for removal of `module_override` (albeit unlikely).
sys.modules.pop(self.module_name, None)
else:
sys.modules[self.module_name] = self.module
def add_scrollback(text, text_type):
for line in text.split("\n"):
bpy.ops.console.scrollback_append(
text=line,
type=text_type,
)
def replace_help(namespace):
def _help(*args):
# because of how the console works. we need our own help() pager func.
# replace the bold function because it adds crazy chars
import pydoc
pydoc.getpager = lambda: pydoc.plainpager
pydoc.Helper.getline = lambda self, prompt: None
pydoc.TextDoc.use_bold = lambda self, text: text
pydoc.help(*args)
namespace["help"] = _help
def get_console(console_id):
"""
helper function for console operators
currently each text data block gets its own
console - code.InteractiveConsole()
...which is stored in this function.
console_id can be any hashable type
"""
from code import InteractiveConsole
consoles = getattr(get_console, "consoles", None)
hash_next = hash(bpy.context.window_manager)
if consoles is None:
consoles = get_console.consoles = {}
get_console.consoles_namespace_hash = hash_next
else:
# check if clearing the namespace is needed to avoid a memory leak.
# the window manager is normally loaded with new blend files
# so this is a reasonable way to deal with namespace clearing.
# bpy.data hashing is reset by undo so can't be used.
hash_prev = getattr(get_console, "consoles_namespace_hash", 0)
if hash_prev != hash_next:
get_console.consoles_namespace_hash = hash_next
consoles.clear()
console_data = consoles.get(console_id)
if console_data:
console, stdout, stderr = console_data
# XXX, bug in python 3.1.2, 3.2 ? (worked in 3.1.1)
# seems there is no way to clear StringIO objects for writing, have to
# make new ones each time.
import io
stdout = io.StringIO()
stderr = io.StringIO()
else:
import types
bpy_main_mod = types.ModuleType("__main__")
namespace = bpy_main_mod.__dict__
namespace["__builtins__"] = sys.modules["builtins"]
namespace["bpy"] = bpy
# weak! - but highly convenient
namespace["C"] = bpy.context
namespace["D"] = bpy.data
replace_help(namespace)
console = InteractiveConsole(locals=namespace,
filename="<blender_console>")
console.push("from mathutils import *")
console.push("from math import *")
console._bpy_main_mod = bpy_main_mod
import io
stdout = io.StringIO()
stderr = io.StringIO()
consoles[console_id] = console, stdout, stderr
return console, stdout, stderr
# Both prompts must be the same length
PROMPT = '>>> '
PROMPT_MULTI = '... '
def execute(context, is_interactive):
sc = context.space_data
try:
line_object = sc.history[-1]
except:
return {'CANCELLED'}
console, stdout, stderr = get_console(hash(context.region))
# redirect output
from contextlib import (
redirect_stdout,
redirect_stderr,
)
# Not included with Python.
class redirect_stdin(redirect_stdout.__base__):
_stream = "stdin"
with (
redirect_stdout(stdout),
redirect_stderr(stderr),
# Don't allow the `stdin` to be used because it can lock Blender.
redirect_stdin(None),
_TempModuleOverride("__main__", console._bpy_main_mod),
):
# in case exception happens
line = "" # in case of encoding error
is_multiline = False
try:
line = line_object.body
# run the console, "\n" executes a multi line statement
line_exec = line if line.strip() else "\n"
is_multiline = console.push(line_exec)
except SystemExit as ex:
# Occurs when `exit(..)` is called, this raises an exception instead of exiting.
# The trace-back isn't helpful in this case, just print the exception.
stderr.write("{!r}\n".format(ex))
# Without this, entering new commands may include the previous command, see: #109435.
console.resetbuffer()
except:
# Unlikely, but this can happen with unicode errors accessing `line_object.body`.
import traceback
stderr.write(traceback.format_exc())
output = stdout.getvalue()
output_err = stderr.getvalue()
# cleanup
sys.last_traceback = None
# So we can reuse, clear all data
stdout.truncate(0)
stderr.truncate(0)
# special exception. its possible the command loaded a new user interface
if hash(sc) != hash(context.space_data):
return {'FINISHED'}
bpy.ops.console.scrollback_append(text=sc.prompt + line, type='INPUT')
if is_multiline:
sc.prompt = PROMPT_MULTI
if is_interactive:
indent = line[:len(line) - len(line.lstrip())]
if line.rstrip().endswith(":"):
indent += " "
else:
indent = ""
else:
sc.prompt = PROMPT
indent = ""
# insert a new blank line
bpy.ops.console.history_append(text=indent, current_character=0,
remove_duplicates=True)
sc.history[-1].current_character = len(indent)
# Insert the output into the editor
# not quite correct because the order might have changed,
# but ok 99% of the time.
if output:
add_scrollback(output, 'OUTPUT')
if output_err:
add_scrollback(output_err, 'ERROR')
# execute any hooks
for func, args in execute.hooks:
func(*args)
return {'FINISHED'}
execute.hooks = []
def autocomplete(context):
from _bl_console_utils.autocomplete import intellisense
sc = context.space_data
console = get_console(hash(context.region))[0]
if not console:
return {'CANCELLED'}
scrollback = ""
scrollback_error = ""
# Don't allow the `stdin` to be used, can lock blender.
# note: unlikely `stdin` would be used for auto-complete - but it's possible.
from contextlib import redirect_stdout
# Not included with Python.
class redirect_stdin(redirect_stdout.__base__):
_stream = "stdin"
with (
# Don't allow the `stdin` to be used because it can lock Blender.
redirect_stdin(None),
_TempModuleOverride("__main__", console._bpy_main_mod),
):
try:
current_line = sc.history[-1]
line = current_line.body
# This function isn't aware of the text editor or being an operator
# just does the autocomplete then copy its results back
result = intellisense.expand(
line=line,
cursor=current_line.current_character,
namespace=console.locals,
private=bpy.app.debug_python)
line_new = result[0]
current_line.body, current_line.current_character, scrollback = result
del result
# update selection. setting body should really do this!
ofs = len(line_new) - len(line)
sc.select_start += ofs
sc.select_end += ofs
except:
# unlikely, but this can happen with unicode errors for example.
# or if the API attribute access itself causes an error.
import traceback
scrollback_error = traceback.format_exc()
# Separate autocomplete output by command prompts
if scrollback != '':
bpy.ops.console.scrollback_append(text=sc.prompt + current_line.body,
type='INPUT')
# Now we need to copy back the line from blender back into the
# text editor. This will change when we don't use the text editor
# anymore
if scrollback:
add_scrollback(scrollback, 'INFO')
if scrollback_error:
add_scrollback(scrollback_error, 'ERROR')
context.area.tag_redraw()
return {'FINISHED'}
def copy_as_script(context):
sc = context.space_data
lines = [
"import bpy",
"from bpy import data as D",
"from bpy import context as C",
"from mathutils import *",
"from math import *",
"",
]
for line in sc.scrollback:
text = line.body
type = line.type
if type == 'INFO': # Ignore auto-completion.
continue
if type == 'INPUT':
if text.startswith(PROMPT):
text = text[len(PROMPT):]
elif text.startswith(PROMPT_MULTI):
text = text[len(PROMPT_MULTI):]
elif type == 'OUTPUT':
text = "#~ " + text
elif type == 'ERROR':
text = "#! " + text
lines.append(text)
context.window_manager.clipboard = "\n".join(lines)
return {'FINISHED'}
def banner(context):
sc = context.space_data
version_string = sys.version.strip().replace('\n', ' ')
message = (
"PYTHON INTERACTIVE CONSOLE {:s}".format(version_string),
"",
"Builtin Modules: "
"bpy, bpy.data, bpy.ops, bpy.props, bpy.types, bpy.context, bpy.utils, gpu, blf, mathutils",
"Convenience Imports: from mathutils import *; from math import *",
"Convenience Variables: C = bpy.context, D = bpy.data",
"",
)
# NOTE: Using `OUTPUT` style (intended for the `stdout` is also valid).
# Using `INFO` has a slight advantage that it's excluded by the "Copy as Script" operator.
# As the banner isn't useful to include in a script - leave it out.
for line in message:
add_scrollback(line, 'INFO')
sc.prompt = PROMPT
return {'FINISHED'}

View File

@@ -0,0 +1,73 @@
# SPDX-FileCopyrightText: 2009-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
__all__ = (
"autocomplete",
"banner",
"execute",
"language_id",
)
import os
import bpy
language_id = "shell"
def add_scrollback(text, text_type):
for line in text.split("\n"):
bpy.ops.console.scrollback_append(
text=line.replace("\t", " "),
type=text_type,
)
def shell_run(text):
import subprocess
val, output = subprocess.getstatusoutput(text)
if not val:
style = 'OUTPUT'
else:
style = 'ERROR'
add_scrollback(output, style)
PROMPT = "$ "
def execute(context, _is_interactive):
sc = context.space_data
try:
line = sc.history[-1].body
except:
return {'CANCELLED'}
bpy.ops.console.scrollback_append(text=sc.prompt + line, type='INPUT')
shell_run(line)
# insert a new blank line
bpy.ops.console.history_append(text="", current_character=0,
remove_duplicates=True)
sc.prompt = os.getcwd() + PROMPT
return {'FINISHED'}
def autocomplete(_context):
# sc = context.space_data
# TODO
return {'CANCELLED'}
def banner(context):
sc = context.space_data
shell_run("bash --version")
sc.prompt = os.getcwd() + PROMPT
return {'FINISHED'}

View File

@@ -0,0 +1,194 @@
# SPDX-FileCopyrightText: 2009-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
import bpy
header = '''
digraph ancestors {
graph [fontsize=30 labelloc="t" label="" splines=false overlap=true, rankdir=BT];
ratio = "auto" ;
'''
footer = '''
}
'''
def compat_str(text, line_length=0):
if line_length:
text_ls = []
while len(text) > line_length:
text_ls.append(text[:line_length])
text = text[line_length:]
if text:
text_ls.append(text)
text = '\n '.join(text_ls)
# text = text.replace(".", ".\n")
# text = text.replace("]", "]\n")
text = text.replace("\n", "\\n")
text = text.replace('"', '\\"')
return text
def graph_armature(obj, filepath, FAKE_PARENT=True, CONSTRAINTS=True, DRIVERS=True, XTRA_INFO=True):
CONSTRAINTS = DRIVERS = True
fileobject = open(filepath, "w")
fw = fileobject.write
fw(header)
fw('label = "{:s}::{:s}" ;'.format(bpy.data.filepath.split("/")[-1].split("\\")[-1], obj.name))
arm = obj.data
bones = [bone.name for bone in arm.bones]
bones.sort()
print("")
for bone in bones:
b = arm.bones[bone]
print(">>", bone, ["*>", "->"][b.use_connect], getattr(getattr(b, "parent", ""), "name", ""))
label = [bone]
bone = arm.bones[bone]
for key, value in obj.pose.bones[bone.name].items():
if key.startswith("_"):
continue
value_type = type(value)
if value_type is float:
value = "{:.3f}".format(value)
elif value_type is str:
value = compat_str(value)
label.append("{:s} = {:s}".format(key, value))
opts = [
"shape=box",
"regular=1",
"style=filled",
"fixedsize=false",
'label="{:s}"'.format(compat_str('\n'.join(label))),
]
if bone.name.startswith('ORG'):
opts.append("fillcolor=yellow")
else:
opts.append("fillcolor=white")
fw('"{:s}" [{:s}];\n'.format(bone.name, ','.join(opts)))
fw('\n\n# Hierarchy:\n')
# Root node.
if FAKE_PARENT:
fw('"Object::{:s}" [];\n'.format(obj.name))
for bone in bones:
bone = arm.bones[bone]
parent = bone.parent
if parent:
parent_name = parent.name
connected = bone.use_connect
elif FAKE_PARENT:
parent_name = "Object::{:s}".format(obj.name)
connected = False
else:
continue
opts = ["dir=forward", "weight=2", "arrowhead=normal"]
if not connected:
opts.append("style=dotted")
fw('"{:s}" -> "{:s}" [{:s}] ;\n'.format(bone.name, parent_name, ','.join(opts)))
del bone
# constraints
if CONSTRAINTS:
fw('\n\n# Constraints:\n')
for bone in bones:
pbone = obj.pose.bones[bone]
# must be ordered
for constraint in pbone.constraints:
subtarget = getattr(constraint, "subtarget", "")
if subtarget:
# TODO, not internal links
opts = [
'dir=forward',
"weight=1",
"arrowhead=normal",
"arrowtail=none",
"constraint=false",
'color="red"',
'labelfontsize=4',
]
if XTRA_INFO:
label = "{:s}\n{:s}".format(constraint.type, constraint.name)
opts.append('label="{:s}"'.format(compat_str(label)))
fw('"{:s}" -> "{:s}" [{:s}] ;\n'.format(pbone.name, subtarget, ','.join(opts)))
# Drivers
if DRIVERS:
fw('\n\n# Drivers:\n')
def rna_path_as_pbone(rna_path):
if not rna_path.startswith("pose.bones["):
return None
# rna_path_bone = rna_path[:rna_path.index("]") + 1]
# return obj.path_resolve(rna_path_bone)
bone_name = rna_path.split("[")[1].split("]")[0]
return obj.pose.bones[bone_name[1:-1]]
animation_data = obj.animation_data
if animation_data:
fcurve_drivers = [fcurve_driver for fcurve_driver in animation_data.drivers]
fcurve_drivers.sort(key=lambda fcurve_driver: fcurve_driver.data_path)
for fcurve_driver in fcurve_drivers:
rna_path = fcurve_driver.data_path
pbone = rna_path_as_pbone(rna_path)
if pbone:
for var in fcurve_driver.driver.variables:
for target in var.targets:
pbone_target = rna_path_as_pbone(target.data_path)
rna_path_target = target.data_path
if pbone_target:
opts = [
'dir=forward',
"weight=1",
"arrowhead=normal",
"arrowtail=none",
"constraint=false",
'color="blue"',
"labelfontsize=4",
]
display_source = rna_path.replace("pose.bones", "")
display_target = rna_path_target.replace("pose.bones", "")
if XTRA_INFO:
label = "{:s}\\n{:s}".format(display_source, display_target)
opts.append('label="{:s}"'.format(compat_str(label)))
fw('"{:s}" -> "{:s}" [{:s}] ;\n'.format(pbone_target.name, pbone.name, ','.join(opts)))
fw(footer)
fileobject.close()
'''
print(".", end="")
import sys
sys.stdout.flush()
'''
print("\nSaved:", filepath)
return True
if __name__ == "__main__":
import os
tmppath = "/tmp/test.dot"
graph_armature(bpy.context.object, tmppath, CONSTRAINTS=True, DRIVERS=True)
os.system("dot -Tpng {:s} > {:s}; eog {:s} &".format(tmppath, tmppath + '.png', tmppath + '.png'))

View File

@@ -0,0 +1,352 @@
# SPDX-FileCopyrightText: 2010-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
# This file defines a set of methods that are useful for various
# Relative Keying Set (RKS) related operations, such as: callbacks
# for polling, iterator callbacks, and also generate callbacks.
# All of these can be used in conjunction with the others.
__all__ = (
"path_add_property",
"RKS_POLL_selected_objects",
"RKS_POLL_selected_bones",
"RKS_POLL_selected_items",
"RKS_ITER_selected_objects",
"RKS_ITER_selected_bones",
"RKS_ITER_selected_item",
"RKS_GEN_available",
"RKS_GEN_custom_props",
"RKS_GEN_location",
"RKS_GEN_rotation",
"RKS_GEN_scaling",
"RKS_GEN_bendy_bones",
)
import bpy
###########################
# General Utilities
# Append the specified property name on the existing path.
def path_add_property(path, prop):
if path:
return path + "." + prop
else:
return prop
###########################
# Poll Callbacks
# selected objects (active object must be in object mode)
def RKS_POLL_selected_objects(_ksi, context):
if context.area.type == 'SEQUENCE_EDITOR':
return False
ob = context.active_object
if ob:
return ob.mode == 'OBJECT'
else:
return bool(context.selected_objects)
# selected bones
def RKS_POLL_selected_bones(_ksi, context):
if context.area.type == 'SEQUENCE_EDITOR':
return False
# we must be in Pose Mode, and there must be some bones selected
ob = context.active_object
if ob and ob.mode == 'POSE':
if context.active_pose_bone or context.selected_pose_bones:
return True
# nothing selected
return False
# Selected sequencer strip.
def RKS_POLL_selected_strip(_ksi, context):
if context.active_strip or context.selected_strips:
return True
# nothing selected
return False
# selected bones, objects or strips
def RKS_POLL_selected_items(ksi, context):
return (RKS_POLL_selected_bones(ksi, context) or
RKS_POLL_selected_objects(ksi, context) or
RKS_POLL_selected_strip(ksi, context))
# selected bones or objects
def RKS_POLL_selected_bones_or_objects(ksi, context):
return (RKS_POLL_selected_bones(ksi, context) or
RKS_POLL_selected_objects(ksi, context))
###########################
# Iterator Callbacks
# All selected objects or pose bones, depending on which we've got.
def RKS_ITER_selected_item(ksi, context, ks):
if context.area.type == 'SEQUENCE_EDITOR':
if context.selected_strips:
for strip in context.selected_strips:
ksi.generate(context, ks, strip)
return
ob = context.active_object
if ob and ob.mode == 'POSE':
for bone in context.selected_pose_bones:
ksi.generate(context, ks, bone)
elif context.selected_objects:
for ob in context.selected_objects:
ksi.generate(context, ks, ob)
# All selected objects only.
def RKS_ITER_selected_objects(ksi, context, ks):
for ob in context.selected_objects:
ksi.generate(context, ks, ob)
# All selected bones only.
def RKS_ITER_selected_bones(ksi, context, ks):
for bone in context.selected_pose_bones:
ksi.generate(context, ks, bone)
###########################
# Generate Callbacks
# "Available" F-Curves.
def RKS_GEN_available(_ksi, _context, ks, data):
from bpy_extras import anim_utils
# try to get the animation data associated with the closest
# ID-block to the data (neither of which may exist/be easy to find)
id_block = data.id_data
adt = getattr(id_block, "animation_data", None)
# there must also be an active action...
if adt is None or adt.action is None:
return
# if we haven't got an ID-block as 'data', try to restrict
# paths added to only those which branch off from here
# i.e. for bones
if id_block != data:
basePath = data.path_from_id()
else:
basePath = None # this is not needed...
# for each F-Curve, include a path to key it
# NOTE: we don't need to set the group settings here
cbag = anim_utils.action_get_channelbag_for_slot(adt.action, adt.action_slot)
if not cbag:
return
for fcu in cbag.fcurves:
if basePath:
if basePath in fcu.data_path:
ks.paths.add(id_block, fcu.data_path, index=fcu.array_index)
else:
ks.paths.add(id_block, fcu.data_path, index=fcu.array_index)
# ------
# get ID block and based ID path for transform generators
# private function
def get_transform_generators_base_info(data):
# ID-block for the data
id_block = data.id_data
# get base path and grouping method/name
if isinstance(data, bpy.types.ID):
# no path in this case
path = ""
# Transform data on ID-blocks directly should get grouped under a
# hard-coded label ("Object Transforms") so that they get grouped
# consistently when key-framed directly.
grouping = "Object Transforms"
else:
# get the path to the ID-block
path = data.path_from_id()
# try to use the name of the data element to group the F-Curve
# else fall back on the KeyingSet name
grouping = getattr(data, "name", None)
# return the ID-block and the path
return id_block, path, grouping
# Location
def RKS_GEN_location(_ksi, _context, ks, data):
# get id-block and path info
id_block, base_path, grouping = get_transform_generators_base_info(data)
if isinstance(data, bpy.types.Strip):
path_x = path_add_property(base_path, "transform.offset_x")
path_y = path_add_property(base_path, "transform.offset_y")
if grouping:
ks.paths.add(id_block, path_x, group_method='NAMED', group_name=grouping)
ks.paths.add(id_block, path_y, group_method='NAMED', group_name=grouping)
else:
ks.paths.add(id_block, path_x)
ks.paths.add(id_block, path_y)
return
# add the property name to the base path
path = path_add_property(base_path, "location")
# add Keying Set entry for this...
if grouping:
ks.paths.add(id_block, path, group_method='NAMED', group_name=grouping)
else:
ks.paths.add(id_block, path)
# Rotation
def RKS_GEN_rotation(_ksi, _context, ks, data):
# get id-block and path info
id_block, base_path, grouping = get_transform_generators_base_info(data)
# add the property name to the base path
if isinstance(data, bpy.types.Strip):
path = path_add_property(base_path, "transform.rotation")
if grouping:
ks.paths.add(id_block, path, group_method='NAMED', group_name=grouping)
else:
ks.paths.add(id_block, path)
return
# rotation mode affects the property used
if data.rotation_mode == 'QUATERNION':
path = path_add_property(base_path, "rotation_quaternion")
elif data.rotation_mode == 'AXIS_ANGLE':
path = path_add_property(base_path, "rotation_axis_angle")
else:
path = path_add_property(base_path, "rotation_euler")
# add Keying Set entry for this...
if grouping:
ks.paths.add(id_block, path, group_method='NAMED', group_name=grouping)
else:
ks.paths.add(id_block, path)
# Scaling
def RKS_GEN_scaling(_ksi, _context, ks, data):
# get id-block and path info
id_block, base_path, grouping = get_transform_generators_base_info(data)
if isinstance(data, bpy.types.Strip):
path_x = path_add_property(base_path, "transform.scale_x")
path_y = path_add_property(base_path, "transform.scale_y")
if grouping:
ks.paths.add(id_block, path_x, group_method='NAMED', group_name=grouping)
ks.paths.add(id_block, path_y, group_method='NAMED', group_name=grouping)
else:
ks.paths.add(id_block, path_x)
ks.paths.add(id_block, path_y)
return
# add the property name to the base path
path = path_add_property(base_path, "scale")
# add Keying Set entry for this...
if grouping:
ks.paths.add(id_block, path, group_method='NAMED', group_name=grouping)
else:
ks.paths.add(id_block, path)
# Custom Properties
def RKS_GEN_custom_props(_ksi, _context, ks, data):
# get id-block and path info
id_block, base_path, grouping = get_transform_generators_base_info(data)
# Only some RNA types can be animated.
prop_type_compat = {bpy.types.BoolProperty,
bpy.types.IntProperty,
bpy.types.FloatProperty,
bpy.types.EnumProperty}
# When working with a pose, 'id_block' is the armature object (which should
# get the animation data), whereas 'data' is the bone being keyed.
for cprop_name in data.keys():
# ignore special "_RNA_UI" used for UI editing
if cprop_name == "_RNA_UI":
continue
if cprop_name in data.bl_rna.properties and not data.bl_rna.properties[cprop_name].is_animatable:
continue
if cprop_name in data.bl_rna.properties:
prop_path = cprop_name
else:
prop_path = '["{:s}"]'.format(bpy.utils.escape_identifier(cprop_name))
try:
rna_property = data.path_resolve(prop_path, False)
except ValueError:
# Can technically happen, but there is no known case.
continue
if rna_property is None:
# In this case the property cannot be converted to an
# FCurve-compatible value, so we can't keyframe it anyways.
continue
if rna_property.rna_type not in prop_type_compat:
continue
path = "{:s}{:s}".format(base_path, prop_path)
if grouping:
ks.paths.add(id_block, path, group_method='NAMED', group_name=grouping)
else:
ks.paths.add(id_block, path)
# ------
# Property identifiers for Bendy Bones
bbone_property_ids = (
"bbone_curveinx",
"bbone_curveinz",
"bbone_curveoutx",
"bbone_curveoutz",
"bbone_rollin",
"bbone_rollout",
"bbone_scalein",
"bbone_scaleout",
"bbone_easein",
"bbone_easeout",
)
# Add Keying Set entries for bendy bones
def RKS_GEN_bendy_bones(_ksi, _context, ks, data):
# get id-block and path info
# NOTE: This assumes that we're dealing with a bone here...
id_block, base_path, grouping = get_transform_generators_base_info(data)
# for each of the bendy bone properties, add a Keying Set entry for it...
for propname in bbone_property_ids:
# add the property name to the base path
path = path_add_property(base_path, propname)
# add Keying Set entry for this...
if grouping:
ks.paths.add(id_block, path, group_method='NAMED', group_name=grouping)
else:
ks.paths.add(id_block, path)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,422 @@
# SPDX-FileCopyrightText: 2012-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
__all__ = (
"xml_file_run",
"xml_file_write",
)
import bpy
def build_property_typemap(skip_classes, skip_typemap):
property_typemap = {}
for attr in dir(bpy.types):
# Skip internal methods.
if attr.startswith("_"):
continue
cls = getattr(bpy.types, attr)
if issubclass(cls, skip_classes):
continue
bl_rna = getattr(cls, "bl_rna", None)
# Needed to skip classes added to the modules `__dict__`.
if bl_rna is None:
continue
# # to support skip-save we can't get all props
# properties = bl_rna.properties.keys()
properties = []
for prop_id, prop in bl_rna.properties.items():
if not prop.is_skip_save:
properties.append(prop_id)
properties.remove("rna_type")
property_typemap[attr] = properties
if skip_typemap:
for cls_name, properties_blacklist in skip_typemap.items():
properties = property_typemap.get(cls_name)
if properties is not None:
for prop_id in properties_blacklist:
try:
properties.remove(prop_id)
except Exception:
print("skip_typemap unknown prop_id '{:s}.{:s}'".format(cls_name, prop_id))
else:
print("skip_typemap unknown class '{:s}'".format(cls_name))
return property_typemap
def print_ln(data):
print(data, end="")
def rna2xml(
fw=print_ln,
root_node="",
root_rna=None, # must be set
root_rna_skip=set(),
root_ident="",
ident_val=" ",
skip_classes=(
bpy.types.Operator,
bpy.types.Panel,
bpy.types.KeyingSet,
bpy.types.Header,
bpy.types.PropertyGroup,
),
skip_typemap=None,
pretty_format=True,
method='DATA',
):
from xml.sax.saxutils import quoteattr
property_typemap = build_property_typemap(skip_classes, skip_typemap)
# don't follow properties of this type, just reference them by name
# they MUST have a unique 'name' property.
# 'ID' covers most types
referenced_classes = (
bpy.types.ID,
bpy.types.Bone,
bpy.types.ActionGroup,
bpy.types.PoseBone,
bpy.types.Node,
bpy.types.Strip,
)
def number_to_str(val, val_type):
if val_type == int:
return "{:d}".format(val)
elif val_type == float:
return "{:.6g}".format(val)
elif val_type == bool:
return "TRUE" if val else "FALSE"
else:
raise NotImplementedError("this type is not a number {:s}".format(val_type))
def rna2xml_node(ident, value, parent):
ident_next = ident + ident_val
# divide into attrs and nodes.
node_attrs = []
nodes_items = []
nodes_lists = []
value_type = type(value)
if issubclass(value_type, skip_classes):
return
# XXX, FIXME, point-cache has eternal nested pointer to itself.
if value == parent:
return
value_type_name = value_type.__name__
for prop in property_typemap[value_type_name]:
subvalue = getattr(value, prop)
subvalue_type = type(subvalue)
if subvalue_type in {int, bool, float}:
node_attrs.append("{:s}=\"{:s}\"".format(prop, number_to_str(subvalue, subvalue_type)))
elif subvalue_type is str:
node_attrs.append("{:s}={:s}".format(prop, quoteattr(subvalue)))
elif subvalue_type is set:
node_attrs.append("{:s}={:s}".format(prop, quoteattr("{" + ",".join(list(subvalue)) + "}")))
elif subvalue is None:
node_attrs.append("{:s}=\"NONE\"".format(prop))
elif issubclass(subvalue_type, referenced_classes):
# special case, ID's are always referenced.
node_attrs.append("{:s}={:s}".format(prop, quoteattr(subvalue_type.__name__ + "::" + subvalue.name)))
else:
try:
subvalue_ls = list(subvalue)
except Exception:
subvalue_ls = None
if subvalue_ls is None:
nodes_items.append((prop, subvalue, subvalue_type))
else:
# check if the list contains native types
subvalue_rna = value.path_resolve(prop, False)
if type(subvalue_rna).__name__ == "bpy_prop_array":
# Check if this is a 0-1 color (RGB, RGBA)
# in that case write as a hexadecimal.
prop_rna = value.bl_rna.properties[prop]
if (prop_rna.subtype == 'COLOR_GAMMA' and
prop_rna.hard_min == 0.0 and
prop_rna.hard_max == 1.0 and
prop_rna.array_length in {3, 4}):
# -----
# color
array_value = "#" + "".join(("{:02x}".format(int(v * 255)) for v in subvalue_rna))
else:
# default
def str_recursive(s):
subsubvalue_type = type(s)
if subsubvalue_type in {int, float, bool}:
return number_to_str(s, subsubvalue_type)
else:
return " ".join([str_recursive(si) for si in s])
array_value = " ".join(str_recursive(v) for v in subvalue_rna)
node_attrs.append("{:s}=\"{:s}\"".format(prop, array_value))
else:
nodes_lists.append((prop, subvalue_ls, subvalue_type))
# declare + attributes
if pretty_format:
if node_attrs:
fw("{:s}<{:s}\n".format(ident, value_type_name))
for node_attr in node_attrs:
fw("{:s}{:s}\n".format(ident_next, node_attr))
fw("{:s}>\n".format(ident_next,))
else:
fw("{:s}<{:s}>\n".format(ident, value_type_name))
else:
fw("{:s}<{:s} {:s}>\n".format(ident, value_type_name, " ".join(node_attrs)))
# unique members
for prop, subvalue, subvalue_type in nodes_items:
fw("{:s}<{:s}>\n".format(ident_next, prop)) # XXX, this is awkward, how best to solve?
rna2xml_node(ident_next + ident_val, subvalue, value)
fw("{:s}</{:s}>\n".format(ident_next, prop)) # XXX, need to check on this.
# list members
for prop, subvalue, subvalue_type in nodes_lists:
fw("{:s}<{:s}>\n".format(ident_next, prop))
for subvalue_item in subvalue:
if subvalue_item is not None:
rna2xml_node(ident_next + ident_val, subvalue_item, value)
fw("{:s}</{:s}>\n".format(ident_next, prop))
fw("{:s}</{:s}>\n".format(ident, value_type_name))
# -------------------------------------------------------------------------
# needs re-working to be generic
if root_node:
fw("{:s}<{:s}>\n".format(root_ident, root_node))
# bpy.data
if method == 'DATA':
ident = root_ident + ident_val
for attr in dir(root_rna):
# exceptions
if attr.startswith("_"):
continue
elif attr in root_rna_skip:
continue
value = getattr(root_rna, attr)
try:
ls = value[:]
except Exception:
ls = None
if type(ls) == list:
fw("{:s}<{:s}>\n".format(ident, attr))
for blend_id in ls:
rna2xml_node(ident + ident_val, blend_id, None)
fw("{:s}</{:s}>\n".format(ident_val, attr))
# any attribute
elif method == 'ATTR':
rna2xml_node(root_ident, root_rna, None)
if root_node:
fw("{:s}</{:s}>\n".format(root_ident, root_node))
# NOTE(@ideasman42): regarding `secure_types`.
# This is a safe guard when loading an untrusted XML to prevent any possibility of the XML
# paths "escaping" the intended data types, potentially writing into unexpected settings.
# This is done because the XML itself defines the attributes which are recursed into,
# there is a possibility the XML recurse into data that isn't logically owned by "root",
# out of the theme and into user preferences for example, which could change trust settings
# even executing code.
#
# At the time of writing it seems this is not possible with themes (the main user of this functionality),
# however this could become possible in the future through additional RNA properties and it wouldn't be
# obvious an exploit existed.
#
# In short, it's safest for users of this API to restrict types when loading untrusted XML.
def xml2rna(
root_xml, *,
root_rna=None, # must be set
secure_types=None, # `Set[str] | None`
):
def xml2rna_node(xml_node, value):
# print("evaluating:", xml_node.nodeName)
if (secure_types is not None) and (xml_node.nodeName not in secure_types):
print("Loading the XML with type restrictions, skipping \"{:s}\"".format(xml_node.nodeName))
return
# ---------------------------------------------------------------------
# Simple attributes
for attr in xml_node.attributes.keys():
# print(" ", attr)
subvalue = getattr(value, attr, Ellipsis)
if subvalue is Ellipsis:
print("{:s}.{:s} not found".format(type(value).__name__, attr))
else:
value_xml = xml_node.attributes[attr].value
subvalue_type = type(subvalue)
# tp_name = 'UNKNOWN'
if subvalue_type == float:
value_xml_coerce = float(value_xml)
# tp_name = 'FLOAT'
elif subvalue_type == int:
value_xml_coerce = int(value_xml)
# tp_name = 'INT'
elif subvalue_type == bool:
value_xml_coerce = {'TRUE': True, 'FALSE': False}[value_xml]
# tp_name = 'BOOL'
elif subvalue_type == str:
value_xml_coerce = value_xml
# tp_name = 'STR'
elif hasattr(subvalue, "__len__"):
if value_xml.startswith("#"):
# read hexadecimal value as float array
value_xml_split = value_xml[1:]
value_xml_coerce = [int(value_xml_split[i:i + 2], 16) /
255 for i in range(0, len(value_xml_split), 2)]
del value_xml_split
else:
value_xml_split = value_xml.split()
try:
value_xml_coerce = [int(v) for v in value_xml_split]
except ValueError:
try:
value_xml_coerce = [float(v) for v in value_xml_split]
except ValueError: # bool vector property
value_xml_coerce = [{'TRUE': True, 'FALSE': False}[v] for v in value_xml_split]
del value_xml_split
# tp_name = 'ARRAY'
# print(" {:s}.{:s} ({:s}) --- {:s}".format(type(value).__name__, attr, tp_name, subvalue_type))
try:
setattr(value, attr, value_xml_coerce)
except ValueError:
# size mismatch
val = getattr(value, attr)
if len(val) < len(value_xml_coerce):
setattr(value, attr, value_xml_coerce[:len(val)])
else:
setattr(value, attr, list(value_xml_coerce) + list(val)[len(value_xml_coerce):])
# ---------------------------------------------------------------------
# Complex attributes
for child_xml in xml_node.childNodes:
if child_xml.nodeType == child_xml.ELEMENT_NODE:
# print()
# print(child_xml.nodeName)
subvalue = getattr(value, child_xml.nodeName, None)
if subvalue is not None:
elems = []
for child_xml_real in child_xml.childNodes:
if child_xml_real.nodeType == child_xml_real.ELEMENT_NODE:
elems.append(child_xml_real)
del child_xml_real
if hasattr(subvalue, "__len__"):
# Collection
if len(elems) != len(subvalue):
print("Size Mismatch! collection:", child_xml.nodeName)
else:
for i in range(len(elems)):
child_xml_real = elems[i]
subsubvalue = subvalue[i]
if child_xml_real is None or subsubvalue is None:
print("None found {:s} - {:d} collection:".format(child_xml.nodeName, i))
else:
xml2rna_node(child_xml_real, subsubvalue)
else:
# print(elems)
if len(elems) == 1:
# sub node named by its type
child_xml_real = elems[0]
# print(child_xml_real, subvalue)
xml2rna_node(child_xml_real, subvalue)
else:
# empty is valid too
pass
xml2rna_node(root_xml, root_rna)
# -----------------------------------------------------------------------------
# Utility function used by presets.
# The idea is you can run a preset like a script with a few args.
#
# This roughly matches the operator 'bpy.ops.script.python_file_run'
def _get_context_val(context, path):
try:
value = context.path_resolve(path)
except Exception as ex:
print("Error: {!r}, path {!r} not found".format(ex, path))
value = Ellipsis
return value
def xml_file_run(
context,
filepath,
rna_map,
secure_types=None, # `set[str] | None`
):
import xml.dom.minidom
xml_nodes = xml.dom.minidom.parse(filepath)
bpy_xml = xml_nodes.getElementsByTagName("bpy")[0]
for rna_path, xml_tag in rna_map:
# first get xml
# TODO, error check
xml_node = bpy_xml.getElementsByTagName(xml_tag)[0]
value = _get_context_val(context, rna_path)
if value is not Ellipsis and value is not None:
# print(" loading XML: {!r} -> {!r}".format(filepath, rna_path))
xml2rna(xml_node, root_rna=value, secure_types=secure_types)
def xml_file_write(context, filepath, rna_map, *, skip_typemap=None):
with open(filepath, "w", encoding="utf-8") as file:
fw = file.write
fw("<bpy>\n")
for rna_path, _xml_tag in rna_map:
# xml_tag is ignored, we get this from the rna
value = _get_context_val(context, rna_path)
rna2xml(
fw=fw,
root_rna=value,
method='ATTR',
root_ident=" ",
ident_val=" ",
skip_typemap=skip_typemap,
)
fw("</bpy>\n")

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,211 @@
# SPDX-FileCopyrightText: 2017-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
"""
Module to manage overriding various parts of Blender.
Intended for use with 'app_templates', though it can be used from anywhere.
"""
__all__ = (
"class_filter",
"ui_draw_filter_register",
"ui_draw_filter_unregister",
)
# TODO, how to check these aren't from add-ons.
# templates might need to un-register while filtering.
def class_filter(cls_parent, **kw):
whitelist = kw.pop("whitelist", None)
blacklist = kw.pop("blacklist", None)
kw_items = tuple(kw.items())
for cls in cls_parent.__subclasses__():
# same as is_registered()
if "bl_rna" in cls.__dict__:
if blacklist is not None and cls.__name__ in blacklist:
continue
if ((whitelist is not None and cls.__name__ is whitelist) or
all((getattr(cls, attr) in expect) for attr, expect in kw_items)):
yield cls
def ui_draw_filter_register(
*,
ui_ignore_classes=None,
ui_ignore_operator=None,
ui_ignore_property=None,
ui_ignore_menu=None,
ui_ignore_label=None,
):
import bpy
UILayout = bpy.types.UILayout
if ui_ignore_classes is None:
ui_ignore_classes = (
bpy.types.Panel,
bpy.types.Menu,
bpy.types.Header,
)
class OperatorProperties_Fake:
pass
class UILayout_Fake(bpy.types.UILayout):
__slots__ = ()
def __getattribute__(self, attr):
# ensure we always pass down UILayout_Fake instances
if attr in {"row", "split", "column", "box", "column_flow"}:
real_func = UILayout.__getattribute__(self, attr)
def dummy_func(*args, **kw):
# print("wrapped", attr)
ret = real_func(*args, **kw)
return UILayout_Fake(ret)
return dummy_func
elif attr in {"operator", "operator_menu_enum", "operator_enum", "operator_menu_hold"}:
if ui_ignore_operator is None:
return UILayout.__getattribute__(self, attr)
real_func = UILayout.__getattribute__(self, attr)
def dummy_func(*args, **kw):
# print("wrapped", attr)
ui_test = ui_ignore_operator(args[0])
if ui_test is False:
ret = real_func(*args, **kw)
else:
if ui_test is None:
UILayout.__getattribute__(self, "label")(text="")
else:
assert ui_test is True
# may need to be set
ret = OperatorProperties_Fake()
return ret
return dummy_func
elif attr in {"prop", "prop_enum"}:
if ui_ignore_property is None:
return UILayout.__getattribute__(self, attr)
real_func = UILayout.__getattribute__(self, attr)
def dummy_func(*args, **kw):
# print("wrapped", attr)
ui_test = ui_ignore_property(args[0].__class__.__name__, args[1])
if ui_test is False:
ret = real_func(*args, **kw)
else:
if ui_test is None:
UILayout.__getattribute__(self, "label")(text="")
else:
assert ui_test is True
ret = None
return ret
return dummy_func
elif attr == "menu":
if ui_ignore_menu is None:
return UILayout.__getattribute__(self, attr)
real_func = UILayout.__getattribute__(self, attr)
def dummy_func(*args, **kw):
# print("wrapped", attr)
ui_test = ui_ignore_menu(args[0])
if ui_test is False:
ret = real_func(*args, **kw)
else:
if ui_test is None:
UILayout.__getattribute__(self, "label")(text="")
else:
assert ui_test is True
ret = None
return ret
return dummy_func
elif attr == "label":
if ui_ignore_label is None:
return UILayout.__getattribute__(self, attr)
real_func = UILayout.__getattribute__(self, attr)
def dummy_func(*args, **kw):
# print("wrapped", attr)
ui_test = ui_ignore_label(args[0] if args else kw.get("text", ""))
if ui_test is False:
ret = real_func(*args, **kw)
else:
if ui_test is None:
real_func(text="")
else:
assert ui_test is True
ret = None
return ret
return dummy_func
else:
return UILayout.__getattribute__(self, attr)
# print(self, attr)
def operator(*args, **kw):
return super().operator(*args, **kw)
def draw_override(func_orig, self_real, context):
cls_real = self_real.__class__
if cls_real is super:
# simple, no wrapping
return func_orig(self_real, context)
class Wrapper(cls_real):
__slots__ = ()
def __getattribute__(self, attr):
if attr == "layout":
return UILayout_Fake(self_real.layout)
else:
cls = super()
try:
return cls.__getattr__(self, attr)
except AttributeError:
# class variable
try:
return getattr(cls, attr)
except AttributeError:
# for preset bl_idname access
return getattr(UILayout(self), attr)
@property
def layout(self):
# print("wrapped")
return self_real.layout
return func_orig(Wrapper(self_real), context)
ui_ignore_store = []
for cls in ui_ignore_classes:
for subcls in list(cls.__subclasses__()):
if "draw" in subcls.__dict__: # don't want to get parents draw()
def replace_draw():
# function also serves to hold draw_old in a local name-space
draw_orig = subcls.draw
def draw(self, context):
return draw_override(draw_orig, self, context)
subcls.draw = draw
ui_ignore_store.append((subcls, "draw", subcls.draw))
replace_draw()
return ui_ignore_store
def ui_draw_filter_unregister(ui_ignore_store):
for (obj, attr, value) in ui_ignore_store:
setattr(obj, attr, value)

View File

@@ -0,0 +1,153 @@
# SPDX-FileCopyrightText: 2017-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
__all__ = (
"AppOverrideState",
)
# -----------------------------------------------------------------------------
# AppOverrideState
class AppOverrideState:
"""
Utility class to encapsulate overriding the application state
so that settings can be restored afterwards.
"""
__slots__ = (
# setup_classes
"_class_store",
# setup_ui_ignore
"_ui_ignore_store",
# setup_addons
"_addon_store",
)
# ---------
# Callbacks
#
# Set as None, to make it simple to check if they're being overridden.
# setup/teardown classes
class_ignore = None
# setup/teardown ui_ignore
ui_ignore_classes = None
ui_ignore_operator = None
ui_ignore_property = None
ui_ignore_menu = None
ui_ignore_label = None
addon_paths = None
addons = None
# End callbacks
def __init__(self):
self._class_store = None
self._addon_store = None
self._ui_ignore_store = None
def _setup_classes(self):
assert self._class_store is None
self._class_store = self.class_ignore()
from bpy.utils import unregister_class
for cls in self._class_store:
unregister_class(cls)
def _teardown_classes(self):
assert self._class_store is not None
from bpy.utils import register_class
for cls in self._class_store:
register_class(cls)
self._class_store = None
def _setup_ui_ignore(self):
import bl_app_override
self._ui_ignore_store = bl_app_override.ui_draw_filter_register(
ui_ignore_classes=(
None if self.ui_ignore_classes is None
else self.ui_ignore_classes()
),
ui_ignore_operator=self.ui_ignore_operator,
ui_ignore_property=self.ui_ignore_property,
ui_ignore_menu=self.ui_ignore_menu,
ui_ignore_label=self.ui_ignore_label,
)
def _teardown_ui_ignore(self):
import bl_app_override
bl_app_override.ui_draw_filter_unregister(
self._ui_ignore_store
)
self._ui_ignore_store = None
def _setup_addons(self):
import sys
sys_path = []
if self.addon_paths is not None:
for path in self.addon_paths():
if path not in sys.path:
sys.path.append(path)
import addon_utils
addons = []
if self.addons is not None:
addons.extend(self.addons())
for addon in addons:
addon_utils.enable(addon)
self._addon_store = {
"sys_path": sys_path,
"addons": addons,
}
def _teardown_addons(self):
import sys
sys_path = self._addon_store["sys_path"]
for path in sys_path:
# should always succeed, but if not it doesn't matter
# (someone else was changing the sys.path), ignore!
try:
sys.path.remove(path)
except Exception:
pass
addons = self._addon_store["addons"]
import addon_utils
for addon in addons:
addon_utils.disable(addon)
self._addon_store.clear()
self._addon_store = None
def setup(self):
if self.class_ignore is not None:
self._setup_classes()
if any((self.addon_paths,
self.addons,
)):
self._setup_addons()
if any((self.ui_ignore_operator,
self.ui_ignore_property,
self.ui_ignore_menu,
self.ui_ignore_label,
)):
self._setup_ui_ignore()
def teardown(self):
if self._class_store is not None:
self._teardown_classes()
if self._addon_store is not None:
self._teardown_addons()
if self._ui_ignore_store is not None:
self._teardown_ui_ignore()

View File

@@ -0,0 +1,177 @@
# SPDX-FileCopyrightText: 2017-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
"""
Similar to ``addon_utils``, except we can only have one active at a time.
In most cases users of this module will simply call 'activate'.
"""
__all__ = (
"activate",
"import_from_path",
"import_from_id",
"reset",
)
import bpy as _bpy
# Normally matches 'preferences.app_template_id',
# but loading new preferences will get us out of sync.
_app_template = {
"id": "",
}
# Instead of `sys.modules`
# note that we only ever have one template enabled at a time
# so it may not seem necessary to use this.
#
# However, templates may want to share between each-other,
# so any loaded modules are stored here?
#
# Note that the ID here is the app_template_id , not the modules __name__.
_modules = {}
def _enable(template_id, *, handle_error=None, ignore_not_found=False):
from _bpy_restrict_state import RestrictBlend
if handle_error is None:
def handle_error(_ex):
import traceback
traceback.print_exc()
# Split registering up into 2 steps so we can undo
# if it fails par way through.
# disable the context, using the context at all is
# really bad while loading an template, don't do it!
with RestrictBlend():
# 1) try import
try:
mod = import_from_id(template_id, ignore_not_found=ignore_not_found)
except Exception as ex:
handle_error(ex)
return None
_modules[template_id] = mod
if mod is None:
return None
mod.__template_enabled__ = False
# 2) try run the modules register function
try:
mod.register()
except Exception as ex:
print("Exception in module register(): {!r}".format(getattr(mod, "__file__", template_id)))
handle_error(ex)
del _modules[template_id]
return None
# * OK loaded successfully! *
mod.__template_enabled__ = True
if _bpy.app.debug_python:
print("\tapp_template_utils.enable", mod.__name__)
return mod
def _disable(template_id, *, handle_error=None):
"""
Disables a template by name.
:param template_id: The name of the template and module.
:type template_id: str
:param handle_error: Called in the case of an error,
taking an exception argument.
:type handle_error: Callable[[Exception], None] | None
"""
if handle_error is None:
def handle_error(_ex):
import traceback
traceback.print_exc()
mod = _modules.get(template_id, False)
if mod is None:
# Loaded but has no module, remove since there is no use in keeping it.
del _modules[template_id]
elif getattr(mod, "__template_enabled__", False) is not False:
mod.__template_enabled__ = False
try:
mod.unregister()
except Exception as ex:
print("Exception in module unregister(): {!r}".format(getattr(mod, "__file__", template_id)))
handle_error(ex)
else:
print(
"\tapp_template_utils.disable: {:s} not {:s}.".format(
template_id,
"disabled" if mod is False else "loaded",
)
)
if _bpy.app.debug_python:
print("\tapp_template_utils.disable", template_id)
def import_from_path(path, *, ignore_not_found=False):
import os
from importlib import import_module
base_module, template_id = path.rsplit(os.sep, 2)[-2:]
module_name = base_module + "." + template_id
try:
return import_module(module_name)
except ModuleNotFoundError as ex:
if ignore_not_found and ex.name == module_name:
return None
raise ex
def import_from_id(template_id, *, ignore_not_found=False):
import os
path = next(iter(_bpy.utils.app_template_paths(path=template_id)), None)
if path is None:
if ignore_not_found:
return None
else:
raise Exception("{!r} template not found!".format(template_id))
else:
if ignore_not_found:
if not os.path.exists(os.path.join(path, "__init__.py")):
return None
return import_from_path(path, ignore_not_found=ignore_not_found)
def activate(*, template_id=None, reload_scripts=False):
template_id_prev = _app_template["id"]
# not needed but may as well avoids redundant
# disable/enable for all add-ons on "File -> New".
if not reload_scripts and template_id_prev == template_id:
return
if template_id_prev:
_disable(template_id_prev)
# ignore_not_found so modules that don't contain scripts don't raise errors
_mod = _enable(template_id, ignore_not_found=True) if template_id else None
_app_template["id"] = template_id
def reset(*, reload_scripts=False):
"""
Sets default state.
"""
template_id = _bpy.context.preferences.app_template
if _bpy.app.debug_python:
print("bl_app_template_utils.reset('{:s}')".format(template_id))
activate(template_id=template_id, reload_scripts=reload_scripts)

View File

@@ -0,0 +1,9 @@
# SPDX-FileCopyrightText: 2009-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
__all__ = (
"io",
"keymap_from_toolbar",
"keymap_hierarchy",
)

View File

@@ -0,0 +1,308 @@
# SPDX-FileCopyrightText: 2018-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
# -----------------------------------------------------------------------------
# Export Functions
__all__ = (
"_init_properties_from_data", # Shared with gizmo default property initialization.
"keyconfig_export_as_data",
"keyconfig_import_from_data",
"keyconfig_init_from_data",
"keyconfig_merge",
"keymap_init_from_data",
)
def indent(levels):
return levels * " "
def round_float_32(f):
from struct import pack, unpack
return unpack("f", pack("f", f))[0]
def repr_f32(f):
f_round = round_float_32(f)
f_str = repr(f)
f_str_frac = f_str.partition(".")[2]
if not f_str_frac:
return f_str
for i in range(1, len(f_str_frac)):
f_test = round(f, i)
f_test_round = round_float_32(f_test)
if f_test_round == f_round:
return "{:.{:d}f}".format(f_test, i)
return f_str
def kmi_args_as_data(kmi):
s = [
f"\"type\": '{kmi.type}'",
f"\"value\": '{kmi.value}'"
]
if kmi.any:
s.append("\"any\": True")
else:
for attr in ("shift", "ctrl", "alt", "oskey", "hyper"):
if mod := getattr(kmi, attr):
s.append(f"\"{attr:s}\": " + ("-1" if mod == -1 else "True"))
if (mod := kmi.key_modifier) and (mod != 'NONE'):
s.append(f"\"key_modifier\": '{mod:s}'")
if (direction := kmi.direction) and (direction != 'ANY'):
s.append(f"\"direction\": '{direction:s}'")
if kmi.repeat:
if (
(kmi.map_type == 'KEYBOARD' and kmi.value in {'PRESS', 'ANY'}) or
(kmi.map_type == 'TEXTINPUT')
):
s.append("\"repeat\": True")
return "{" + ", ".join(s) + "}"
def _kmi_properties_to_lines_recursive(level, properties, lines):
from bpy.types import OperatorProperties
def string_value(value):
if isinstance(value, (str, bool, int, set)):
return repr(value)
elif isinstance(value, float):
return repr_f32(value)
elif getattr(value, '__len__', False):
return repr(tuple(value))
raise Exception(f"Export key configuration: cannot write {value!r}")
for pname in properties.bl_rna.properties.keys():
if pname != "rna_type":
value = getattr(properties, pname)
if isinstance(value, OperatorProperties):
lines_test = []
_kmi_properties_to_lines_recursive(level + 2, value, lines_test)
if lines_test:
lines.append("(")
lines.append(f"\"{pname}\",\n")
lines.append(f"{indent(level + 3)}" "[")
lines.extend(lines_test)
lines.append("],\n")
lines.append(f"{indent(level + 3)}" "),\n" f"{indent(level + 2)}")
del lines_test
elif properties.is_property_set(pname):
value = string_value(value)
lines.append((f"(\"{pname}\", {value:s}),\n" f"{indent(level + 2)}"))
def _kmi_properties_to_lines(level, kmi_props, lines):
if kmi_props is None:
return
lines_test = [f"\"properties\":\n" f"{indent(level + 1)}" "["]
_kmi_properties_to_lines_recursive(level, kmi_props, lines_test)
if len(lines_test) > 1:
lines_test.append("],\n")
lines.extend(lines_test)
def _kmi_attrs_or_none(level, kmi):
lines = []
_kmi_properties_to_lines(level + 1, kmi.properties, lines)
if kmi.active is False:
lines.append(f"{indent(level)}\"active\":" "False,\n")
if not lines:
return None
return "".join(lines)
def keyconfig_export_as_data(wm, kc, filepath, *, all_keymaps=False):
# Alternate format
# Generate a list of keymaps to export:
#
# First add all user_modified keymaps (found in keyconfigs.user.keymaps list),
# then add all remaining keymaps from the currently active custom keyconfig.
#
# Sort the resulting list according to top context name,
# while this isn't essential, it makes comparing keymaps simpler.
#
# This will create a final list of keymaps that can be used as a "diff" against
# the default blender keyconfig, recreating the current setup from a fresh blender
# without needing to export keymaps which haven't been edited.
class FakeKeyConfig:
keymaps = []
edited_kc = FakeKeyConfig()
for km in wm.keyconfigs.user.keymaps:
if all_keymaps or km.is_user_modified:
edited_kc.keymaps.append(km)
# merge edited keymaps with non-default keyconfig, if it exists
if kc != wm.keyconfigs.default:
export_keymaps = keyconfig_merge(edited_kc, kc)
else:
export_keymaps = keyconfig_merge(edited_kc, edited_kc)
# Sort the keymap list by top context name before exporting,
# not essential, just convenient to order them predictably.
export_keymaps.sort(key=lambda k: k[0].name)
with open(filepath, "w", encoding="utf-8") as fh:
fw = fh.write
# Use the file version since it includes the sub-version
# which we can bump multiple times between releases.
from bpy.app import version_file
fw(f"keyconfig_version = {version_file!r}\n")
del version_file
fw("keyconfig_data = \\\n[")
for km, _kc_x in export_keymaps:
km = km.active()
fw("(")
fw(f"\"{km.name:s}\",\n")
fw(f"{indent(2)}" "{")
fw(f"\"space_type\": '{km.space_type:s}'")
fw(f", \"region_type\": '{km.region_type:s}'")
# We can detect from the kind of items.
if km.is_modal:
fw(", \"modal\": True")
fw("},\n")
fw(f"{indent(2)}" "{")
is_modal = km.is_modal
fw("\"items\":\n")
fw(f"{indent(3)}[")
for kmi in km.keymap_items:
if is_modal:
kmi_id = kmi.propvalue
else:
kmi_id = kmi.idname
fw("(")
kmi_args = kmi_args_as_data(kmi)
kmi_data = _kmi_attrs_or_none(4, kmi)
fw(f"\"{kmi_id:s}\"")
if kmi_data is None:
fw(", ")
else:
fw(",\n" f"{indent(5)}")
fw(kmi_args)
if kmi_data is None:
fw(", None),\n")
else:
fw(",\n")
fw(f"{indent(5)}" "{")
fw(kmi_data)
fw(f"{indent(6)}")
fw("},\n" f"{indent(5)}")
fw("),\n")
fw(f"{indent(4)}")
fw("],\n" f"{indent(3)}")
fw("},\n" f"{indent(2)}")
fw("),\n" f"{indent(1)}")
fw("]\n")
fw("\n\n")
fw("if __name__ == \"__main__\":\n")
# We could remove this in the future, as loading new key-maps in older Blender versions
# makes less and less sense as Blender changes.
fw(" # Only add keywords that are supported.\n")
fw(" from bpy.app import version as blender_version\n")
fw(" keywords = {}\n")
fw(" if blender_version >= (2, 92, 0):\n")
fw(" keywords[\"keyconfig_version\"] = keyconfig_version\n")
fw(" import os\n")
fw(" from bl_keymap_utils.io import keyconfig_import_from_data\n")
fw(" keyconfig_import_from_data(\n")
fw(" os.path.splitext(os.path.basename(__file__))[0],\n")
fw(" keyconfig_data,\n")
fw(" **keywords,\n")
fw(" )\n")
# -----------------------------------------------------------------------------
# Import Functions
#
# NOTE: unlike export, this runs on startup.
# Take care making changes that could impact performance.
def _init_properties_from_data(base_props, base_value):
assert type(base_value) is list
for attr, value in base_value:
if type(value) is list:
base_props.property_unset(attr)
props = getattr(base_props, attr)
_init_properties_from_data(props, value)
else:
try:
setattr(base_props, attr, value)
except AttributeError:
print(f"Warning: property '{attr}' not found in item '{base_props.__class__.__name__}'")
except Exception as ex:
print(f"Warning: {ex!r}")
def keymap_init_from_data(km, km_items, is_modal=False):
new_fn = getattr(km.keymap_items, "new_modal" if is_modal else "new")
for (kmi_idname, kmi_args, kmi_data) in km_items:
kmi = new_fn(kmi_idname, **kmi_args)
if kmi_data is not None:
if not kmi_data.get("active", True):
kmi.active = False
kmi_props_data = kmi_data.get("properties", None)
if kmi_props_data is not None:
kmi_props = kmi.properties
assert type(kmi_props_data) is list
_init_properties_from_data(kmi_props, kmi_props_data)
def keyconfig_init_from_data(kc, keyconfig_data):
# Load data in the format defined above.
#
# Runs at load time, keep this fast!
for (km_name, km_args, km_content) in keyconfig_data:
km = kc.keymaps.new(km_name, **km_args)
km_items = km_content["items"]
# Check here instead of inside 'keymap_init_from_data'
# because we want to allow both tuple & list types in that case.
#
# For full keymaps, ensure these are always lists to allow for extending them
# in a generic way that doesn't have to check for the type each time.
assert type(km_items) is list
keymap_init_from_data(km, km_items, is_modal=km_args.get("modal", False))
def keyconfig_import_from_data(name, keyconfig_data, *, keyconfig_version=(0, 0, 0)):
# Load data in the format defined above.
#
# Runs at load time, keep this fast!
import bpy
wm = bpy.context.window_manager
kc = wm.keyconfigs.new(name)
if keyconfig_version is not None:
from .versioning import keyconfig_update
keyconfig_data = keyconfig_update(keyconfig_data, keyconfig_version)
keyconfig_init_from_data(kc, keyconfig_data)
return kc
# -----------------------------------------------------------------------------
# Utility Functions
def keyconfig_merge(kc1, kc2):
""" note: kc1 takes priority over kc2
"""
kc1_names = {km.name for km in kc1.keymaps}
merged_keymaps = [(km, kc1) for km in kc1.keymaps]
if kc1 != kc2:
merged_keymaps.extend(
(km, kc2)
for km in kc2.keymaps
if km.name not in kc1_names
)
return merged_keymaps

View File

@@ -0,0 +1,394 @@
# SPDX-FileCopyrightText: 2018-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
# Dynamically create a keymap which is used by the popup toolbar
# for accelerator key access.
__all__ = (
"generate",
)
def generate(context, space_type, *, use_fallback_keys=True, use_reset=True):
"""
Keymap for popup toolbar, currently generated each time.
"""
from bl_ui.space_toolsystem_common import ToolSelectPanelHelper
def modifier_keywords_from_item(kmi):
kw = {}
for (attr, default) in (
("any", False),
("shift", False),
("ctrl", False),
("alt", False),
("oskey", False),
("hyper", False),
("key_modifier", 'NONE'),
):
val = getattr(kmi, attr)
if val != default:
kw[attr] = val
return kw
def dict_as_tuple(d):
return tuple((k, v) for (k, v) in sorted(d.items()))
cls = ToolSelectPanelHelper._tool_class_from_space_type(space_type)
items_all = [
# 0: tool
# 1: keymap item (direct access)
# 2: keymap item (newly calculated for toolbar)
[item, None, None]
for item in ToolSelectPanelHelper._tools_flatten(cls.tools_from_context(context))
if item is not None
]
items_all_id = {item_container[0].idname for item_container in items_all}
# Press the toolbar popup key again to set the default tool,
# this is useful because the select box tool is useful as a way
# to 'drop' currently active tools (it's basically a 'none' tool).
# so this allows us to quickly go back to a state that allows
# a shortcut based workflow (before the tool system was added).
use_tap_reset = use_reset
# TODO: support other tools for modes which don't use this tool.
tap_reset_tool = "builtin.cursor"
# Check the tool is available in the current context.
if tap_reset_tool not in items_all_id:
use_tap_reset = False
# Pie-menu style release to activate.
use_release_confirm = use_reset
# Generate items when no keys are mapped.
use_auto_keymap_alpha = False # Map manually in the default key-map.
use_auto_keymap_num = use_fallback_keys
# Temporary, only create so we can pass 'properties' to find_item_from_operator.
use_hack_properties = True
km_name_default = "Toolbar Popup"
km_name = km_name_default + " <temp>"
wm = context.window_manager
keyconf_user = wm.keyconfigs.user
keyconf_active = wm.keyconfigs.active
keymap = keyconf_active.keymaps.get(km_name)
if keymap is None:
keymap = keyconf_active.keymaps.new(km_name, space_type='EMPTY', region_type='TEMPORARY')
for kmi in keymap.keymap_items:
keymap.keymap_items.remove(kmi)
keymap_src = keyconf_user.keymaps.get(km_name_default)
if keymap_src is not None:
for kmi_src in keymap_src.keymap_items:
# Skip tools that aren't currently shown.
if (
(kmi_src.idname == "wm.tool_set_by_id") and
(kmi_src.properties.name not in items_all_id)
):
continue
keymap.keymap_items.new_from_item(kmi_src)
del keymap_src
del items_all_id
kmi_unique_args = set()
def kmi_unique_or_pass(kmi_args):
kmi_unique_len = len(kmi_unique_args)
kmi_unique_args.add(dict_as_tuple(kmi_args))
return kmi_unique_len != len(kmi_unique_args)
cls = ToolSelectPanelHelper._tool_class_from_space_type(space_type)
if use_hack_properties:
kmi_hack = keymap.keymap_items.new("wm.tool_set_by_id", 'NONE', 'PRESS')
kmi_hack_properties = kmi_hack.properties
kmi_hack.active = False
if use_release_confirm or use_tap_reset:
kmi_toolbar = wm.keyconfigs.find_item_from_operator(
idname="wm.toolbar",
)[1]
kmi_toolbar_type = None if not kmi_toolbar else kmi_toolbar.type
if use_tap_reset and kmi_toolbar_type is not None:
kmi_toolbar_args_type_only = {"type": kmi_toolbar_type}
kmi_toolbar_args = {**kmi_toolbar_args_type_only, **modifier_keywords_from_item(kmi_toolbar)}
else:
use_tap_reset = False
del kmi_toolbar
if use_tap_reset:
kmi_found = None
if use_hack_properties:
# First check for direct assignment, if this tool already has a key, no need to add a new one.
kmi_hack_properties.name = tap_reset_tool
kmi_found = wm.keyconfigs.find_item_from_operator(
idname="wm.tool_set_by_id",
context='INVOKE_REGION_WIN',
# properties={"name": item.idname},
properties=kmi_hack_properties,
include={'KEYBOARD'},
)[1]
if kmi_found:
use_tap_reset = False
del kmi_found
if use_tap_reset:
use_tap_reset = kmi_unique_or_pass(kmi_toolbar_args)
if use_tap_reset:
items_all[:] = [
item_container
for item_container in items_all
if item_container[0].idname != tap_reset_tool
]
# -----------------------
# Begin Keymap Generation
# -------------------------------------------------------------------------
# Direct Tool Assignment & Brushes
for item_container in items_all:
item = item_container[0]
# Only check the first item in the tools key-map (a little arbitrary).
if use_hack_properties:
# First check for direct assignment.
kmi_hack_properties.name = item.idname
kmi_found = wm.keyconfigs.find_item_from_operator(
idname="wm.tool_set_by_id",
context='INVOKE_REGION_WIN',
# properties={"name": item.idname},
properties=kmi_hack_properties,
include={'KEYBOARD'},
)[1]
else:
kmi_found = None
if kmi_found is not None:
pass
elif item.operator is not None:
kmi_found = wm.keyconfigs.find_item_from_operator(
idname=item.operator,
context='INVOKE_REGION_WIN',
include={'KEYBOARD'},
)[1]
elif item.keymap is not None:
km = keyconf_user.keymaps.get(item.keymap[0])
if km is None:
print("Keymap", repr(item.keymap[0]), "not found for tool", item.idname)
kmi_found = None
else:
kmi_first = km.keymap_items
kmi_first = kmi_first[0] if kmi_first else None
if kmi_first is not None:
kmi_found = wm.keyconfigs.find_item_from_operator(
idname=kmi_first.idname,
# properties=kmi_first.properties, # prevents matches, don't use.
context='INVOKE_REGION_WIN',
include={'KEYBOARD'},
)[1]
if kmi_found is None:
# We need non-keyboard events so keys with 'key_modifier' key is found.
kmi_found = wm.keyconfigs.find_item_from_operator(
idname=kmi_first.idname,
# properties=kmi_first.properties, # prevents matches, don't use.
context='INVOKE_REGION_WIN',
exclude={'KEYBOARD'},
)[1]
if kmi_found is not None:
if kmi_found.key_modifier == 'NONE':
kmi_found = None
else:
kmi_found = None
del kmi_first
del km
else:
kmi_found = None
item_container[1] = kmi_found
# -------------------------------------------------------------------------
# Single Key Access
# More complex multi-pass test.
for item_container in items_all:
item, kmi_found = item_container[:2]
if kmi_found is None:
continue
kmi_found_type = kmi_found.type
# Only for single keys.
if (
(len(kmi_found_type) == 1) or
# When a tool is being activated instead of running an operator, just copy the shortcut.
(kmi_found.idname in {"wm.tool_set_by_id", "WM_OT_tool_set_by_id"})
):
kmi_args = {"type": kmi_found_type, **modifier_keywords_from_item(kmi_found)}
if kmi_unique_or_pass(kmi_args):
kmi = keymap.keymap_items.new(idname="wm.tool_set_by_id", value='PRESS', **kmi_args)
kmi.properties.name = item.idname
item_container[2] = kmi
# -------------------------------------------------------------------------
# Single Key Modifier
#
#
# Test for key_modifier, where alpha key is used as a 'key_modifier'
# (grease pencil holding 'D' for example).
for item_container in items_all:
item, kmi_found, kmi_exist = item_container
if kmi_found is None or kmi_exist:
continue
kmi_found_type = kmi_found.type
if kmi_found_type in {
'LEFTMOUSE',
'RIGHTMOUSE',
'MIDDLEMOUSE',
'BUTTON4MOUSE',
'BUTTON5MOUSE',
'BUTTON6MOUSE',
'BUTTON7MOUSE',
}:
kmi_found_type = kmi_found.key_modifier
# excludes 'NONE'
if len(kmi_found_type) == 1:
kmi_args = {"type": kmi_found_type, **modifier_keywords_from_item(kmi_found)}
del kmi_args["key_modifier"]
if kmi_unique_or_pass(kmi_args):
kmi = keymap.keymap_items.new(idname="wm.tool_set_by_id", value='PRESS', **kmi_args)
kmi.properties.name = item.idname
item_container[2] = kmi
# -------------------------------------------------------------------------
# Assign A-Z to Keys
#
# When the keys are free.
if use_auto_keymap_alpha:
# Map all unmapped keys to numbers,
# while this is a bit strange it means users will not confuse regular key bindings to ordered bindings.
# First map A-Z.
kmi_type_alpha_char = [chr(i) for i in range(65, 91)]
kmi_type_alpha_args = {c: {"type": c} for c in kmi_type_alpha_char}
kmi_type_alpha_args_tuple = {c: dict_as_tuple(kmi_type_alpha_args[c]) for c in kmi_type_alpha_char}
for item_container in items_all:
item, kmi_found, kmi_exist = item_container
if kmi_exist:
continue
kmi_type = item.label[0].upper()
kmi_tuple = kmi_type_alpha_args_tuple.get(kmi_type)
if kmi_tuple and kmi_tuple not in kmi_unique_args:
kmi_unique_args.add(kmi_tuple)
kmi = keymap.keymap_items.new(
idname="wm.tool_set_by_id",
value='PRESS',
**kmi_type_alpha_args[kmi_type],
)
kmi.properties.name = item.idname
item_container[2] = kmi
del kmi_type_alpha_char, kmi_type_alpha_args, kmi_type_alpha_args_tuple
# -------------------------------------------------------------------------
# Assign Numbers to Keys
if use_auto_keymap_num:
# Free events (last used first).
kmi_type_auto = ('ONE', 'TWO', 'THREE', 'FOUR', 'FIVE', 'SIX', 'SEVEN', 'EIGHT', 'NINE', 'ZERO')
# Map both numbers and num-pad.
kmi_type_dupe = {
'ONE': 'NUMPAD_1',
'TWO': 'NUMPAD_2',
'THREE': 'NUMPAD_3',
'FOUR': 'NUMPAD_4',
'FIVE': 'NUMPAD_5',
'SIX': 'NUMPAD_6',
'SEVEN': 'NUMPAD_7',
'EIGHT': 'NUMPAD_8',
'NINE': 'NUMPAD_9',
'ZERO': 'NUMPAD_0',
}
def iter_free_events():
for mod in ({}, {"shift": True}, {"ctrl": True}, {"alt": True}):
for e in kmi_type_auto:
yield (e, mod)
iter_events = iter(iter_free_events())
for item_container in items_all:
item, kmi_found, kmi_exist = item_container
if kmi_exist:
continue
kmi_args = None
while True:
key, mod = next(iter_events, (None, None))
if key is None:
break
kmi_args = {"type": key, **mod}
kmi_tuple = dict_as_tuple(kmi_args)
if kmi_tuple in kmi_unique_args:
kmi_args = None
else:
break
if kmi_args is not None:
kmi = keymap.keymap_items.new(idname="wm.tool_set_by_id", value='PRESS', **kmi_args)
kmi.properties.name = item.idname
item_container[2] = kmi
kmi_unique_args.add(kmi_tuple)
key = kmi_type_dupe.get(kmi_args["type"])
if key is not None:
kmi_args["type"] = key
kmi_tuple = dict_as_tuple(kmi_args)
if kmi_tuple not in kmi_unique_args:
kmi = keymap.keymap_items.new(idname="wm.tool_set_by_id", value='PRESS', **kmi_args)
kmi.properties.name = item.idname
kmi_unique_args.add(kmi_tuple)
# ---------------------
# End Keymap Generation
if use_hack_properties:
keymap.keymap_items.remove(kmi_hack)
# Keep last so we can try add a key without any modifiers
# in the case this toolbar was activated with modifiers.
if use_tap_reset:
if len(kmi_toolbar_args_type_only) == len(kmi_toolbar_args):
kmi_toolbar_args_available = kmi_toolbar_args
else:
# We have modifiers, see if we have a free key w/o modifiers.
kmi_toolbar_tuple = dict_as_tuple(kmi_toolbar_args_type_only)
if kmi_toolbar_tuple not in kmi_unique_args:
kmi_toolbar_args_available = kmi_toolbar_args_type_only
kmi_unique_args.add(kmi_toolbar_tuple)
else:
kmi_toolbar_args_available = kmi_toolbar_args
del kmi_toolbar_tuple
kmi = keymap.keymap_items.new(
"wm.tool_set_by_id",
value='DOUBLE_CLICK',
**kmi_toolbar_args_available,
)
kmi.properties.name = tap_reset_tool
if use_release_confirm and (kmi_toolbar_type is not None):
kmi = keymap.keymap_items.new(
"ui.button_execute",
type=kmi_toolbar_type,
value='RELEASE',
any=True,
)
kmi.properties.skip_depressed = True
wm.keyconfigs.update()
return keymap

View File

@@ -0,0 +1,248 @@
# SPDX-FileCopyrightText: 2011-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
__all__ = (
"generate",
)
def _km_expand_from_toolsystem(space_type, context_mode):
def _fn():
from bl_ui.space_toolsystem_common import ToolSelectPanelHelper
for cls in ToolSelectPanelHelper.__subclasses__():
if cls.bl_space_type == space_type:
return cls.keymap_ui_hierarchy(context_mode)
raise Exception("keymap not found")
return _fn
def _km_hierarchy_iter_recursive(items):
for sub in items:
if callable(sub):
yield from sub()
else:
yield (*sub[:3], list(_km_hierarchy_iter_recursive(sub[3])))
def generate():
import bpy
if bpy.app.background:
from bl_ui.space_toolsystem_common import ToolSelectPanelHelper
for cls in ToolSelectPanelHelper.__subclasses__():
cls.register_ensure()
return list(_km_hierarchy_iter_recursive(_km_hierarchy))
# bpy.type.KeyMap: (km.name, km.space_type, km.region_type, [...])
# ('Script', 'EMPTY', 'WINDOW', []),
# Access via 'km_hierarchy'.
_km_hierarchy = [
('Window', 'EMPTY', 'WINDOW', []), # file save, window change, exit
('Screen', 'EMPTY', 'WINDOW', [ # full screen, undo, screenshot
('Screen Editing', 'EMPTY', 'WINDOW', []), # re-sizing, action corners
('Region Context Menu', 'EMPTY', 'WINDOW', []), # header/footer/navigation_bar stuff (per region)
]),
('View2D', 'EMPTY', 'WINDOW', []), # view 2d navigation (per region)
('View2D Buttons List', 'EMPTY', 'WINDOW', []), # view 2d with buttons navigation
('User Interface', 'EMPTY', 'WINDOW', []),
('3D View', 'VIEW_3D', 'WINDOW', [ # view 3d navigation and generic stuff (select, transform)
('Object Mode', 'EMPTY', 'WINDOW', [
_km_expand_from_toolsystem('VIEW_3D', 'OBJECT'),
]),
('Mesh', 'EMPTY', 'WINDOW', [
_km_expand_from_toolsystem('VIEW_3D', 'EDIT_MESH'),
]),
('Curve', 'EMPTY', 'WINDOW', [
_km_expand_from_toolsystem('VIEW_3D', 'EDIT_CURVE'),
]),
('Curves', 'EMPTY', 'WINDOW', []),
('Armature', 'EMPTY', 'WINDOW', [
_km_expand_from_toolsystem('VIEW_3D', 'EDIT_ARMATURE'),
]),
('Metaball', 'EMPTY', 'WINDOW', [
_km_expand_from_toolsystem('VIEW_3D', 'EDIT_METABALL'),
]),
('Lattice', 'EMPTY', 'WINDOW', [
_km_expand_from_toolsystem('VIEW_3D', 'EDIT_LATTICE'),
]),
('Font', 'EMPTY', 'WINDOW', [
_km_expand_from_toolsystem('VIEW_3D', 'EDIT_TEXT'),
]),
('Grease Pencil', 'EMPTY', 'WINDOW', []),
('Point Cloud', 'EMPTY', 'WINDOW', []),
('Pose', 'EMPTY', 'WINDOW', [
_km_expand_from_toolsystem('VIEW_3D', 'POSE'),
]),
('Vertex Paint', 'EMPTY', 'WINDOW', [
_km_expand_from_toolsystem('VIEW_3D', 'PAINT_VERTEX'),
]),
('Weight Paint', 'EMPTY', 'WINDOW', [
_km_expand_from_toolsystem('VIEW_3D', 'PAINT_WEIGHT'),
]),
('Paint Vertex Selection (Weight, Vertex)', 'EMPTY', 'WINDOW', []),
('Paint Face Mask (Weight, Vertex, Texture)', 'EMPTY', 'WINDOW', []),
# image and view3d
('Image Paint', 'EMPTY', 'WINDOW', [
_km_expand_from_toolsystem('VIEW_3D', 'PAINT_TEXTURE'),
]),
('Sculpt', 'EMPTY', 'WINDOW', [
_km_expand_from_toolsystem('VIEW_3D', 'SCULPT'),
]),
('Sculpt Curves', 'EMPTY', 'WINDOW', [
_km_expand_from_toolsystem('VIEW_3D', 'CURVES_SCULPT'),
]),
('Particle', 'EMPTY', 'WINDOW', [
_km_expand_from_toolsystem('VIEW_3D', 'PARTICLE'),
]),
('Primitive Tool Modal Map', 'EMPTY', 'WINDOW', []),
('Pen Tool Modal Map', 'EMPTY', 'WINDOW', []),
('Knife Tool Modal Map', 'EMPTY', 'WINDOW', []),
('Custom Normals Modal Map', 'EMPTY', 'WINDOW', []),
('Bevel Modal Map', 'EMPTY', 'WINDOW', []),
('Paint Stroke Modal', 'EMPTY', 'WINDOW', []),
('Sculpt Expand Modal', 'EMPTY', 'WINDOW', []),
('Paint Curve', 'EMPTY', 'WINDOW', []),
('Curve Pen Modal Map', 'EMPTY', 'WINDOW', []),
('Object Non-modal', 'EMPTY', 'WINDOW', []), # mode change
('View3D Placement Modal', 'EMPTY', 'WINDOW', []),
('View3D Walk Modal', 'EMPTY', 'WINDOW', []),
('View3D Fly Modal', 'EMPTY', 'WINDOW', []),
('View3D Rotate Modal', 'EMPTY', 'WINDOW', []),
('View3D Move Modal', 'EMPTY', 'WINDOW', []),
('View3D Zoom Modal', 'EMPTY', 'WINDOW', []),
('View3D Dolly Modal', 'EMPTY', 'WINDOW', []),
('View3D VR Location Scouting Capture Review Modal', 'EMPTY', 'WINDOW', []),
# toolbar and properties
('3D View Generic', 'VIEW_3D', 'WINDOW', [
_km_expand_from_toolsystem('VIEW_3D', None),
]),
]),
('Graph Editor', 'GRAPH_EDITOR', 'WINDOW', [
('Graph Editor Generic', 'GRAPH_EDITOR', 'WINDOW', []),
]),
('Dopesheet', 'DOPESHEET_EDITOR', 'WINDOW', [
('Dopesheet Generic', 'DOPESHEET_EDITOR', 'WINDOW', []),
]),
('NLA Editor', 'NLA_EDITOR', 'WINDOW', [
('NLA Tracks', 'NLA_EDITOR', 'WINDOW', []),
('NLA Generic', 'NLA_EDITOR', 'WINDOW', []),
]),
('Image', 'IMAGE_EDITOR', 'WINDOW', [
# Image (reverse order, UVEdit before Image).
('UV Editor', 'EMPTY', 'WINDOW', [
_km_expand_from_toolsystem('IMAGE_EDITOR', 'UV'),
]),
('UV Sculpt', 'EMPTY', 'WINDOW', []),
# Image and view3d.
('Image Paint', 'EMPTY', 'WINDOW', [
_km_expand_from_toolsystem('IMAGE_EDITOR', 'PAINT'),
]),
('Image View', 'IMAGE_EDITOR', 'WINDOW', [
_km_expand_from_toolsystem('IMAGE_EDITOR', 'VIEW'),
]),
('Image Generic', 'IMAGE_EDITOR', 'WINDOW', [
_km_expand_from_toolsystem('IMAGE_EDITOR', None),
]),
]),
('Outliner', 'OUTLINER', 'WINDOW', []),
('Node Editor', 'NODE_EDITOR', 'WINDOW', [
('Node Generic', 'NODE_EDITOR', 'WINDOW', []),
('Node Tool: Tweak', 'NODE_EDITOR', 'WINDOW', []),
('Node Tool: Tweak (fallback)', 'NODE_EDITOR', 'WINDOW', []),
('Node Tool: Select Box', 'NODE_EDITOR', 'WINDOW', []),
('Node Tool: Select Box (fallback)', 'NODE_EDITOR', 'WINDOW', []),
('Node Tool: Select Lasso', 'NODE_EDITOR', 'WINDOW', []),
('Node Tool: Select Lasso (fallback)', 'NODE_EDITOR', 'WINDOW', []),
('Node Tool: Select Circle', 'NODE_EDITOR', 'WINDOW', []),
('Node Tool: Select Circle (fallback)', 'NODE_EDITOR', 'WINDOW', []),
]),
('Spreadsheet Generic', 'SPREADSHEET', 'WINDOW', []),
('Video Sequence Editor', 'SEQUENCE_EDITOR', 'WINDOW', [
('Sequencer', 'SEQUENCE_EDITOR', 'WINDOW', [
_km_expand_from_toolsystem('SEQUENCE_EDITOR', 'SEQUENCER'),
]),
('Preview', 'SEQUENCE_EDITOR', 'WINDOW', [
_km_expand_from_toolsystem('SEQUENCE_EDITOR', 'PREVIEW'),
]),
]),
('File Browser', 'FILE_BROWSER', 'WINDOW', [
('File Browser Main', 'FILE_BROWSER', 'WINDOW', []),
('File Browser Buttons', 'FILE_BROWSER', 'WINDOW', []),
]),
('Info', 'INFO', 'WINDOW', []),
('Property Editor', 'PROPERTIES', 'WINDOW', []), # align context menu
('Text', 'TEXT_EDITOR', 'WINDOW', [
('Text Generic', 'TEXT_EDITOR', 'WINDOW', []),
]),
('Console', 'CONSOLE', 'WINDOW', []),
('Clip', 'CLIP_EDITOR', 'WINDOW', [
('Clip Editor', 'CLIP_EDITOR', 'WINDOW', []),
('Clip Graph Editor', 'CLIP_EDITOR', 'WINDOW', []),
('Clip Dopesheet Editor', 'CLIP_EDITOR', 'WINDOW', []),
]),
('Grease Pencil', 'EMPTY', 'WINDOW', [
# Grease Pencil
('Grease Pencil Draw Mode', 'EMPTY', 'WINDOW', []),
('Grease Pencil Brush Stroke', 'EMPTY', 'WINDOW', []),
('Grease Pencil Edit Mode', 'EMPTY', 'WINDOW', []),
('Grease Pencil Sculpt Mode', 'EMPTY', 'WINDOW', []),
('Grease Pencil Weight Paint', 'EMPTY', 'WINDOW', []),
('Grease Pencil Vertex Paint', 'EMPTY', 'WINDOW', []),
# Grease Pencil Fill Tool
('Grease Pencil Fill Tool', 'EMPTY', 'WINDOW', []),
]),
('Mask Editing', 'EMPTY', 'WINDOW', []),
('Frames', 'EMPTY', 'WINDOW', []), # frame navigation (per region)
('Markers', 'EMPTY', 'WINDOW', []), # markers (per region)
('Animation', 'EMPTY', 'WINDOW', []), # frame change on click, preview range (per region)
('Animation Channels', 'EMPTY', 'WINDOW', []),
('View3D Gesture Circle', 'EMPTY', 'WINDOW', []),
('Gesture Straight Line', 'EMPTY', 'WINDOW', []),
('Gesture Zoom Border', 'EMPTY', 'WINDOW', []),
('Gesture Box', 'EMPTY', 'WINDOW', []),
('Standard Modal Map', 'EMPTY', 'WINDOW', []),
('Transform Modal Map', 'EMPTY', 'WINDOW', []),
('Eyedropper Modal Map', 'EMPTY', 'WINDOW', []),
('Eyedropper ColorRamp PointSampling Map', 'EMPTY', 'WINDOW', []),
('Mesh Filter Modal Map', 'EMPTY', 'WINDOW', []),
# Grease Pencil Fill Tool
('Fill Tool Modal Map', 'EMPTY', 'WINDOW', []),
('Generic Gizmo', 'EMPTY', 'WINDOW', [
('Generic Gizmo Drag', 'EMPTY', 'WINDOW', []),
('Generic Gizmo Click Drag', 'EMPTY', 'WINDOW', []),
('Generic Gizmo Maybe Drag', 'EMPTY', 'WINDOW', []),
('Generic Gizmo Select', 'EMPTY', 'WINDOW', []),
('Generic Gizmo Tweak Modal Map', 'EMPTY', 'WINDOW', []),
]),
]

View File

@@ -0,0 +1,55 @@
# SPDX-FileCopyrightText: 2019-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
__all__ = (
"keyconfig_data_oskey_from_ctrl",
"keyconfig_data_oskey_from_ctrl_for_macos",
)
def keyconfig_data_oskey_from_ctrl(keyconfig_data_src, *, filter_fn=None):
keyconfig_data_dst = []
for km_name, km_parms, km_items_data_src in keyconfig_data_src:
km_items_data_dst = km_items_data_src.copy()
items_dst = []
km_items_data_dst["items"] = items_dst
for item_src in km_items_data_src["items"]:
item_op, item_event, item_prop = item_src
if "ctrl" in item_event:
if filter_fn is None or filter_fn(item_event):
item_event = item_event.copy()
item_event["oskey"] = item_event["ctrl"]
del item_event["ctrl"]
items_dst.append((item_op, item_event, item_prop))
items_dst.append(item_src)
keyconfig_data_dst.append((km_name, km_parms, km_items_data_dst))
return keyconfig_data_dst
def keyconfig_data_oskey_from_ctrl_for_macos(keyconfig_data_src):
"""Use for apple since Cmd is typically used in-place of Ctrl."""
def filter_fn(item_event):
if item_event.get("ctrl"):
event_type = item_event["type"]
# Ctrl-{Key}
if (event_type in {
'H',
'M',
'SPACE',
'W',
'ACCENT_GRAVE',
'PERIOD',
'TAB',
}):
if (not item_event.get("alt")) and (not item_event.get("shift")):
return False
# Ctrl-Alt-{Key}
if (event_type in {
'Q',
}):
if item_event.get("alt") and (not item_event.get("shift")):
return False
return True
return keyconfig_data_oskey_from_ctrl(keyconfig_data_src, filter_fn=filter_fn)

View File

@@ -0,0 +1,363 @@
# SPDX-FileCopyrightText: 2020-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
# Update Blender version this key-map was written in.
# The update runs when loading key-map presets written in older versions of Blender.
# Failing to run this means those key-maps may fail to load with an error.
#
# When the version is `(0, 0, 0)`, the key-map being loaded didn't contain any versioning information.
# This will older than `(2, 92, 0)`.
__all__ = (
"keyconfig_update",
)
def keyconfig_update(keyconfig_data, keyconfig_version):
import re
from bpy.app import version_file as blender_version
if keyconfig_version >= blender_version:
return keyconfig_data
# Version the key-map.
import copy
# Only copy once.
has_copy = False
def get_transform_modal_map():
for km_name, _km_params, km_items_data in keyconfig_data:
if km_name == "Transform Modal Map":
return km_items_data
return None
def get_ui_keymap():
for km_name, _km_params, km_items_data in keyconfig_data:
if km_name == "User Interface":
return km_items_data
return None
def remove_properties(op_prop_map):
nonlocal keyconfig_data
nonlocal has_copy
changed_items = []
for km_index, (_km_name, _km_parms, km_items_data) in enumerate(keyconfig_data):
for kmi_item_index, (item_op, item_event, item_prop) in enumerate(km_items_data["items"]):
if item_prop and item_op in op_prop_map:
properties = item_prop.get("properties", [])
filtered_properties = [
prop for prop in properties if not any(
key in prop for key in op_prop_map[item_op])
]
if not filtered_properties:
filtered_properties = None
if filtered_properties is None or len(filtered_properties) < len(properties):
changed_items.append((km_index, kmi_item_index, filtered_properties))
if changed_items:
if not has_copy:
keyconfig_data = copy.deepcopy(keyconfig_data)
has_copy = True
for km_index, kmi_item_index, filtered_properties in changed_items:
item_op, item_event, item_prop = keyconfig_data[km_index][2]["items"][kmi_item_index]
item_prop["properties"] = filtered_properties
keyconfig_data[km_index][2]["items"][kmi_item_index] = (item_op, item_event, item_prop)
def rename_keymap(km_name_map):
nonlocal keyconfig_data
nonlocal has_copy
for km_index, (km_name, km_parms, km_items_data) in enumerate(keyconfig_data):
km_name_dst = km_name_map.get(km_name)
if km_name_dst is None:
continue
if not has_copy:
keyconfig_data = copy.deepcopy(keyconfig_data)
has_copy = True
keyconfig_data[km_index] = (km_name_dst, km_parms, km_items_data)
# Default repeat to false.
if keyconfig_version <= (2, 92, 0):
if not has_copy:
keyconfig_data = copy.deepcopy(keyconfig_data)
has_copy = True
for _km_name, _km_parms, km_items_data in keyconfig_data:
for (_item_op, item_event, _item_prop) in km_items_data["items"]:
if item_event.get("value") == 'PRESS':
# Unfortunately we don't know the `map_type` at this point.
# Setting repeat true on other kinds of events is harmless.
item_event["repeat"] = True
if keyconfig_version <= (3, 2, 5):
if not has_copy:
keyconfig_data = copy.deepcopy(keyconfig_data)
has_copy = True
for _km_name, _km_parms, km_items_data in keyconfig_data:
for (_item_op, item_event, _item_prop) in km_items_data["items"]:
if ty_new := {
'EVT_TWEAK_L': 'LEFTMOUSE',
'EVT_TWEAK_M': 'MIDDLEMOUSE',
'EVT_TWEAK_R': 'RIGHTMOUSE',
}.get(item_event.get("type")):
item_event["type"] = ty_new
if (value := item_event["value"]) != 'ANY':
item_event["direction"] = value
item_event["value"] = 'CLICK_DRAG'
if keyconfig_version <= (3, 2, 6):
if not has_copy:
keyconfig_data = copy.deepcopy(keyconfig_data)
has_copy = True
for _km_name, _km_parms, km_items_data in keyconfig_data:
for (_item_op, item_event, _item_prop) in km_items_data["items"]:
if ty_new := {
'NDOF_BUTTON_ESC': 'ESC',
'NDOF_BUTTON_ALT': 'LEFT_ALT',
'NDOF_BUTTON_SHIFT': 'LEFT_SHIFT',
'NDOF_BUTTON_CTRL': 'LEFT_CTRL',
}.get(item_event.get("type")):
item_event["type"] = ty_new
if keyconfig_version <= (3, 6, 0):
# The modal keys "Vert/Edge Slide" and "TrackBall" didn't exist until then.
# The operator reused the "Move" and "Rotate" respectively.
if not has_copy:
keyconfig_data = copy.deepcopy(keyconfig_data)
has_copy = True
if km_items_data := get_transform_modal_map():
km_items = km_items_data["items"]
for (item_modal, item_event, _item_prop) in km_items:
if item_modal == 'TRANSLATE':
km_items.append(('VERT_EDGE_SLIDE', item_event, None))
elif item_modal == 'ROTATE':
km_items.append(('TRACKBALL', item_event, None))
# The modal key for "Rotate Normals" also didn't exist until then.
km_items.append(('ROTATE_NORMALS', {"type": 'N', "value": 'PRESS'}, None))
if keyconfig_version <= (4, 0, 3):
if not has_copy:
keyconfig_data = copy.deepcopy(keyconfig_data)
has_copy = True
# "Snap Source Toggle" did not exist until then.
if km_items_data := get_transform_modal_map():
km_items_data["items"].append(("EDIT_SNAP_SOURCE_ON", {"type": 'B', "value": 'PRESS'}, None))
km_items_data["items"].append(("EDIT_SNAP_SOURCE_OFF", {"type": 'B', "value": 'PRESS'}, None))
if keyconfig_version <= (4, 1, 5):
remove_properties({
"transform.edge_slide": ["alt_navigation"],
"transform.resize": ["alt_navigation"],
"transform.rotate": ["alt_navigation"],
"transform.shrink_fatten": ["alt_navigation"],
"transform.transform": ["alt_navigation"],
"transform.translate": ["alt_navigation"],
"transform.vert_slide": ["alt_navigation"],
"view3d.edit_mesh_extrude_move_normal": ["alt_navigation"],
"armature.extrude_move": ["TRANSFORM_OT_translate"],
"curve.extrude_move": ["TRANSFORM_OT_translate"],
"gpencil.extrude_move": ["TRANSFORM_OT_translate"],
"mesh.rip_edge_move": ["TRANSFORM_OT_translate"],
"mesh.duplicate_move": ["TRANSFORM_OT_translate"],
"object.duplicate_move": ["TRANSFORM_OT_translate"],
"object.duplicate_move_linked": ["TRANSFORM_OT_translate"],
})
if km_items_data := get_transform_modal_map():
def use_alt_navigate():
km_item = next(
(i for i in km_items_data["items"] if i[0] ==
"PROPORTIONAL_SIZE" and i[1]["type"] == 'TRACKPADPAN'),
None,
)
if km_item:
return "alt" not in km_item[1] or km_item[1]["alt"] is False
# Fallback.
import bpy
return getattr(
bpy.context.window_manager.keyconfigs.active.preferences,
"use_alt_navigation",
False)
if use_alt_navigate():
if not has_copy:
keyconfig_data = copy.deepcopy(keyconfig_data)
has_copy = True
km_items_data = get_transform_modal_map()
km_items_data["items"].append(
("PASSTHROUGH_NAVIGATE", {"type": 'LEFT_ALT', "value": 'ANY', "any": True}, None))
if keyconfig_version <= (4, 1, 21):
rename_keymap({"NLA Channels": "NLA Tracks"})
if keyconfig_version <= (4, 5, 10):
rename_keymap({"SequencerCommon": "Video Sequence Editor"})
rename_keymap({"SequencerPreview": "Preview"})
mappings = [
("Sequencer Timeline Tool: Select Box", "Sequencer Tool: Select Box"),
("Sequencer Preview Tool: Tweak", "Preview Tool: Tweak"),
("Sequencer Preview Tool: Select Box", "Preview Tool: Select Box"),
]
for old, new in mappings:
rename_keymap({old: new})
rename_keymap({f"{old} (fallback)": f"{new} (fallback)"})
rename_keymap({"Sequencer Tool: Cursor": "Preview Tool: Cursor"})
rename_keymap({"Sequencer Tool: Sample": "Preview Tool: Sample"})
rename_keymap({"Sequencer Tool: Move": "Preview Tool: Move"})
rename_keymap({"Sequencer Tool: Rotate": "Preview Tool: Rotate"})
rename_keymap({"Sequencer Tool: Scale": "Preview Tool: Scale"})
if keyconfig_version < (5, 0, 53):
if not has_copy:
keyconfig_data = copy.deepcopy(keyconfig_data)
has_copy = True
# The `unified_paint_setting` struct was moved from `tool_settings` to be a sub-property of a given individual
# paint type.
#
# The following conversion maps from the old values of
# `tool_settings.unified_paint_settings.<property_name>`
# to
# `tool_settings.<paint_mode>.unified_paint_settings.<property_name>`
# where <paint_mode> is retrieved from the `data_path_primary` property
#
# Example:
# `tool_settings.unified_paint_settings.size`
# and
# `tool_settings.unified_paint_settings.use_unified_size`
# for an operator with
# `tool_settings.sculpt.brush.size`
# become
# `tool_settings.sculpt.unified_paint_settings.size`
# and
# `tool_settings.sculpt.unified_paint_settings.use_unified_size`
# Match paths of the form 'tool_settings.<paint_mode>.brush.<remaining_path>'
re_toolsetting_brush = re.compile(r"^(tool_settings)\.([a-z_]+)\.(brush)\.(.*)")
for _km_name, _km_parms, km_items_data in keyconfig_data:
for (item_op, _item_event, item_prop) in km_items_data["items"]:
if item_op == "wm.radial_control":
updated_path_elements = []
secondary_path_index = -1
secondary_path_identifier = ""
toggle_path_index = -1
toggle_path_identifier = ""
for prop_index, (prop_id, prop_path) in enumerate(item_prop["properties"]):
if prop_id == "data_path_primary":
if re_toolsetting_brush.fullmatch(prop_path):
# Example:
# 'tool_settings.sculpt.brush.size'
# results in
# ['tool_settings', 'sculpt', 'unified_paint_settings']
updated_path_elements = prop_path.split(".")[0:2]
updated_path_elements.append("unified_paint_settings")
elif prop_id == "data_path_secondary":
if prop_path.startswith("tool_settings.unified_paint_settings."):
# Example:
# 'tool_settings.unified_paint_settings.size'
# results in
# 'size'
secondary_path_index = prop_index
secondary_path_identifier = prop_path.split(".", 2)[-1]
elif prop_id == "use_secondary":
if prop_path.startswith("tool_settings.unified_paint_settings."):
# Example:
# 'tool_settings.unified_paint_settings.use_unified_size'
# results in
# 'use_unified_size'
toggle_path_index = prop_index
toggle_path_identifier = prop_path.split(".", 2)[-1]
if updated_path_elements and secondary_path_index != -1 and toggle_path_index != -1:
item_prop["properties"][secondary_path_index] = (
"data_path_secondary", ".".join((*updated_path_elements, secondary_path_identifier)))
item_prop["properties"][toggle_path_index] = (
"use_secondary", ".".join((*updated_path_elements, toggle_path_identifier)))
if keyconfig_version < (5, 1, 6):
has_view_select = False
has_view_scroll = False
if km_ui_items_data := get_ui_keymap():
for (item_op, _item_event, _item_prop) in km_ui_items_data["items"]:
if item_op == "ui.view_item_select":
has_view_select = True
if item_op == "ui.view_scroll":
has_view_scroll = True
if not has_view_select:
if not has_copy:
keyconfig_data = copy.deepcopy(keyconfig_data)
has_copy = True
km_ui_items_data = get_ui_keymap()
select_items = [
("ui.view_item_select", {"type": 'LEFTMOUSE', "value": 'PRESS'}, None),
("ui.view_item_select", {"type": 'LEFTMOUSE', "value": 'PRESS', "ctrl": True},
{"properties": [("extend", True)]}),
("ui.view_item_select", {"type": 'LEFTMOUSE', "value": 'PRESS', "shift": True},
{"properties": [("range_select", True)]}),
]
km_ui_items_data["items"].extend(select_items)
if not has_view_scroll:
if not has_copy:
keyconfig_data = copy.deepcopy(keyconfig_data)
has_copy = True
km_ui_items_data = get_ui_keymap()
scroll_items = [
("ui.view_scroll", {"type": 'WHEELUPMOUSE', "value": 'ANY'}, None),
("ui.view_scroll", {"type": 'WHEELDOWNMOUSE', "value": 'ANY'}, None),
("ui.view_scroll", {"type": 'TRACKPADPAN', "value": 'ANY'}, None),
]
km_ui_items_data["items"].extend(scroll_items)
else:
print("Error versioning keymap: Missing \"User Interface\" keymap")
if keyconfig_version < (5, 1, 11):
rename_keymap({"Grease Pencil Paint Mode": "Grease Pencil Draw Mode"})
if keyconfig_version < (5, 2, 21):
if not has_copy:
keyconfig_data = copy.deepcopy(keyconfig_data)
has_copy = True
for _km_name, _km_parms, km_items_data in keyconfig_data:
for (item_op, _item_event, item_prop) in km_items_data["items"]:
if item_op in {
"grease_pencil.brush_stroke",
"grease_pencil.sculpt_paint",
"paint.image_paint",
"paint.vertex_paint",
"paint.weight_paint",
"sculpt.brush_stroke",
"sculpt_curves.brush_stroke",
} and item_prop:
index_to_fix = -1
value_to_copy = None
for prop_index, (prop_id, prop_value) in enumerate(item_prop["properties"]):
if prop_id == "mode" and prop_value != 'INVERT':
# The 'INVERT' value does not need to be migrated, as it is still a valid enum value
index_to_fix = prop_index
value_to_copy = prop_value
break
if index_to_fix != -1:
item_prop["properties"][index_to_fix] = ("brush_toggle", value_to_copy)
return keyconfig_data

View File

@@ -0,0 +1,123 @@
#!/usr/bin/env python3
# SPDX-FileCopyrightText: 2010-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
# This module can get render info without running from inside blender.
__all__ = (
"read_blend_rend_chunk",
)
import _blendfile_header
class RawBlendFileReader:
"""
Return a file handle to the raw blend file data (abstracting compressed formats).
"""
__slots__ = (
# The path to load.
"_filepath",
# The file base file handler or None (only set for compressed formats).
"_blendfile_base",
# The file handler to return to the caller (always uncompressed data).
"_blendfile",
)
def __init__(self, filepath):
self._filepath = filepath
self._blendfile_base = None
self._blendfile = None
def __enter__(self):
blendfile = open(self._filepath, "rb")
blendfile_base = None
head = blendfile.read(4)
blendfile.seek(0)
if head[0:2] == b'\x1f\x8b': # GZIP magic.
import gzip
blendfile_base = blendfile
blendfile = gzip.open(blendfile, "rb")
elif head[0:4] == b'\x28\xb5\x2f\xfd': # Z-standard magic.
import zstandard
blendfile_base = blendfile
blendfile = zstandard.open(blendfile, "rb")
self._blendfile_base = blendfile_base
self._blendfile = blendfile
return self._blendfile
def __exit__(self, _exc_type, _exc_value, _exc_traceback):
self._blendfile.close()
if self._blendfile_base is not None:
self._blendfile_base.close()
return False
def get_render_info_structure(endian_str, size):
import struct
# The maximum size of the scene name changed over time, so create a different
# structure depending on the size of the entire block.
if size == 2 * 4 + 24:
return struct.Struct(endian_str + b'ii24s')
if size == 2 * 4 + 64:
return struct.Struct(endian_str + b'ii64s')
if size == 2 * 4 + 256:
return struct.Struct(endian_str + b'ii256s')
raise ValueError("Unknown REND chunk size: {:d}".format(size))
def _read_blend_rend_chunk_from_file(blendfile, filepath):
import sys
from os import SEEK_CUR
try:
blender_header = _blendfile_header.BlendFileHeader(blendfile)
except _blendfile_header.BlendHeaderError:
sys.stderr.write("Not a blend file: {:s}\n".format(filepath))
return []
scenes = []
endian_str = b'<' if blender_header.is_little_endian else b'>'
block_header_struct = blender_header.create_block_header_struct()
while bhead := _blendfile_header.BlockHeader(blendfile, block_header_struct):
if bhead.code == b'ENDB':
break
remaining_bytes = bhead.size
if bhead.code == b'REND':
rend_block_struct = get_render_info_structure(endian_str, bhead.size)
start_frame, end_frame, scene_name = rend_block_struct.unpack(blendfile.read(rend_block_struct.size))
remaining_bytes -= rend_block_struct.size
scene_name = scene_name[:scene_name.index(b'\0')]
# It's possible old blend files are not UTF8 compliant, use `surrogateescape`.
scene_name = scene_name.decode("utf8", errors="surrogateescape")
scenes.append((start_frame, end_frame, scene_name))
blendfile.seek(remaining_bytes, SEEK_CUR)
return scenes
def read_blend_rend_chunk(filepath):
with RawBlendFileReader(filepath) as blendfile:
return _read_blend_rend_chunk_from_file(blendfile, filepath)
def main():
import sys
for filepath in sys.argv[1:]:
for value in read_blend_rend_chunk(filepath):
print("{:d} {:d} {:s}".format(*value))
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,75 @@
# SPDX-FileCopyrightText: 2009-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
"""
Give access to blender data and utility functions.
"""
__all__ = (
"app",
"context",
"data",
"msgbus",
"ops",
"path",
"props",
"types",
"utils",
)
# Internal Blender C-API modules.
from _bpy import (
app,
context,
data,
msgbus,
props,
types,
)
# python modules
from . import (
ops,
path,
utils,
)
def main():
import sys
# Possibly temp. addons path
from os.path import join, dirname, exists
# It's unlikely this directory exists.
# Keep it so users can bundle their own add-ons with app-templates which share modules.
# Also keep this for consistency with the other `addons` directories.
# Check this exists because the bundled scripts should not be manipulated at run-time.
dirpath = join(dirname(dirname(dirname(__file__))), "addons_core", "modules")
if exists(dirpath):
sys.path.append(dirpath)
# Don't check if this exists as it may be created as part of installing add-ons.
sys.path.append(join(utils.user_resource('SCRIPTS'), "addons", "modules"))
# fake module to allow:
# from bpy.types import Panel
sys.modules.update({
"bpy.app": app,
"bpy.app.handlers": app.handlers,
"bpy.app.translations": app.translations,
"bpy.types": types,
})
# Initializes Python classes.
# (good place to run a profiler or trace).
# Postpone loading `extensions` scripts (add-ons & app-templates),
# until after the key-maps have been initialized.
utils.load_scripts(extensions=False)
main()
del main

View File

@@ -0,0 +1,65 @@
# SPDX-FileCopyrightText: 2009-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
# for slightly faster access
from _bpy import ops as _ops_module
# op_add = _ops_module.add
_op_dir = _ops_module.dir
_op_create_function = _ops_module.create_function
_ModuleType = type(_ops_module)
# -----------------------------------------------------------------------------
# Sub-Module Access
def _bpy_ops_submodule__getattr__(module, func):
# Return a `BPyOpsCallable` object that bypasses Python `__call__` overhead
# for improved operator execution performance.
if func.startswith("__"):
raise AttributeError(func)
return _op_create_function(module, func)
def _bpy_ops_submodule__dir__(module):
functions = set()
module_upper = module.upper()
for id_name in _op_dir():
id_split = id_name.split("_OT_", 1)
if len(id_split) == 2 and module_upper == id_split[0]:
functions.add(id_split[1])
return list(functions)
def _bpy_ops_submodule(module):
result = _ModuleType("bpy.ops." + module)
result.__getattr__ = lambda func: _bpy_ops_submodule__getattr__(module, func)
result.__dir__ = lambda: _bpy_ops_submodule__dir__(module)
return result
# -----------------------------------------------------------------------------
# Module Access
def __getattr__(module):
# Return a value from `bpy.ops.{module}`.
if module.startswith("__"):
raise AttributeError(module)
return _bpy_ops_submodule(module)
def __dir__():
submodules = set()
for id_name in _op_dir():
id_split = id_name.split("_OT_", 1)
if len(id_split) == 2:
submodules.add(id_split[0].lower())
else:
submodules.add(id_split[0])
return list(submodules)

View File

@@ -0,0 +1,465 @@
# SPDX-FileCopyrightText: 2010-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
"""
This module has a similar scope to os.path, containing utility
functions for dealing with paths in Blender.
"""
__all__ = (
"abspath",
"basename",
"clean_name",
"display_name",
"display_name_to_filepath",
"display_name_from_filepath",
"ensure_ext",
"extensions_image",
"extensions_movie",
"extensions_audio",
"is_subdir",
"module_names",
"native_pathsep",
"reduce_dirs",
"relpath",
"resolve_ncase",
)
import bpy as _bpy
import os as _os
from _bpy_path import (
extensions_audio,
extensions_movie,
extensions_image,
)
def _getattr_bytes(var, attr):
return var.path_resolve(attr, False).as_bytes()
def abspath(path, *, start=None, library=None):
"""
Returns the absolute path relative to the current blend file
using the "//" prefix.
:param path: The path to convert to absolute.
:type path: str | bytes
:param start: Relative to this path,
when not set the current filename is used.
:type start: str | bytes | None
:param library: The library this path is from. This is only included for
convenience, when the library is not None its path replaces *start*.
:type library: :class:`bpy.types.Library` | None
:return: The absolute path.
:rtype: str
"""
if isinstance(path, bytes):
if path.startswith(b"//"):
if library:
start = _os.path.dirname(
abspath(_getattr_bytes(library, "filepath")))
return _os.path.join(
_os.path.dirname(_getattr_bytes(_bpy.data, "filepath"))
if start is None else start,
path[2:],
)
else:
if path.startswith("//"):
if library:
start = _os.path.dirname(
abspath(library.filepath))
return _os.path.join(
_os.path.dirname(_bpy.data.filepath)
if start is None else start,
path[2:],
)
return path
def relpath(path, *, start=None):
"""
Returns the path relative to the current blend file using the "//" prefix.
:param path: An absolute path.
:type path: str | bytes
:param start: Relative to this path,
when not set the current filename is used.
:type start: str | bytes | None
:return: The relative path.
:rtype: str
"""
if isinstance(path, bytes):
if not path.startswith(b"//"):
if start is None:
start = _os.path.dirname(_getattr_bytes(_bpy.data, "filepath"))
return b"//" + _os.path.relpath(path, start)
else:
if not path.startswith("//"):
if start is None:
start = _os.path.dirname(_bpy.data.filepath)
return "//" + _os.path.relpath(path, start)
return path
def is_subdir(path, directory):
"""
Returns true if *path* is in a subdirectory of *directory*.
Both paths must be absolute.
:param path: An absolute path.
:type path: str | bytes
:param directory: The parent directory to check against.
:type directory: str | bytes
:return: Whether or not the path is a subdirectory.
:rtype: bool
"""
from os.path import normpath, normcase, sep
path = normpath(normcase(path))
directory = normpath(normcase(directory))
if len(path) > len(directory):
sep = sep.encode('ascii') if isinstance(directory, bytes) else sep
if path.startswith(directory.rstrip(sep) + sep):
return True
return False
def clean_name(name, *, replace="_"):
"""
Returns a name with characters replaced that
may cause problems under various circumstances,
such as writing to a file.
All characters besides A-Z/a-z, 0-9 are replaced with "_"
or the *replace* argument if defined.
:param name: The path name.
:type name: str | bytes
:param replace: The replacement for non-valid characters.
:type replace: str
:return: The cleaned name.
:rtype: str
"""
if replace != "_":
if len(replace) != 1 or ord(replace) > 255:
raise ValueError("Value must be a single ascii character")
def maketrans_init():
trans_cache = clean_name._trans_cache
trans = trans_cache.get(replace)
if trans is None:
bad_chars = (
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07,
0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f,
0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17,
0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f,
0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27,
0x28, 0x29, 0x2a, 0x2b, 0x2c, 0x2e, 0x2f, 0x3a,
0x3b, 0x3c, 0x3d, 0x3e, 0x3f, 0x40, 0x5b, 0x5c,
0x5d, 0x5e, 0x60, 0x7b, 0x7c, 0x7d, 0x7e, 0x7f,
0x80, 0x81, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87,
0x88, 0x89, 0x8a, 0x8b, 0x8c, 0x8d, 0x8e, 0x8f,
0x90, 0x91, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97,
0x98, 0x99, 0x9a, 0x9b, 0x9c, 0x9d, 0x9e, 0x9f,
0xa0, 0xa1, 0xa2, 0xa3, 0xa4, 0xa5, 0xa6, 0xa7,
0xa8, 0xa9, 0xaa, 0xab, 0xac, 0xad, 0xae, 0xaf,
0xb0, 0xb1, 0xb2, 0xb3, 0xb4, 0xb5, 0xb6, 0xb7,
0xb8, 0xb9, 0xba, 0xbb, 0xbc, 0xbd, 0xbe, 0xbf,
0xc0, 0xc1, 0xc2, 0xc3, 0xc4, 0xc5, 0xc6, 0xc7,
0xc8, 0xc9, 0xca, 0xcb, 0xcc, 0xcd, 0xce, 0xcf,
0xd0, 0xd1, 0xd2, 0xd3, 0xd4, 0xd5, 0xd6, 0xd7,
0xd8, 0xd9, 0xda, 0xdb, 0xdc, 0xdd, 0xde, 0xdf,
0xe0, 0xe1, 0xe2, 0xe3, 0xe4, 0xe5, 0xe6, 0xe7,
0xe8, 0xe9, 0xea, 0xeb, 0xec, 0xed, 0xee, 0xef,
0xf0, 0xf1, 0xf2, 0xf3, 0xf4, 0xf5, 0xf6, 0xf7,
0xf8, 0xf9, 0xfa, 0xfb, 0xfc, 0xfd, 0xfe,
)
trans = str.maketrans({char: replace for char in bad_chars})
trans_cache[replace] = trans
return trans
trans = maketrans_init()
return name.translate(trans)
clean_name._trans_cache = {}
def _clean_utf8(name):
if type(name) is bytes:
return name.decode("utf8", "replace")
else:
return name.encode("utf8", "replace").decode("utf8")
_display_name_literals = {
":": "_colon_",
"+": "_plus_",
"/": "_slash_",
}
def display_name(name, *, has_ext=True, title_case=True):
"""
Creates a display string from name to be used in menus and the user interface.
Intended for use with filenames and module names.
:param name: The name to be used for displaying the user interface.
:type name: str
:param has_ext: Remove file extension from name.
:type has_ext: bool
:param title_case: Convert lowercase names to title case.
:type title_case: bool
:return: The display string.
:rtype: str
"""
if has_ext:
name = _os.path.splitext(basename(name))[0]
# string replacements
for disp_value, file_value in _display_name_literals.items():
name = name.replace(file_value, disp_value)
# strip to allow underscore prefix
# (when paths can't start with numbers for eg).
name = name.replace("_", " ").lstrip(" ")
if title_case and name.islower():
name = name.lower().title()
name = _clean_utf8(name)
return name
def display_name_to_filepath(name):
"""
Performs the reverse of display_name using literal versions of characters
which aren't supported in a filepath.
:param name: The display name to convert.
:type name: str
:return: The file path.
:rtype: str
"""
for disp_value, file_value in _display_name_literals.items():
name = name.replace(disp_value, file_value)
return name
def display_name_from_filepath(name):
"""
Returns the path stripped of directory and extension,
ensured to be UTF-8 compatible.
:param name: The file path to convert.
:type name: str
:return: The display name.
:rtype: str
"""
name = _os.path.splitext(basename(name))[0]
name = _clean_utf8(name)
return name
def resolve_ncase(path):
"""
Resolve a case insensitive path on a case sensitive system,
returning a string with the path if found else return the original path.
:param path: The path name to resolve.
:type path: str
:return: The resolved path.
:rtype: str
"""
def _ncase_path_found(path):
if not path or _os.path.exists(path):
return path, True
# filename may be a directory or a file
filename = _os.path.basename(path)
dirpath = _os.path.dirname(path)
suffix = path[:0] # "" but ensure byte/str match
if not filename: # Check if the directory ends with a slash.
if len(dirpath) < len(path):
suffix = path[:len(path) - len(dirpath)]
filename = _os.path.basename(dirpath)
dirpath = _os.path.dirname(dirpath)
if not _os.path.exists(dirpath):
if dirpath == path:
return path, False
dirpath, found = _ncase_path_found(dirpath)
if not found:
return path, False
# at this point, the directory exists but not the file
# we are expecting 'dirpath' to be a directory, but it could be a file
if _os.path.isdir(dirpath):
try:
files = _os.listdir(dirpath)
except PermissionError:
# We might not have the permission to list dirpath...
return path, False
else:
return path, False
filename_low = filename.lower()
f_iter_nocase = None
for f_iter in files:
if f_iter.lower() == filename_low:
f_iter_nocase = f_iter
break
if f_iter_nocase:
return _os.path.join(dirpath, f_iter_nocase) + suffix, True
else:
# can't find the right one, just return the path as is.
return path, False
ncase_path, found = _ncase_path_found(path)
return ncase_path if found else path
def ensure_ext(filepath, ext, *, case_sensitive=False):
"""
Return the path with the extension added if it is not already set.
:param filepath: The file path.
:type filepath: str
:param ext: The extension to check for, can be a compound extension. Should
start with a dot, such as ``.blend`` or ``.tar.gz``.
:type ext: str
:param case_sensitive: Check for matching case when comparing extensions.
:type case_sensitive: bool
:return: The file path with the given extension.
:rtype: str
"""
if case_sensitive:
if filepath.endswith(ext):
return filepath
else:
if filepath[-len(ext):].lower().endswith(ext.lower()):
return filepath
return filepath + ext
def module_names(path, *, recursive=False, package=""):
"""
Return a list of modules which can be imported from *path*.
:param path: a directory to scan.
:type path: str
:param recursive: Also return submodule names for packages.
:type recursive: bool
:param package: Optional string, used as the prefix for module names (without the trailing ".").
:type package: str
:return: a list of string pairs (module_name, module_file).
:rtype: list[tuple[str, str]]
"""
from os.path import join, isfile
modules = []
package_prefix = (package + ".") if package else ""
for filename in sorted(_os.listdir(path)):
if (filename == "modules") and (not package_prefix):
pass # XXX, hard coded exception.
elif filename.endswith(".py") and filename != "__init__.py":
fullpath = join(path, filename)
modules.append((package_prefix + filename[0:-3], fullpath))
elif not filename.startswith("."):
# Skip hidden files since they are used for version control.
directory = join(path, filename)
fullpath = join(directory, "__init__.py")
if isfile(fullpath):
modules.append((package_prefix + filename, fullpath))
if recursive:
for mod_name, mod_path in module_names(directory, recursive=True):
modules.append((
"{:s}.{:s}".format(package_prefix + filename, mod_name),
mod_path,
))
return modules
def basename(path):
"""
Equivalent to ``os.path.basename``, but skips a "//" prefix.
Use for Windows compatibility.
:param path: The path to get the base name of.
:type path: str | bytes
:return: The base name of the given path.
:rtype: str
"""
return _os.path.basename(path[2:] if path[:2] in {"//", b"//"} else path)
def native_pathsep(path):
"""
Replace the path separator with the system's native ``os.sep``.
:param path: The path to replace.
:type path: str
:return: The path with system native separators.
:rtype: str
"""
if type(path) is str:
if _os.sep == "/":
return path.replace("\\", "/")
else:
if path.startswith("//"):
return "//" + path[2:].replace("/", "\\")
else:
return path.replace("/", "\\")
else: # bytes
if _os.sep == "/":
return path.replace(b"\\", b"/")
else:
if path.startswith(b"//"):
return b"//" + path[2:].replace(b"/", b"\\")
else:
return path.replace(b"/", b"\\")
def reduce_dirs(dirs):
"""
Given a sequence of directories, remove duplicates and
any directories nested in one of the other paths.
(Useful for recursive path searching).
:param dirs: Sequence of directory paths.
:type dirs: Sequence[str]
:return: A unique list of paths.
:rtype: list[str]
"""
dirs = list({_os.path.normpath(_os.path.abspath(d)) for d in dirs})
dirs.sort(key=lambda d: len(d))
for i in range(len(dirs) - 1, -1, -1):
for j in range(i):
if len(dirs[i]) == len(dirs[j]):
break
elif is_subdir(dirs[i], dirs[j]):
del dirs[i]
break
return dirs

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,136 @@
# SPDX-FileCopyrightText: 2015-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
"""
This module contains utility functions to handle custom previews.
It behaves as a high-level 'cached' previews manager.
This allows scripts to generate their own previews, and use them as icons in UI widgets
('icon_value' for UILayout functions).
Custom Icon Example
-------------------
.. literalinclude:: __/__/__/scripts/templates_py/UI/previews_custom_icon.py
"""
__all__ = (
"new",
"remove",
"ImagePreviewCollection",
)
from _bpy import _utils_previews
_uuid_open = set()
# High-level previews manager.
# not accessed directly
class ImagePreviewCollection(dict):
"""
Dictionary-like class of previews.
This is a subclass of Python's built-in dict type,
used to store multiple image previews.
.. note::
- instance with :mod:`bpy.utils.previews.new`
- keys must be ``str`` type.
- values will be :class:`bpy.types.ImagePreview`
"""
# Internal notes:
# - Blender's internal 'PreviewImage' struct uses 'self._uuid' prefix.
# - Blender's preview.new/load return the data if it exists,
# don't do this for the Python API as it allows accidental re-use of names,
# anyone who wants to reuse names can use dict.get() to check if it exists.
# We could use this for the C API too (would need some investigation).
def __init__(self):
super().__init__()
self._uuid = hex(id(self))
_uuid_open.add(self._uuid)
def __del__(self):
if self._uuid not in _uuid_open:
return
raise ResourceWarning(
"{!r}: left open, remove with 'bpy.utils.previews.remove()'".format(self)
)
self.close()
def _gen_key(self, name):
return ":".join((self._uuid, name))
def new(self, name):
if name in self:
raise KeyError("key {!r} already exists".format(name))
p = self[name] = _utils_previews.new(
self._gen_key(name))
return p
new.__doc__ = _utils_previews.new.__doc__
def load(self, name, filepath, file_type, force_reload=False):
if name in self:
raise KeyError("key {!r} already exists".format(name))
p = self[name] = _utils_previews.load(
self._gen_key(name), filepath, file_type, force_reload)
return p
load.__doc__ = _utils_previews.load.__doc__
def clear(self):
"""Clear all previews."""
for name in self.keys():
_utils_previews.release(self._gen_key(name))
super().clear()
def close(self):
"""Close the collection and clear all previews."""
self.clear()
_uuid_open.remove(self._uuid)
def __delitem__(self, key):
_utils_previews.release(self._gen_key(key))
super().__delitem__(key)
def __repr__(self):
return "<{:s} id={:s}[{:d}], {!r}>".format(
self.__class__.__name__, self._uuid, len(self), super()
)
def new():
"""
:return: a new preview collection.
:rtype: :class:`ImagePreviewCollection`
"""
return ImagePreviewCollection()
def remove(pcoll):
"""
Remove the specified previews collection.
:param pcoll: Preview collection to close.
:type pcoll: :class:`ImagePreviewCollection`
"""
pcoll.close()
# don't complain about resources on exit (only unregister)
import atexit
def exit_clear_warning():
del ImagePreviewCollection.__del__
atexit.register(exit_clear_warning)
del atexit, exit_clear_warning

View File

@@ -0,0 +1,11 @@
# SPDX-FileCopyrightText: 2009-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
__all__ = (
"ToolDef",
)
# Until we untangle ToolDef from bl_ui internals,
# use this module to document ToolDef.
from bl_ui.space_toolsystem_common import ToolDef

View File

@@ -0,0 +1,20 @@
# SPDX-FileCopyrightText: 2009-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
"""
Utility modules associated with the bpy module.
"""
__all__ = (
"anim_utils",
"asset_utils",
"object_utils",
"io_utils",
"image_utils",
"keyconfig_utils",
"mesh_utils",
"node_utils",
"view3d_utils",
"id_map_utils",
)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,91 @@
# SPDX-FileCopyrightText: 2020-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
"""
Helpers for asset management tasks.
"""
__all__ = (
"AssetBrowserPanel",
"AssetMetaDataPanel",
"SpaceAssetInfo",
)
class SpaceAssetInfo:
"""Utility class for checking if a space is an asset browser."""
@classmethod
def is_asset_browser(cls, space_data):
"""
Check if the given space is an asset browser.
:param space_data: The space to check.
:type space_data: :class:`bpy.types.Space`
:return: True when the space is an asset browser.
:rtype: bool
"""
return space_data and space_data.type == 'FILE_BROWSER' and space_data.browse_mode == 'ASSETS'
@classmethod
def is_asset_browser_poll(cls, context):
"""
Poll whether the active space is an asset browser.
:param context: The context.
:type context: :class:`bpy.types.Context`
:return: True when the active space is an asset browser.
:rtype: bool
"""
return cls.is_asset_browser(context.space_data)
class AssetBrowserPanel:
"""Mixin class for panels that should only show in the asset browser."""
bl_space_type = 'FILE_BROWSER'
@classmethod
def asset_browser_panel_poll(cls, context):
"""
Check if the panel should be shown in the asset browser.
:param context: The context.
:type context: :class:`bpy.types.Context`
:return: True when the panel should be visible.
:rtype: bool
"""
return SpaceAssetInfo.is_asset_browser_poll(context)
@classmethod
def poll(cls, context):
"""
Poll for asset browser visibility.
:param context: The context.
:type context: :class:`bpy.types.Context`
:return: True when the panel should be visible.
:rtype: bool
"""
return cls.asset_browser_panel_poll(context)
class AssetMetaDataPanel:
"""Mixin class for panels that display asset metadata in the asset browser."""
bl_space_type = 'FILE_BROWSER'
bl_region_type = 'TOOL_PROPS'
@classmethod
def poll(cls, context):
"""
Poll for asset browser with active asset metadata.
:param context: The context.
:type context: :class:`bpy.types.Context`
:return: True when the asset browser has active asset data.
:rtype: bool
"""
active_file = context.active_file
return SpaceAssetInfo.is_asset_browser_poll(context) and active_file and active_file.asset_data

View File

@@ -0,0 +1,56 @@
# SPDX-FileCopyrightText: 2022-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
__all__ = (
"bmesh_linked_uv_islands",
)
def match_uv(face, vert, uv, uv_layer):
for loop in face.loops:
if loop.vert == vert:
return uv == loop[uv_layer].uv
return False
def bmesh_linked_uv_islands(bm, uv_layer):
"""
Returns lists of faces connected by UV islands.
For meshes use :class:`bpy.types.Mesh.mesh_linked_uv_islands` instead.
:param bm: the bmesh used to group with.
:type bmesh: :class:`BMesh`
:param uv_layer: the UV layer to source UVs from.
:type bmesh: :class:`BMLayerItem`
:return: list of lists containing polygon indices
:rtype: list[list[int]]
"""
result = []
used = set()
for seed_face in bm.faces:
if seed_face in used:
continue # Face has already been processed.
used.add(seed_face)
island = [seed_face]
stack = [seed_face] # Faces still to consider on this island.
while stack:
current_face = stack.pop()
for loop in current_face.loops:
v = loop.vert
uv = loop[uv_layer].uv
for f in v.link_faces:
if f is current_face or f in used:
continue
if not match_uv(f, v, uv, uv_layer):
continue
# `f` is part of island, add to island and stack
used.add(f)
island.append(f)
stack.append(f)
result.append(island)
return result

View File

@@ -0,0 +1,53 @@
# SPDX-FileCopyrightText: 2022-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
import bpy
__all__ = (
"get_id_reference_map",
"get_all_referenced_ids",
)
def get_id_reference_map():
"""Return a dictionary of direct data-block references for every data-block in the blend file.
:return: Each datablock of the .blend file mapped to the set of IDs they directly reference.
:rtype: dict[bpy.types.ID, set[bpy.types.ID]]
"""
inv_map = {}
for key, values in bpy.data.user_map().items():
for value in values:
if value == key:
# So an object is not considered to be referencing itself.
continue
inv_map.setdefault(value, set()).add(key)
return inv_map
def get_all_referenced_ids(id, ref_map):
"""
Return a set of IDs directly or indirectly referenced by id.
:param id: Datablock whose references we're interested in.
:type id: bpy.types.ID
:param ref_map: The global ID reference map, retrieved from get_id_reference_map()
:type ref_map: dict[bpy.types.ID, set[bpy.types.ID]]
:return: Set of datablocks referenced by `id`.
:rtype: set[bpy.types.ID]
"""
def recursive_helper(ref_map, id, referenced_ids, visited):
if id in visited:
# Avoid infinite recursion from circular references.
return
visited.add(id)
for ref in ref_map.get(id, []):
referenced_ids.add(ref)
recursive_helper(ref_map=ref_map, id=ref, referenced_ids=referenced_ids, visited=visited)
referenced_ids = set()
recursive_helper(ref_map=ref_map, id=id, referenced_ids=referenced_ids, visited=set())
return referenced_ids

View File

@@ -0,0 +1,196 @@
# SPDX-FileCopyrightText: 2009-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
__all__ = (
"load_image",
)
# limited replacement for BPyImage.comprehensiveImageLoad
def load_image(
imagepath,
dirname="",
place_holder=False,
recursive=False,
ncase_cmp=True,
convert_callback=None,
verbose=False,
relpath=None,
check_existing=False,
force_reload=False,
):
"""
Return an image from the file path with options to search multiple paths
and return a placeholder if it's not found.
:param imagepath: The image filename
If a path precedes it, this will be searched as well.
:type imagepath: str
:param dirname: is the directory where the image may be located - any file at
the end will be ignored.
:type dirname: str
:param place_holder: if True a new place holder image will be created.
this is useful so later you can relink the image to its original data.
:type place_holder: bool
:param recursive: If True, directories will be recursively searched.
Be careful with this if you have files in your root directory because
it may take a long time.
:type recursive: bool
:param ncase_cmp: on non windows systems, find the correct case for the file.
:type ncase_cmp: bool
:param convert_callback: a function that takes an existing path and returns
a new one. Use this when loading image formats blender may not support,
the CONVERT_CALLBACK can take the path for a GIF (for example),
convert it to a PNG and return the PNG's path.
For formats blender can read, simply return the path that is given.
:type convert_callback: Callable[[str], str] | None
:param verbose: If True, print extra information when searching for the image.
:type verbose: bool
:param relpath: If not None, make the file relative to this path.
:type relpath: str | None
:param check_existing: If true,
returns already loaded image data-block if possible
(based on file path).
:type check_existing: bool
:param force_reload: If true,
force reloading of image (only useful when ``check_existing``
is also enabled).
:type force_reload: bool
:return: an image or None
:rtype: :class:`bpy.types.Image` | None
"""
import os
import bpy
# -------------------------------------------------------------------------
# Utility Functions
def _image_load_placeholder(path):
name = path
if type(path) is str:
name = name.encode("utf-8", "replace")
name = name.decode("utf-8", "replace")
name = os.path.basename(name)
image = bpy.data.images.new(name, 128, 128)
# allow the path to be resolved later
image.filepath = path
image.source = 'FILE'
return image
def _image_load(path):
import bpy
if convert_callback:
path = convert_callback(path)
# Ensure we're not relying on the 'CWD' to resolve the path.
if not os.path.isabs(path):
path = os.path.abspath(path)
try:
image = bpy.data.images.load(path, check_existing=check_existing)
except RuntimeError:
image = None
if verbose:
if image:
print(" image loaded '{:s}'".format(path))
else:
print(" image load failed '{:s}'".format(path))
# image path has been checked so the path could not be read for some
# reason, so be sure to return a placeholder
if place_holder and image is None:
image = _image_load_placeholder(path)
if image:
if force_reload:
image.reload()
if relpath is not None:
# make relative
from bpy.path import relpath as relpath_fn
# can't always find the relative path
# (between drive letters on windows)
try:
filepath_rel = relpath_fn(path, start=relpath)
except ValueError:
filepath_rel = None
if filepath_rel is not None:
image.filepath_raw = filepath_rel
return image
def _recursive_search(paths, filename_check):
for path in paths:
for dirpath, _dirnames, filenames in os.walk(path):
# skip '.svn'
if dirpath[0] in {".", b'.'}:
continue
for filename in filenames:
if filename_check(filename):
yield os.path.join(dirpath, filename)
# -------------------------------------------------------------------------
imagepath = bpy.path.native_pathsep(imagepath)
if verbose:
print("load_image('{:s}', '{:s}', ...)".format(imagepath, dirname))
if os.path.exists(imagepath):
return _image_load(imagepath)
variants = [imagepath]
if dirname:
variants += [
os.path.join(dirname, imagepath),
os.path.join(dirname, bpy.path.basename(imagepath)),
]
for filepath_test in variants:
if ncase_cmp:
ncase_variants = (
filepath_test,
bpy.path.resolve_ncase(filepath_test),
)
else:
ncase_variants = (filepath_test, )
for nfilepath in ncase_variants:
if os.path.exists(nfilepath):
return _image_load(nfilepath)
if recursive:
search_paths = []
for dirpath_test in (os.path.dirname(imagepath), dirname):
if os.path.exists(dirpath_test):
search_paths.append(dirpath_test)
search_paths[:] = bpy.path.reduce_dirs(search_paths)
imagepath_base = bpy.path.basename(imagepath)
if ncase_cmp:
imagepath_base = imagepath_base.lower()
def image_filter(fn):
return (imagepath_base == fn.lower())
else:
def image_filter(fn):
return (imagepath_base == fn)
nfilepath = next(_recursive_search(search_paths, image_filter), None)
if nfilepath is not None:
return _image_load(nfilepath)
# None of the paths exist so return placeholder
if place_holder:
return _image_load_placeholder(imagepath)
# TODO comprehensiveImageLoad also searched in bpy.config.textureDir
return None

View File

@@ -0,0 +1,725 @@
# SPDX-FileCopyrightText: 2010-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
__all__ = (
"ExportHelper",
"ImportHelper",
"orientation_helper",
"axis_conversion",
"axis_conversion_ensure",
"create_derived_objects",
"poll_file_object_drop",
"unpack_list",
"unpack_face_list",
"path_reference",
"path_reference_copy",
"path_reference_mode",
"unique_name",
)
import bpy
from bpy.props import (
BoolProperty,
EnumProperty,
StringProperty,
)
from bpy.app.translations import (
contexts as i18n_contexts,
pgettext_iface as iface_,
pgettext_data as data_,
)
def _check_axis_conversion(op):
if hasattr(op, "axis_forward") and hasattr(op, "axis_up"):
return axis_conversion_ensure(
op,
"axis_forward",
"axis_up",
)
return False
class ExportHelper:
filepath: StringProperty(
name="File Path",
description="Filepath used for exporting the file",
maxlen=1024,
subtype='FILE_PATH',
)
check_existing: BoolProperty(
name="Check Existing",
description="Check and warn on overwriting existing files",
default=True,
options={'HIDDEN'},
)
# subclasses can override with decorator
# True == use ext, False == no ext, None == do nothing.
check_extension = True
def invoke(self, context, event):
"""
Invoke the file selector for exporting, setting a default filepath
based on the current blend file name.
:param context: The context.
:type context: :class:`bpy.types.Context`
:param event: The window event.
:type event: :class:`bpy.types.Event`
:return: The operator return value.
:rtype: set[str]
"""
del event
import os
if not self.filepath:
blend_filepath = context.blend_data.filepath
if not blend_filepath:
blend_filepath = data_("Untitled")
else:
blend_filepath = os.path.splitext(blend_filepath)[0]
self.filepath = blend_filepath + self.filename_ext
context.window_manager.fileselect_add(self)
return {'RUNNING_MODAL'}
def check(self, context):
"""
Validate the filepath and axis conversion settings.
:param context: The context.
:type context: :class:`bpy.types.Context`
:return: True when a property was updated.
:rtype: bool
"""
del context
import os
change_ext = False
change_axis = _check_axis_conversion(self)
check_extension = self.check_extension
if check_extension is not None:
filepath = self.filepath
if os.path.basename(filepath):
if check_extension:
filepath = bpy.path.ensure_ext(
os.path.splitext(filepath)[0],
self.filename_ext,
)
if filepath != self.filepath:
self.filepath = filepath
change_ext = True
return (change_ext or change_axis)
class ImportHelper:
filepath: StringProperty(
name="File Path",
description="Filepath used for importing the file",
maxlen=1024,
subtype='FILE_PATH',
options={'SKIP_PRESET', 'HIDDEN'}
)
def invoke(self, context, event):
"""
Invoke the file selector for importing.
:param context: The context.
:type context: :class:`bpy.types.Context`
:param event: The window event.
:type event: :class:`bpy.types.Event`
:return: The operator return value.
:rtype: set[str]
"""
del event
context.window_manager.fileselect_add(self)
return {'RUNNING_MODAL'}
def invoke_popup(self, context, confirm_text=""):
"""
Invoke as a popup confirmation dialog when a filepath is already set,
otherwise fall back to the file selector.
:param context: The context.
:type context: :class:`bpy.types.Context`
:param confirm_text: Label for the confirm button,
defaults to the operator label.
:type confirm_text: str
:return: The operator return value.
:rtype: set[str]
"""
if self.properties.is_property_set("filepath"):
title = self.filepath
if len(self.files) > 1:
title = iface_("Import {:d} files").format(len(self.files))
if confirm_text:
confirm_text = iface_(confirm_text)
else:
# Use the operator's bl_label, extracted with an "Operator" translation context.
confirm_text = iface_(self.bl_label, i18n_contexts.operator_default)
return context.window_manager.invoke_props_dialog(
self,
confirm_text=confirm_text,
title=title,
translate=False,
)
context.window_manager.fileselect_add(self)
return {'RUNNING_MODAL'}
def check(self, context):
"""
Validate axis conversion settings.
:param context: The context.
:type context: :class:`bpy.types.Context`
:return: True when a property was updated.
:rtype: bool
"""
del context
return _check_axis_conversion(self)
def orientation_helper(axis_forward='Y', axis_up='Z'):
"""
A decorator for import/export classes, generating properties needed by the axis conversion system and IO helpers,
with specified default values (axes).
:param axis_forward: The default forward axis.
:type axis_forward: Literal['X', 'Y', 'Z', '-X', '-Y', '-Z']
:param axis_up: The default up axis.
:type axis_up: Literal['X', 'Y', 'Z', '-X', '-Y', '-Z']
:return: A class decorator.
:rtype: Callable[[type], type]
"""
def wrapper(cls):
# Python 3.14+ (PEP 649): This workaround is no longer needed because annotations
# are lazily evaluated. Accessing `cls.__annotations__` always returns a dict
# specific to that class (never the parent's), so adding items is safe.
import sys
if sys.version_info < (3, 14):
# Without this, we may end up adding those fields to some **parent** class'
# `__annotations__` property (like the ImportHelper or ExportHelper ones)! See #58772.
if "__annotations__" not in cls.__dict__:
setattr(cls, "__annotations__", {})
def _update_axis_forward(self, _context):
if self.axis_forward[-1] == self.axis_up[-1]:
self.axis_up = (
self.axis_up[0:-1] +
'XYZ'[('XYZ'.index(self.axis_up[-1]) + 1) % 3]
)
cls.__annotations__["axis_forward"] = EnumProperty(
name="Forward",
items=(
('X', "X Forward", ""),
('Y', "Y Forward", ""),
('Z', "Z Forward", ""),
('-X', "-X Forward", ""),
('-Y', "-Y Forward", ""),
('-Z', "-Z Forward", ""),
),
default=axis_forward,
update=_update_axis_forward,
)
def _update_axis_up(self, _context):
if self.axis_up[-1] == self.axis_forward[-1]:
self.axis_forward = (
self.axis_forward[0:-1] +
'XYZ'[('XYZ'.index(self.axis_forward[-1]) + 1) % 3]
)
cls.__annotations__["axis_up"] = EnumProperty(
name="Up",
items=(
('X', "X Up", ""),
('Y', "Y Up", ""),
('Z', "Z Up", ""),
('-X', "-X Up", ""),
('-Y', "-Y Up", ""),
('-Z', "-Z Up", ""),
),
default=axis_up,
update=_update_axis_up,
)
return cls
return wrapper
# Axis conversion function, not pretty LUT
# use lookup table to convert between any axis
_axis_convert_matrix = (
((-1.0, 0.0, 0.0), (0.0, -1.0, 0.0), (0.0, 0.0, 1.0)),
((-1.0, 0.0, 0.0), (0.0, 0.0, -1.0), (0.0, -1.0, 0.0)),
((-1.0, 0.0, 0.0), (0.0, 0.0, 1.0), (0.0, 1.0, 0.0)),
((-1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, 0.0, -1.0)),
((0.0, -1.0, 0.0), (-1.0, 0.0, 0.0), (0.0, 0.0, -1.0)),
((0.0, 0.0, 1.0), (-1.0, 0.0, 0.0), (0.0, -1.0, 0.0)),
((0.0, 0.0, -1.0), (-1.0, 0.0, 0.0), (0.0, 1.0, 0.0)),
((0.0, 1.0, 0.0), (-1.0, 0.0, 0.0), (0.0, 0.0, 1.0)),
((0.0, -1.0, 0.0), (0.0, 0.0, 1.0), (-1.0, 0.0, 0.0)),
((0.0, 0.0, -1.0), (0.0, -1.0, 0.0), (-1.0, 0.0, 0.0)),
((0.0, 0.0, 1.0), (0.0, 1.0, 0.0), (-1.0, 0.0, 0.0)),
((0.0, 1.0, 0.0), (0.0, 0.0, -1.0), (-1.0, 0.0, 0.0)),
((0.0, -1.0, 0.0), (0.0, 0.0, -1.0), (1.0, 0.0, 0.0)),
((0.0, 0.0, 1.0), (0.0, -1.0, 0.0), (1.0, 0.0, 0.0)),
((0.0, 0.0, -1.0), (0.0, 1.0, 0.0), (1.0, 0.0, 0.0)),
((0.0, 1.0, 0.0), (0.0, 0.0, 1.0), (1.0, 0.0, 0.0)),
((0.0, -1.0, 0.0), (1.0, 0.0, 0.0), (0.0, 0.0, 1.0)),
((0.0, 0.0, -1.0), (1.0, 0.0, 0.0), (0.0, -1.0, 0.0)),
((0.0, 0.0, 1.0), (1.0, 0.0, 0.0), (0.0, 1.0, 0.0)),
((0.0, 1.0, 0.0), (1.0, 0.0, 0.0), (0.0, 0.0, -1.0)),
((1.0, 0.0, 0.0), (0.0, -1.0, 0.0), (0.0, 0.0, -1.0)),
((1.0, 0.0, 0.0), (0.0, 0.0, 1.0), (0.0, -1.0, 0.0)),
((1.0, 0.0, 0.0), (0.0, 0.0, -1.0), (0.0, 1.0, 0.0)),
)
# store args as a single int
# (X Y Z -X -Y -Z) --> (0, 1, 2, 3, 4, 5)
# each value is ((src_forward, src_up), (dst_forward, dst_up))
# where all 4 values are or'd into a single value...
# (i1<<0 | i1<<3 | i1<<6 | i1<<9)
_axis_convert_lut = (
{0x8C8, 0x4D0, 0x2E0, 0xAE8, 0x701, 0x511, 0x119, 0xB29, 0x682, 0x88A,
0x09A, 0x2A2, 0x80B, 0x413, 0x223, 0xA2B, 0x644, 0x454, 0x05C, 0xA6C,
0x745, 0x94D, 0x15D, 0x365},
{0xAC8, 0x8D0, 0x4E0, 0x2E8, 0x741, 0x951, 0x159, 0x369, 0x702, 0xB0A,
0x11A, 0x522, 0xA0B, 0x813, 0x423, 0x22B, 0x684, 0x894, 0x09C, 0x2AC,
0x645, 0xA4D, 0x05D, 0x465},
{0x4C8, 0x2D0, 0xAE0, 0x8E8, 0x681, 0x291, 0x099, 0x8A9, 0x642, 0x44A,
0x05A, 0xA62, 0x40B, 0x213, 0xA23, 0x82B, 0x744, 0x354, 0x15C, 0x96C,
0x705, 0x50D, 0x11D, 0xB25},
{0x2C8, 0xAD0, 0x8E0, 0x4E8, 0x641, 0xA51, 0x059, 0x469, 0x742, 0x34A,
0x15A, 0x962, 0x20B, 0xA13, 0x823, 0x42B, 0x704, 0xB14, 0x11C, 0x52C,
0x685, 0x28D, 0x09D, 0x8A5},
{0x708, 0xB10, 0x120, 0x528, 0x8C1, 0xAD1, 0x2D9, 0x4E9, 0x942, 0x74A,
0x35A, 0x162, 0x64B, 0xA53, 0x063, 0x46B, 0x804, 0xA14, 0x21C, 0x42C,
0x885, 0x68D, 0x29D, 0x0A5},
{0xB08, 0x110, 0x520, 0x728, 0x941, 0x151, 0x359, 0x769, 0x802, 0xA0A,
0x21A, 0x422, 0xA4B, 0x053, 0x463, 0x66B, 0x884, 0x094, 0x29C, 0x6AC,
0x8C5, 0xACD, 0x2DD, 0x4E5},
{0x508, 0x710, 0xB20, 0x128, 0x881, 0x691, 0x299, 0x0A9, 0x8C2, 0x4CA,
0x2DA, 0xAE2, 0x44B, 0x653, 0xA63, 0x06B, 0x944, 0x754, 0x35C, 0x16C,
0x805, 0x40D, 0x21D, 0xA25},
{0x108, 0x510, 0x720, 0xB28, 0x801, 0x411, 0x219, 0xA29, 0x882, 0x08A,
0x29A, 0x6A2, 0x04B, 0x453, 0x663, 0xA6B, 0x8C4, 0x4D4, 0x2DC, 0xAEC,
0x945, 0x14D, 0x35D, 0x765},
{0x748, 0x350, 0x160, 0x968, 0xAC1, 0x2D1, 0x4D9, 0x8E9, 0xA42, 0x64A,
0x45A, 0x062, 0x68B, 0x293, 0x0A3, 0x8AB, 0xA04, 0x214, 0x41C, 0x82C,
0xB05, 0x70D, 0x51D, 0x125},
{0x948, 0x750, 0x360, 0x168, 0xB01, 0x711, 0x519, 0x129, 0xAC2, 0x8CA,
0x4DA, 0x2E2, 0x88B, 0x693, 0x2A3, 0x0AB, 0xA44, 0x654, 0x45C, 0x06C,
0xA05, 0x80D, 0x41D, 0x225},
{0x348, 0x150, 0x960, 0x768, 0xA41, 0x051, 0x459, 0x669, 0xA02, 0x20A,
0x41A, 0x822, 0x28B, 0x093, 0x8A3, 0x6AB, 0xB04, 0x114, 0x51C, 0x72C,
0xAC5, 0x2CD, 0x4DD, 0x8E5},
{0x148, 0x950, 0x760, 0x368, 0xA01, 0x811, 0x419, 0x229, 0xB02, 0x10A,
0x51A, 0x722, 0x08B, 0x893, 0x6A3, 0x2AB, 0xAC4, 0x8D4, 0x4DC, 0x2EC,
0xA45, 0x04D, 0x45D, 0x665},
{0x688, 0x890, 0x0A0, 0x2A8, 0x4C1, 0x8D1, 0xAD9, 0x2E9, 0x502, 0x70A,
0xB1A, 0x122, 0x74B, 0x953, 0x163, 0x36B, 0x404, 0x814, 0xA1C, 0x22C,
0x445, 0x64D, 0xA5D, 0x065},
{0x888, 0x090, 0x2A0, 0x6A8, 0x501, 0x111, 0xB19, 0x729, 0x402, 0x80A,
0xA1A, 0x222, 0x94B, 0x153, 0x363, 0x76B, 0x444, 0x054, 0xA5C, 0x66C,
0x4C5, 0x8CD, 0xADD, 0x2E5},
{0x288, 0x690, 0x8A0, 0x0A8, 0x441, 0x651, 0xA59, 0x069, 0x4C2, 0x2CA,
0xADA, 0x8E2, 0x34B, 0x753, 0x963, 0x16B, 0x504, 0x714, 0xB1C, 0x12C,
0x405, 0x20D, 0xA1D, 0x825},
{0x088, 0x290, 0x6A0, 0x8A8, 0x401, 0x211, 0xA19, 0x829, 0x442, 0x04A,
0xA5A, 0x662, 0x14B, 0x353, 0x763, 0x96B, 0x4C4, 0x2D4, 0xADC, 0x8EC,
0x505, 0x10D, 0xB1D, 0x725},
{0x648, 0x450, 0x060, 0xA68, 0x2C1, 0x4D1, 0x8D9, 0xAE9, 0x282, 0x68A,
0x89A, 0x0A2, 0x70B, 0x513, 0x123, 0xB2B, 0x204, 0x414, 0x81C, 0xA2C,
0x345, 0x74D, 0x95D, 0x165},
{0xA48, 0x650, 0x460, 0x068, 0x341, 0x751, 0x959, 0x169, 0x2C2, 0xACA,
0x8DA, 0x4E2, 0xB0B, 0x713, 0x523, 0x12B, 0x284, 0x694, 0x89C, 0x0AC,
0x205, 0xA0D, 0x81D, 0x425},
{0x448, 0x050, 0xA60, 0x668, 0x281, 0x091, 0x899, 0x6A9, 0x202, 0x40A,
0x81A, 0xA22, 0x50B, 0x113, 0xB23, 0x72B, 0x344, 0x154, 0x95C, 0x76C,
0x2C5, 0x4CD, 0x8DD, 0xAE5},
{0x048, 0xA50, 0x660, 0x468, 0x201, 0xA11, 0x819, 0x429, 0x342, 0x14A,
0x95A, 0x762, 0x10B, 0xB13, 0x723, 0x52B, 0x2C4, 0xAD4, 0x8DC, 0x4EC,
0x285, 0x08D, 0x89D, 0x6A5},
{0x808, 0xA10, 0x220, 0x428, 0x101, 0xB11, 0x719, 0x529, 0x142, 0x94A,
0x75A, 0x362, 0x8CB, 0xAD3, 0x2E3, 0x4EB, 0x044, 0xA54, 0x65C, 0x46C,
0x085, 0x88D, 0x69D, 0x2A5},
{0xA08, 0x210, 0x420, 0x828, 0x141, 0x351, 0x759, 0x969, 0x042, 0xA4A,
0x65A, 0x462, 0xACB, 0x2D3, 0x4E3, 0x8EB, 0x084, 0x294, 0x69C, 0x8AC,
0x105, 0xB0D, 0x71D, 0x525},
{0x408, 0x810, 0xA20, 0x228, 0x081, 0x891, 0x699, 0x2A9, 0x102, 0x50A,
0x71A, 0xB22, 0x4CB, 0x8D3, 0xAE3, 0x2EB, 0x144, 0x954, 0x75C, 0x36C,
0x045, 0x44D, 0x65D, 0xA65},
)
_axis_convert_num = {'X': 0, 'Y': 1, 'Z': 2, '-X': 3, '-Y': 4, '-Z': 5}
def axis_conversion(from_forward='Y', from_up='Z', to_forward='Y', to_up='Z'):
"""
Each argument is an axis
where the first 2 are a source and the second 2 are the target.
:param from_forward: Source forward axis.
:type from_forward: Literal['X', 'Y', 'Z', '-X', '-Y', '-Z']
:param from_up: Source up axis.
:type from_up: Literal['X', 'Y', 'Z', '-X', '-Y', '-Z']
:param to_forward: Target forward axis.
:type to_forward: Literal['X', 'Y', 'Z', '-X', '-Y', '-Z']
:param to_up: Target up axis.
:type to_up: Literal['X', 'Y', 'Z', '-X', '-Y', '-Z']
:return: The conversion matrix.
:rtype: :class:`mathutils.Matrix`
"""
from mathutils import Matrix
from functools import reduce
if from_forward == to_forward and from_up == to_up:
return Matrix().to_3x3()
if from_forward[-1] == from_up[-1] or to_forward[-1] == to_up[-1]:
raise Exception("Invalid axis arguments passed, cannot use up/forward on the same axis")
value = reduce(
int.__or__,
(_axis_convert_num[a] << (i * 3) for i, a in enumerate((
from_forward,
from_up,
to_forward,
to_up,
)))
)
for i, axis_lut in enumerate(_axis_convert_lut):
if value in axis_lut:
return Matrix(_axis_convert_matrix[i])
assert False, "unreachable"
def axis_conversion_ensure(operator, forward_attr, up_attr):
"""
Function to ensure an operator has valid axis conversion settings, intended
to be used from :class:`bpy.types.Operator.check`.
:param operator: the operator to access axis attributes from.
:type operator: :class:`bpy.types.Operator`
:param forward_attr: attribute storing the forward axis
:type forward_attr: str
:param up_attr: attribute storing the up axis
:type up_attr: str
:return: True if the value was modified.
:rtype: bool
"""
def validate(axis_forward, axis_up):
if axis_forward[-1] == axis_up[-1]:
axis_up = axis_up[0:-1] + 'XYZ'[('XYZ'.index(axis_up[-1]) + 1) % 3]
return axis_forward, axis_up
axis = getattr(operator, forward_attr), getattr(operator, up_attr)
axis_new = validate(*axis)
if axis != axis_new:
setattr(operator, forward_attr, axis_new[0])
setattr(operator, up_attr, axis_new[1])
return True
else:
return False
def create_derived_objects(depsgraph, objects):
"""
This function takes a sequence of objects, returning their instances.
:param depsgraph: The evaluated depsgraph.
:type depsgraph: :class:`bpy.types.Depsgraph`
:param objects: A sequence of objects.
:type objects: Sequence[:class:`bpy.types.Object`]
:return: A dictionary where each key is an object from ``objects``,
values are lists of (object, matrix) tuples representing instances.
:rtype: dict[:class:`bpy.types.Object`, list[tuple[:class:`bpy.types.Object`, :class:`mathutils.Matrix`]]]
"""
result = {}
for ob in objects:
ob_parent = ob.parent
if ob_parent and ob_parent.instance_type in {'VERTS', 'FACES'}:
continue
result[ob] = [] if ob.is_instancer else [(ob, ob.matrix_world.copy())]
if result:
for dup in depsgraph.object_instances:
dup_parent = dup.parent
if dup_parent is None:
continue
dup_parent_original = dup_parent.original
if not dup_parent_original.is_instancer:
# The instance has already been added (on assignment).
continue
instance_list = result.get(dup_parent_original)
if instance_list is None:
continue
instance_list.append((dup.instance_object.original, dup.matrix_world.copy()))
return result
def unpack_list(list_of_tuples):
"""
Flatten a sequence of tuples into a single list.
:param list_of_tuples: A sequence of tuples to unpack.
:type list_of_tuples: Sequence[tuple]
:return: A flat list of all values.
:rtype: list
"""
flat_list = []
flat_list_extend = flat_list.extend # a tiny bit faster
for t in list_of_tuples:
flat_list_extend(t)
return flat_list
# same as above except that it adds 0 for triangle faces
def unpack_face_list(list_of_tuples):
"""
Unpack a list of faces (triangles or quads) into a flat list,
padding triangles with a zero to fit into groups of four.
:param list_of_tuples: A sequence of face index tuples (3 or 4 elements each).
:type list_of_tuples: Sequence[tuple[int, ...]]
:return: A flat list of face indices, padded with zeros.
:rtype: list[int]
"""
# allocate the entire list
flat_ls = [0] * (len(list_of_tuples) * 4)
i = 0
for t in list_of_tuples:
if len(t) == 3:
if t[2] == 0:
t = t[1], t[2], t[0]
else: # assume quad
if t[3] == 0 or t[2] == 0:
t = t[2], t[3], t[0], t[1]
flat_ls[i:i + len(t)] = t
i += 4
return flat_ls
def poll_file_object_drop(context):
"""
A default implementation for FileHandler poll_drop methods. Allows for both the 3D Viewport and
the Outliner (in ViewLayer display mode) to be targets for file drag and drop.
:param context: The context.
:type context: :class:`bpy.types.Context`
:return: Whether the drop target is valid.
:rtype: bool
"""
area = context.area
if not area:
return False
is_v3d = area.type == 'VIEW_3D'
is_outliner_view_layer = area.type == 'OUTLINER' and area.spaces.active.display_mode == 'VIEW_LAYER'
return is_v3d or is_outliner_view_layer
path_reference_mode = EnumProperty(
name="Path Mode",
description="Method used to reference paths",
items=(
('AUTO', "Auto", "Use relative paths with subdirectories only"),
('ABSOLUTE', "Absolute", "Always write absolute paths"),
('RELATIVE', "Relative", "Write relative paths where possible"),
('MATCH', "Match", "Match absolute/relative "
"setting with input path"),
('STRIP', "Strip", "Filename only"),
('COPY', "Copy", "Copy the file to the destination path "
"(or subdirectory)"),
),
translation_context=i18n_contexts.editor_filebrowser,
default='AUTO',
)
def path_reference(
filepath,
base_src,
base_dst,
mode='AUTO',
copy_subdir="",
copy_set=None,
library=None,
):
"""
Return a filepath relative to a destination directory, for use with
exporters.
:param filepath: the file path to return,
supporting blenders relative '//' prefix.
:type filepath: str
:param base_src: the directory the *filepath* is relative to
(normally the blend file).
:type base_src: str
:param base_dst: the directory the *filepath* will be referenced from
(normally the export path).
:type base_dst: str
:param mode: the method used to reference the path.
:type mode: Literal['AUTO', 'ABSOLUTE', 'RELATIVE', 'MATCH', 'STRIP', 'COPY']
:param copy_subdir: the subdirectory of *base_dst* to use when mode='COPY'.
:type copy_subdir: str
:param copy_set: collect from/to pairs when mode='COPY',
pass to *path_reference_copy* when exporting is done.
:type copy_set: set[tuple[str, str]] | None
:param library: The library this path is relative to.
:type library: :class:`bpy.types.Library` | None
:return: the new filepath.
:rtype: str
"""
import os
is_relative = filepath.startswith("//")
filepath_abs = bpy.path.abspath(filepath, start=base_src, library=library)
filepath_abs = os.path.normpath(filepath_abs)
if mode in {'ABSOLUTE', 'RELATIVE', 'STRIP'}:
pass
elif mode == 'MATCH':
mode = 'RELATIVE' if is_relative else 'ABSOLUTE'
elif mode == 'AUTO':
mode = (
'RELATIVE' if bpy.path.is_subdir(filepath_abs, base_dst) else
'ABSOLUTE'
)
elif mode == 'COPY':
subdir_abs = os.path.normpath(base_dst)
if copy_subdir:
subdir_abs = os.path.join(subdir_abs, copy_subdir)
filepath_cpy = os.path.join(subdir_abs, os.path.basename(filepath_abs))
copy_set.add((filepath_abs, filepath_cpy))
filepath_abs = filepath_cpy
mode = 'RELATIVE'
else:
raise Exception("invalid mode given {!r}".format(mode))
if mode == 'ABSOLUTE':
return filepath_abs
elif mode == 'RELATIVE':
# can't always find the relative path
# (between drive letters on windows)
try:
return os.path.relpath(filepath_abs, base_dst)
except ValueError:
return filepath_abs
elif mode == 'STRIP':
return os.path.basename(filepath_abs)
def path_reference_copy(copy_set, report=print):
"""
Execute copying files of path_reference
:param copy_set: set of (from, to) pairs to copy.
:type copy_set: set[tuple[str, str]]
:param report: function used for reporting warnings, takes a string argument.
:type report: Callable[[str], None]
"""
if not copy_set:
return
import os
import shutil
for file_src, file_dst in copy_set:
if not os.path.exists(file_src):
report("missing {!r}, not copying".format(file_src))
elif os.path.exists(file_dst) and os.path.samefile(file_src, file_dst):
pass
else:
dir_to = os.path.dirname(file_dst)
try:
os.makedirs(dir_to, exist_ok=True)
except Exception:
import traceback
traceback.print_exc()
try:
shutil.copy(file_src, file_dst)
except Exception:
import traceback
traceback.print_exc()
def unique_name(key, name, name_dict, name_max=-1, clean_func=None, sep="."):
"""
Helper function for storing unique names which may have special characters
stripped and restricted to a maximum length.
:param key: Unique item this name belongs to, name_dict[key] will be reused
when available.
This can be the object, mesh, material, etc instance itself.
Any hashable object associated with the *name*.
:type key: Any
:param name: The name used to create a unique value in *name_dict*.
:type name: str
:param name_dict: This is used to cache namespace to ensure no collisions
occur, this should be an empty dict initially and only modified by this
function.
:type name_dict: dict[Any, str]
:param name_max: Maximum length of the name. When ``-1`` the name is unlimited.
:type name_max: int
:param clean_func: Function to call on *name* before creating a unique value.
:type clean_func: Callable[[str], str] | None
:param sep: Separator to use when between the name and a number when a
duplicate name is found.
:type sep: str
:return: A unique name.
:rtype: str
"""
name_new = name_dict.get(key)
if name_new is None:
count = 1
name_dict_values = name_dict.values()
name_new = name_new_orig = (
name if clean_func is None
else clean_func(name)
)
if name_max == -1:
while name_new in name_dict_values:
name_new = "{:s}{:s}{:03d}".format(
name_new_orig,
sep,
count,
)
count += 1
else:
name_new = name_new[:name_max]
while name_new in name_dict_values:
count_str = "{:03d}".format(count)
name_new = "{:.{:d}s}{:s}{:s}".format(
name_new_orig,
name_max - (len(count_str) + 1),
sep,
count_str,
)
count += 1
name_dict[key] = name_new
return name_new

View File

@@ -0,0 +1,155 @@
# SPDX-FileCopyrightText: 2011-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
__all__ = (
"addon_keymap_register",
"addon_keymap_unregister",
"keyconfig_test",
)
# -----------------------------------------------------------------------------
# Add-on helpers to properly (un)register their own keymaps.
def addon_keymap_register(keymap_data):
"""
Register a set of keymaps for addons using a list of keymaps.
See 'blender_default.py' for examples of the format this takes.
:param keymap_data: A list of keymap definitions to register.
:type keymap_data: list[tuple[str, dict[str, Any], dict[str, Any]]]
"""
import bpy
wm = bpy.context.window_manager
from bl_keymap_utils.io import keymap_init_from_data
kconf = wm.keyconfigs.addon
if not kconf:
return # happens in background mode...
for km_name, km_args, km_content in keymap_data:
km_space_type = km_args["space_type"]
km_region_type = km_args["region_type"]
km_modal = km_args.get("modal", False)
kmap = next(iter(
k for k in kconf.keymaps
if k.name == km_name and
k.region_type == km_region_type and
k.space_type == km_space_type and
k.is_modal == km_modal
), None)
if kmap is None:
kmap = kconf.keymaps.new(km_name, **km_args)
keymap_init_from_data(kmap, km_content["items"], is_modal=km_modal)
def addon_keymap_unregister(keymap_data):
"""
Unregister a set of keymaps for addons.
:param keymap_data: A list of keymap definitions to unregister.
:type keymap_data: list[tuple[str, dict[str, Any], dict[str, Any]]]
"""
# NOTE: We must also clean up user keyconfig, else, if user has customized one of add-on's shortcut, this
# customization remains in memory, and comes back when re-enabling the addon, causing a segfault... :/
import bpy
wm = bpy.context.window_manager
kconfs = wm.keyconfigs
for kconf in (kconfs.user, kconfs.addon):
for km_name, km_args, km_content in keymap_data:
km_space_type = km_args["space_type"]
km_region_type = km_args["region_type"]
km_modal = km_args.get("modal", False)
kmaps = (
k for k in kconf.keymaps
if k.name == km_name and
k.region_type == km_region_type and
k.space_type == km_space_type and
k.is_modal == km_modal
)
for kmap in kmaps:
for kmi_idname, _, _ in km_content["items"]:
for kmi in kmap.keymap_items:
if kmi.idname == kmi_idname:
kmap.keymap_items.remove(kmi)
# NOTE: We won't remove addons keymaps themselves, other addons might also use them!
# -----------------------------------------------------------------------------
# Utility Functions
def keyconfig_test(kc):
"""
Test a key configuration for duplicate key-map item assignments.
:param kc: The key configuration to test.
:type kc: :class:`bpy.types.KeyConfig`
:return: True if any duplicates were found.
:rtype: bool
"""
from bl_keymap_utils.io import kmi_args_as_data
def _kmistr(kmi, is_modal):
if is_modal:
kmi_id = kmi.propvalue
else:
kmi_id = kmi.idname
return "{:s}({:s})".format(kmi_id, kmi_args_as_data(kmi))
def testEntry(kc, entry, src=None, parent=None):
result = False
idname, spaceid, regionid, children = entry
km = kc.keymaps.find(idname, space_type=spaceid, region_type=regionid)
if km:
km = km.active()
is_modal = km.is_modal
if src:
for item in km.keymap_items:
if src.compare(item):
print("===========")
print(parent.name, "[parent]")
print(_kmistr(src, is_modal).strip())
print(km.name, "[child]")
print(_kmistr(item, is_modal).strip())
result = True
for child in children:
if testEntry(kc, child, src, parent):
result = True
else:
for i, src in enumerate(km.keymap_items):
for child in children:
if testEntry(kc, child, src, km):
result = True
for j in range(len(km.keymap_items) - i - 1):
item = km.keymap_items[j + i + 1]
if src.compare(item):
print("===========")
print(km.name, "[self conflict]")
print(_kmistr(src, is_modal).strip())
print(_kmistr(item, is_modal).strip())
result = True
for child in children:
if testEntry(kc, child):
result = True
return result
# -------------------------------------------------------------------------
# Function body
from bl_keymap_utils import keymap_hierarchy
result = False
for entry in keymap_hierarchy.generate():
if testEntry(kc, entry):
result = True
return result

Some files were not shown because too many files have changed in this diff Show More