Files
test2/tests/performance/api/environment.py
2024-10-23 12:48:09 +11:00

320 lines
12 KiB
Python

# SPDX-FileCopyrightText: 2021-2023 Blender Authors
#
# SPDX-License-Identifier: Apache-2.0
import base64
import glob
import inspect
import multiprocessing
import os
import pathlib
import platform
import pickle
import subprocess
import sys
from collections.abc import (
Callable,
)
from .config import TestConfig
from .device import TestMachine
class TestEnvironment:
def __init__(self, blender_git_dir: pathlib.Path, base_dir: pathlib.Path):
self.blender_git_dir = blender_git_dir
self.base_dir = base_dir
self.blender_dir = base_dir / 'blender'
self.build_dir = base_dir / 'build'
self.install_dir = self.build_dir / "bin"
self.benchmarks_dir = self.blender_git_dir / 'tests' / 'benchmarks'
self.git_executable = 'git'
self.cmake_executable = 'cmake'
self.cmake_options = ['-DWITH_INTERNATIONAL=OFF', '-DWITH_BUILDINFO=OFF']
self.log_file = None
self.machine = None
self._init_default_blender_executable()
self.set_default_blender_executable()
def get_machine(self, need_gpus: bool = True) -> None:
if not self.machine or (need_gpus and not self.machine.has_gpus):
self.machine = TestMachine(self, need_gpus)
return self.machine
def init(self, build) -> None:
if not self.benchmarks_dir.exists():
sys.stderr.write(f'Error: benchmark files directory not found at {self.benchmarks_dir}')
sys.exit(1)
# Create benchmarks folder contents.
print(f'Init {self.base_dir}')
self.base_dir.mkdir(parents=True, exist_ok=True)
if len(self.get_config_names()) == 0:
config_dir = self.base_dir / 'default'
print(f'Creating default configuration in {config_dir}')
TestConfig.write_default_config(self, config_dir)
if build:
if not self.blender_dir.exists():
print(f'Init git worktree in {self.blender_dir}')
self.call([self.git_executable, 'worktree', 'add', '--detach',
self.blender_dir, 'HEAD'], self.blender_git_dir)
else:
print(f'Exists {self.blender_dir}')
if not self.build_dir.exists():
print(f'Init build in {self.build_dir}')
self.build_dir.mkdir()
# No translation to avoid dealing with submodules
self.call([self.cmake_executable, self.blender_dir, '.'] + self.cmake_options, self.build_dir)
else:
print(f'Exists {self.build_dir}')
print("Building")
self.build()
print('Done')
def checkout(self, git_hash: str) -> None:
# Checkout Blender revision
if not self.blender_dir.exists():
sys.stderr.write('\n\nError: no build set up, run `./benchmark init --build` first\n')
sys.exit(1)
self.call([self.git_executable, 'clean', '-f', '-d'], self.blender_dir)
self.call([self.git_executable, 'reset', '--hard', 'HEAD'], self.blender_dir)
self.call([self.git_executable, 'checkout', '--detach', git_hash], self.blender_dir)
def build(self, git_hash: str, install_dir: pathlib.Path) -> bool:
# Build Blender revision
if not self.build_dir.exists():
sys.stderr.write('\n\nError: no build set up, run `./benchmark init --build` first\n')
sys.exit(1)
# Skip if build with same hash is already done.
if install_dir.resolve() != self.install_dir.resolve():
complete_txt = pathlib.Path(install_dir) / "complete.txt"
if complete_txt.is_file():
if complete_txt.read_text().strip() == git_hash:
self._init_default_blender_executable()
return True
# Different hash, build again.
complete_txt.unlink()
else:
complete_txt = None
self.checkout(git_hash)
jobs = str(multiprocessing.cpu_count())
cmake_options = list(self.cmake_options)
cmake_options += [f"-DCMAKE_INSTALL_PREFIX={install_dir}"]
try:
self.call([self.cmake_executable, '.'] + cmake_options, self.build_dir)
self.call([self.cmake_executable, '--build', '.', '-j', jobs, '--target', 'install'], self.build_dir)
if complete_txt:
complete_txt.write_text(git_hash)
except KeyboardInterrupt as e:
raise e
except:
return False
self._init_default_blender_executable()
return True
def set_blender_executable(self, executable_path: pathlib.Path, environment: dict = {}) -> None:
if executable_path.is_dir():
executable_path = self._blender_executable_from_path(executable_path)
# Run all Blender commands with this executable.
self.blender_executable = executable_path
self.blender_executable_environment = environment
def _blender_executable_name(self) -> pathlib.Path:
if platform.system() == "Windows":
return pathlib.Path('blender.exe')
elif platform.system() == "Darwin":
return pathlib.Path('Blender.app') / 'Contents' / 'MacOS' / 'Blender'
else:
return pathlib.Path('blender')
def _blender_executable_from_path(self, executable: pathlib.Path) -> pathlib.Path:
if executable.is_dir():
# Directory
executable = executable / self._blender_executable_name()
elif not executable.is_file() and executable.name == 'blender':
# Executable path without proper path on Windows or macOS.
executable = executable.parent / self._blender_executable_name()
if executable.is_file():
return executable
return None
def _init_default_blender_executable(self) -> None:
# Find a default executable to run commands independent of testing a specific build.
# Try own built executable.
built_executable = self._blender_executable_from_path(self.install_dir)
if built_executable:
self.default_blender_executable = built_executable
return
# Try find an executable in the configs.
for config_name in self.get_config_names():
for executable in TestConfig.read_blender_executables(self, config_name):
executable = self._blender_executable_from_path(executable)
if executable:
self.default_blender_executable = executable
return
# Fallback to a "blender" command in the hope it's available.
self.default_blender_executable = pathlib.Path("blender")
def set_default_blender_executable(self) -> None:
self.blender_executable = self.default_blender_executable
self.blender_executable_environment = {}
def set_log_file(self, filepath: pathlib.Path, clear=True) -> None:
# Log all commands and output to this file.
self.log_file = filepath
if clear:
self.log_file.unlink(missing_ok=True)
def unset_log_file(self) -> None:
self.log_file = None
def call(self, args: list[str], cwd: pathlib.Path, silent: bool = False, environment: dict = {}) -> list[str]:
# Execute command with arguments in specified directory,
# and return combined stdout and stderr output.
# Open log file for writing
f = None
if self.log_file:
if not self.log_file.exists():
self.log_file.parent.mkdir(parents=True, exist_ok=True)
f = open(self.log_file, 'a', encoding='utf-8', errors='ignore')
f.write('\n' + ' '.join([str(arg) for arg in args]) + '\n\n')
env = os.environ
if len(environment):
env = env.copy()
for key, value in environment.items():
env[key] = value
proc = subprocess.Popen(args, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=env)
# Read line by line
lines = []
try:
while proc.poll() is None:
line = proc.stdout.readline()
if line:
line_str = line.decode('utf-8', 'ignore')
lines.append(line_str)
if f:
f.write(line_str)
except KeyboardInterrupt as e:
# Avoid processes that keep running when interrupting.
proc.terminate()
raise e
# Raise error on failure
if proc.returncode != 0 and not silent:
raise Exception("Error executing command")
return lines
def call_blender(self, args: list[str], foreground=False) -> list[str]:
# Execute Blender command with arguments.
common_args = ['--factory-startup', '-noaudio', '--enable-autoexec', '--python-exit-code', '1']
if foreground:
common_args += ['--no-window-focus', '--window-geometry', '0', '0', '1024', '768']
else:
common_args += ['--background']
return self.call([self.blender_executable] + common_args + args, cwd=self.base_dir,
environment=self.blender_executable_environment)
def run_in_blender(self,
function: Callable[[dict], dict],
args: dict,
blender_args: list = [],
foreground=False) -> dict:
# Run function in a Blender instance. Arguments and return values are
# passed as a Python object that must be serializable with pickle.
# Get information to call this function from Blender.
package_path = pathlib.Path(__file__).parent.parent
functionname = function.__name__
modulename = inspect.getmodule(function).__name__
# Serialize arguments in base64, to avoid having to escape it.
args = base64.b64encode(pickle.dumps(args))
output_prefix = 'TEST_OUTPUT: '
expression = (f'import sys, pickle, base64;'
f'sys.path.append(r"{package_path}");'
f'import {modulename};'
f'args = pickle.loads(base64.b64decode({args}));'
f'result = {modulename}.{functionname}(args);'
f'result = base64.b64encode(pickle.dumps(result));'
f'print("\\n{output_prefix}" + result.decode() + "\\n")')
expr_args = blender_args + ['--python-expr', expression]
lines = self.call_blender(expr_args, foreground=foreground)
# Parse output.
for line in lines:
if line.startswith(output_prefix):
output = line[len(output_prefix):].strip()
result = pickle.loads(base64.b64decode(output))
return result, lines
return {}, lines
def find_blend_files(self, dirpath: pathlib.Path) -> list:
# Find .blend files in subdirectories of the given directory in the
# lib/benchmarks directory.
dirpath = self.benchmarks_dir / dirpath
filepaths = []
for filename in glob.iglob(str(dirpath / '*.blend'), recursive=True):
filepaths.append(pathlib.Path(filename))
return filepaths
def get_config_names(self) -> list:
names = []
if self.base_dir.exists():
for dirname in os.listdir(self.base_dir):
dirpath = self.base_dir / dirname / 'config.py'
if dirpath.exists():
names.append(dirname)
return names
def get_configs(self, name: str = None, names_only: bool = False) -> list:
# Get list of configurations in the benchmarks directory.
configs = []
for config_name in self.get_config_names():
if not name or config_name == name:
if names_only:
configs.append(config_name)
else:
configs.append(TestConfig(self, config_name))
return configs
def resolve_git_hash(self, revision):
# Get git hash for a tag or branch.
lines = self.call([self.git_executable, 'rev-parse', revision], self.blender_git_dir)
return lines[0].strip() if len(lines) else revision
def git_hash_date(self, git_hash):
# Get commit data for a git hash.
lines = self.call([self.git_executable, 'log', '-n1', git_hash, '--format=%at'], self.blender_git_dir)
return int(lines[0].strip()) if len(lines) else 0