Add Chromium-only Blender WebEngine parity work
This commit is contained in:
197
blender-5.2.0/tests/utils/batch_load_blendfiles.py
Normal file
197
blender-5.2.0/tests/utils/batch_load_blendfiles.py
Normal file
@@ -0,0 +1,197 @@
|
||||
# SPDX-FileCopyrightText: 2011-2023 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
__all__ = (
|
||||
"main",
|
||||
)
|
||||
|
||||
r"""
|
||||
Example usage:
|
||||
|
||||
blender --factory-startup --python ./tests/utils/batch_load_blendfiles.py
|
||||
|
||||
Arguments may be passed in:
|
||||
|
||||
blender --factory-startup --python ./tests/utils/batch_load_blendfiles.py -- --sort-by=SIZE --range=0:10 --wait=0.1
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import argparse
|
||||
|
||||
from collections.abc import (
|
||||
Iterator,
|
||||
)
|
||||
|
||||
SOURCE_DIR = os.path.abspath(os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..")))
|
||||
LIB_DIR = os.path.abspath(os.path.normpath(os.path.join(SOURCE_DIR, "lib")))
|
||||
|
||||
SORT_BY_FN = {
|
||||
"PATH": lambda path: path,
|
||||
"SIZE": lambda path: os.path.getsize(path),
|
||||
}
|
||||
|
||||
|
||||
def blend_list(path: str) -> Iterator[str]:
|
||||
for dirpath, dirnames, filenames in os.walk(path):
|
||||
# skip '.git'
|
||||
dirnames[:] = [d for d in dirnames if not d.startswith(".")]
|
||||
for filename in filenames:
|
||||
if filename.lower().endswith(".blend"):
|
||||
filepath = os.path.join(dirpath, filename)
|
||||
yield filepath
|
||||
|
||||
|
||||
def print_load_message(filepath: str, index: int) -> None:
|
||||
msg = "({:d}): {:s}".format(index, filepath)
|
||||
print("=" * len(msg))
|
||||
print(msg)
|
||||
print("=" * len(msg))
|
||||
|
||||
|
||||
def load_blend_file(filepath: str) -> None:
|
||||
import bpy # type: ignore
|
||||
bpy.ops.wm.open_mainfile(filepath=filepath)
|
||||
|
||||
|
||||
def load_files_immediately(blend_files: list[str], blend_file_index_offset: int) -> None:
|
||||
index = blend_file_index_offset
|
||||
for filepath in blend_files:
|
||||
print_load_message(filepath, index)
|
||||
index += 1
|
||||
load_blend_file(filepath)
|
||||
|
||||
|
||||
def load_files_with_wait(blend_files: list[str], blend_file_index_offset: int, wait: float) -> None:
|
||||
index = 0
|
||||
|
||||
def load_on_timer() -> float | None:
|
||||
nonlocal index
|
||||
if index >= len(blend_files):
|
||||
sys.exit(0)
|
||||
|
||||
filepath = blend_files[index]
|
||||
print_load_message(filepath, index + blend_file_index_offset)
|
||||
index += 1
|
||||
|
||||
load_blend_file(filepath)
|
||||
return wait
|
||||
|
||||
import bpy
|
||||
bpy.app.timers.register(load_on_timer, persistent=True)
|
||||
|
||||
|
||||
def argparse_handle_int_range(value: str) -> tuple[int, int]:
|
||||
range_beg, sep, range_end = value.partition(":")
|
||||
if not sep:
|
||||
raise argparse.ArgumentTypeError("Expected a \":\" separator!")
|
||||
try:
|
||||
result = int(range_beg), int(range_end)
|
||||
except Exception as ex:
|
||||
raise argparse.ArgumentTypeError("Expected two integers: {!s}".format(ex))
|
||||
return result
|
||||
|
||||
|
||||
def argparse_create() -> argparse.ArgumentParser:
|
||||
import argparse
|
||||
sort_by_choices = tuple(sorted(SORT_BY_FN.keys()))
|
||||
|
||||
# When `--help` or no arguments are given, print this help.
|
||||
epilog = "Use to automate loading many blend files in a single Blender instance."
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
formatter_class=argparse.RawTextHelpFormatter,
|
||||
description=__doc__,
|
||||
epilog=epilog,
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--blend-dir",
|
||||
dest="blend_dir",
|
||||
metavar='BLEND_DIR',
|
||||
default=LIB_DIR,
|
||||
required=False,
|
||||
help="Path to recursively search blend files.",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--sort-by',
|
||||
dest='files_sort_by',
|
||||
choices=sort_by_choices,
|
||||
default="PATH",
|
||||
required=False,
|
||||
metavar='SORT_METHOD',
|
||||
help='Order to load files {:s}.'.format(repr(sort_by_choices)),
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--range',
|
||||
dest='files_range',
|
||||
type=argparse_handle_int_range,
|
||||
required=False,
|
||||
default=(0, sys.maxsize),
|
||||
metavar='RANGE',
|
||||
help=(
|
||||
"The beginning and end range separated by a \":\", e.g."
|
||||
"useful for loading a range of files known to cause problems."
|
||||
),
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--wait",
|
||||
dest="wait",
|
||||
type=float,
|
||||
default=-1.0,
|
||||
required=False,
|
||||
help=(
|
||||
"Time to wait between loading files, "
|
||||
"implies redrawing and even allows user interaction (-1.0 to disable)."
|
||||
),
|
||||
)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> int | None:
|
||||
try:
|
||||
argv_sep = sys.argv.index("--")
|
||||
except ValueError:
|
||||
argv_sep = -1
|
||||
|
||||
argv = [] if argv_sep == -1 else sys.argv[argv_sep + 1:]
|
||||
args = argparse_create().parse_args(argv)
|
||||
del argv
|
||||
|
||||
if not os.path.exists(args.blend_dir):
|
||||
sys.stderr.write("Path {!r} not found!\n".format(args.blend_dir))
|
||||
return 1
|
||||
blend_files = list(blend_list(args.blend_dir))
|
||||
if not blend_files:
|
||||
sys.stderr.write("No blend files in {!r}!\n".format(args.blend_dir))
|
||||
return 1
|
||||
|
||||
blend_files.sort(key=SORT_BY_FN[args.files_sort_by])
|
||||
|
||||
range_beg, range_end = args.files_range
|
||||
|
||||
blend_files_total = len(blend_files)
|
||||
|
||||
blend_files = blend_files[range_beg:range_end]
|
||||
|
||||
print("Found {:,d} files within {!r}".format(blend_files_total, args.blend_dir))
|
||||
if len(blend_files) != blend_files_total:
|
||||
print("Using a sub-range of {:,d}".format(len(blend_files)))
|
||||
|
||||
if args.wait == -1.0:
|
||||
load_files_immediately(blend_files, range_beg)
|
||||
else:
|
||||
load_files_with_wait(blend_files, range_beg, args.wait)
|
||||
return None
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
result = main()
|
||||
if result is not None:
|
||||
sys.exit(result)
|
||||
1050
blender-5.2.0/tests/utils/bl_run_operators.py
Normal file
1050
blender-5.2.0/tests/utils/bl_run_operators.py
Normal file
File diff suppressed because it is too large
Load Diff
615
blender-5.2.0/tests/utils/bl_run_operators_event_simulate.py
Normal file
615
blender-5.2.0/tests/utils/bl_run_operators_event_simulate.py
Normal file
@@ -0,0 +1,615 @@
|
||||
# SPDX-FileCopyrightText: 2021-2022 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
r"""
|
||||
Overview
|
||||
========
|
||||
|
||||
This is a utility to generate events from the command line,
|
||||
so reproducible test cases can be written without having to create a custom script each time.
|
||||
|
||||
The key differentiating feature for this utility as is that it's able to control modal operators.
|
||||
|
||||
Possible use cases for this script include:
|
||||
|
||||
- Creating reproducible user interactions for the purpose of benchmarking and profiling.
|
||||
|
||||
Note that cursor-motion actions report the update time between events
|
||||
which can be helpful when measuring optimizations.
|
||||
|
||||
- As a convenient way to replay interactive actions that reproduce a bug.
|
||||
|
||||
- For writing tests (although some extra functionality may be necessary in this case).
|
||||
|
||||
|
||||
Actions
|
||||
=======
|
||||
|
||||
You will notice most of the functionality is supported using the actions command line argument,
|
||||
this is a kind of mini-language to drive Blender.
|
||||
|
||||
While the current set of commands is fairly limited more can be added as needed.
|
||||
|
||||
To see a list of actions as well as their arguments run:
|
||||
|
||||
./blender.bin --python tests/utils/bl_run_operators_event_simulate.py -- --help
|
||||
|
||||
|
||||
Examples
|
||||
========
|
||||
|
||||
Rotate in edit-mode examples:
|
||||
|
||||
./blender.bin \
|
||||
--factory-startup \
|
||||
--enable-event-simulate \
|
||||
--python tests/utils/bl_run_operators_event_simulate.py \
|
||||
-- \
|
||||
--actions \
|
||||
'area_maximize(ui_type="VIEW_3D")' \
|
||||
'operator("object.mode_set", mode="EDIT")' \
|
||||
'operator("mesh.select_all", action="SELECT")' \
|
||||
'operator("mesh.subdivide", number_cuts=5)' \
|
||||
'operator("transform.rotate")' \
|
||||
'cursor_motion(path="CIRCLE", radius=300, steps=100, repeat=2)'
|
||||
|
||||
Sculpt stroke:
|
||||
|
||||
./blender.bin \
|
||||
--factory-startup \
|
||||
--enable-event-simulate \
|
||||
--python tests/utils/bl_run_operators_event_simulate.py \
|
||||
-- \
|
||||
--actions \
|
||||
'area_maximize(ui_type="VIEW_3D")' \
|
||||
'event(type="FIVE", value="TAP", ctrl=True)' \
|
||||
'menu("Visual Geometry to Mesh")' \
|
||||
'menu("Frame Selected")' \
|
||||
'menu("Toggle Sculpt Mode")' \
|
||||
'event(type="WHEELDOWNMOUSE", value="TAP", repeat=2)' \
|
||||
'event(type="LEFTMOUSE", value="PRESS")' \
|
||||
'cursor_motion(path="CIRCLE", radius=300, steps=100, repeat=5)' \
|
||||
'event(type="LEFTMOUSE", value="RELEASE")'
|
||||
|
||||
|
||||
Implementation
|
||||
==============
|
||||
|
||||
While most of the operations listed above can be executed in Python directly,
|
||||
either the event loop won't be handled between actions (the case for typical Python script),
|
||||
or the context for executing the actions is not properly set (the case for timers).
|
||||
|
||||
This utility executes actions as if the user initiated them from a key shortcut.
|
||||
"""
|
||||
__all__ = (
|
||||
"main",
|
||||
)
|
||||
|
||||
import os
|
||||
import sys
|
||||
import argparse
|
||||
from argparse import ArgumentTypeError
|
||||
|
||||
import bpy
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Constants
|
||||
|
||||
EVENT_TYPES = tuple(bpy.types.Event.bl_rna.properties["type"].enum_items.keys())
|
||||
EVENT_VALUES = tuple(bpy.types.Event.bl_rna.properties["value"].enum_items.keys())
|
||||
# `TAP` is just convenience for (`PRESS`, `RELEASE`).
|
||||
EVENT_VALUES_EXTRA = EVENT_VALUES + ('TAP',)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Globals
|
||||
|
||||
# Assign a global since this script is not going to be loading new files (which would free the window).
|
||||
win = bpy.context.window_manager.windows[0]
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Utilities
|
||||
|
||||
def find_main_area(ui_type=None):
|
||||
"""
|
||||
Find the largest area from the current screen.
|
||||
"""
|
||||
area_best = None
|
||||
size_best = -1
|
||||
for area in win.screen.areas:
|
||||
if ui_type is not None:
|
||||
if ui_type != area.ui_type:
|
||||
continue
|
||||
|
||||
size = area.width * area.height
|
||||
if size > size_best:
|
||||
size_best = size
|
||||
area_best = area
|
||||
return area_best
|
||||
|
||||
|
||||
def gen_events_type_text(text):
|
||||
"""
|
||||
Generate events to type in ``text``.
|
||||
"""
|
||||
for ch in text:
|
||||
kw_extra = {}
|
||||
# The event type in this case is ignored as only the unicode value is used for text input.
|
||||
type = 'SPACE'
|
||||
if ch == '\t':
|
||||
type = 'TAB'
|
||||
elif ch == '\n':
|
||||
type = 'RET'
|
||||
else:
|
||||
kw_extra["unicode"] = ch
|
||||
|
||||
yield dict(type=type, value='PRESS', **kw_extra)
|
||||
kw_extra.pop("unicode", None)
|
||||
yield dict(type=type, value='RELEASE', **kw_extra)
|
||||
|
||||
|
||||
def repr_action(name, args, kwargs):
|
||||
return "%s(%s)" % (
|
||||
name,
|
||||
", ".join(
|
||||
[repr(value) for value in args] +
|
||||
[("%s=%r" % (key, value)) for key, value in kwargs.items()]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Simulate Events
|
||||
|
||||
def mouse_location_get():
|
||||
return (
|
||||
run_event_simulate.last_event["x"],
|
||||
run_event_simulate.last_event["y"],
|
||||
)
|
||||
|
||||
|
||||
def run_event_simulate(*, event_iter, exit_fn):
|
||||
"""
|
||||
Pass events from event_iter into Blender.
|
||||
"""
|
||||
last_event = run_event_simulate.last_event
|
||||
|
||||
def event_step():
|
||||
win = bpy.context.window_manager.windows[0]
|
||||
|
||||
val = next(event_step.run_events, Ellipsis)
|
||||
if val is Ellipsis:
|
||||
bpy.app.use_event_simulate = False
|
||||
print("Finished simulation")
|
||||
exit_fn()
|
||||
return None
|
||||
|
||||
# Run event simulation.
|
||||
for attr in ("x", "y"):
|
||||
if attr in val:
|
||||
last_event[attr] = val[attr]
|
||||
else:
|
||||
val[attr] = last_event[attr]
|
||||
|
||||
# Fake event value, since press, release is so common.
|
||||
if val.get("value") == 'TAP':
|
||||
del val["value"]
|
||||
win.event_simulate(**val, value='PRESS')
|
||||
# Needed if new files are loaded.
|
||||
# win = bpy.context.window_manager.windows[0]
|
||||
win.event_simulate(**val, value='RELEASE')
|
||||
else:
|
||||
# print("val", val)
|
||||
win.event_simulate(**val)
|
||||
return 0.0
|
||||
|
||||
event_step.run_events = iter(event_iter)
|
||||
|
||||
bpy.app.timers.register(event_step, first_interval=0.0, persistent=True)
|
||||
|
||||
|
||||
run_event_simulate.last_event = dict(
|
||||
x=win.width // 2,
|
||||
y=win.height // 2,
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Action Implementations
|
||||
|
||||
# Static methods from this class are automatically exposed as actions and included in the help text.
|
||||
class action_handlers:
|
||||
|
||||
@staticmethod
|
||||
def area_maximize(*, ui_type=None, only_validate=False):
|
||||
"""
|
||||
ui_type:
|
||||
Select the area type (typically 'VIEW_3D').
|
||||
Note that this area type needs to exist in the current screen.
|
||||
"""
|
||||
if not ((ui_type is None) or (isinstance(ui_type, str))):
|
||||
raise ArgumentTypeError("'type' argument %r not None or a string type")
|
||||
|
||||
if only_validate:
|
||||
return
|
||||
|
||||
area = find_main_area(ui_type=ui_type)
|
||||
if area is None:
|
||||
raise ArgumentTypeError("Area with ui_type=%r not found" % ui_type)
|
||||
|
||||
x = area.x + (area.width // 2)
|
||||
y = area.y + (area.height // 2)
|
||||
|
||||
yield dict(type='MOUSEMOVE', value='NOTHING', x=x, y=y)
|
||||
yield dict(type='SPACE', value='TAP', ctrl=True, alt=True)
|
||||
|
||||
x = win.width // 2
|
||||
y = win.height // 2
|
||||
|
||||
yield dict(type='MOUSEMOVE', value='NOTHING', x=x, y=y)
|
||||
|
||||
@staticmethod
|
||||
def menu(text, *, only_validate=False):
|
||||
"""
|
||||
text: Menu item to search for and execute.
|
||||
"""
|
||||
if not isinstance(text, str):
|
||||
raise ArgumentTypeError("'text' argument not a string")
|
||||
|
||||
if only_validate:
|
||||
return
|
||||
|
||||
yield dict(type='F3', value='TAP')
|
||||
yield from gen_events_type_text(text)
|
||||
yield dict(type='RET', value='TAP')
|
||||
|
||||
@staticmethod
|
||||
def event(*, type, value, ctrl=False, alt=False, shift=False, hyper=False, repeat=1, only_validate=False):
|
||||
"""
|
||||
type: The event, typically key, e.g. 'ESC', 'RET', 'SPACE', 'A'.
|
||||
value: The event type, valid values include: 'PRESS', 'RELEASE', 'TAP'.
|
||||
ctrl: Control modifier.
|
||||
alt: Alt modifier.
|
||||
shift: Shift modifier.
|
||||
hyper: Hyper modifier.
|
||||
"""
|
||||
valid_items = EVENT_VALUES_EXTRA
|
||||
if value not in valid_items:
|
||||
raise ArgumentTypeError("'value' argument %r not in %r" % (value, valid_items))
|
||||
valid_items = EVENT_TYPES
|
||||
if type not in valid_items:
|
||||
raise ArgumentTypeError("'type' argument %r not in %r" % (value, valid_items))
|
||||
valid_items = range(1, sys.maxsize)
|
||||
if repeat not in valid_items:
|
||||
raise ArgumentTypeError("'repeat' argument %r not in %r" % (repeat, valid_items))
|
||||
del valid_items
|
||||
|
||||
if only_validate:
|
||||
return
|
||||
|
||||
for _ in range(repeat):
|
||||
yield dict(type=type, ctrl=ctrl, alt=alt, shift=shift, hyper=hyper, value=value)
|
||||
|
||||
@staticmethod
|
||||
def cursor_motion(*, path, steps, radius=100, repeat=1, only_validate=False):
|
||||
"""
|
||||
path: The path type to use in ('CIRCLE').
|
||||
steps: The number of events to generate.
|
||||
radius: The radius in pixels.
|
||||
repeat: Number of times to repeat the cursor rotation.
|
||||
"""
|
||||
|
||||
import time
|
||||
from math import sin, cos, pi
|
||||
|
||||
valid_items = range(1, sys.maxsize)
|
||||
if steps not in valid_items:
|
||||
raise ArgumentTypeError("'steps' argument %r not in %r" % (steps, valid_items))
|
||||
|
||||
valid_items = range(1, sys.maxsize)
|
||||
if radius not in valid_items:
|
||||
raise ArgumentTypeError("'radius' argument %r not in %r" % (steps, valid_items))
|
||||
|
||||
valid_items = ('CIRCLE',)
|
||||
if path not in valid_items:
|
||||
raise ArgumentTypeError("'path' argument %r not in %r" % (path, valid_items))
|
||||
|
||||
valid_items = range(1, sys.maxsize)
|
||||
if repeat not in valid_items:
|
||||
raise ArgumentTypeError("'repeat' argument %r not in %r" % (repeat, valid_items))
|
||||
del valid_items
|
||||
|
||||
if only_validate:
|
||||
return
|
||||
|
||||
x_init, y_init = mouse_location_get()
|
||||
|
||||
y_init_ofs = y_init + radius
|
||||
|
||||
yield dict(type='MOUSEMOVE', value='NOTHING', x=x_init, y=y_init_ofs)
|
||||
|
||||
print("\n" "Times for: %s" % os.path.basename(bpy.data.filepath))
|
||||
|
||||
t = time.time()
|
||||
step_total = 0
|
||||
|
||||
if path == 'CIRCLE':
|
||||
for _ in range(repeat):
|
||||
for i in range(1, steps + 1):
|
||||
phi = (i / steps) * 2.0 * pi
|
||||
x_ofs = -radius * sin(phi)
|
||||
y_ofs = +radius * cos(phi)
|
||||
step_total += 1
|
||||
yield dict(
|
||||
type='MOUSEMOVE',
|
||||
value='NOTHING',
|
||||
x=int(x_init + x_ofs),
|
||||
y=int(y_init + y_ofs),
|
||||
)
|
||||
|
||||
delta = time.time() - t
|
||||
delta_step = delta / step_total
|
||||
print(
|
||||
"Average:",
|
||||
("%.6f FPS" % (1 / delta_step)).rjust(10),
|
||||
)
|
||||
|
||||
yield dict(type='MOUSEMOVE', value='NOTHING', x=x_init, y=y_init)
|
||||
|
||||
@staticmethod
|
||||
def operator(idname, *, only_validate=False, **kw):
|
||||
"""
|
||||
idname: The operator identifier (positional argument only).
|
||||
kw: Passed to the operator.
|
||||
"""
|
||||
|
||||
# Create a temporary key binding to call the operator.
|
||||
wm = bpy.context.window_manager
|
||||
keyconf = wm.keyconfigs.user
|
||||
|
||||
keymap_id = "Screen"
|
||||
key_to_map = 'F24'
|
||||
|
||||
if only_validate:
|
||||
op_mod, op_submod = idname.partition(".")[0::2]
|
||||
op = getattr(getattr(bpy.ops, op_mod), op_submod)
|
||||
try:
|
||||
# The poll result doesn't matter we only want to know if the operator exists or not.
|
||||
op.poll()
|
||||
except AttributeError:
|
||||
raise ArgumentTypeError("Operator %r does not exist" % (idname))
|
||||
|
||||
keymap = keyconf.keymaps[keymap_id]
|
||||
kmi = keymap.keymap_items.new(idname=idname, type=key_to_map, value='PRESS')
|
||||
kmi.idname = idname
|
||||
props = kmi.properties
|
||||
for key, value in kw.items():
|
||||
if not hasattr(props, key):
|
||||
raise ArgumentTypeError("Operator %r does not have a %r property" % (idname, key))
|
||||
|
||||
try:
|
||||
setattr(props, key, value)
|
||||
except Exception as ex:
|
||||
raise ArgumentTypeError("Operator %r assign %r property with error %s" % (idname, key, str(ex)))
|
||||
|
||||
keymap.keymap_items.remove(kmi)
|
||||
return
|
||||
|
||||
keymap = keyconf.keymaps[keymap_id]
|
||||
kmi = keymap.keymap_items.new(idname=idname, type=key_to_map, value='PRESS')
|
||||
kmi.idname = idname
|
||||
props = kmi.properties
|
||||
for key, value in kw.items():
|
||||
setattr(props, key, value)
|
||||
|
||||
yield dict(type=key_to_map, value='TAP')
|
||||
|
||||
keymap = keyconf.keymaps[keymap_id]
|
||||
kmi = keymap.keymap_items[-1]
|
||||
keymap.keymap_items.remove(kmi)
|
||||
|
||||
|
||||
ACTION_DIR = tuple([
|
||||
key for key in sorted(action_handlers.__dict__.keys())
|
||||
if not key.startswith("_")
|
||||
])
|
||||
|
||||
|
||||
def handle_action(op, args, kwargs, only_validate=False):
|
||||
fn = getattr(action_handlers, op, None)
|
||||
if fn is None:
|
||||
raise ArgumentTypeError("Action %r is not found in %r" % (op, ACTION_DIR))
|
||||
yield from fn(*args, **kwargs, only_validate=only_validate)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Argument Parsing
|
||||
|
||||
|
||||
class BlenderAction(argparse.Action):
|
||||
"""
|
||||
This class is used to extract positional & keyword arguments from
|
||||
a string, validate them, and return the (action, positional_args, keyword_args).
|
||||
|
||||
All of this happens during argument parsing so any errors in the actions
|
||||
show useful error messages instead of failing to execute part way through.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _parse_value(value, index):
|
||||
"""
|
||||
Convert:
|
||||
"value(1, 2, a=1, b='', c=None)"
|
||||
To:
|
||||
("value", (1, 2), {"a": 1, "b": "", "c": None})
|
||||
"""
|
||||
split = value.find("(")
|
||||
if split == -1:
|
||||
op = value
|
||||
args = None
|
||||
kwargs = None
|
||||
else:
|
||||
op = value[:split]
|
||||
namespace = {op: lambda *args, **kwargs: (args, kwargs)}
|
||||
expr = value
|
||||
try:
|
||||
args, kwargs = eval(expr, namespace, namespace)
|
||||
except Exception as ex:
|
||||
raise ArgumentTypeError("Unable to parse \"%s\" at index %d, error: %s" % (expr, index, str(ex)))
|
||||
|
||||
# Creating a list is necessary since this is a generator.
|
||||
try:
|
||||
dummy_result = list(handle_action(op, args, kwargs, only_validate=True))
|
||||
except ArgumentTypeError as ex:
|
||||
raise ArgumentTypeError("Invalid 'action' arguments \"%s\" at index %d, %s" % (value, index, str(ex)))
|
||||
# Validation should never yield any events.
|
||||
assert not dummy_result
|
||||
|
||||
return (op, args, kwargs)
|
||||
|
||||
def __call__(self, parser, namespace, values, option_string=None):
|
||||
setattr(
|
||||
namespace,
|
||||
self.dest, [
|
||||
self._parse_value(value, index)
|
||||
for index, value in enumerate(values)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def argparse_create():
|
||||
import inspect
|
||||
import textwrap
|
||||
|
||||
# When --help or no args are given, print this help
|
||||
parser = argparse.ArgumentParser(
|
||||
description=__doc__,
|
||||
formatter_class=argparse.RawTextHelpFormatter,
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--keep-open",
|
||||
dest="keep_open",
|
||||
default=False,
|
||||
action="store_true",
|
||||
help=(
|
||||
"Keep the window open instead of exiting once event simulation is complete.\n"
|
||||
"This can be useful to inspect the state of the file once the simulation is complete."
|
||||
),
|
||||
required=False,
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--time-actions",
|
||||
dest="time_actions",
|
||||
default=False,
|
||||
action="store_true",
|
||||
help=(
|
||||
"Display the time each action takes\n"
|
||||
"(useful for measuring delay between key-presses)."
|
||||
),
|
||||
required=False,
|
||||
)
|
||||
|
||||
# Collect doc-strings from static methods in `actions`.
|
||||
actions_docstring = []
|
||||
for action_key in ACTION_DIR:
|
||||
action = getattr(action_handlers, action_key)
|
||||
args = str(inspect.signature(action))
|
||||
args = "(" + args[1:].removeprefix("*, ")
|
||||
args = args.replace(", *, ", ", ") # Needed in case the are positional arguments.
|
||||
args = args.replace(", only_validate=False", "")
|
||||
|
||||
actions_docstring.append("- %s%s\n" % (action_key, args))
|
||||
docs = textwrap.dedent((action.__doc__ or "").lstrip("\n").rstrip()) + "\n\n"
|
||||
|
||||
actions_docstring.append(textwrap.indent(docs, " "))
|
||||
|
||||
parser.add_argument(
|
||||
"--actions",
|
||||
dest="actions",
|
||||
metavar='ACTIONS', type=str,
|
||||
help=(
|
||||
"\n" "Arguments must use one of the following prefix:\n"
|
||||
"\n" + "".join(actions_docstring)
|
||||
),
|
||||
nargs='+',
|
||||
required=True,
|
||||
action=BlenderAction,
|
||||
)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Default Startup
|
||||
|
||||
|
||||
def setup_default_preferences(prefs):
|
||||
"""
|
||||
Set preferences useful for automation.
|
||||
"""
|
||||
prefs.view.show_splash = False
|
||||
prefs.view.smooth_view = 0
|
||||
prefs.view.use_save_prompt = False
|
||||
prefs.view.show_developer_ui = True
|
||||
prefs.filepaths.use_auto_save_temporary_files = False
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Main Function
|
||||
|
||||
|
||||
def main_event_iter(*, action_list, time_actions):
|
||||
"""
|
||||
Yield all events from action handlers.
|
||||
"""
|
||||
area = find_main_area()
|
||||
|
||||
x_init = area.x + (area.width // 2)
|
||||
y_init = area.y + (area.height // 2)
|
||||
|
||||
yield dict(type='MOUSEMOVE', value='NOTHING', x=x_init, y=y_init)
|
||||
|
||||
if time_actions:
|
||||
import time
|
||||
t_prev = time.time()
|
||||
|
||||
for (op, args, kwargs) in action_list:
|
||||
yield from handle_action(op, args, kwargs)
|
||||
|
||||
if time_actions:
|
||||
t = time.time()
|
||||
print("%.4f: %s" % ((t - t_prev), repr_action(op, args, kwargs)))
|
||||
t_prev = t
|
||||
|
||||
|
||||
def main():
|
||||
from sys import argv
|
||||
argv = argv[argv.index("--") + 1:] if "--" in argv else []
|
||||
|
||||
try:
|
||||
args = argparse_create().parse_args(argv)
|
||||
except ArgumentTypeError as ex:
|
||||
print(ex)
|
||||
sys.exit(1)
|
||||
|
||||
setup_default_preferences(bpy.context.preferences)
|
||||
|
||||
def exit_fn():
|
||||
if not args.keep_open:
|
||||
sys.exit(0)
|
||||
else:
|
||||
bpy.app.use_event_simulate = False
|
||||
|
||||
run_event_simulate(
|
||||
event_iter=main_event_iter(action_list=args.actions, time_actions=args.time_actions),
|
||||
exit_fn=exit_fn,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
224
blender-5.2.0/tests/utils/bl_run_operators_isolate.py
Executable file
224
blender-5.2.0/tests/utils/bl_run_operators_isolate.py
Executable file
@@ -0,0 +1,224 @@
|
||||
#!/usr/bin/env python3
|
||||
# SPDX-FileCopyrightText: 2026 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
__all__ = (
|
||||
"main",
|
||||
)
|
||||
|
||||
r"""
|
||||
This utility is a special purpose tool to narrow down the cause of errors
|
||||
(typically crashes or asserts) in scripts generated by ``bl_run_operators.py``.
|
||||
|
||||
Minimize a script by commenting out ``run_op(...)`` lines while preserving an exit code.
|
||||
|
||||
It simply takes the command that fails and the script, then comments out operators
|
||||
as long as the error persists. If the operator *was* needed, it's left in.
|
||||
|
||||
Example usage::
|
||||
|
||||
./tests/utils/bl_run_operators_isolate.py \
|
||||
--command "blender -b -X -P -- generated_script.py" \
|
||||
--exit-code 139 \
|
||||
--script generated_script.py
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from collections.abc import (
|
||||
Sequence,
|
||||
)
|
||||
|
||||
|
||||
# The prefix that identifies lines to be isolated.
|
||||
RUN_OP_PREFIX = "run_op("
|
||||
|
||||
# The prefix for the function to load a new file (session).
|
||||
CTX_PREFIX = "ctx_"
|
||||
|
||||
|
||||
def trim_to_last_session(lines: list[str]) -> list[str]:
|
||||
# Drop all but the last session,
|
||||
# from many "sessions" each formatted:
|
||||
#
|
||||
# ctx_scene_reset(...)
|
||||
# run_op(...)
|
||||
# run_op(...)
|
||||
#
|
||||
# Only pick the last, see `--all-sessions` help text for why this is a good default.
|
||||
ctx_indices = [i for i, line in enumerate(lines) if line.startswith(CTX_PREFIX)]
|
||||
if len(ctx_indices) < 2:
|
||||
# Zero or one session: nothing earlier to drop.
|
||||
return lines
|
||||
return lines[:ctx_indices[0]] + lines[ctx_indices[-1]:]
|
||||
|
||||
|
||||
def run_command(command: str, verbose: bool) -> int:
|
||||
"""Run ``command`` and return its exit code, showing its output when ``verbose``."""
|
||||
if verbose:
|
||||
proc = subprocess.run(shlex.split(command))
|
||||
else:
|
||||
proc = subprocess.run(shlex.split(command), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
returncode = proc.returncode
|
||||
# A signal-killed process has a negative `returncode`,
|
||||
# normalize it - as this is what the user will see in the console.
|
||||
if returncode < 0:
|
||||
returncode = 128 - returncode
|
||||
return returncode
|
||||
|
||||
|
||||
def is_run_op_line(line: str) -> bool:
|
||||
return line.startswith(RUN_OP_PREFIX)
|
||||
|
||||
|
||||
def comment_line(line: str) -> str:
|
||||
return "# {:s}".format(line)
|
||||
|
||||
|
||||
def argparse_create() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
formatter_class=argparse.RawTextHelpFormatter,
|
||||
description=__doc__,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--command",
|
||||
dest="command",
|
||||
required=True,
|
||||
help="The command to run (it should execute the script).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--exit-code",
|
||||
dest="exit_code",
|
||||
type=int,
|
||||
required=True,
|
||||
help="The exit code to preserve.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--script",
|
||||
dest="script",
|
||||
required=True,
|
||||
help="The script file whose run_op(...) lines are isolated (modified in-place).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--keep-comments",
|
||||
dest="keep_comments",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Leave commented out lines in place, otherwise they are removed once complete.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--all-sessions",
|
||||
dest="all_sessions",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Keep all sessions; by default only the last (after the final ctx_ line) is kept.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--verbose",
|
||||
dest="verbose",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Show the full output of each command run.",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
if argv is None:
|
||||
argv = sys.argv[1:]
|
||||
|
||||
args = argparse_create().parse_args(argv)
|
||||
|
||||
command = args.command
|
||||
exit_code_expect = args.exit_code
|
||||
script = args.script
|
||||
keep_comments = args.keep_comments
|
||||
all_sessions = args.all_sessions
|
||||
verbose = args.verbose
|
||||
|
||||
with open(script, "r", encoding="utf-8") as fh:
|
||||
lines = fh.readlines()
|
||||
|
||||
# Keep a backup in case rewriting loses data.
|
||||
script_backup = script + ".backup"
|
||||
if os.path.exists(script_backup):
|
||||
print("Backup {:s} already exists, keeping it.".format(script_backup))
|
||||
else:
|
||||
with open(script_backup, "w", encoding="utf-8") as fh:
|
||||
fh.writelines(lines)
|
||||
print("Backed up original script to {:s}.".format(script_backup))
|
||||
|
||||
if not all_sessions:
|
||||
lines_trimmed = trim_to_last_session(lines)
|
||||
if len(lines_trimmed) != len(lines):
|
||||
lines = lines_trimmed
|
||||
with open(script, "w", encoding="utf-8") as fh:
|
||||
fh.writelines(lines)
|
||||
print("Trimmed to the last session ({:d} lines).".format(len(lines)))
|
||||
|
||||
# Ensure the given command generates the expected exit-code.
|
||||
print("Baseline run, expecting exit-code {:d}...".format(exit_code_expect))
|
||||
exit_code = run_command(command, verbose)
|
||||
if exit_code != exit_code_expect:
|
||||
print("Baseline exit-code was {:d}, expected {:d}, aborting.".format(exit_code, exit_code_expect))
|
||||
return 1
|
||||
print("Baseline OK.")
|
||||
|
||||
# The total number of run_op(...) lines (only used for the final summary).
|
||||
run_op_total = sum(1 for line in lines if is_run_op_line(line))
|
||||
print("Found {:d} run_op(...) lines to isolate.".format(run_op_total))
|
||||
|
||||
# Comment lines which aren't needed to redo the error,
|
||||
# afterwards, all of them are removed.
|
||||
commented_indices: set[int] = set()
|
||||
pass_number = 0
|
||||
while True:
|
||||
pass_number += 1
|
||||
commented_this_pass = 0
|
||||
|
||||
# Re-scan each pass: every still-active `run_op(...)` line is a candidate again.
|
||||
candidates = [i for i, line in enumerate(lines) if is_run_op_line(line)]
|
||||
if not candidates:
|
||||
break
|
||||
|
||||
print("Pass {:d}: {:d} candidate run_op(...) lines.".format(pass_number, len(candidates)))
|
||||
for i in candidates:
|
||||
line_orig = lines[i]
|
||||
lines[i] = comment_line(line_orig)
|
||||
with open(script, "w", encoding="utf-8") as fh:
|
||||
fh.writelines(lines)
|
||||
|
||||
# In verbose mode the command's output follows, so end the line first.
|
||||
print("Line {:d}: trying commented... ".format(i + 1), end="\n" if verbose else "", flush=True)
|
||||
exit_code = run_command(command, verbose)
|
||||
if exit_code == exit_code_expect:
|
||||
commented_indices.add(i)
|
||||
commented_this_pass += 1
|
||||
print("still exit-code {:d}, leaving commented.".format(exit_code_expect))
|
||||
else:
|
||||
lines[i] = line_orig
|
||||
with open(script, "w", encoding="utf-8") as fh:
|
||||
fh.writelines(lines)
|
||||
print("exit-code changed to {:d}, restoring.".format(exit_code))
|
||||
|
||||
if commented_this_pass == 0:
|
||||
break
|
||||
|
||||
# Unless requested, remove the commented out lines entirely.
|
||||
if commented_indices and not keep_comments:
|
||||
lines = [line for i, line in enumerate(lines) if i not in commented_indices]
|
||||
|
||||
# Ensure the final state is written to disk.
|
||||
with open(script, "w", encoding="utf-8") as fh:
|
||||
fh.writelines(lines)
|
||||
|
||||
print("Done: commented {:d} of {:d} run_op(...) lines.".format(len(commented_indices), run_op_total))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
391
blender-5.2.0/tests/utils/blender_headless.py
Normal file
391
blender-5.2.0/tests/utils/blender_headless.py
Normal file
@@ -0,0 +1,391 @@
|
||||
#!/usr/bin/env python3
|
||||
# SPDX-FileCopyrightText: 2011-2023 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
"""
|
||||
Wrapper for Blender that launches a graphical instances of Blender
|
||||
in its own display-server.
|
||||
|
||||
This can be useful when a graphical context is required (when ``--background`` can't be used)
|
||||
and it's preferable not to have windows opening on the user's system.
|
||||
|
||||
The main use case for this is tests that run simulated events, see: ``bl_run_operators_event_simulate.py``.
|
||||
|
||||
- All arguments are forwarded to Blender.
|
||||
- Headless operation checks for environment variables.
|
||||
- Blender's exit code is used on exit.
|
||||
|
||||
Environment Variables:
|
||||
|
||||
- ``BLENDER_BIN``: the Blender binary to run.
|
||||
(defaults to ``blender`` which must be in the ``PATH``).
|
||||
- ``USE_WINDOW``: When nonzero:
|
||||
Show the window (not actually headless).
|
||||
Useful for troubleshooting so it's possible to see the contents of the window.
|
||||
Note that using a window causes WAYLAND to define a "seat",
|
||||
where the headless session doesn't define a seat.
|
||||
- ``USE_DEBUG``: When nonzero:
|
||||
Run Blender in a debugger.
|
||||
- ``PASS_THROUGH``: When nonzero:
|
||||
Don't start a display server to run Blender in.
|
||||
It's useful to execute Blender from this wrapper script to provide additional control of the environment.
|
||||
|
||||
WAYLAND Environment Variables:
|
||||
|
||||
- ``WESTON_BIN``: The weston binary to run,
|
||||
(defaults to ``weston`` which must be in the ``PATH``).
|
||||
- ``WAYLAND_ROOT_DIR``: The base directory (prefix) of a portable WAYLAND installation,
|
||||
(may be left unset, in that case the system's installed WAYLAND is used).
|
||||
- ``WESTON_ROOT_DIR``: The base directory (prefix) of a portable WESTON installation,
|
||||
(may be left unset, in that case the system's installed WESTON is used).
|
||||
|
||||
Currently only WAYLAND is supported, other systems could be added.
|
||||
"""
|
||||
__all__ = (
|
||||
"main",
|
||||
)
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
import signal
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
from typing import (
|
||||
Any,
|
||||
)
|
||||
from collections.abc import (
|
||||
Iterator,
|
||||
Sequence,
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Constants
|
||||
|
||||
def environ_nonzero(var: str) -> bool:
|
||||
return os.environ.get(var, "").lstrip("0") != ""
|
||||
|
||||
|
||||
BLENDER_BIN = os.environ.get("BLENDER_BIN", "blender")
|
||||
|
||||
# Skips starting a display server, run Blender in the user's environment.
|
||||
PASS_THROUGH = environ_nonzero("PASS_THROUGH")
|
||||
|
||||
# For debugging, print out all information.
|
||||
VERBOSE = environ_nonzero("VERBOSE")
|
||||
|
||||
# Show the window in the foreground.
|
||||
USE_WINDOW = environ_nonzero("USE_WINDOW")
|
||||
USE_DEBUG = environ_nonzero("USE_DEBUG")
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Generic Utilities
|
||||
|
||||
|
||||
def scantree(path: str) -> Iterator[os.DirEntry[str]]:
|
||||
"""Recursively yield DirEntry objects for given directory."""
|
||||
for entry in os.scandir(path):
|
||||
if entry.is_dir(follow_symlinks=False):
|
||||
yield from scantree(entry.path)
|
||||
else:
|
||||
yield entry
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Implementation Back-Ends
|
||||
|
||||
class backend_base:
|
||||
@staticmethod
|
||||
def run(args: Sequence[str]) -> int:
|
||||
sys.stderr.write("No headless back-ends for {!r} with args {!r}\n".format(sys.platform, args))
|
||||
return 1
|
||||
|
||||
|
||||
class backend_passthrough(backend_base):
|
||||
@staticmethod
|
||||
def run(blender_args: Sequence[str]) -> int:
|
||||
with tempfile.TemporaryDirectory() as empty_user_dir:
|
||||
blender_env = {**os.environ, "BLENDER_USER_RESOURCES": empty_user_dir}
|
||||
|
||||
cmd = [
|
||||
# "strace", # Can be useful for debugging any startup issues.
|
||||
BLENDER_BIN,
|
||||
*blender_args,
|
||||
]
|
||||
|
||||
if USE_DEBUG:
|
||||
cmd = ["gdb", BLENDER_BIN, "--ex=run", "--args", *cmd]
|
||||
|
||||
if VERBOSE:
|
||||
print("Env:", blender_env)
|
||||
print("Run:", cmd)
|
||||
with subprocess.Popen(cmd, env=blender_env) as proc_blender:
|
||||
proc_blender.communicate()
|
||||
blender_exit_code = proc_blender.returncode
|
||||
del cmd
|
||||
|
||||
# Forward Blender's exit code.
|
||||
return blender_exit_code
|
||||
|
||||
|
||||
class backend_wayland(backend_base):
|
||||
@staticmethod
|
||||
def _wait_for_wayland_server(*, socket: str, timeout: float) -> bool:
|
||||
"""
|
||||
Uses the expected socket file in `XDG_RUNTIME_DIR` to detect when the WAYLAND server starts.
|
||||
"""
|
||||
import time
|
||||
time_idle = min(timeout / 100.0, 0.05)
|
||||
|
||||
xdg_runtime_dir = os.environ.get("XDG_RUNTIME_DIR", "")
|
||||
if not xdg_runtime_dir:
|
||||
xdg_runtime_dir = "/var/run/user/{:d}".format(os.getuid())
|
||||
|
||||
filepath = os.path.join(xdg_runtime_dir, socket)
|
||||
|
||||
t_beg = time.time()
|
||||
t_end = t_beg + timeout
|
||||
while True:
|
||||
if os.path.exists(filepath):
|
||||
return True
|
||||
if time.time() >= t_end:
|
||||
break
|
||||
time.sleep(time_idle)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _weston_env_and_ini_from_portable(
|
||||
*,
|
||||
wayland_root_dir: str | None,
|
||||
weston_root_dir: str | None,
|
||||
) -> tuple[dict[str, str] | None, str]:
|
||||
"""
|
||||
Construct a portable environment to run WESTON in.
|
||||
"""
|
||||
# NOTE(@ideasman42): WESTON does not make it convenient to run a portable instance,
|
||||
# a reasonable amount of logic here is simply to get WESTON running with references to portable paths.
|
||||
# Once packages are available on the Linux distribution used for the CI-environment,
|
||||
# we can consider removing this entire function.
|
||||
weston_env = {}
|
||||
weston_ini = []
|
||||
ld_library_paths = []
|
||||
|
||||
if weston_root_dir is None:
|
||||
# There is very little to do, simply write a configuration
|
||||
# that removes the panel to give some extra screen real estate.
|
||||
weston_ini.extend([
|
||||
"[shell]",
|
||||
"background-color=0x00000000",
|
||||
"panel-position=none",
|
||||
# Don't look for a background image.
|
||||
"background-image=",
|
||||
])
|
||||
else:
|
||||
weston_ini.extend([
|
||||
"[core]",
|
||||
"",
|
||||
"[shell]",
|
||||
"background-color=0x00000000",
|
||||
"client={:s}/libexec/weston-desktop-shell".format(weston_root_dir),
|
||||
"panel-position=none",
|
||||
# Don't look for a background image.
|
||||
"background-image=",
|
||||
"",
|
||||
"[keyboard]",
|
||||
"numlock-on=true",
|
||||
"",
|
||||
"[output]",
|
||||
"seat=default",
|
||||
"",
|
||||
"[input-method]",
|
||||
"path={:s}/libexec/weston-keyboard".format(weston_root_dir),
|
||||
])
|
||||
|
||||
if wayland_root_dir is not None:
|
||||
ld_library_paths.append(os.path.join(wayland_root_dir, "lib64"))
|
||||
|
||||
if weston_root_dir is not None:
|
||||
weston_lib_dir = os.path.join(weston_root_dir, "lib")
|
||||
ld_library_paths.extend([
|
||||
weston_lib_dir,
|
||||
os.path.join(weston_lib_dir, "weston"),
|
||||
])
|
||||
|
||||
# Setup the `WESTON_MODULE_MAP`.
|
||||
weston_map_filenames = {
|
||||
"wayland-backend.so": "",
|
||||
"gl-renderer.so": "",
|
||||
"headless-backend.so": "",
|
||||
"desktop-shell.so": "",
|
||||
}
|
||||
|
||||
for entry in scantree(weston_lib_dir):
|
||||
if entry.name in weston_map_filenames:
|
||||
weston_map_filenames[entry.name] = os.path.normpath(entry.path)
|
||||
|
||||
module_map = []
|
||||
for key, value in sorted(weston_map_filenames.items()):
|
||||
if not value:
|
||||
raise Exception("Failure to find {!r} in {!r}".format(key, weston_lib_dir))
|
||||
module_map.append("{:s}={:s}".format(key, value))
|
||||
|
||||
weston_env["WESTON_MODULE_MAP"] = ";".join(module_map)
|
||||
del module_map
|
||||
|
||||
if ld_library_paths:
|
||||
ld_library_paths_str = os.environ.get("LD_LIBRARY_PATH", "")
|
||||
if ld_library_paths_str:
|
||||
ld_library_paths.insert(0, ld_library_paths_str.rstrip(":"))
|
||||
weston_env["LD_LIBRARY_PATH"] = ":".join(ld_library_paths)
|
||||
del ld_library_paths_str
|
||||
|
||||
return (
|
||||
{**os.environ, **weston_env} if weston_env else None,
|
||||
"\n".join(weston_ini),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _weston_env_and_ini_from_system() -> tuple[dict[str, str] | None, str]:
|
||||
weston_env = None
|
||||
weston_ini = [
|
||||
"[shell]",
|
||||
"background-color=0x00000000",
|
||||
"panel-position=none",
|
||||
# Don't look for a background image.
|
||||
"background-image=",
|
||||
]
|
||||
return (
|
||||
weston_env,
|
||||
"\n".join(weston_ini),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _weston_env_and_ini() -> tuple[dict[str, str] | None, str]:
|
||||
wayland_root_dir = os.environ.get("WAYLAND_ROOT_DIR")
|
||||
weston_root_dir = os.environ.get("WESTON_ROOT_DIR")
|
||||
|
||||
if wayland_root_dir or weston_root_dir:
|
||||
weston_env, weston_ini = backend_wayland._weston_env_and_ini_from_portable(
|
||||
wayland_root_dir=wayland_root_dir,
|
||||
weston_root_dir=weston_root_dir,
|
||||
)
|
||||
else:
|
||||
weston_env, weston_ini = backend_wayland._weston_env_and_ini_from_system()
|
||||
return weston_env, weston_ini
|
||||
|
||||
@staticmethod
|
||||
def run(blender_args: Sequence[str]) -> int:
|
||||
# Use the PID to support running multiple tests at once.
|
||||
socket = "wl-blender-{:d}".format(os.getpid())
|
||||
|
||||
weston_bin = os.environ.get("WESTON_BIN", "weston")
|
||||
|
||||
# Ensure the WAYLAND server is NOT running (for this socket).
|
||||
if backend_wayland._wait_for_wayland_server(socket=socket, timeout=0.0):
|
||||
sys.stderr.write("Wayland server for socket \"{:s}\" already running, exiting!\n".format(socket))
|
||||
return 1
|
||||
|
||||
weston_env, weston_ini = backend_wayland._weston_env_and_ini()
|
||||
|
||||
cmd = [
|
||||
weston_bin,
|
||||
"--socket={:s}".format(socket),
|
||||
*(() if USE_WINDOW else ("--backend=headless",)),
|
||||
"--width=800",
|
||||
"--height=600",
|
||||
# `--config={..}` is added to point to a temp file.
|
||||
]
|
||||
cmd_kw: dict[str, Any] = {}
|
||||
if weston_env is not None:
|
||||
cmd_kw["env"] = weston_env
|
||||
if not VERBOSE:
|
||||
cmd_kw["stderr"] = subprocess.PIPE
|
||||
cmd_kw["stdout"] = subprocess.PIPE
|
||||
|
||||
if VERBOSE:
|
||||
print("Env:", weston_env)
|
||||
print("Run:", cmd)
|
||||
|
||||
with tempfile.NamedTemporaryFile(
|
||||
prefix="weston_",
|
||||
suffix=".ini",
|
||||
mode='w',
|
||||
encoding="utf-8",
|
||||
) as weston_ini_tempfile:
|
||||
weston_ini_tempfile.write(weston_ini)
|
||||
weston_ini_tempfile.flush()
|
||||
with subprocess.Popen(
|
||||
[*cmd, "--config={:s}".format(weston_ini_tempfile.name)],
|
||||
**cmd_kw,
|
||||
) as proc_server:
|
||||
del cmd, cmd_kw
|
||||
if not backend_wayland._wait_for_wayland_server(socket=socket, timeout=1.0):
|
||||
# The verbose mode will have written to standard out/error already.
|
||||
# Only show the output is the server wasn't able to start.
|
||||
if not VERBOSE:
|
||||
assert proc_server.stdout is not None
|
||||
assert proc_server.stderr is not None
|
||||
sys.stderr.write("Unable to start wayland server, exiting!\n")
|
||||
sys.stderr.write(proc_server.stdout.read().decode("utf-8", errors="surrogateescape"))
|
||||
sys.stderr.write(proc_server.stderr.read().decode("utf-8", errors="surrogateescape"))
|
||||
sys.stderr.write("\n")
|
||||
proc_server.send_signal(signal.SIGINT)
|
||||
# Wait for the interrupt to be handled.
|
||||
proc_server.communicate()
|
||||
return 1
|
||||
with tempfile.TemporaryDirectory() as empty_user_dir:
|
||||
blender_env = {**os.environ, "WAYLAND_DISPLAY": socket, "BLENDER_USER_RESOURCES": empty_user_dir}
|
||||
|
||||
# Needed so Blender can find WAYLAND libraries such as `libwayland-cursor.so`.
|
||||
if weston_env is not None and "LD_LIBRARY_PATH" in weston_env:
|
||||
blender_env["LD_LIBRARY_PATH"] = weston_env["LD_LIBRARY_PATH"]
|
||||
|
||||
cmd = [
|
||||
# "strace", # Can be useful for debugging any startup issues.
|
||||
BLENDER_BIN,
|
||||
*blender_args,
|
||||
]
|
||||
|
||||
if USE_DEBUG:
|
||||
cmd = ["gdb", BLENDER_BIN, "--ex=run", "--args", *cmd]
|
||||
|
||||
if VERBOSE:
|
||||
print("Env:", blender_env)
|
||||
print("Run:", cmd)
|
||||
with subprocess.Popen(cmd, env=blender_env) as proc_blender:
|
||||
proc_blender.communicate()
|
||||
blender_exit_code = proc_blender.returncode
|
||||
del cmd
|
||||
|
||||
# Blender has finished, close the server.
|
||||
proc_server.send_signal(signal.SIGINT)
|
||||
# Wait for the interrupt to be handled.
|
||||
proc_server.communicate()
|
||||
|
||||
# Forward Blender's exit code.
|
||||
return blender_exit_code
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Main Function
|
||||
|
||||
def main() -> int:
|
||||
backend: type[backend_base]
|
||||
if PASS_THROUGH:
|
||||
backend = backend_passthrough
|
||||
else:
|
||||
match sys.platform:
|
||||
case "darwin":
|
||||
backend = backend_base
|
||||
case "win32":
|
||||
backend = backend_base
|
||||
case _:
|
||||
backend = backend_wayland
|
||||
return backend.run(sys.argv[1:])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
16
blender-5.2.0/tests/utils/readme.rst
Normal file
16
blender-5.2.0/tests/utils/readme.rst
Normal file
@@ -0,0 +1,16 @@
|
||||
|
||||
Test Utilities
|
||||
==============
|
||||
|
||||
These tests are not intended to run as part of automated unit testing,
|
||||
rather they can be used to expose issues though stress testing or other less predictable
|
||||
actions that aren't practical to include in unit tests.
|
||||
|
||||
Examples include:
|
||||
|
||||
- Loading many blend files from a directory, which can expose issues in file reading.
|
||||
- Running operators in various contexts which can expose crashes.
|
||||
- Simulating user input for so ``git bisect`` can be performed on bugs that require user interaction.
|
||||
- Fuzz testing file importers & file format support.
|
||||
|
||||
Note that we could make reduced versions of these tests into unit tests at some point.
|
||||
Reference in New Issue
Block a user