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,291 @@
#!/usr/bin/env python3
# SPDX-FileCopyrightText: 2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
"""
Extract line & function information from addresses (found in back-traces) using addr2line.
Example:
addr2line_backtrace.py --exe=/path/to/blender error.log
Piping from the standard-input is also supported:
cat error.log | addr2line_backtrace.py --exe=blender.bin
The text is printed to the standard output.
"""
__all__ = (
"main",
)
import argparse
import multiprocessing
import os
import re
import subprocess
import sys
import time
from collections.abc import (
Sequence,
)
RE_ADDR = re.compile("\\[(0x[A-Fa-f0-9]+)\\]")
IS_ATTY = sys.stdout.isatty()
def value_as_percentage(value_partial: int, value_final: int) -> str:
percent = 0.0 if (value_final == 0) else (value_partial / value_final)
return "{:-6.2f}%".format(percent * 100)
if IS_ATTY:
def progress_output(value_partial: int, value_final: int, info: str) -> None:
sys.stdout.write("\r\033[K[{:s}]: {:s}".format(value_as_percentage(value_partial, value_final), info))
else:
def progress_output(value_partial: int, value_final: int, info: str) -> None:
sys.stdout.write("[{:s}]: {:s}\n".format(value_as_percentage(value_partial, value_final), info))
def find_gitroot(filepath_reference: str) -> str | None:
path = filepath_reference
path_prev = ""
found = False
while not (found := os.path.exists(os.path.join(path, ".git"))) and path != path_prev:
path_prev = path
path = os.path.dirname(path)
if found:
return path
return None
def addr2line_fn(arg_pair: tuple[tuple[str, str, bool], Sequence[str]]) -> Sequence[tuple[str, str]]:
shared_args, addr_list = arg_pair
(exe, base_path, time_command) = shared_args
cmd = (
"addr2line",
*addr_list,
"--functions",
"--demangle",
"--exe=" + exe,
)
if time_command:
time_beg = time.time()
output = subprocess.check_output(cmd).rstrip().decode("utf-8", errors="surrogateescape")
output_lines = output.split("\n")
result: list[tuple[str, str]] = []
while output_lines:
# Swap (function, line), to (line, function).
output_lines_for_addr = output_lines[:2]
assert len(output_lines_for_addr) == 2
del output_lines[:2]
line_list = []
for line in output_lines_for_addr:
if base_path and line.startswith(base_path):
line = "." + os.sep + line[len(base_path):]
line_list.append(line)
output = ": ".join(reversed(line_list))
if time_command:
time_end = time.time()
output = "{:s} ({:.2f})".format(output, time_end - time_beg)
result.append((addr_list[len(result)], output))
return result
def argparse_create() -> argparse.ArgumentParser:
# When `--help` or no arguments are given, print this help.
epilog = "This is typically used from the output of a stack-trace on Linux/Unix."
parser = argparse.ArgumentParser(
formatter_class=argparse.RawTextHelpFormatter,
description=__doc__,
epilog=epilog,
)
parser.add_argument(
"--exe",
dest="exe",
metavar='EXECUTABLE',
required=True,
help="Path to the binary.",
)
parser.add_argument(
"--base",
dest="base",
metavar='BASE_PATH',
default="",
required=False,
help="Base path.",
)
parser.add_argument(
"--time",
dest="time_command",
action='store_true',
required=False,
help="Time addr2line (useful for checking on especially slow lookup).",
)
parser.add_argument(
"--jobs",
dest="jobs",
type=int,
default=4,
help=(
"The number of processes to use. "
"Defaults to 4 to prevent using too much memory, 1 is single threaded (useful for debugging)."
),
required=False,
)
parser.add_argument(
"--skip-last",
dest="skip_last",
type=int,
default=0,
required=False,
help=(
"Optionally skip the last N addresses found.\n"
"You may want to set this to 1 as the first address\n"
"may point to an address that calls the main() function."
"\n"
"In this case the lookup will fail, taking significantly longer than all other lookups.\n"
"Typically values between 1-4 should be used."
),
)
parser.add_argument(
"backtraces",
nargs="*",
help="Back-trace files to scan for addresses.",
)
return parser
def backtraces_extract_from_text(backtrace_data: str) -> set[str]:
addr_set = set()
for match in RE_ADDR.finditer(backtrace_data):
addr = match.group(1)
addr_set.add(addr)
return addr_set
def backtraces_extract_from_text_skip_last(
backtrace_data: str,
skip_last: int,
) -> tuple[set[str], set[str]]:
addr_set = set()
addr_list = []
for match in RE_ADDR.finditer(backtrace_data):
addr = match.group(1)
addr_set.add(addr)
addr_list.append(addr)
# Remove the last N items.
addr_set_skip = set()
for i, addr in enumerate(reversed(addr_list)):
if i == skip_last:
break
# Unlikely but possible, probably too many values are being skipped.
if addr in addr_set_skip:
skip_last += 1
continue
addr_set_skip.add(addr)
addr_set.remove(addr)
return addr_set, addr_set_skip
def addr2line_for_filedata(
exe: str,
base_path: str,
time_command: bool,
skip_last: int,
jobs: int,
backtrace_data: str,
) -> None:
if skip_last == 0:
addr_set = backtraces_extract_from_text(backtrace_data)
addr_set_skip: set[str] = set()
else:
addr_set, addr_set_skip = backtraces_extract_from_text_skip_last(backtrace_data, skip_last)
shared_args = exe, base_path, time_command
if jobs >= len(addr_set):
addr2line_args = [(shared_args, [addr]) for addr in addr_set]
else:
addr2line_args = [(shared_args, []) for _ in range(jobs)]
# Avoid using consecutive addresses in chunks since slower lookups are likely to be groups.
for i, addr in enumerate(addr_set):
addr2line_args[i % jobs][1].append(addr)
addr_map = {}
addr_done = 0
addr_len = len(addr_set)
with multiprocessing.Pool(jobs) as pool:
for i, result_list in enumerate(pool.imap_unordered(addr2line_fn, addr2line_args), 1):
for (addr, result) in result_list:
if addr_set_skip:
skip_text = " (skipped {:d})".format(len(addr_set_skip))
else:
skip_text = ""
progress_output(addr_done, addr_len, "{:d} of {:d}{:s}".format(addr_done, addr_len, skip_text))
del skip_text
addr_map[addr] = result
addr_done += 1
if IS_ATTY:
print()
def re_replace_fn(match: re.Match[str]) -> str:
addr = match.group(1)
if addr in addr_set_skip:
return "{:s} ({:s})".format("<SKIP>", addr)
return "{:s} ({:s})".format(addr_map[addr], addr)
backtrace_data_updated = RE_ADDR.sub(re_replace_fn, backtrace_data)
sys.stdout.write(backtrace_data_updated)
sys.stdout.write("\n")
def main() -> None:
args = argparse_create().parse_args()
jobs = args.jobs
if jobs <= 0:
jobs = multiprocessing.cpu_count()
base_path = args.base
if not base_path:
base_test = find_gitroot(os.getcwd())
if base_test is not None:
base_path = base_test
if base_path:
base_path = base_path.rstrip(os.sep) + os.sep
if args.backtraces:
for backtrace_filepath in args.backtraces:
try:
with open(backtrace_filepath, 'r', encoding="utf-8", errors="surrogateescape") as fh:
bactrace_data = fh.read()
except Exception as ex:
print("Filed to open {!r}, {:s}".format(backtrace_filepath, str(ex)))
continue
addr2line_for_filedata(args.exe, base_path, args.time_command, args.skip_last, jobs, bactrace_data)
else:
bactrace_data = sys.stdin.read()
addr2line_for_filedata(args.exe, base_path, args.time_command, args.skip_last, jobs, bactrace_data)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,458 @@
#!/usr/bin/env python3
# SPDX-FileCopyrightText: 2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
"""
Example use to generate all authors:
authors_git_gen.py
Example use a custom range:
authors_git_gen.py --source=/src/blender --range=SHA1..HEAD
"""
__all__ = (
"main",
)
# NOTE: this shares the basic structure with `credits_git_gen.py`,
# however details differ enough for them to be separate scripts.
# Improvements to this script may apply there too.
import argparse
import io
import multiprocessing
import os
import sys
import unicodedata
from collections.abc import (
Iterable,
Iterator,
)
from git_log import (
GitCommitIter,
GitCommit,
)
import git_data_canonical_authors
import git_data_sha1_override_authors
IS_ATTY = sys.stdout.isatty()
BASE_DIR = os.path.abspath(os.path.dirname(__file__))
SOURCE_DIR = os.path.normpath(os.path.join(BASE_DIR, "..", ".."))
# Exclude authors with this many lines modified.
AUTHOR_LINES_SKIP = 4
# Stop counting after this line limit.
AUTHOR_LINES_LIMIT = 100
AUTHOR_DIRECTORIES_SKIP: tuple[bytes, ...] = (
b"blender/extern/",
b"blender/intern/opennl/",
# Will have own authors file.
b"intern/cycles/",
# Will have own authors file.
b"intern/libmv/libmv/",
)
# -----------------------------------------------------------------------------
# Lookup Table to clean up the authors
#
# This is a combination of unifying git logs as well as
# name change requested by the authors.
# Some projects prefer not to have their developers listed.
author_exclude_individuals = {
"Jason Fielder <jason-fielder@noreply.localhost>", # `@apple.com` developer.
"LLM Assistant <llm-assistant>", # Set as a co-author for some commits.
"Michael B Johnson <wave@noreply.localhost>", # `@apple.com` developer.
}
author_exclude_glob = (
"* <*@apple.com>",
)
author_table = git_data_canonical_authors.canonical_author_map()
author_override_table = git_data_sha1_override_authors.sha1_authors_map()
# -----------------------------------------------------------------------------
# Multi-Processing
def process_commits_for_map(commits: Iterable[GitCommit]) -> "Credits":
result = Credits()
for c in commits:
result.process_commit(c)
return result
# -----------------------------------------------------------------------------
# Progress Display
def value_as_percentage(value_partial: int, value_final: int) -> str:
percent = 0.0 if (value_final == 0) else (value_partial / value_final)
return "{:-6.2f}%".format(percent * 100)
if IS_ATTY:
def progress_output(value_partial: int, value_final: int, info: str) -> None:
sys.stdout.write("\r\033[K[{:s}]: {:s}".format(value_as_percentage(value_partial, value_final), info))
else:
def progress_output(value_partial: int, value_final: int, info: str) -> None:
sys.stdout.write("[{:s}]: {:s}\n".format(value_as_percentage(value_partial, value_final), info))
# -----------------------------------------------------------------------------
# Class for generating authors
class CreditUser:
__slots__ = (
"commit_total",
"lines_change",
)
def __init__(self) -> None:
self.commit_total = 0
self.lines_change = -1
class Credits:
__slots__ = (
"users",
# Use for progress, simply the number of times `process_commit` has been called.
"process_commits_count",
)
def __init__(self) -> None:
self.users: dict[str, CreditUser] = {}
self.process_commits_count = 0
@classmethod
def commit_authors_get(cls, c: GitCommit) -> list[str]:
if (authors_overwrite := author_override_table.get(c.sha1, None)) is not None:
# Ignore git commit info for these having an entry in `author_override_table`.
return [author_table.get(author, author) for author in authors_overwrite]
authors = [c.author] + c.co_authors
# Normalize author string into canonical form, prevents duplicate credit users
authors = [unicodedata.normalize('NFC', author) for author in authors]
return [author_table.get(author, author) for author in authors]
@classmethod
def is_credit_commit_valid(cls, c: GitCommit) -> bool:
if not any(f for f in c.files if not f.startswith(AUTHOR_DIRECTORIES_SKIP)):
return False
return True
def merge(self, other: "Credits") -> None:
"""
Merge other Credits into this, clearing the other.
"""
for user_key, user_other in other.users.items():
user = self.users.get(user_key)
if user is None:
# Consume the user.
self.users[user_key] = user_other
else:
user.commit_total += user_other.commit_total
user.lines_change += user_other.lines_change
other.users.clear()
def process_commit(self, c: GitCommit) -> None:
self.process_commits_count += 1
if not self.is_credit_commit_valid(c):
return
lines_change = -1
authors = self.commit_authors_get(c)
for author in authors:
cu = self.users.get(author)
if cu is None:
cu = self.users[author] = CreditUser()
cu.commit_total += 1
lines_change_for_author = cu.lines_change
if lines_change_for_author < AUTHOR_LINES_LIMIT:
if lines_change_for_author == -1:
cu.lines_change = lines_change_for_author = 0
if lines_change == -1:
diff = c.diff
lines_change = diff.count("\n-") + diff.count("\n+")
cu.lines_change += lines_change
def _process_multiprocessing(self, commit_iter: Iterable[GitCommit], *, jobs: int) -> None:
print("Collecting commits...")
# NOTE(@ideasman42): that the chunk size doesn't have as much impact on
# performance as you might expect, values between 16 and 1024 seem reasonable.
# Although higher values tend to bottleneck as the process finishes.
chunk_size = 256
chunk_list = []
chunk = []
for c in commit_iter:
chunk.append(c)
if len(chunk) >= chunk_size:
chunk_list.append(chunk)
chunk = []
if chunk:
chunk_list.append(chunk)
commit_count_total = (max(len(chunk_list) - 1, 0) * chunk_size) + len(chunk)
print("Found {:,d} commits, processing...".format(commit_count_total))
commit_count_handled = 0
with multiprocessing.Pool(processes=jobs) as pool:
for result in pool.imap_unordered(process_commits_for_map, chunk_list):
commit_count_handled += result.process_commits_count
progress_output(
commit_count_handled,
commit_count_total,
"{:,d} / {:,d} commits".format(commit_count_handled, commit_count_total),
)
self.merge(result)
if IS_ATTY:
print("") # Was printing on one-line, move to next.
def process(self, commit_iter: Iterable[GitCommit], *, jobs: int) -> None:
if jobs > 1:
self._process_multiprocessing(commit_iter, jobs=jobs)
return
# Simple single process operation.
for i, c in enumerate(commit_iter):
self.process_commit(c)
if not (i % 100):
print(i)
def iter_authors(
self,
*,
use_metadata: bool = False,
verbose: bool = False,
) -> Iterator[str]:
import fnmatch
import re
commit_word = "commit", "commits"
metadata_right_margin = 79
author_exclude_regex = tuple(
(match_glob, re.compile(fnmatch.translate(match_glob)))
for match_glob in author_exclude_glob
)
# Normalize using decomposed strings for sorting.
# That makes letters with diacritics grouped with the undiacritized variant.
# Collation is not perfect, but better than having non-English letters at the very bottom of the list.
from unicodedata import normalize
sorted_authors = dict(sorted(
self.users.items(),
key=lambda item: (normalize('NFD', item[0]).casefold(), item[1].commit_total),
))
for author_with_email, cu in sorted_authors.items():
if author_with_email.endswith(" <>"):
if verbose:
print("Skipping:", author_with_email, "(no email)")
continue
if cu.lines_change <= AUTHOR_LINES_SKIP:
if verbose:
print("Skipping:", author_with_email, cu.lines_change, "line(s) changed.")
continue
if author_with_email in author_exclude_individuals:
if verbose:
print("Skipping:", author_with_email, "explicit exclusion requested.")
continue
if match_glob_found := next(iter([
match_glob for match_glob, match_regex in author_exclude_regex
if match_regex.match(author_with_email)
]), None):
if verbose:
print("Skipping:", author_with_email, "glob exclusion \"{:s}\" requested.".format(match_glob_found))
continue
if use_metadata:
yield "{:s} {:s}# lines={:,d} ({:s}), {:,d} {:s}".format(
author_with_email,
(" " * max(1, metadata_right_margin - len(author_with_email))),
min(cu.lines_change, AUTHOR_LINES_LIMIT),
"" if cu.lines_change >= AUTHOR_LINES_LIMIT else "<SKIP?>",
cu.commit_total,
commit_word[cu.commit_total > 1],
)
else:
yield author_with_email
def report_warnings(self) -> None:
# Warn about authors that share an email field.
emails: dict[str, list[str]] = {}
for author in self.iter_authors(use_metadata=False):
name, email = author.rstrip(">").rsplit(" <", 1)
try:
emails[email].append(name)
except KeyError:
emails[email] = [name]
for email, names in emails.items():
if len(names) > 1:
print("Warning: multiple names for an email '{:s}' {!r}".format(email, names))
# -----------------------------------------------------------------------------
# Argument Parser
def argparse_create() -> argparse.ArgumentParser:
# When --help or no args are given, print this help
usage_text = "List authors."
epilog = "This script is used to generate an AUTHORS file"
parser = argparse.ArgumentParser(description=usage_text, epilog=epilog)
parser.add_argument(
"--source",
dest="source_dir",
metavar='PATH',
required=False,
default=SOURCE_DIR,
help="Path to git repository",
)
parser.add_argument(
"--output",
dest="output_filepath",
metavar='PATH',
required=False,
default="",
help="File path to write the output to (when omitted, update the \"AUTHORS\" file)",
)
parser.add_argument(
"--range",
dest="range_sha1",
metavar='SHA1_RANGE',
required=False,
default="HEAD",
help="Range to use, eg: 169c95b8..HEAD or HEAD for all history.",
)
parser.add_argument(
"--jobs",
dest="jobs",
type=int,
default=0,
help=(
"The number of processes to use. "
"Defaults to zero which detects the available cores, 1 is single threaded (useful for debugging)."
),
required=False,
)
parser.add_argument(
"--use-metadata",
action='store_true',
dest="use_metadata",
default=False,
help="Add commits & changed lines as metadata after the author."
)
parser.add_argument(
"--all-authors",
action='store_true',
dest="all_authors",
default=False,
help=(
"Include all authors (don't skip based on paths or commit count/size). "
"This can be useful when updating: "
"'./tools/utils/git_data_canonical_authors.py' which is also used for credit generation."
)
)
return parser
# -----------------------------------------------------------------------------
# Main Function
def main() -> int:
global AUTHOR_DIRECTORIES_SKIP
global AUTHOR_LINES_SKIP
global AUTHOR_LINES_LIMIT
# ----------
# Parse Args
args = argparse_create().parse_args()
if args.all_authors:
AUTHOR_DIRECTORIES_SKIP = ()
AUTHOR_LINES_SKIP = 0
AUTHOR_LINES_LIMIT = 0
credits = Credits()
# commit_range = "HEAD~10..HEAD"
# commit_range = "blender-v2.81-release..blender-v2.82-release"
# commit_range = "blender-v2.82-release"
commit_range = args.range_sha1
jobs = args.jobs
if jobs <= 0:
# Clamp the value, higher values give errors with too many open files.
# Allow users to manually pass very high values in as they might want to tweak system limits themselves.
jobs = min(multiprocessing.cpu_count(), 400)
credits.process(GitCommitIter(args.source_dir, commit_range), jobs=jobs)
credits.report_warnings()
authors_text_io = io.StringIO()
for author in credits.iter_authors(use_metadata=args.use_metadata, verbose=True):
authors_text_io.write("{:s}\n".format(author))
authors_text = authors_text_io.getvalue()
del authors_text_io
if args.output_filepath:
with open(args.output_filepath, 'w', encoding="utf8", errors='xmlcharrefreplace') as fh:
fh.write(authors_text)
print("Written:", args.output_filepath)
return 0
filepath_authors = os.path.join(args.source_dir, "AUTHORS")
text_beg = "# BEGIN individuals section.\n"
text_end = "# Please DO NOT APPEND here. See comments at the top of the file.\n"
with open(filepath_authors, "r", encoding="utf-8") as fh:
authors_contents = fh.read()
text_beg_index = authors_contents.index(text_beg)
text_end_index = authors_contents.index(text_end)
if text_beg_index == -1:
print("Text: {!r} not found in {!r}".format(text_beg, filepath_authors))
return 1
if text_end_index == -1:
print("Text: {!r} not found in {!r}".format(text_end, filepath_authors))
return 1
text_beg_index += len(text_beg)
with open(filepath_authors, "w", encoding="utf-8") as fh:
fh.write(authors_contents[:text_beg_index])
fh.write(authors_text)
fh.write(authors_contents[text_end_index:])
print("Updated:", filepath_authors)
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,122 @@
#!/usr/bin/env python3
# SPDX-FileCopyrightText: 2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
__all__ = (
"main",
)
import subprocess
import os
from os.path import join
from autopep8_clean_config import PATHS, PATHS_EXCLUDE
from collections.abc import (
Callable,
Iterator,
Sequence,
)
# Useful to disable when debugging warnings.
USE_MULTIPROCESS = True
print(PATHS)
SOURCE_EXT = (
# Python
".py",
)
def is_source_and_included(filename: str) -> bool:
return (
filename.endswith(SOURCE_EXT) and
filename not in PATHS_EXCLUDE
)
def path_iter(
path: str,
filename_check: Callable[[str], bool] | None = None,
) -> 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.startswith("."):
continue
filepath = join(dirpath, filename)
if filename_check is None or filename_check(filepath):
yield filepath
def path_expand(
paths: Sequence[str],
filename_check: Callable[[str], bool] | None = None,
) -> Iterator[str]:
for f in paths:
if not os.path.exists(f):
print("Missing:", f)
elif os.path.isdir(f):
yield from path_iter(f, filename_check)
else:
yield f
def autopep8_format_file(f: str) -> None:
print(f)
subprocess.call((
"autopep8",
"--ignore",
",".join((
# Info: Use `isinstance()` instead of comparing types directly.
# Why disable?: Changes code logic, in rare cases we want to compare exact types.
"E721",
# Info: Fix bare except.
# Why disable?: Disruptive, leave our exceptions alone.
"E722",
# Info: Fix module level import not at top of file.
# Why disable?: re-ordering imports is disruptive and breaks some scripts
# that need to check if a module has already been loaded in the case of reloading.
"E402",
# Info: Try to make lines fit within --max-line-length characters.
# Why disable? Causes lines to be wrapped, where long lines have the
# trailing bracket moved to the end of the line.
# If trailing commas were respected as they are by clang-format this might be acceptable.
# Note that this doesn't disable all line wrapping.
"E501",
# Info: Fix various deprecated code (via lib2to3)
# Why disable?: causes imports to be added/re-arranged.
"W690",
)),
"--aggressive",
"--in-place",
"--max-line-length", "120",
f,
))
def main() -> None:
import sys
if os.path.samefile(sys.argv[-1], __file__):
paths = path_expand(PATHS, is_source_and_included)
else:
paths = path_expand(sys.argv[1:], is_source_and_included)
if USE_MULTIPROCESS:
import multiprocessing
job_total = multiprocessing.cpu_count()
pool = multiprocessing.Pool(processes=job_total)
pool.map(autopep8_format_file, paths)
else:
for f in paths:
autopep8_format_file(f)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,45 @@
# SPDX-FileCopyrightText: 2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
__all__ = (
"PATHS",
"PATHS_EXCLUDE",
"SOURCE_DIR",
)
import os
PATHS: tuple[str, ...] = (
"build_files",
"doc",
"release/datafiles",
"release/lts",
"scripts/freestyle",
"scripts/modules",
"scripts/presets",
"scripts/startup",
"scripts/templates_py",
"source/blender",
"tools",
"tests",
)
SOURCE_DIR = os.path.normpath(os.path.abspath(os.path.normpath(
os.path.join(os.path.dirname(__file__), "..", ".."))))
PATHS = tuple(
os.path.join(SOURCE_DIR, p.replace("/", os.sep))
for p in PATHS
)
PATHS_EXCLUDE: set[str] = set(
os.path.join(SOURCE_DIR, p.replace("/", os.sep))
for p in
(
"tools/svn_rev_map/sha1_to_rev.py",
"tools/svn_rev_map/rev_to_sha1.py",
"tools/svn_rev_map/rev_to_sha1.py",
"scripts/modules/_rna_manual_reference.py",
)
)

View File

@@ -0,0 +1,450 @@
#!/usr/bin/env python3
# SPDX-FileCopyrightText: 2015 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
"""
This is a tool for generating a JSON version of a blender file (only its structure, or all its data included).
It can also run some simple validity checks over a .blend file.
WARNING! This is still WIP tool!
Example usage:
./blend2json.py foo.blend
To output also all 'name' fields from data:
./blend2json.py --filter-data="name" foo.blend
To output complete DNA struct info:
./blend2json.py --full-dna foo.blend
To avoid getting all 'uid' old addresses (those will change really often even when data itself does not change,
making diff pretty noisy):
./blend2json.py --no-old-addresses foo.blend
To check a .blend file instead of outputting its JSON version (use explicit -o option to do both at the same time):
./blend2json.py -c foo.blend
"""
__all__ = (
"main",
)
FILTER_DOC = """
Each generic filter is made of three arguments, the include/exclude toggle ('+'/'-'), a regex to match against the name
of the field to check (either one of the 'meta-data' generated by json exporter, or actual data field from DNA structs),
and some regex to match against the data of this field (JSON-ified representation of the data, hence always a string).
Filters are evaluated in the order they are given, that is, if a block does not pass the first filter,
it is immediately rejected and no further check is done on it.
You can add some recursivity to a filter (that is, if an 'include' filter is successful over a 'pointer' property,
it will also automatically include pointed data, with a level of recursivity), by adding either
'*' (for infinite recursion) or a number (to specify the maximum level of recursion) to the include/exclude toggle.
Note that it only makes sense in 'include' case, and gets ignored for 'exclude' one.
Examples:
To include only MESH blocks:
./blend2json.py --filter-block "+" "code" "ME" foo.blend
To include only MESH or CURVE blocks and all data used by them:
./blend2json.py --filter-block "+" "code" "(ME)|(CU)" --filter-block "+*" ".*" ".*" foo.blend
"""
import os
import json
import re
# Avoid maintaining multiple blendfile modules
import sys
sys.path.append(os.path.join(os.path.dirname(__file__), "..", "modules"))
del sys
import blendfile
##### Utils (own json formatting) #####
def json_default(o):
if isinstance(o, bytes):
return repr(o)[2:-1]
elif o is ...:
return "<...>"
return o
def json_dumps(i):
return json.dumps(i, default=json_default)
def keyval_to_json(kvs, indent, indent_step, compact_output=False):
if compact_output:
return ('{' + ', '.join('"%s": %s' % (k, v) for k, v in kvs) + '}')
else:
return ('{%s' % indent_step[:-1] +
(',\n%s%s' % (indent, indent_step)).join(
('"%s":\n%s%s%s' % (k, indent, indent_step, v) if (v and v[0] in {'[', '{'}) else
'"%s": %s' % (k, v)) for k, v in kvs) +
'\n%s}' % indent)
def list_to_json(lst, indent, indent_step, compact_output=False):
if compact_output:
return ('[' + ', '.join(l for l in lst) + ']')
else:
return ('[%s' % indent_step[:-1] +
((',\n%s%s' % (indent, indent_step)).join(
('\n%s%s%s' % (indent, indent_step, l) if (i == 0 and l[0] in {'[', '{'}) else l)
for i, l in enumerate(lst))
) +
'\n%s]' % indent)
##### Main 'struct' writers #####
def gen_fake_addresses(args, blend):
if args.use_fake_address:
hashes = set()
ret = {}
hash_seed = 1
for block in blend.blocks:
if not block.addr_old:
continue
hsh = block.get_data_hash(hash_seed)
while hsh in hashes:
hsh += 1
hashes.add(hsh)
ret[block.addr_old] = hsh
if (args.raw_bblock):
hash_seed += 1
return ret
return {}
def bheader_to_json(args, fw, blend, indent, indent_step):
fw('%s"%s": [\n' % (indent, "HEADER"))
indent = indent + indent_step
keyval = (
("magic", json_dumps(blend.header.magic)),
("pointer_size", json_dumps(blend.header.pointer_size)),
("is_little_endian", json_dumps(blend.header.is_little_endian)),
("version", json_dumps(blend.header.version)),
)
keyval = keyval_to_json(keyval, indent, indent_step)
fw('%s%s' % (indent, keyval))
indent = indent[:-len(indent_step)]
fw('\n%s]' % indent)
def do_bblock_filter(filters, blend, block, meta_keyval, data_keyval):
def do_bblock_filter_data_recursive(blend, block, rec_lvl, rec_iter, key=None):
fields = (blend.structs[block.sdna_index].fields if key is None else
[blend.structs[block.sdna_index].field_from_name.get(key[1:-1].encode())])
for fld in fields:
if fld is None:
continue
if fld.dna_name.is_pointer:
paths = ([(fld.dna_name.name_only, i) for i in range(fld.dna_name.array_size)]
if fld.dna_name.array_size > 1 else [fld.dna_name.name_only])
for p in paths:
child_block = block.get_pointer(p)
if child_block is not None:
child_block.user_data = max(block.user_data, rec_iter)
if rec_lvl != 0:
do_bblock_filter_data_recursive(blend, child_block, rec_lvl - 1, rec_iter + 1)
has_include = False
do_break = False
rec_iter = 1
if block.user_data is None:
block.user_data = 0
for include, rec_lvl, key, val in filters:
if rec_lvl < 0:
rec_lvl = 100
has_include = has_include or include
# Skip exclude filters if block was already processed some way.
if not include and block.user_data is not None:
continue
has_match = False
for k, v in meta_keyval:
if key.search(k) and val.search(v):
has_match = True
if include:
block.user_data = max(block.user_data, rec_iter)
# Note that in include cases, we have to keep checking filters, since some 'include recursive'
# ones may still have to be processed...
else:
block.user_data = min(block.user_data, -rec_iter)
do_break = True # No need to check more filters in exclude case...
break
for k, v in data_keyval:
if key.search(k) and val.search(v):
has_match = True
if include:
block.user_data = max(block.user_data, rec_iter)
if rec_lvl != 0:
do_bblock_filter_data_recursive(blend, block, rec_lvl - 1, rec_iter + 1, k)
# Note that in include cases, we have to keep checking filters, since some 'include recursive'
# ones may still have to be processed...
else:
block.user_data = min(block.user_data, -rec_iter)
do_break = True # No need to check more filters in exclude case...
break
if include and not has_match: # Include check failed, implies exclusion.
block.user_data = min(block.user_data, -rec_iter)
do_break = True # No need to check more filters in exclude case...
if do_break:
break
# Implicit 'include all' in case no include filter is specified...
if block.user_data == 0 and not has_include:
block.user_data = max(block.user_data, rec_iter)
def bblocks_to_json(args, fw, blend, address_map, indent, indent_step):
raw_bblock = args.raw_bblock
no_address = args.no_address
full_data = args.full_data
filter_data = args.filter_data
def gen_meta_keyval(blend, block):
keyval = [
("code", json_dumps(block.code)),
("size", json_dumps(block.size)),
("file_offset", json_dumps(block.file_offset)),
]
if not no_address:
keyval += [("addr_old", json_dumps(address_map.get(block.addr_old, block.addr_old)))]
if raw_bblock:
keyval += [("dna_index", json_dumps(block.sdna_index))]
else:
keyval += [("dna_type_id", json_dumps(blend.structs[block.sdna_index].dna_type_id))]
keyval += [("count", json_dumps(block.count))]
return keyval
def gen_data_keyval(blend, block, key_filter=None):
if raw_bblock:
return []
def _is_pointer(k):
return blend.structs[block.sdna_index].field_from_path(blend.header, blend.handle, k).dna_name.is_pointer
if key_filter is not None:
return [(json_dumps(k)[1:-1], json_dumps(address_map.get(v, v) if _is_pointer(k) else v))
for k, v in block.items_recursive_iter() if k in key_filter]
return [(json_dumps(k)[1:-1], json_dumps(address_map.get(v, v) if _is_pointer(k) else v))
for k, v in block.items_recursive_iter()]
if args.block_filters:
for block in blend.blocks:
meta_keyval = gen_meta_keyval(blend, block)
data_keyval = gen_data_keyval(blend, block)
do_bblock_filter(args.block_filters, blend, block, meta_keyval, data_keyval)
fw('%s"%s": [\n' % (indent, "DATA"))
indent = indent + indent_step
is_first = True
for block in blend.blocks:
if block.user_data is None or block.user_data > 0:
meta_keyval = gen_meta_keyval(blend, block)
if full_data:
meta_keyval.append(("data", keyval_to_json(gen_data_keyval(blend, block),
indent + indent_step, indent_step, args.compact_output)))
elif filter_data:
meta_keyval.append(("data", keyval_to_json(gen_data_keyval(blend, block, filter_data),
indent + indent_step, indent_step, args.compact_output)))
keyval = keyval_to_json(meta_keyval, indent, indent_step, args.compact_output)
fw('%s%s%s' % ('' if is_first else ',\n', indent, keyval))
is_first = False
indent = indent[:-len(indent_step)]
fw('\n%s]' % indent)
def bdna_to_json(args, fw, blend, indent, indent_step):
full_dna = args.full_dna and not args.compact_output
def bdna_fields_to_json(blend, dna, indent, indent_step):
lst = []
for field in dna.fields:
keyval = (
("dna_name", json_dumps(field.dna_name.name_only)),
("dna_type_id", json_dumps(field.dna_type.dna_type_id)),
("is_pointer", json_dumps(field.dna_name.is_pointer)),
("is_method_pointer", json_dumps(field.dna_name.is_method_pointer)),
("array_size", json_dumps(field.dna_name.array_size)),
)
lst.append(keyval_to_json(keyval, indent + indent_step, indent_step))
return list_to_json(lst, indent, indent_step)
fw('%s"%s": [\n' % (indent, "DNA_STRUCT"))
indent = indent + indent_step
is_first = True
for dna in blend.structs:
keyval = [
("dna_type_id", json_dumps(dna.dna_type_id)),
("size", json_dumps(dna.size)),
]
if full_dna:
keyval += [("fields", bdna_fields_to_json(blend, dna, indent + indent_step, indent_step))]
else:
keyval += [("nbr_fields", json_dumps(len(dna.fields)))]
keyval = keyval_to_json(keyval, indent, indent_step, args.compact_output)
fw('%s%s%s' % ('' if is_first else ',\n', indent, keyval))
is_first = False
indent = indent[:-len(indent_step)]
fw('\n%s]' % indent)
def blend_to_json(args, f, blend, address_map):
fw = f.write
fw('{\n')
indent = indent_step = " "
bheader_to_json(args, fw, blend, indent, indent_step)
fw(',\n')
bblocks_to_json(args, fw, blend, address_map, indent, indent_step)
if not args.raw_bblock:
fw(',\n')
bdna_to_json(args, fw, blend, indent, indent_step)
fw('\n}\n')
##### Checks #####
def check_file(args, blend):
addr_old = set()
for block in blend.blocks:
if block.addr_old in addr_old:
print("ERROR! Several data blocks share same 'addr_old' UUID %d, "
"this should never happen!" % block.addr_old)
continue
addr_old.add(block.addr_old)
##### Main #####
def argparse_create():
import argparse
global __doc__
# When --help or no args are given, print this help
usage_text = __doc__
epilog = "This script is typically used to check differences between .blend files, or to check their validity."
parser = argparse.ArgumentParser(description=usage_text, epilog=epilog,
formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument(
dest="input", nargs="+", metavar='PATH',
help="Input .blend file(s)")
parser.add_argument(
"-o", "--output", dest="output", action="append", metavar='PATH', required=False,
help="Output .json file(s) (same path/name as input file(s) if not specified)")
parser.add_argument(
"-c", "--check-file", dest="check_file", default=False, action='store_true', required=False,
help=("Perform some basic validation checks over the .blend file"))
parser.add_argument(
"--compact-output", dest="compact_output", default=False, action='store_true', required=False,
help=("Output a very compact representation of blendfile (one line per block/DNAStruct)"))
parser.add_argument(
"--no-old-addresses", dest="no_address", default=False, action='store_true', required=False,
help=("Do not output old memory address of each block of data "
"(used as 'UUID' in .blend files, but change pretty noisily)"))
parser.add_argument(
"--no-fake-old-addresses", dest="use_fake_address", default=True, action='store_false',
required=False,
help=("Do not 'rewrite' old memory address of each block of data "
"(they are rewritten by default to some hash of their content, "
"to try to avoid too much diff noise between different but similar files)"))
parser.add_argument(
"--full-data", dest="full_data",
default=False, action='store_true', required=False,
help=("Also put in JSON file data itself "
"(WARNING! will generate *huge* verbose files - and is far from complete yet)"))
parser.add_argument(
"--filter-data", dest="filter_data",
default=None, required=False,
help=("Only put in JSON file data fields which names match given comma-separated list "
"(ignored if --full-data is set)"))
parser.add_argument(
"--full-dna", dest="full_dna", default=False, action='store_true', required=False,
help=("Also put in JSON file dna properties description (ignored when --compact-output is used)"))
parser.add_argument(
"--raw-bblock", dest="raw_bblock",
default=False, action='store_true', required=False,
help=("Do not attempt to open and parse the blend-file at a high level, but only handles its basic data layout "
"(usable when the given files are not valid blend-files - e.g. corrupted ones)"))
group = parser.add_argument_group("Filters", FILTER_DOC)
group.add_argument(
"--filter-block", dest="block_filters", nargs=3, action='append',
help=("Filter to apply to BLOCKS (a.k.a. data itself)"))
return parser
def main():
# ----------
# Parse Args
args = argparse_create().parse_args()
if not args.output:
if args.check_file:
args.output = [None] * len(args.input)
else:
args.output = [os.path.splitext(infile)[0] + ".json" for infile in args.input]
if args.block_filters:
args.block_filters = [(True if m[0] == "+" else False,
0 if len(m) == 1 else (-1 if m[1] == "*" else int(m[1:])),
re.compile(f), re.compile(d))
for m, f, d in args.block_filters]
if args.filter_data:
if args.full_data:
args.filter_data = None
else:
args.filter_data = {n.encode() for n in args.filter_data.split(',')}
for infile, outfile in zip(args.input, args.output):
if args.raw_bblock:
with blendfile.open_blend(infile, wrapper_type=blendfile.BlendFileRaw) as blend:
address_map = gen_fake_addresses(args, blend)
if outfile:
with open(outfile, 'w', encoding="ascii", errors='xmlcharrefreplace') as f:
blend_to_json(args, f, blend, address_map)
continue
with blendfile.open_blend(infile) as blend:
address_map = gen_fake_addresses(args, blend)
if args.check_file:
check_file(args, blend)
if outfile:
with open(outfile, 'w', encoding="ascii", errors='xmlcharrefreplace') as f:
blend_to_json(args, f, blend, address_map)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,203 @@
#!/usr/bin/env python3
# SPDX-FileCopyrightText: 2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
r"""
This script exports permutations of key-maps with different settings modified.
Useful for checking changes intended for one configuration don't impact others accidentally.
./blender.bin -b --factory-startup \
--python tools/utils/blender_keyconfig_export_permutations.py -- \
--preset=Blender \
--output-dir=./output \
--keymap-prefs=select_mouse:rmb_action
/blender.bin -b --factory-startup \
--python tools/utils/blender_keyconfig_export_permutations.py -- \
--preset=Blender_27x \
--output-dir=output \
--keymap-prefs="select_mouse"
The preferences setting: ``select_mouse:rmb_action`` expands into:
config = [
("select_mouse", ('LEFT', 'RIGHT')),
("rmb_action", ('TWEAK', 'FALLBACK_TOOL')),
]
"""
__all__ = (
"main",
)
import os
import sys
def argparse_create():
import argparse
parser = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawTextHelpFormatter,
)
parser.add_argument(
"--preset",
dest="preset",
default="Blender",
metavar='PRESET', type=str,
help="The name of the preset to export",
required=False,
)
parser.add_argument(
"--output-dir",
dest="output_dir",
default=".",
metavar='OUTPUT_DIR', type=str,
help="The directory to output to.",
required=False,
)
parser.add_argument(
"--keymap-prefs",
dest="keymap_prefs",
default="select_mouse:rmb_action",
metavar='KEYMAP_PREFS', type=str,
help=(
"Colon separated list of attributes to generate key-map configuration permutations. "
"WARNING: as all combinations are tested, their number increases exponentially!"
),
required=False,
)
return parser
def permutations_from_attrs_impl(config, permutation, index):
index_next = index + 1
attr, values = config[index]
for val in values:
permutation[index] = (attr, val)
if index_next == len(config):
yield tuple(permutation)
else:
# Keep walking down the list of permutations.
yield from permutations_from_attrs_impl(config, permutation, index_next)
# Not necessary, just ensure stale values aren't used.
permutation[index] = None
def permutations_from_attrs(config):
"""
Take a list of attributes and possible values:
config = [
("select_mouse", ('LEFT', 'RIGHT')),
("rmb_action", ('TWEAK', 'FALLBACK_TOOL')),
]
Yielding all permutations:
[("select_mouse", 'LEFT'), ("rmb_action", 'TWEAK')],
[("select_mouse", 'LEFT'), ("rmb_action", 'FALLBACK_TOOL')],
... etc ...
"""
if not config:
return ()
permutation = [None] * len(config)
result = list(permutations_from_attrs_impl(config, permutation, 0))
assert permutation == ([None] * len(config))
return result
def permutation_as_filename(preset, values):
"""
Takes a configuration, eg:
[("select_mouse", 'LEFT'), ("rmb_action", 'TWEAK')]
And returns a filename compatible path:
"""
from urllib.parse import quote
if not values:
return quote(preset)
return quote(
preset + "_" + ".".join([
"-".join((str(key), str(val)))
for key, val in values
]),
# Needed so forward slashes aren't included in the resulting name.
safe="",
)
def main():
args = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
try:
import bpy
except ImportError:
# Run outside of Blender, just show "--help".
bpy = None
args.insert(0, "--help")
args = argparse_create().parse_args(args)
if bpy is None:
return
from bpy import context
preset = args.preset
output_dir = args.output_dir
os.makedirs(output_dir, exist_ok=True)
# Needed for background mode.
preset_filepath = bpy.utils.preset_find(preset, preset_path="keyconfig")
bpy.ops.preferences.keyconfig_activate(filepath=preset_filepath)
# Key-map preferences..
km_prefs = context.window_manager.keyconfigs.active.preferences
config = []
# Use RNA introspection:
if args.keymap_prefs:
for attr in args.keymap_prefs.split(":"):
if not hasattr(km_prefs, attr):
print(f"KeyMap preferences does not have attribute: {attr:s}")
sys.exit(1)
prop_def = km_prefs.rna_type.properties.get(attr)
match prop_def.type:
case 'ENUM':
value = tuple(val.identifier for val in prop_def.enum_items)
case 'BOOLEAN':
value = (True, False)
case _ as prop_def_type:
raise Exception(f"Unhandled attribute type {prop_def_type:s}")
config.append((attr, value))
config = tuple(config)
for attr_permutation in (permutations_from_attrs(config) or ((),)):
# Reload and set.
if attr_permutation is not None:
km_prefs = context.window_manager.keyconfigs.active.preferences
for attr, value in attr_permutation:
setattr(km_prefs, attr, value)
# Re-activate after setting preferences, tsk, ideally this shouldn't be necessary.
bpy.ops.preferences.keyconfig_activate(filepath=preset_filepath)
filepath = os.path.join(output_dir, permutation_as_filename(preset, attr_permutation) + ".py")
print("Writing:", filepath)
bpy.ops.preferences.keyconfig_export(filepath=filepath, all=True)
sys.exit()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,127 @@
#!/usr/bin/env python3
# SPDX-FileCopyrightText: 2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
__all__ = (
"main",
)
import os
import subprocess
import sys
# We unfortunately ended with three commits instead of a single one to be handled as
# 'clang-format' commit, we are handling them as a single 'block'.
format_commits = (
'e12c08e8d170b7ca40f204a5b0423c23a9fbc2c1',
'91a9cd0a94000047248598394c41ac30f893f147',
'3076d95ba441cd32706a27d18922a30f8fd28b8a',
)
pre_format_commit = format_commits[0] + '~1'
def get_string(cmd):
return subprocess.run(cmd, stdout=subprocess.PIPE).stdout.decode('utf8').strip()
def main() -> int:
# Parse arguments.
mode = None
base_branch = 'main'
if len(sys.argv) >= 2:
# Note that recursive conflict resolution strategy has to reversed in rebase compared to merge.
# See https://git-scm.com/docs/git-rebase#Documentation/git-rebase.txt--m
if sys.argv[1] == '--rebase':
mode = 'rebase'
recursive_format_commit_merge_options = '-Xignore-all-space -Xtheirs'
elif sys.argv[1] == '--merge':
mode = 'merge'
recursive_format_commit_merge_options = '-Xignore-all-space -Xours'
if len(sys.argv) == 4:
if sys.argv[2] == '--base_branch':
base_branch = sys.argv[3]
if mode is None:
print("Merge or rebase Blender main (or another base branch) into a branch in 3 steps,")
print("to automatically merge clang-format changes.")
print("")
print(" --rebase Perform equivalent of 'git rebase main'")
print(" --merge Perform equivalent of 'git merge main'")
print("")
print("Optional arguments:")
print(" --base_branch <branch name> Use given branch instead of main")
print(" (assuming that base branch has already been updated")
print(" and has the initial clang-format commit).")
return 0
# Verify we are in the right directory.
root_path = get_string(['git', 'rev-parse', '--show-superproject-working-tree'])
if os.path.realpath(root_path) != os.path.realpath(os.getcwd()):
print("BLENDER MERGE: must run from blender repository root directory")
return 1
# Abort if a rebase is still progress.
rebase_merge = get_string(['git', 'rev-parse', '--git-path', 'rebase-merge'])
rebase_apply = get_string(['git', 'rev-parse', '--git-path', 'rebase-apply'])
merge_head = get_string(['git', 'rev-parse', '--git-path', 'MERGE_HEAD'])
if (
os.path.exists(rebase_merge) or
os.path.exists(rebase_apply) or
os.path.exists(merge_head)
):
print("BLENDER MERGE: rebase or merge in progress, complete it first")
return 1
# Abort if uncommitted changes.
changes = get_string(['git', 'status', '--porcelain', '--untracked-files=no'])
if len(changes) != 0:
print("BLENDER MERGE: detected uncommitted changes, cannot run")
return 1
# Setup command, with commit message for merge commits.
if mode == 'rebase':
mode_cmd = 'rebase'
else:
branch = get_string(['git', 'rev-parse', '--abbrev-ref', 'HEAD'])
mode_cmd = 'merge --no-edit -m "Merge \'' + base_branch + '\' into \'' + branch + '\'"'
# Rebase up to the clang-format commit.
code = os.system('git merge-base --is-ancestor ' + pre_format_commit + ' HEAD')
if code != 0:
code = os.system('git ' + mode_cmd + ' ' + pre_format_commit)
if code != 0:
print("BLENDER MERGE: resolve conflicts, complete " + mode + " and run again")
return code
# Rebase clang-format commit.
code = os.system('git merge-base --is-ancestor ' + format_commits[-1] + ' HEAD')
if code != 0:
os.system('git ' + mode_cmd + ' ' + recursive_format_commit_merge_options + ' ' + format_commits[-1])
paths = get_string(('git', '--no-pager', 'diff', '--name-only', format_commits[-1])).replace('\n', ' ')
if sys.platform == 'win32' and len(paths) > 8000:
# Windows command-line does not accept more than 8191 chars.
os.system('make format')
else:
os.system('make format PATHS="' + paths + '"')
os.system('git add -u')
count = int(get_string(['git', 'rev-list', '--count', '' + format_commits[-1] + '..HEAD']))
if count == 1 or mode == 'merge':
# Amend if we just have a single commit or are merging.
os.system('git commit --amend --no-edit')
else:
# Otherwise create a commit for formatting.
os.system('git commit -m "Cleanup: apply clang format"')
# Rebase remaining commits
code = os.system('git ' + mode_cmd + ' ' + base_branch)
if code != 0:
print("BLENDER MERGE: resolve conflicts, complete " + mode + " and you're done")
else:
print("BLENDER MERGE: done")
return code
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,292 @@
#!/usr/bin/env python3
# SPDX-FileCopyrightText: 2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
"""
Generates 'userdef_default_theme.c' from a 'userpref.blend' file.
Pass your user preferences blend file to this script to update the C source file.
eg:
./tools/utils/blender_theme_as_c.py ~/.config/blender/2.80/config/userpref.blend
.. or find the latest:
./tools/utils/blender_theme_as_c.py $(find ~/.config/blender -name "userpref.blend" | sort | tail -1)
"""
__all__ = (
"main",
)
C_SOURCE_HEADER = r'''/* SPDX-FileCopyrightText: 2018 Blender Authors
*
* SPDX-License-Identifier: GPL-2.0-or-later */
/**
* Generated by 'tools/utils/blender_theme_as_c.py'
*
* Do not hand edit this file!
*/
#include "DNA_theme_types.h"
/* clang-format off */
/* NOTE: this is endianness-sensitive. */
#define RGBA(c) {((c) >> 24) & 0xff, ((c) >> 16) & 0xff, ((c) >> 8) & 0xff, (c) & 0xff}
#define RGB(c) {((c) >> 16) & 0xff, ((c) >> 8) & 0xff, (c) & 0xff}
'''
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
import os
# Avoid maintaining multiple blendfile modules
import sys
sys.path.append(os.path.join(os.path.dirname(__file__), "..", "modules"))
del sys
source_dst = os.path.join(
os.path.dirname(__file__),
"..", "..",
"release", "datafiles", "userdef", "userdef_default_theme.c",
)
dna_rename_defs_h = os.path.join(
os.path.dirname(__file__),
"..", "..",
"source", "blender", "makesdna", "intern", "dna_rename_defs.h",
)
def dna_rename_defs(blend):
"""
"""
from blendfile import DNAName
import re
re_dna_struct_rename = re.compile(
r'DNA_STRUCT_RENAME+\('
r'([a-zA-Z0-9_]+)' r',\s*'
r'([a-zA-Z0-9_]+)' r'\)',
)
re_dna_struct_rename_elem = re.compile(
r'DNA_STRUCT_RENAME_MEMBER+\('
r'([a-zA-Z0-9_]+)' r',\s*'
r'([a-zA-Z0-9_]+)' r',\s*'
r'([a-zA-Z0-9_]+)' r'\)',
)
with open(dna_rename_defs_h, 'r', encoding='utf-8') as fh:
data = fh.read()
struct_runtime_to_storage_map = {}
member_runtime_to_storage_map = {}
for line in data.split('\n'):
m = re_dna_struct_rename.match(line)
if m is not None:
struct_storage, struct_runtime = m.groups()
struct_runtime_to_storage_map[struct_runtime] = struct_storage
continue
m = re_dna_struct_rename_elem.match(line)
if m is not None:
struct_name_runtime, member_storage, member_runtime = m.groups()
if struct_name_runtime not in member_runtime_to_storage_map:
member_runtime_to_storage_map[struct_name_runtime] = []
member_runtime_to_storage_map[struct_name_runtime].append((member_storage, member_runtime))
continue
for struct_name_runtime, members in member_runtime_to_storage_map.items():
if len(members) > 1:
# Order renames that are themselves destinations to go first, so that the item is not removed.
# Needed e.g.
# `DNA_STRUCT_RENAME_ELEM(Light, energy_new, energy);`
# `DNA_STRUCT_RENAME_ELEM(Light, energy, energy_deprecated)`
# ... in this case the order matters.
member_runtime_set = set(member_runtime for (_member_storage, member_runtime) in members)
members_ordered = ([], [])
for (member_storage, member_runtime) in members:
members_ordered[member_storage not in member_runtime_set].append((member_storage, member_runtime))
members = members_ordered[0] + members_ordered[1]
del member_runtime_set, members_ordered
for (member_storage, member_runtime) in members:
struct_name_storage = struct_runtime_to_storage_map.get(struct_name_runtime, struct_name_runtime)
struct_name_storage = struct_name_storage.encode('utf-8')
# The struct itself may have been renamed.
member_storage = member_storage.encode('utf-8')
member_runtime = member_runtime.encode('utf-8')
dna_struct = blend.structs[blend.sdna_index_from_id[struct_name_storage]]
for field in dna_struct.fields:
dna_name = field.dna_name
if member_storage == dna_name.name_only:
field.dna_name = dna_name = DNAName(dna_name.name_full)
del dna_struct.field_from_name[dna_name.name_only]
dna_name.name_full = dna_name.name_full.replace(member_storage, member_runtime)
dna_name.name_only = member_runtime
dna_struct.field_from_name[dna_name.name_only] = field
def theme_data(userpref_filename):
import blendfile
blend = blendfile.open_blend(userpref_filename)
dna_rename_defs(blend)
u = next((c for c in blend.blocks if c.code == b'USER'), None)
# theme_type = b.sdna_index_from_id[b'bTheme']
t = u.get_pointer((b'themes', b'first'))
t.refine_type(b'bTheme')
return blend, t
def is_ignore_dna_name(name):
if name.startswith(b'_'):
return True
elif name in {
b'active_theme_area',
}:
return True
else:
return False
def write_member(fw, indent, _blend, _theme, ls):
path_old = ()
for key, value in ls:
key = key if type(key) is tuple else (key,)
path_new = key[:-1]
if tuple(path_new) != tuple(path_old):
if path_old:
p = len(path_old) - 1
while p >= 0 and (p >= len(path_new) or path_new[p] != path_old[p]):
indent = p + 1
fw('\t' * indent)
fw('},\n')
p -= 1
del p
p = 0
for p in range(min(len(path_old), len(path_new))):
if path_old[p] != key[p]:
break
else:
p = p + 1
for i, c in enumerate(path_new[p:]):
indent = p + i + 1
fw('\t' * indent)
if type(c) is bytes:
attr = c.decode('ascii')
fw(f'.{attr} = ')
fw('{\n')
if not is_ignore_dna_name(key[-1]):
indent = '\t' * (len(path_new) + 1)
attr = key[-1].decode('ascii')
if isinstance(value, float):
if value != 0.0:
value_repr = repr_f32(value)
fw(f'{indent}.{attr} = {value_repr}f,\n')
elif isinstance(value, int):
if value != 0:
fw(f'{indent}.{attr} = {value},\n')
elif isinstance(value, bytes):
if set(value) != {0}:
if len(value) == 3:
value_repr = "".join(f'{ub:02x}' for ub in value)
fw(f'{indent}.{attr} = RGB(0x{value_repr}),\n')
elif len(value) == 4:
value_repr = "".join(f'{ub:02x}' for ub in value)
fw(f'{indent}.{attr} = RGBA(0x{value_repr}),\n')
else:
value = value.rstrip(b'\x00')
is_ascii = True
for ub in value:
if not (ub >= 32 and ub < 127):
is_ascii = False
break
if is_ascii:
value_repr = value.decode('ascii')
fw(f'{indent}.{attr} = "{value_repr}",\n')
else:
value_repr = "".join(f'{ub:02x}' for ub in value)
fw(f'{indent}.{attr} = {{{value_repr}}},\n')
else:
fw(f'{indent}.{attr} = {value},\n')
path_old = path_new
def convert_data(blend, theme, f):
fw = f.write
fw(C_SOURCE_HEADER)
fw('const bTheme U_theme_default = {\n')
ls = list(theme.items_recursive_iter(use_nil=False))
write_member(fw, 1, blend, theme, ls)
fw('};\n')
fw('\n')
fw('/* clang-format on */\n')
def file_remove_empty_braces(source_dst):
with open(source_dst, 'r', encoding='utf-8') as fh:
data = fh.read()
# Remove:
# .foo = { }
import re
def key_replace(match):
del match
return ""
data_prev = None
# Braces may become empty by removing nested
while data != data_prev:
data_prev = data
data = re.sub(
r'\s+\.[a-zA-Z_0-9]+\s+=\s+\{\s*\},',
key_replace, data, re.MULTILINE,
)
# Use two spaces instead of tabs.
data = data.replace('\t', ' ')
with open(source_dst, 'w', encoding='utf-8') as fh:
fh.write(data)
def main():
import sys
blend, theme = theme_data(sys.argv[-1])
with open(source_dst, 'w', encoding='utf-8') as fh:
convert_data(blend, theme, fh)
# Microsoft Visual Studio doesn't support empty braces.
file_remove_empty_braces(source_dst)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,441 @@
#!/usr/bin/env python3
# SPDX-FileCopyrightText: 2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
"""
Example use to generate all credits:
credits_git_gen.py
Example use a custom range:
credits_git_gen.py --source=/src/blender --range=SHA1..HEAD
"""
__all__ = (
"main",
)
# NOTE: this shares the basic structure with `credits_git_gen.py`,
# however details differ enough for them to be separate scripts.
# Improvements to this script may apply there too.
import argparse
import html
import io
import multiprocessing
import os
import sys
import unicodedata
from collections.abc import (
Iterable,
)
from git_log import (
GitCommitIter,
GitCommit,
)
import git_data_canonical_authors
import git_data_sha1_override_authors
IS_ATTY = sys.stdout.isatty()
BASE_DIR = os.path.abspath(os.path.dirname(__file__))
SOURCE_DIR = os.path.normpath(os.path.join(BASE_DIR, "..", ".."))
author_table = git_data_canonical_authors.canonical_author_map()
author_override_table = git_data_sha1_override_authors.sha1_authors_map()
# -----------------------------------------------------------------------------
# Multi-Processing
def process_commits_for_map(commits: Iterable[GitCommit]) -> "Credits":
result = Credits()
for c in commits:
result.process_commit(c)
return result
# -----------------------------------------------------------------------------
# Progress Display
def value_as_percentage(value_partial: int, value_final: int) -> str:
percent = 0.0 if (value_final == 0) else (value_partial / value_final)
return "{:-6.2f}%".format(percent * 100)
if IS_ATTY:
def progress_output(value_partial: int, value_final: int, info: str) -> None:
sys.stdout.write("\r\033[K[{:s}]: {:s}".format(value_as_percentage(value_partial, value_final), info))
else:
def progress_output(value_partial: int, value_final: int, info: str) -> None:
sys.stdout.write("[{:s}]: {:s}\n".format(value_as_percentage(value_partial, value_final), info))
# -----------------------------------------------------------------------------
# Class for generating credits
class CreditUser:
__slots__ = (
"commit_total",
"year_min",
"year_max",
)
def __init__(self) -> None:
self.commit_total = 0
self.year_min = 0
self.year_max = 0
class Credits:
__slots__ = (
"users",
# Use for progress, simply the number of times `process_commit` has been called.
"process_commits_count",
)
def __init__(self) -> None:
self.users: dict[str, CreditUser] = {}
self.process_commits_count = 0
@classmethod
def commit_authors_get(cls, c: GitCommit) -> list[str]:
if (authors_overwrite := author_override_table.get(c.sha1, None)) is not None:
# Ignore git commit info for these having an entry in `author_override_table`.
return [author_table.get(author, author) for author in authors_overwrite]
authors = [c.author] + c.co_authors
# Normalize author string into canonical form, prevents duplicate credit users
authors = [unicodedata.normalize('NFC', author) for author in authors]
return [author_table.get(author, author) for author in authors]
@classmethod
def is_credit_commit_valid(cls, c: GitCommit) -> bool:
ignore_dir = (
b"blender/extern/",
b"blender/intern/opennl/",
)
if not any(f for f in c.files if not f.startswith(ignore_dir)):
return False
return True
def merge(self, other: "Credits") -> None:
"""
Merge other Credits into this, clearing the other.
"""
for user_key, user_other in other.users.items():
user = self.users.get(user_key)
if user is None:
# Consume the user.
self.users[user_key] = user_other
else:
user.commit_total += user_other.commit_total
user.year_min = min(user.year_min, user_other.year_min)
user.year_max = max(user.year_max, user_other.year_max)
other.users.clear()
def process_commit(self, c: GitCommit) -> None:
self.process_commits_count += 1
if not self.is_credit_commit_valid(c):
return
authors = self.commit_authors_get(c)
year = c.date.year
for author in authors:
cu = self.users.get(author)
if cu is None:
cu = self.users[author] = CreditUser()
cu.year_min = year
cu.year_max = year
cu.commit_total += 1
cu.year_min = min(cu.year_min, year)
cu.year_max = max(cu.year_max, year)
def _process_multiprocessing(self, commit_iter: Iterable[GitCommit], *, jobs: int) -> None:
print("Collecting commits...")
# NOTE(@ideasman42): that the chunk size doesn't have as much impact on
# performance as you might expect, values between 16 and 1024 seem reasonable.
# Although higher values tend to bottleneck as the process finishes.
chunk_size = 256
chunk_list = []
chunk = []
for c in commit_iter:
chunk.append(c)
if len(chunk) >= chunk_size:
chunk_list.append(chunk)
chunk = []
if chunk:
chunk_list.append(chunk)
commit_count_total = (max(len(chunk_list) - 1, 0) * chunk_size) + len(chunk)
print("Found {:,d} commits, processing...".format(commit_count_total))
commit_count_handled = 0
with multiprocessing.Pool(processes=jobs) as pool:
for result in pool.imap_unordered(process_commits_for_map, chunk_list):
commit_count_handled += result.process_commits_count
progress_output(
commit_count_handled,
commit_count_total,
"{:,d} / {:,d} commits".format(commit_count_handled, commit_count_total),
)
self.merge(result)
if IS_ATTY:
print("") # Was printing on one-line, move to next.
def process(self, commit_iter: Iterable[GitCommit], *, jobs: int) -> None:
if jobs > 1:
self._process_multiprocessing(commit_iter, jobs=jobs)
return
# Simple single process operation.
for i, c in enumerate(commit_iter):
self.process_commit(c)
if not (i % 100):
print(i)
def write_object(
self,
fh: io.TextIOWrapper,
*,
is_main_credits: bool = True,
contrib_companies: tuple[str, ...] = (),
sort: str = "name",
use_email: bool = False,
) -> None:
commit_word = "commit", "commits"
from unicodedata import normalize
# Normalize using decomposed strings for sorting.
# That makes letters with diacritics grouped with the undiacritized variant.
# Collation is not perfect, but better than having non-English letters at the very bottom of the list.
if sort == "commit":
sorted_authors = dict(sorted(
self.users.items(),
key=lambda item: (item[1].commit_total, normalize('NFD', item[0]).casefold()),
))
else:
sorted_authors = dict(sorted(
self.users.items(),
key=lambda item: (normalize('NFD', item[0]).casefold(), item[1].commit_total),
))
fh.write("<h3>Individual Contributors</h3>\n\n")
for author, cu in sorted_authors.items():
fh.write("{:s}, {:,d} {:s} {:s}<br />\n".format(
html.escape(author if use_email else author.split("<", 1)[0].rstrip()),
cu.commit_total,
commit_word[cu.commit_total > 1],
(
"" if not is_main_credits else (
"- {:d}".format(cu.year_min) if cu.year_min == cu.year_max else
"({:d} - {:d})".format(cu.year_min, cu.year_max)
)
),
))
# -------------------------------------------------------------------------
# Companies, hard coded
if is_main_credits:
fh.write("<h3>Contributions from Companies & Organizations</h3>\n")
fh.write("<p>\n")
for line in contrib_companies:
fh.write("{:s}<br />\n".format(line))
fh.write("</p>\n")
import datetime
now = datetime.datetime.now()
fn = os.path.basename(__file__)
fh.write(
"<p><center><i>Generated by '{:s}' {:d}/{:02d}/{:02d}</i></center></p>\n".format(
fn, now.year, now.month, now.day,
))
def write(
self,
filepath: str,
*,
is_main_credits: bool = True,
contrib_companies: tuple[str, ...] = (),
sort: str = "name",
use_email: bool = False,
) -> None:
with open(filepath, 'w', encoding="ascii", errors='xmlcharrefreplace') as fh:
self.write_object(
fh,
is_main_credits=is_main_credits,
contrib_companies=contrib_companies,
sort=sort,
use_email=use_email,
)
def argparse_create() -> argparse.ArgumentParser:
# When `--help` or no arguments are given, print this help.
usage_text = "Review revisions."
epilog = "This script is used to generate credits"
parser = argparse.ArgumentParser(description=usage_text, epilog=epilog)
parser.add_argument(
"--source",
dest="source_dir",
metavar='PATH',
required=False,
default=SOURCE_DIR,
help="Path to git repository",
)
parser.add_argument(
"--range",
dest="range_sha1",
metavar='SHA1_RANGE',
required=False,
default="HEAD",
help="Range to use, eg: 169c95b8..HEAD or HEAD for all history.",
)
parser.add_argument(
"--sort",
dest="sort",
metavar='METHOD',
required=False,
help="Sort credits by 'name' (default) or 'commit'",
)
# Don't include email addresses, they're useful for identifying developers but better not include in credits.
# Even though it's publicly available, developers may not want this so easily accessible on the credits page.
parser.add_argument(
"--use-email",
dest="use_email",
required=False,
action='store_true',
help="Include the email address in the credits (useful for debugging/investigating issues)",
)
parser.add_argument(
"--jobs",
dest="jobs",
type=int,
default=0,
help=(
"The number of processes to use. "
"Defaults to zero which detects the available cores, 1 is single threaded (useful for debugging)."
),
required=False,
)
return parser
def parse_organizations(authors_file: str) -> tuple[str, ...] | None:
"""
Parse the organizations section from the AUTHORS file.
Return the sorted organizations or None if they could not be found.
"""
orgs = []
section_begin = "# BEGIN organizations section"
section_end = "# END organizations section"
in_section = False
found_end = False
with open(authors_file, "r", encoding="utf-8") as fh:
for line in fh:
if line.startswith(section_begin):
in_section = True
continue
if line.startswith(section_end):
found_end = True
break
if in_section and line and not line.startswith("#"):
# Extract organization name before `<`.
org_name = line.split("<")[0].strip()
# Expected result: `<b>Blender Foundation</b>`.
orgs.append("<b>{:s}</b>".format(html.escape(org_name)))
if not in_section:
print("Error: Could not find \"{:s}\" in AUTHORS file: {:s}".format(section_begin, authors_file))
return None
if not found_end:
print("Error: Could not find \"{:s}\" in AUTHORS file: {:s}".format(section_end, authors_file))
return None
sorted_unique_organizations = tuple(sorted(set(orgs), key=str.casefold))
return sorted_unique_organizations
def main() -> int:
# ----------
# Parse Args
args = argparse_create().parse_args()
# Get organizations in HTML `<b>` tags.
authors_file = os.path.join(args.source_dir, "AUTHORS")
contrib_companies = parse_organizations(authors_file)
if contrib_companies is None:
return 1
credits = Credits()
# commit_range = "HEAD~10..HEAD"
# commit_range = "blender-v2.81-release..blender-v2.82-release"
# commit_range = "blender-v2.82-release"
commit_range = args.range_sha1
sort = args.sort
jobs = args.jobs
if jobs <= 0:
# Clamp the value, higher values give errors with too many open files.
# Allow users to manually pass very high values in as they might want to tweak system limits themselves.
jobs = min(multiprocessing.cpu_count(), 400)
credits.process(GitCommitIter(args.source_dir, commit_range), jobs=jobs)
# Report when a single name has multiple entries,
# as they may need deduplicating in git_data_canonical_authors.py.
unique_author_names = {}
unique_author_emails = {}
for author in credits.users:
author_name, author_email = author.rsplit("<", 1)
author_name = author_name.rstrip()
author_email = "<" + author_email
unique_author_name = unique_author_names.setdefault(author_name, [])
unique_author_name.append(author_email)
unique_author_email = unique_author_emails.setdefault(author_email, [])
unique_author_email.append(author_name)
for name, emails in sorted(unique_author_names.items()):
if len(emails) > 1:
print(f"More than one entry for {name}:")
for email in sorted(emails):
print(f' "{name} {email}",')
for email, names in sorted(unique_author_emails.items()):
if len(names) > 1:
print(f"More than one entry for {email}:")
for name in sorted(names):
print(f' "{name} {email}",')
credits.write(
"credits.html",
is_main_credits=True,
contrib_companies=contrib_companies,
sort=sort,
use_email=args.use_email,
)
print("Written: credits.html")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,229 @@
#!/usr/bin/env python3
# SPDX-FileCopyrightText: 2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
__all__ = (
"main",
)
import argparse
import re
import shutil
import subprocess
import sys
import time
class COLORS:
HEADER = '\033[95m'
OKBLUE = '\033[94m'
OKGREEN = '\033[92m'
WARNING = '\033[93m'
FAIL = '\033[91m'
ENDC = '\033[0m'
BOLD = '\033[1m'
UNDERLINE = '\033[4m'
VERBOSE = False
#########################################
# Generic helper functions.
def logVerbose(*args):
if VERBOSE:
print(*args)
def logHeader(*args):
print(COLORS.HEADER + COLORS.BOLD, end="")
print(*args, end="")
print(COLORS.ENDC)
def logWarning(*args):
print(COLORS.WARNING + COLORS.BOLD, end="")
print(*args, end="")
print(COLORS.ENDC)
def logOk(*args):
print(COLORS.OKGREEN + COLORS.BOLD, end="")
print(*args, end="")
print(COLORS.ENDC)
def progress(count, total, prefix="", suffix=""):
if VERBOSE:
return
size = shutil.get_terminal_size((80, 20))
if prefix != "":
prefix = prefix + " "
if suffix != "":
suffix = " " + suffix
bar_len = size.columns - len(prefix) - len(suffix) - 10
filled_len = int(round(bar_len * count / float(total)))
percents = round(100.0 * count / float(total), 1)
bar = '=' * filled_len + '-' * (bar_len - filled_len)
sys.stdout.write('%s[%s] %s%%%s\r' % (prefix, bar, percents, suffix))
sys.stdout.flush()
def progressClear():
if VERBOSE:
return
size = shutil.get_terminal_size((80, 20))
sys.stdout.write(" " * size.columns + "\r")
sys.stdout.flush()
def humanReadableTimeDifference(seconds):
hours = int(seconds) // 60 // 60
seconds = seconds - hours * 60 * 60
minutes = int(seconds) // 60
seconds = seconds - minutes * 60
if hours == 0:
return "%02d:%05.2f" % (minutes, seconds)
else:
return "%02d:%02d:%05.2f" % (hours, minutes, seconds)
def humanReadableTimeToSeconds(time):
tokens = time.split(".")
result = 0
if len(tokens) == 2:
result = float("0." + tokens[1])
mult = 1
for token in reversed(tokens[0].split(":")):
result += int(token) * mult
mult *= 60
return result
#########################################
# Benchmark specific helper functions.
def configureArgumentParser():
parser = argparse.ArgumentParser(
description="Cycles benchmark helper script.")
parser.add_argument("-b", "--binary",
help="Full file path to Blender's binary " +
"to use for rendering",
default="blender")
parser.add_argument("-f", "--files", nargs='+')
parser.add_argument("-v", "--verbose",
help="Perform fully verbose communication",
action="store_true",
default=False)
return parser
def benchmarkFile(blender, blendfile, stats):
logHeader("Begin benchmark of file {}" . format(blendfile))
# Prepare some regex for parsing
re_path_tracing = re.compile(".*Path Tracing Tile ([0-9]+)/([0-9]+)$")
re_total_render_time = re.compile(r".*Total render time: ([0-9]+(\.[0-9]+)?)")
re_render_time_no_sync = re.compile(
".*Render time \\(without synchronization\\): ([0-9]+(\\.[0-9]+)?)")
re_pipeline_time = re.compile(r"Time: ([0-9:\.]+) \(Saving: ([0-9:\.]+)\)")
# Prepare output folder.
# TODO(sergey): Use some proper output folder.
output_folder = "/tmp/"
# Configure command for the current file.
command = (blender,
"--background",
"--factory-startup",
blendfile,
"--engine", "CYCLES",
"--debug-cycles",
"--render-output", output_folder,
"--render-format", "PNG",
"-f", "1")
# Run Blender with configured command line.
logVerbose("About to execute command: {}" . format(command))
start_time = time.time()
process = subprocess.Popen(command,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT)
# Keep reading status while Blender is alive.
total_render_time = "N/A"
render_time_no_sync = "N/A"
pipeline_render_time = "N/A"
while True:
line = process.stdout.readline()
if line == b"" and process.poll() is not None:
break
line = line.decode().strip()
if line == "":
continue
logVerbose("Line from stdout: {}" . format(line))
match = re_path_tracing.match(line)
if match:
current_tiles = int(match.group(1))
total_tiles = int(match.group(2))
elapsed_time = time.time() - start_time
elapsed_time_str = humanReadableTimeDifference(elapsed_time)
progress(current_tiles,
total_tiles,
prefix="Path Tracing Tiles {}" . format(elapsed_time_str))
match = re_total_render_time.match(line)
if match:
total_render_time = float(match.group(1))
match = re_render_time_no_sync.match(line)
if match:
render_time_no_sync = float(match.group(1))
match = re_pipeline_time.match(line)
if match:
pipeline_render_time = humanReadableTimeToSeconds(match.group(1))
if process.returncode != 0:
return False
# Clear line used by progress.
progressClear()
print("Total pipeline render time: {} ({} sec)"
. format(humanReadableTimeDifference(pipeline_render_time),
pipeline_render_time))
print("Total Cycles render time: {} ({} sec)"
. format(humanReadableTimeDifference(total_render_time),
total_render_time))
print("Pure Cycles render time (without sync): {} ({} sec)"
. format(humanReadableTimeDifference(render_time_no_sync),
render_time_no_sync))
logOk("Successfully rendered")
stats[blendfile] = {'PIPELINE_TOTAL': pipeline_render_time,
'CYCLES_TOTAL': total_render_time,
'CYCLES_NO_SYNC': render_time_no_sync}
return True
def benchmarkAll(blender, files):
stats = {}
for blendfile in files:
try:
benchmarkFile(blender, blendfile, stats)
except KeyboardInterrupt:
print("")
logWarning("Rendering aborted!")
return
def main():
parser = configureArgumentParser()
args = parser.parse_args()
if args.verbose:
global VERBOSE
VERBOSE = True
benchmarkAll(args.binary, args.files)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,66 @@
# SPDX-FileCopyrightText: 2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
'''
Define the command 'print_struct_c99' for gdb,
useful for creating a literal for a nested runtime struct.
Example use:
(gdb) source tools/utils/gdb_struct_repr_c99.py
(gdb) print_struct_c99 scene->toolsettings
'''
__all__ = (
"main",
)
import sys
class PrintStructC99(gdb.Command):
def __init__(self):
super(PrintStructC99, self).__init__(
"print_struct_c99",
gdb.COMMAND_USER,
)
def get_count_heading(self, string):
for i, s in enumerate(string):
if s != ' ':
break
return i
def extract_typename(self, string):
first_line = string.split('\n')[0]
return first_line.split('=')[1][:-1].strip()
def invoke(self, arg, _from_tty):
ret_ptype = gdb.execute('ptype {}'.format(arg), to_string=True)
tname = self.extract_typename(ret_ptype)
print('{} {} = {{'.format(tname, arg))
r = gdb.execute('p {}'.format(arg), to_string=True)
r = r.split('\n')
for rr in r[1:]:
if '=' not in rr:
print(rr)
continue
hs = self.get_count_heading(rr)
rr_s = rr.strip().split('=', 1)
rr_rval = rr_s[1].strip()
print(' ' * hs + '.' + rr_s[0] + '= ' + rr_rval)
def main() -> int:
print('Running GDB from: {:s}\n'.format(gdb.PYTHONDIR))
gdb.execute("set print pretty")
gdb.execute('set pagination off')
gdb.execute('set print repeats 0')
gdb.execute('set print elements unlimited')
# instantiate
PrintStructC99()
return 0
if __name__ == "__main__":
sys.exit(main())

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,34 @@
#!/usr/bin/env python3
# SPDX-FileCopyrightText: 2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
"""
The intent of this map is to be able to a single canonical author
for every ``Name <email>`` combination.
"""
__all__ = (
"sha1_authors_map",
)
def sha1_authors_map() -> dict[bytes, tuple[str, ...]]:
"""
Return SHA1 to authors map.
"""
# Mapping from a commit hash to additional authors.
# Fully overwrite authors gathered from git commit info.
# Intended usage: Correction of info stored in git commit itself.
# Note that the names of the authors here are assumed fully valid and usable as-is.
return {
# Format: {full_git_hash: (tuple, of, authors),}.
# Author was: `blender <blender@localhost.localdomain>`.
b"ba3d49225c9ff3514fb87ae5d692baefe5edec30": ("Sergey Sharybin <sergey@blender.org>", ),
# Author was: `Author Name <email@address.com>`.
b"4b6a4b5bc25bce10367dffadf7718e373f81f299": ("Antonio Vazquez <blendergit@gmail.com>", ),
# Author was: `Campbell Barton <campbell@blender.org>`.
b"584b96018a575d56e564a239dce0de572bf26c48": ("Lukasz Czyz <lukasz.czyzz@gmail.com>", ),
# Author was: `Sergey Sharybin <sergey.vfx@gmail.com>`.
b"a76e69f5f75c06dda6d35113d80b6b65dcc94ea0": ("John Cox <johnedwardcox@yahoo.com>", ),
}

View File

@@ -0,0 +1,259 @@
# SPDX-FileCopyrightText: 2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
# Simple module for inspecting git commits
__all__ = (
"GitCommit",
"GitCommitIter",
"GitRepo",
)
import os
import subprocess
import datetime
import re
_GIT_COMMIT_COAUTHORS_RE = re.compile(r"^Co-authored-by:[ \t]*([^\n]+)$", re.MULTILINE)
class GitCommit:
__slots__ = (
"sha1",
# to extract more info
"_git_dir",
# cached values
"_author",
"_date",
"_body",
"_files",
"_files_status",
"_diff",
)
def __init__(self, sha1: bytes, git_dir: str):
self.sha1 = sha1
self._git_dir = git_dir
self._author: str | None = None
self._date: datetime.datetime | None = None
self._body: str | None = None
self._files: list[bytes] | None = None
self._files_status: list[list[bytes]] | None = None
self._diff: str | None = None
def cache(self) -> None:
"""
Cache all properties
(except for diff as it's significantly larger than other members).
"""
self.author
self.date
self.body
self.files
self.files_status
def _log_format(
self,
format: str,
*,
args_prefix: tuple[str | bytes, ...] = (),
args_suffix: tuple[str | bytes, ...] = (),
) -> bytes:
# sha1 = self.sha1.decode('ascii')
cmd: tuple[str | bytes, ...] = (
"git",
*args_prefix,
"--git-dir",
self._git_dir,
"log",
"-1", # only this rev
self.sha1,
"--format=" + format,
*args_suffix,
)
# print(" ".join(cmd))
with subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
) as p:
assert p is not None and p.stdout is not None
return p.stdout.read()
@property
def sha1_short(self) -> str:
cmd = (
"git",
"--git-dir",
self._git_dir,
"rev-parse",
"--short",
self.sha1,
)
with subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
) as p:
assert p is not None and p.stdout is not None
return p.stdout.read().strip().decode('ascii')
@property
def author(self) -> str:
ret = self._author
if ret is None:
content = self._log_format("%an <%ae>")[:-1]
ret = content.decode("utf8", errors="ignore")
self._author = ret
return ret
@property
def co_authors(self) -> list[str]:
authors = []
for author in _GIT_COMMIT_COAUTHORS_RE.findall(self.body):
if not ("<" in author and ">" in author):
# Always follow `Name <>` spec, even when no email is given.
author = author + " <>"
elif not author.endswith(">"):
# In case the co-author line has rubbish after the author email.
author = author.rsplit(">", 1)[0] + ">"
authors.append(author)
return authors
@property
def date(self) -> datetime.datetime:
ret = self._date
if ret is None:
import datetime
ret = datetime.datetime.fromtimestamp(int(self._log_format("%ct")))
self._date = ret
return ret
@property
def body(self) -> str:
ret = self._body
if ret is None:
content = self._log_format("%B")[:-1]
ret = content.decode("utf8", errors="ignore")
self._body = ret
return ret
@property
def subject(self) -> str:
return self.body.lstrip().partition("\n")[0]
@property
def files(self) -> list[bytes]:
ret = self._files
if ret is None:
ret = [
f for f in self._log_format(
"format:",
args_prefix=("-c", "diff.renameLimit=10000"),
args_suffix=("--name-only",),
).split(b"\n") if f
]
self._files = ret
return ret
@property
def files_status(self) -> list[list[bytes]]:
ret = self._files_status
if ret is None:
ret = [
f.split(None, 1) for f in self._log_format(
"format:",
args_prefix=("-c", "diff.renameLimit=10000"),
args_suffix=("--name-status",),
).split(b"\n")
if f
]
self._files_status = ret
return ret
@property
def diff(self) -> str:
ret = self._diff
if ret is None:
content = self._log_format(
"",
args_prefix=("-c", "diff.renameLimit=10000"),
args_suffix=("-p",),
)
ret = content.decode("utf8", errors="ignore")
self._diff = ret
return ret
class GitCommitIter:
__slots__ = (
"_path",
"_git_dir",
"_sha1_range",
"_process",
)
def __init__(self, path: str, sha1_range: str):
self._path = path
self._git_dir = os.path.join(path, ".git")
self._sha1_range = sha1_range
self._process: subprocess.Popen[bytes] | None = None
def __iter__(self) -> "GitCommitIter":
cmd = (
"git",
"--git-dir",
self._git_dir,
"log",
self._sha1_range,
"--format=%H",
)
# print(" ".join(cmd))
self._process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
)
return self
def __next__(self) -> GitCommit:
assert self._process is not None and self._process.stdout is not None
sha1 = self._process.stdout.readline()[:-1]
if sha1:
return GitCommit(sha1, self._git_dir)
else:
raise StopIteration
class GitRepo:
__slots__ = (
"_path",
"_git_dir",
)
def __init__(self, path: str):
self._path = path
self._git_dir = os.path.join(path, ".git")
@property
def branch(self) -> bytes:
cmd = (
"git",
"--git-dir",
self._git_dir,
"rev-parse",
"--abbrev-ref",
"HEAD",
)
# print(" ".join(cmd))
p = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
)
assert p is not None and p.stdout is not None
return p.stdout.read()

View File

@@ -0,0 +1,258 @@
#!/usr/bin/env python3
# SPDX-FileCopyrightText: 2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
"""
This is a tool for reviewing commit ranges, writing into accept/reject files.
Useful for reviewing revisions to backport to stable builds.
Example usage:
./git_log_review_commits.py --source=../../.. --range=HEAD~40..HEAD --filter=BUGFIX
"""
__all__ = (
"main",
)
class _Getch:
"""
Gets a single character from standard input.
Does not echo to the screen.
"""
def __init__(self):
try:
self.impl = _GetchWindows()
except ImportError:
self.impl = _GetchUnix()
def __call__(self):
return self.impl()
class _GetchUnix:
def __init__(self):
import tty
del tty
def __call__(self):
import tty
import termios
fd = sys.stdin.fileno()
old_settings = termios.tcgetattr(fd)
try:
tty.setraw(sys.stdin.fileno())
ch = sys.stdin.read(1)
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
return ch
class _GetchWindows:
def __init__(self):
import msvcrt
del msvcrt
def __call__(self):
import msvcrt
return msvcrt.getch()
getch = _Getch()
# ------------------------------------------------------------------------------
# Pretty Printing
USE_COLOR = True
if USE_COLOR:
color_codes = {
'black': '\033[0;30m',
'bright_gray': '\033[0;37m',
'blue': '\033[0;34m',
'white': '\033[1;37m',
'green': '\033[0;32m',
'bright_blue': '\033[1;34m',
'cyan': '\033[0;36m',
'bright_green': '\033[1;32m',
'red': '\033[0;31m',
'bright_cyan': '\033[1;36m',
'purple': '\033[0;35m',
'bright_red': '\033[1;31m',
'yellow': '\033[0;33m',
'bright_purple': '\033[1;35m',
'dark_gray': '\033[1;30m',
'bright_yellow': '\033[1;33m',
'normal': '\033[0m',
}
def colorize(msg, color=None):
return (color_codes[color] + msg + color_codes['normal'])
else:
def colorize(msg, color=None):
return msg
bugfix = ""
# avoid encoding issues
import os
import sys
import io
sys.stdin = os.fdopen(sys.stdin.fileno(), "rb")
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='surrogateescape', line_buffering=True)
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='surrogateescape', line_buffering=True)
def print_commit(c):
print("------------------------------------------------------------------------------")
print(colorize("{{GitCommit|%s}}" % c.sha1.decode(), color='green'), end=" ")
# print("Author: %s" % colorize(c.author, color='bright_blue'))
print(colorize(c.author, color='bright_blue'))
print()
print(colorize(c.body, color='normal'))
print()
print(colorize("Files: (%d)" % len(c.files_status), color='yellow'))
for f in c.files_status:
print(colorize(" %s %s" % (f[0].decode('ascii'), f[1].decode('ascii')), 'yellow'))
print()
def argparse_create():
import argparse
# When --help or no args are given, print this help
usage_text = "Review revisions."
epilog = "This script is typically used to help write release notes"
parser = argparse.ArgumentParser(description=usage_text, epilog=epilog)
parser.add_argument(
"--source", dest="source_dir",
metavar='PATH', required=True,
help="Path to git repository")
parser.add_argument(
"--range", dest="range_sha1",
metavar='SHA1_RANGE', required=True,
help="Range to use, eg: 169c95b8..HEAD")
parser.add_argument(
"--author", dest="author",
metavar='AUTHOR', type=str, required=False,
help=("Method to filter commits in ['BUGFIX', todo]"))
parser.add_argument(
"--filter", dest="filter_type",
metavar='FILTER', type=str, required=False,
help=("Method to filter commits in ['BUGFIX', todo]"))
return parser
def main():
ACCEPT_FILE = "review_accept.txt"
REJECT_FILE = "review_reject.txt"
# ----------
# Parse Args
args = argparse_create().parse_args()
from git_log import GitCommitIter
# --------------
# Filter Commits
def match(c):
# filter_type
if not args.filter_type:
pass
elif args.filter_type == 'BUGFIX':
first_line = c.body.strip().split("\n")[0]
assert len(first_line)
if any(w for w in first_line.split() if w.lower().startswith(("fix", "bugfix", "bug-fix"))):
pass
else:
return False
elif args.filter_type == 'NOISE':
first_line = c.body.strip().split("\n")[0]
assert len(first_line)
if any(w for w in first_line.split() if w.lower().startswith("cleanup")):
pass
else:
return False
else:
raise Exception("Filter type %r isn't known" % args.filter_type)
# author
if not args.author:
pass
elif args.author != c.author:
return False
return True
commits = [c for c in GitCommitIter(args.source_dir, args.range_sha1) if match(c)]
# oldest first
commits.reverse()
tot_accept = 0
tot_reject = 0
def exit_message():
print(" Written",
colorize(ACCEPT_FILE, color='green'), "(%d)" % tot_accept,
colorize(REJECT_FILE, color='red'), "(%d)" % tot_reject,
)
for i, c in enumerate(commits):
if os.name == "posix":
# Also clears scroll-back.
os.system("tput reset")
else:
print('\x1b[2J') # clear
sha1 = c.sha1
# diff may scroll off the screen, that's OK
os.system("git --git-dir %s show %s --format=%%n" % (c._git_dir, sha1.decode('ascii')))
print("")
print_commit(c)
sys.stdout.flush()
# print(ch)
while True:
print("Space=" + colorize("Accept", 'green'),
"Enter=" + colorize("Skip", 'red'),
"Ctrl+C or Q=" + colorize("Quit", color='white'),
"[%d of %d]" % (i + 1, len(commits)),
"(+%d | -%d)" % (tot_accept, tot_reject),
)
ch = getch()
if ch == b'\x03' or ch == b'q':
# Ctrl+C
exit_message()
print("Goodbye! (%s)" % c.sha1.decode())
return
elif ch == b' ':
log_filepath = ACCEPT_FILE
tot_accept += 1
break
elif ch == b'\r':
log_filepath = REJECT_FILE
tot_reject += 1
break
else:
print("Unknown input %r" % ch)
with open(log_filepath, 'ab') as f:
f.write(sha1 + b'\n')
exit_message()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,772 @@
#!/usr/bin/env python3
# SPDX-FileCopyrightText: 2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
"""
This is a tool for reviewing commit ranges, writing into accept/reject files,
and optionally generate release-log-ready data.
Useful for reviewing revisions to back-port to stable builds.
Note that, if any of the data files generated already exist, they will be extended
with new revisions, not overwritten.
Note that, for the most complex 'wiki-ready' file generated by `--accept-releaselog`,
proof-reading after this tool has ran is heavily suggested!
Example usage:
./git_log_review_commits_advanced.py --source ../../.. --range HEAD~40..HEAD --filter 'BUGFIX' --accept-pretty --accept-releaselog --blender-rev 2.79
To add list of fixes between RC2 and RC3, and list both RC2 and RC3 fixes also in their own sections:
./git_log_review_commits_advanced.py --source ../../.. --range <RC2 revision>..<RC3 revision> --filter 'BUGFIX' --accept-pretty --accept-releaselog --blender-rev 2.79 --blender-rstate=RC3 --blender-rstate-list="RC2,RC3"
To exclude all commits from some given files, by sha1 or by commit message (from previously generated release logs) - much handy when going over commits which were partially cherry-picked into a previous release branch e.g.:
./git_log_review_commits_advanced.py --source ../../.. --range HEAD~40..HEAD --filter 'BUGFIX' --filter-exclude-sha1-fromfiles "review_accept.txt" "review_reject.txt" --filter-exclude-fromreleaselogs "review_accept_release_log.txt" --accept-pretty --accept-releaselog --blender-rev 2.75
"""
__all__ = (
"main",
)
import os
import sys
import io
import re
ACCEPT_FILE = "review_accept.txt"
REJECT_FILE = "review_reject.txt"
ACCEPT_LOG_FILE = "review_accept_log.txt"
ACCEPT_PRETTY_FILE = "review_accept_pretty.txt"
ACCEPT_RELEASELOG_FILE = "review_accept_release_log.txt"
IGNORE_START_LINE = "<!-- IGNORE_START -->"
IGNORE_END_LINE = "<!-- IGNORE_END -->"
_cwd = os.getcwd()
__doc__ = __doc__ + \
"\nRaw GIT revisions files:\n\t* Accepted: %s\n\t* Rejected: %s\n\n" \
"Basic log accepted revisions: %s\n\nWiki-printed accepted revisions: %s\n\n" \
"Full release notes wiki page: %s\n" \
% (os.path.join(_cwd, ACCEPT_FILE), os.path.join(_cwd, REJECT_FILE),
os.path.join(_cwd, ACCEPT_LOG_FILE), os.path.join(_cwd, ACCEPT_PRETTY_FILE),
os.path.join(_cwd, ACCEPT_RELEASELOG_FILE))
del _cwd
class _Getch:
"""
Gets a single character from standard input.
Does not echo to the screen.
"""
def __init__(self):
try:
self.impl = _GetchWindows()
except ImportError:
self.impl = _GetchUnix()
def __call__(self):
return self.impl()
class _GetchUnix:
def __init__(self):
import tty
del tty
def __call__(self):
import tty
import termios
fd = sys.stdin.fileno()
old_settings = termios.tcgetattr(fd)
try:
tty.setraw(sys.stdin.fileno())
ch = sys.stdin.read(1)
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
return ch
class _GetchWindows:
def __init__(self):
import msvcrt
del msvcrt
def __call__(self):
import msvcrt
return msvcrt.getch()
getch = _Getch()
# ------------------------------------------------------------------------------
# Pretty Printing
USE_COLOR = True
if USE_COLOR:
color_codes = {
'black': '\033[0;30m',
'bright_gray': '\033[0;37m',
'blue': '\033[0;34m',
'white': '\033[1;37m',
'green': '\033[0;32m',
'bright_blue': '\033[1;34m',
'cyan': '\033[0;36m',
'bright_green': '\033[1;32m',
'red': '\033[0;31m',
'bright_cyan': '\033[1;36m',
'purple': '\033[0;35m',
'bright_red': '\033[1;31m',
'yellow': '\033[0;33m',
'bright_purple': '\033[1;35m',
'dark_gray': '\033[1;30m',
'bright_yellow': '\033[1;33m',
'normal': '\033[0m',
}
def colorize(msg, color=None):
return (color_codes[color] + msg + color_codes['normal'])
else:
def colorize(msg, color=None):
return msg
bugfix = ""
BUGFIX_CATEGORIES = (
("Objects / Animation / GP", (
"Animation",
"Constraints",
"Grease Pencil",
"Objects",
"Dependency Graph",
),
),
("Data / Geometry", (
"Armatures",
"Curve/Text Editing",
"Mesh Editing",
"Meta Editing",
"Modifiers",
"Material / Texture",
),
),
("Physics / Simulations / Sculpt / Paint", (
"Particles",
"Physics / Hair / Simulations",
"Sculpting / Painting",
),
),
("Image / Video / Render", (
"Image / UV Editing",
"Masking",
"Motion Tracking",
"Movie Clip Editor",
"Nodes / Compositor",
"Render",
"Render: Cycles",
"Render: Freestyle",
"Sequencer",
),
),
("UI / Spaces / Transform", (
"3D View",
"Input (NDOF / 3D Mouse)",
"Outliner",
"Text Editor",
"Transform",
"User Interface",
),
),
("Game Engine", (
),
),
("System / Misc", (
"Audio",
"File I/O",
"Other",
"Python",
"System",
),
),
)
sys.stdin = os.fdopen(sys.stdin.fileno(), "rb")
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='surrogateescape', line_buffering=True)
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='surrogateescape', line_buffering=True)
def gen_commit_summary(c):
# In git, all commit message lines until first empty one are part of 'summary'.
return c.body.split("\n\n")[0].strip(" :.;-\n").replace("\n", " ")
def print_commit(c):
print("------------------------------------------------------------------------------")
print(colorize(c.sha1.decode(), color='green'), end=" ")
print(colorize(c.date.strftime("%Y/%m/%d"), color='purple'), end=" ")
print(colorize(c.author, color='bright_blue'))
print()
print(colorize(c.body, color='normal'))
print()
print(colorize("Files: (%d)" % len(c.files_status), color='yellow'))
for f in c.files_status:
print(colorize(" %s %s" % (f[0].decode('ascii'), f[1].decode('ascii')), 'yellow'))
print()
def gen_commit_log(c):
return "rB%s %s %-30s %s" % (c.sha1.decode()[:10], c.date.strftime("%Y/%m/%d"),
c.author, gen_commit_summary(c))
re_bugify_str = r"T([0-9]{1,})"
re_bugify = re.compile(re_bugify_str)
re_commitify = re.compile(r"\W(r(?:B|BA|BAC|BTS)[0-9a-fA-F]{6,})")
re_prettify = re.compile(r"(.{,20}?)(Fix(?:ing|es)?\s*(?:for)?\s*" + re_bugify_str + r")\s*[-:,]*\s*", re.IGNORECASE)
def gen_commit_message_pretty(c, unreported=None):
body = gen_commit_summary(c)
tbody = re_prettify.sub(r"Fix {{BugReport|\3}}: \1", body)
if tbody == body:
if unreported is not None:
unreported[0] = True
tbody = "Fix unreported: %s" % body
body = re_bugify.sub(r"{{BugReport|\1}}", tbody)
body = re_commitify.sub(r"{{GitCommit|\1}}", body)
return body
def gen_commit_pretty(c, unreported=None, rstate=None):
body = gen_commit_message_pretty(c, unreported)
if rstate is not None:
return "* [%s] %s ({{GitCommit|rB%s}})." % (rstate, body, c.sha1.decode()[:10])
return "* %s ({{GitCommit|rB%s}})." % (body, c.sha1.decode()[:10])
def gen_commit_unprettify(body):
if body.startswith("* ["):
end = body.find("]")
if end > 0:
body = body[end + 2:] # +2 to remove ] itself, and following space.
start = body.rfind("({{GitCommit|rB")
if start > 0:
body = body[:start - 1] # -1 to remove trailing space.
return body
def print_categories_tree():
for i, (main_cat, sub_cats) in enumerate(BUGFIX_CATEGORIES):
print("\t[%d] %s" % (i, main_cat))
for j, sub_cat in enumerate(sub_cats):
print("\t\t[%d] %s" % (j, sub_cat))
def release_log_extract_messages(path):
messages = set()
if os.path.exists(path):
with open(path, 'r') as f:
ignore = False
header = True
for l in f:
if IGNORE_END_LINE in l:
ignore = False
continue
elif ignore or IGNORE_START_LINE in l:
ignore = True
continue
l = l.strip(" \n")
if header and not l.startswith("=="):
continue # Header, we don't care here.
header = False
if not l.startswith("==") and "Fix " in l:
messages.add(gen_commit_unprettify(l))
return messages
def release_log_init(path, source_dir, blender_rev, start_sha1, end_sha1, rstate, rstate_list):
from git_log import GitRepo
if rstate is not None:
header = "= Blender %s: Bug Fixes =\n\n" \
"[%s] Changes from revision {{GitCommit|rB%s}} to {{GitCommit|rB%s}}, inclusive.\n\n" \
% (blender_rev, rstate, start_sha1[:10], end_sha1[:10])
else:
header = "= Blender %s: Bug Fixes =\n\n" \
"Changes from revision {{GitCommit|rB%s}} to {{GitCommit|rB%s}}, inclusive.\n\n" \
% (blender_rev, start_sha1[:10], end_sha1[:10])
release_log = {"__HEADER__": header, "__COUNT__": [0, 0], "__RSTATES__": {k: [] for k in rstate_list}}
if os.path.exists(path):
branch = GitRepo(source_dir).branch.decode().strip()
sub_cats_to_main_cats = {s_cat: m_cat[0] for m_cat in BUGFIX_CATEGORIES for s_cat in m_cat[1]}
main_cats = {m_cat[0] for m_cat in BUGFIX_CATEGORIES}
with open(path, 'r') as f:
header = []
main_cat = None
sub_cat = None
ignore = False
for l in f:
if IGNORE_END_LINE in l:
ignore = False
continue
elif ignore or IGNORE_START_LINE in l:
ignore = True
continue
l = l.strip(" \n")
if not header:
header.append(l)
for hl in f:
if IGNORE_END_LINE in hl:
ignore = False
continue
elif ignore or IGNORE_START_LINE in hl:
ignore = True
continue
hl = hl.strip(" \n")
if hl.startswith("=="):
main_cat = hl.strip(" =")
if main_cat not in main_cats:
sub_cat = main_cat
main_cat = sub_cats_to_main_cats.get(main_cat, None)
else:
sub_cat = None
# print("hl MAINCAT:", hl, main_cat, " | ", sub_cat)
break
header.append(hl)
if rstate is not None:
release_log["__HEADER__"] = "%s[%s] Changes from revision {{GitCommit|%s}} to " \
"{{GitCommit|%s}}, inclusive (''%s'' branch).\n\n" \
"" % ("\n".join(header), rstate,
start_sha1[:10], end_sha1[:10], branch)
else:
release_log["__HEADER__"] = "%sChanges from revision {{GitCommit|%s}} to {{GitCommit|%s}}, " \
"inclusive (''%s'' branch).\n\n" \
"" % ("\n".join(header), start_sha1[:10], end_sha1[:10], branch)
count = release_log["__COUNT__"] = [0, 0]
continue
if l.startswith("==="):
sub_cat = l.strip(" =")
if sub_cat in sub_cats_to_main_cats:
main_cat = sub_cats_to_main_cats.get(sub_cat, None)
elif sub_cat in main_cats:
main_cat = sub_cat
sub_cat = None
else:
main_cat = None
# print("l SUBCAT:", l, main_cat, " | ", sub_cat)
elif l.startswith("=="):
main_cat = l.strip(" =")
if main_cat not in main_cats:
sub_cat = main_cat
main_cat = sub_cats_to_main_cats.get(main_cat, None)
else:
sub_cat = None
# print("l MAINCAT:", l, main_cat, " | ", sub_cat)
elif "Fix " in l:
if "Fix {{BugReport|" in l:
main_cat_data, _ = release_log.setdefault(main_cat, ({}, {}))
main_cat_data.setdefault(sub_cat, []).append(l)
count[0] += 1
# print("l REPORTED:", l)
else:
_, main_cat_data_unreported = release_log.setdefault(main_cat, ({}, {}))
main_cat_data_unreported.setdefault(sub_cat, []).append(l)
count[1] += 1
# print("l UNREPORTED:", l)
l_rstate = l.strip("* ")
if l_rstate.startswith("["):
end = l_rstate.find("]")
if end > 0:
rstate = l_rstate[1:end]
if rstate in release_log["__RSTATES__"]:
release_log["__RSTATES__"][rstate].append("* %s" % l_rstate[end + 1:].strip())
return release_log
def write_release_log(path, release_log, c, cat, rstate, rstate_list):
main_cat, sub_cats = BUGFIX_CATEGORIES[cat[0]]
sub_cat = sub_cats[cat[1]] if cat[1] is not None else None
main_cat_data, main_cat_data_unreported = release_log.setdefault(main_cat, ({}, {}))
unreported = [False]
entry = gen_commit_pretty(c, unreported, rstate)
if unreported[0]:
main_cat_data_unreported.setdefault(sub_cat, []).append(entry)
release_log["__COUNT__"][1] += 1
else:
main_cat_data.setdefault(sub_cat, []).append(entry)
release_log["__COUNT__"][0] += 1
if rstate in release_log["__RSTATES__"]:
release_log["__RSTATES__"][rstate].append(gen_commit_pretty(c))
lines = []
main_cat_lines = []
sub_cat_lines = []
for main_cat, sub_cats in BUGFIX_CATEGORIES:
main_cat_data = release_log.get(main_cat, ({}, {}))
main_cat_lines[:] = ["== %s ==" % main_cat]
for data in main_cat_data:
entries = data.get(None, [])
if entries:
main_cat_lines.extend(entries)
main_cat_lines.append("")
if len(main_cat_lines) == 1:
main_cat_lines.append("")
for sub_cat in sub_cats:
sub_cat_lines[:] = ["=== %s ===" % sub_cat]
for data in main_cat_data:
entries = data.get(sub_cat, [])
if entries:
sub_cat_lines.extend(entries)
sub_cat_lines.append("")
if len(sub_cat_lines) > 2:
main_cat_lines += sub_cat_lines
if len(main_cat_lines) > 2:
lines += main_cat_lines
if None in release_log:
main_cat_data = release_log.get(None, ({}, {}))
main_cat_lines[:] = ["== %s ==\n\n" % "UNSORTED"]
for data in main_cat_data:
entries = data.get(None, [])
if entries:
main_cat_lines.extend(entries)
main_cat_lines.append("")
if len(main_cat_lines) > 2:
lines += main_cat_lines
with open(path, 'w') as f:
f.write(release_log["__HEADER__"])
count = release_log["__COUNT__"]
f.write("%s\n" % IGNORE_START_LINE)
f.write("Total fixed bugs: %d (%d from tracker, %d reported/found by other ways).\n\n"
"" % (sum(count), count[0], count[1]))
f.write("%s\n%s\n\n" % ("{{Note|Note|Before RC1 (i.e. during regular development of next version in main "
"branch), only fixes of issues which already existed in previous official releases are "
"listed here. Fixes for regressions introduced since last release, or for new "
"features, are '''not''' listed here.<br/>For following RCs and final release, "
"'''all''' back-ported fixes are listed.}}", IGNORE_END_LINE))
f.write("\n".join(lines))
f.write("\n")
f.write("%s\n\n<hr/>\n\n" % IGNORE_START_LINE)
for rst in rstate_list:
entries = release_log["__RSTATES__"].get(rst, [])
if entries:
f.write("== %s ==\n" % rst)
f.write("For %s, %d bugs were fixed:\n\n" % (rst, len(entries)))
f.write("\n".join(entries))
f.write("\n\n")
f.write("%s\n" % IGNORE_END_LINE)
def argparse_create():
import argparse
global __doc__
# When --help or no args are given, print this help
usage_text = __doc__
epilog = "This script is typically used to help write release notes"
parser = argparse.ArgumentParser(description=usage_text, epilog=epilog,
formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument(
"--source", dest="source_dir",
metavar='PATH', required=True,
help="Path to git repository")
parser.add_argument(
"--range", dest="range_sha1",
metavar='SHA1_RANGE', required=False,
help="Range to use, eg: 169c95b8..HEAD")
parser.add_argument(
"--author", dest="author",
metavar='AUTHOR', type=str, required=False,
help=("Author(s) to filter commits ("))
parser.add_argument(
"--filter", dest="filter_type",
metavar='FILTER', type=str, required=False,
help=("Method to filter commits in ['BUGFIX', 'NOISE']"))
parser.add_argument(
"--filter-exclude-sha1", dest="filter_exclude_sha1_list",
default=[], required=False, type=lambda s: s.split(","),
help=("Coma-separated list of commits to ignore/skip"))
parser.add_argument(
"--filter-exclude-sha1-fromfiles", dest="filter_exclude_sha1_filepaths",
default="", required=False, nargs='*',
help=("One or more text files storing list of commits to ignore/skip"))
parser.add_argument(
"--filter-exclude-fromreleaselogs", dest="filter_exclude_releaselogs",
default="", required=False, nargs='*',
help=("One or more text files storing release logs, to ignore/skip their entries "
"(based on message comparison, not commit sha1)"))
parser.add_argument(
"--accept-log", dest="accept_log",
default=False, action='store_true', required=False,
help=("Also output more complete info about accepted commits (summary, author...)"))
parser.add_argument(
"--accept-pretty", dest="accept_pretty",
default=False, action='store_true', required=False,
help=("Also output pretty-printed accepted commits (nearly ready for WIKI release notes)"))
parser.add_argument(
"--accept-releaselog", dest="accept_releaselog",
default=False, action='store_true', required=False,
help=("Also output accepted commits as a wiki release log page (adds sorting by categories)"))
parser.add_argument(
"--blender-rev", dest="blender_rev",
default=None, required=False,
help=("Blender revision (only used to generate release notes page)"))
parser.add_argument(
"--blender-rstate", dest="blender_rstate",
default="alpha", required=False,
help=("Blender release state (like alpha, beta, rc1, final, corr_a, corr_b, etc.), "
"each revision will be tagged by given one"))
parser.add_argument(
"--blender-rstate-list", dest="blender_rstate_list",
default="", required=False, type=lambda s: s.split(","),
help=("Blender release state(s) to additionally list in their own sections "
"(e.g. pass 'RC2' to list fixes between RC1 and RC2, ie tagged as RC2, etc.)"))
return parser
def main():
# ----------
# Parse Args
args = argparse_create().parse_args()
for path in args.filter_exclude_sha1_filepaths:
if os.path.exists(path):
with open(path, 'r') as f:
args.filter_exclude_sha1_list += [sha1 for l in f for sha1 in l.split()]
args.filter_exclude_sha1_list = {sha1.encode() for sha1 in args.filter_exclude_sha1_list}
messages = set()
for path in args.filter_exclude_releaselogs:
messages |= release_log_extract_messages(path)
args.filter_exclude_releaselogs = messages
from git_log import GitCommitIter
# --------------
# Filter Commits
def match(c):
# filter_type
if not args.filter_type:
pass
elif args.filter_type == 'BUGFIX':
first_line = c.body.split("\n\n")[0].strip(" :.;-\n").replace("\n", " ")
assert len(first_line)
if any(w for w in first_line.split() if w.lower().startswith(("fix", "bugfix", "bug-fix"))):
pass
else:
return False
elif args.filter_type == 'NOISE':
first_line = c.body.strip().split("\n")[0]
assert len(first_line)
if any(w for w in first_line.split() if w.lower().startswith("cleanup")):
pass
else:
return False
else:
raise Exception("Filter type %r isn't known" % args.filter_type)
# author
if not args.author:
pass
elif args.author != c.author:
return False
# commits to exclude
if c.sha1 in args.filter_exclude_sha1_list:
return False
# exclude by commit message (because cherry-pick totally breaks relations with original commit...)
if args.filter_exclude_releaselogs:
if gen_commit_message_pretty(c) in args.filter_exclude_releaselogs:
return False
return True
if args.accept_releaselog:
blender_rev = args.blender_rev or "<UNKNOWN>"
commits = tuple(GitCommitIter(args.source_dir, args.range_sha1))
release_log = release_log_init(ACCEPT_RELEASELOG_FILE, args.source_dir, blender_rev,
commits[-1].sha1.decode(), commits[0].sha1.decode(),
args.blender_rstate, args.blender_rstate_list)
commits = [c for c in commits if match(c)]
else:
commits = [c for c in GitCommitIter(args.source_dir, args.range_sha1) if match(c)]
# oldest first
commits.reverse()
tot_accept = 0
tot_reject = 0
def exit_message():
print(" Written",
colorize(ACCEPT_FILE, color='green'), "(%d)" % tot_accept,
colorize(ACCEPT_LOG_FILE, color='yellow'), "(%d)" % tot_accept,
colorize(ACCEPT_PRETTY_FILE, color='blue'), "(%d)" % tot_accept,
colorize(REJECT_FILE, color='red'), "(%d)" % tot_reject,
)
def get_cat(ch, max_idx):
cat = -1
try:
cat = int(ch)
except:
pass
if 0 <= cat < max_idx:
return cat
print("Invalid input %r" % ch)
return None
for i, c in enumerate(commits):
if os.name == "posix":
# Also clears scroll-back.
os.system("tput reset")
else:
print('\x1b[2J') # clear
sha1 = c.sha1
# diff may scroll off the screen, that's OK
os.system("git --git-dir %s show %s --format=%%n" % (c._git_dir, sha1.decode('ascii')))
print("")
print_commit(c)
sys.stdout.flush()
while True:
print("Space=" + colorize("Accept", 'green'),
"Enter=" + colorize("Skip", 'red'),
"Ctrl+C or X=" + colorize("Exit", color='white'),
"[%d of %d]" % (i + 1, len(commits)),
"(+%d | -%d)" % (tot_accept, tot_reject),
)
ch = getch()
if ch == b'\x03' or ch == b'x':
# Ctrl+C
exit_message()
print("Goodbye! (%s)" % c.sha1.decode())
return False
elif ch == b' ':
log_filepath = ACCEPT_FILE
log_filepath_log = ACCEPT_LOG_FILE
log_filepath_pretty = ACCEPT_PRETTY_FILE
tot_accept += 1
if args.accept_releaselog: # Enter sub-loop for category selection.
done_main = True
c1 = c2 = None
while True:
if c1 is None:
print("Select main category (V=View all categories, M=Commit message): \n\t%s"
"" % " | ".join("[%d] %s" % (i, cat[0]) for i, cat in enumerate(BUGFIX_CATEGORIES)))
else:
main_cat = BUGFIX_CATEGORIES[c1][0]
sub_cats = BUGFIX_CATEGORIES[c1][1]
if not sub_cats:
break
print("[%d] %s: Select sub category "
"(V=View all categories, M=Commit message, Enter=No sub-categories, "
"Backspace=Select other main category): \n\t%s"
"" % (c1, main_cat,
" | ".join("[%d] %s" % (i, cat) for i, cat in enumerate(sub_cats))))
ch = getch()
if ch == b'\x7f': # backspace
done_main = False
break
elif ch == b'\x03' or ch == b'x':
# Ctrl+C
exit_message()
print("Goodbye! (%s)" % c.sha1.decode())
return
elif ch == b'v':
print_categories_tree()
print("")
elif ch == b'm':
print_commit(c)
print("")
elif c1 is None:
c1 = get_cat(ch, len(BUGFIX_CATEGORIES))
elif c2 is None:
if ch == b'\r':
break
elif ch == b'\x7f': # backspace
c1 = None
continue
c2 = get_cat(ch, len(BUGFIX_CATEGORIES[c1][1]))
if c2 is not None:
break
else:
print("BUG! this should not happen!")
if done_main is False:
# Go back to main loop, this commit is no more accepted nor rejected.
tot_accept -= 1
continue
write_release_log(ACCEPT_RELEASELOG_FILE, release_log, c, (c1, c2),
args.blender_rstate, args.blender_rstate_list)
break
elif ch == b'\r':
log_filepath = REJECT_FILE
log_filepath_log = None
log_filepath_pretty = None
tot_reject += 1
break
else:
print("Invalid input %r" % ch)
with open(log_filepath, 'ab') as f:
f.write(sha1 + b'\n')
if args.accept_pretty and log_filepath_pretty:
with open(log_filepath_pretty, 'a') as f:
f.write(gen_commit_pretty(c, rstate=args.blender_rstate) + "\n")
if args.accept_log and log_filepath_log:
with open(log_filepath_log, 'a') as f:
f.write(gen_commit_log(c) + "\n")
exit_message()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,254 @@
# SPDX-FileCopyrightText: 2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
# Script to get all the inactive GITEA developers
# Usage: GITEA_API_TOKEN=<yourtoken> python3 gitea_inactive_developers.py
#
# The API Token have the "read:org" or "admin:org" scope.
#
# Potential errors:
# * 403 Client Error: That means the token doesn't have the right scope.
# * 500 Server Error: The token is invalid.
__all__ = (
"main",
)
import logging
import os
import requests
import sys
import yarl
from retry import retry as retry_decorator
import datetime
import dataclasses as dc
import iso8601
from typing import (
cast,
NewType,
TypeVar,
)
from collections.abc import (
Callable,
Iterable,
)
from requests.structures import CaseInsensitiveDict
logger = logging.getLogger(__file__)
GITEA_DOMAIN = "https://projects.blender.org/"
@dc.dataclass
class TeamMember():
id: int
login: str
full_name: str
last_login: datetime.datetime
def __str__(self) -> str:
return "{id};{login};{full_name};{last_login};{url}\n".format(
id=self.id,
login=self.login,
full_name=self.full_name,
last_login=self.last_login,
url=GITEA_DOMAIN + self.login,
)
Page = NewType('Page', int)
F = TypeVar('F', bound=Callable[..., object])
T = TypeVar('T', bound=object)
retry: Callable[[F], F] = retry_decorator(
tries=10, delay=1, backoff=2, logger=logger)
def assert_cast(typ: type[T], obj: object) -> T:
assert isinstance(obj, typ), f'object is not of type {typ}: {obj}'
# NOTE: MYPY warns the cast is redundant, we might consider removing it.
return cast(T, obj) # type: ignore
def get_date_object(date_string: str) -> datetime.datetime:
result = iso8601.parse_date(date_string)
assert isinstance(result, datetime.datetime)
return result
results_per_page = 25
def get_next_page(headers: CaseInsensitiveDict[str], page: Page) -> Page | None:
"""
Parse the header looking for reference to next.
"""
total_count = int(assert_cast(str, headers.get('X-Total-Count')))
# If current page already accounts to all the results we need
# there is no need for extra pages.
if page * results_per_page >= total_count:
return None
next_page = Page(page + 1)
return next_page
@retry
def fetch_single(
api_url: yarl.URL,
api_token: str,
method: str,
data: dict[str, str],
page: Page,
) -> tuple[list[object], Page | None]:
"""Generic function to query a single item from the API.
Returns:
A dictionary containing the item data.
"""
headers = {
'accept': 'application/json',
'Authorization': 'token ' + api_token,
}
params: dict[str, str | int] = {
'limit': results_per_page,
'page': page,
**data,
}
logger.info(f"Calling {method} ({params=}).")
response = requests.get(str(api_url / method), params=params, headers=headers)
response.raise_for_status()
response_json = response.json()
next_page = get_next_page(response.headers, page)
return response_json, None if next_page is None else next_page
def fetch_all(
api_url: yarl.URL,
api_token: str,
method: str,
data: dict[str, str],
) -> Iterable[object]:
"""Generic function to query lists from API.
Yields:
response_data - the result of fetch_single()
"""
page: Page | None = Page(1)
while page is not None:
response_data, page = fetch_single(api_url, api_token, method, data, page)
yield from response_data if response_data is not None else ()
def fetch_team_members(
api_url: yarl.URL,
api_token: str,
organization_name: str,
team_name: str,
) -> list[TeamMember]:
"""Query API for all the members of a team.
Yields:
TeamMember objects.
"""
method = "orgs/{org}/teams".format(org=organization_name)
team_id = None
for team in cast(
Iterable[dict[object, object]],
fetch_all(
api_url,
api_token,
method,
data={},
),
):
if team.get('name') != team_name:
continue
team_id = team.get('id')
break
if team_id is None:
logger.error('No team found with name: ' + team_name)
sys.exit(2)
method = "teams/{id}/members".format(id=team_id)
users = list()
for member in cast(
Iterable[dict[object, object]],
fetch_all(
api_url,
api_token,
method,
data={},
),
):
users.append(
TeamMember(
id=assert_cast(int, member.get('id')),
login=assert_cast(str, member.get('login')),
full_name=assert_cast(str, member.get('full_name')),
last_login=get_date_object(
assert_cast(str, member.get('last_login'))),
))
return users
def is_inactive(member: TeamMember) -> bool:
"""
Returns whether the member is no longer active.
Users are active when they logged in the past 2 years.
"""
tzinfo = member.last_login.tzinfo
two_years_ago = datetime.datetime.now(tzinfo) - datetime.timedelta(days=2 * 365)
return member.last_login < two_years_ago
def main() -> None:
teams = (
"Developers",
"Add-ons",
"Translation",
"Documentation",
"Technical-Artists",
"Contributors",
)
api_token = os.environ['GITEA_API_TOKEN']
api_url = yarl.URL(GITEA_DOMAIN + 'api/v1/')
organization_name = "blender"
for team_name in teams:
logger.warning(team_name)
members = fetch_team_members(
api_url, api_token, organization_name, team_name)
inactive_members = [str(m) for m in members if is_inactive(m)]
logger.warning(" Total members:" + str(len(members)))
logger.warning(" Inactive members: " + str(len(inactive_members)))
file_name = team_name.lower() + ".csv"
with open(file_name, 'w', newline='') as csv_file:
csv_file.write("id;login;full_name;last_login;url\n")
csv_file.writelines(inactive_members)
logger.warning(" Output file: " + file_name)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,307 @@
#!/usr/bin/python3
# SPDX-FileCopyrightText: 2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
# Created by Robert Wenzlaff (Det. Thorn).
# Oct. 30, 2003
__all__ = (
"main",
)
import sys
from tkinter import (
Button,
Canvas,
Checkbutton,
END,
Frame,
IntVar,
Label,
RIDGE,
Text,
Tk,
)
color = ("black", "white", "darkgreen", "gray")
class App:
def __init__(self, master):
frame = Frame(master, borderwidth=5)
frame.grid(column=0, row=0, pady=5)
self.state = []
self.states = 256
self.laststate = 2 # 0=Black, 1=White, 2=Transparent.
self.size = 16
self.gridsz = 20
for x in range(1024):
self.state.append(2)
self.screen = Canvas(frame, height=320, width=320, bg=color[2])
self.screen.bind("<Button-1>", self.scrnclick1)
self.screen.bind("<Button-3>", self.scrnclick2)
self.screen.bind("<B1-Motion>", self.scrndrag)
for x in range(16):
self.screen.create_line((x * 20, 0, x * 20, 320), fill=color[3])
self.screen.create_line((0, x * 20, 320, x * 20), fill=color[3])
self.screen.grid(row=0, column=0, columnspan=5)
frame2 = Frame(master, borderwidth=5)
frame2.grid(column=0, row=1, pady=5)
self.clear = Button(frame2, text="Clear", command=self.clearit)
self.clear.grid(row=0, column=0, pady=20)
self.doit = Button(frame2, text="Print", command=self.doit)
self.doit.grid(row=0, column=1, pady=20)
# self.doitlab = Label(frame2, text="(Output to stdout)");
# self.doitlab.grid(row=1, column=1);
self.parse = Button(frame2, text="Parse", command=self.parsetext)
self.parse.grid(row=0, column=2, pady=20)
self.large = 0
self.dummy = IntVar()
self.largeb = Checkbutton(frame2, text="Large", var=self.dummy, command=self.changesize)
self.largeb.grid(row=0, column=3, pady=20)
self.prev = Canvas(frame2, height=17, width=17, bg=color[2], relief=RIDGE)
self.prev.grid(row=0, column=4, pady=20, padx=20)
# DataParsers
self.bmlabel = Label(frame2, text="Bitmap Data (paste hex from code)")
self.bmlabel.grid(row=2, column=0, columnspan=5, sticky="W")
self.bmentry = Text(frame2, width=80, height=9, font="Times 8")
self.bmentry.bind("<Leave>", self.bmtextpaste)
self.bmentry.grid(row=3, column=0, columnspan=5, pady=5)
self.msklabel = Label(frame2, text="Mask Data (paste hex from code)")
self.msklabel.grid(row=4, column=0, columnspan=5, sticky="W")
self.mskentry = Text(frame2, width=80, height=9, font="Times 8")
self.mskentry.bind("<Leave>", self.msktextpaste)
self.mskentry.grid(row=5, column=0, columnspan=5, pady=5)
def changesize(self):
self.large = ~self.large
if self.large:
self.size = 32
self.gridsz = 10
self.states = 1024
oldstate = self.state
self.state = []
for n in range(1024):
col = (n // 2) % 16
row = int(n // 64)
self.state.append(oldstate[16 * row + col])
oldstate = []
else:
self.size = 16
self.gridsz = 20
self.states = 256
oldstate = self.state
self.state = []
for n in range(1024):
if not ((n % 2) or ((n // 32) % 2)):
self.state.append(oldstate[n])
for n in range(256, 1024):
self.state.append(2)
oldstate = []
# Insert scaling here
self.updatescrn()
self.prev.config(width=self.size + 1, height=self.size + 1)
for n in range(self.states):
self.updateprev(n)
# self.prev.grid(row=0, column=4, padx=self.gridsz, pady=self.gridsz)
def scrnclick1(self, event):
self.scrnclick(event, 1)
def scrnclick2(self, event):
self.scrnclick(event, -1)
def scrnclick(self, event, direction):
if (event.x > 319) or (event.y > 319) or (event.x < 0) or (event.y < 0):
return
n = (event.x // self.gridsz) + self.size * (event.y // self.gridsz)
self.state[n] += direction
self.state[n] %= 3
row = n % self.size
col = n // self.size
self.screen.create_rectangle((self.gridsz * row + 1,
self.gridsz * col + 1,
self.gridsz * row + self.gridsz - 1,
self.gridsz * col + self.gridsz - 1),
fill=color[self.state[n]], outline="")
self.laststate = self.state[n]
self.updateprev(n)
def scrndrag(self, event):
if (event.x > 319) or (event.y > 319) or (event.x < 0) or (event.y < 0):
return
n = (event.x // self.gridsz) + self.size * (event.y // self.gridsz)
row = n % self.size
col = n // self.size
self.screen.create_rectangle((self.gridsz * row + 1,
self.gridsz * col + 1,
self.gridsz * row + self.gridsz - 1,
self.gridsz * col + self.gridsz - 1),
fill=color[self.laststate], outline="")
self.state[n] = self.laststate
self.updateprev(n)
def updateprev(self, n):
x = n % self.size + 1
y = n // self.size + 1
if self.large:
pad = 12
else:
pad = 20
self.prev.create_line(x + 1, y + 1, x + 2, y + 1, fill=color[self.state[n]])
self.prev.grid(row=0, column=4, padx=pad, pady=pad)
def updatescrn(self):
self.screen.create_rectangle(0, 0, 320, 320, fill=color[2])
for x in range(self.size):
self.screen.create_line((x * self.gridsz, 0, x * self.gridsz, 320), fill=color[3])
self.screen.create_line((0, x * self.gridsz, 320, x * self.gridsz), fill=color[3])
for n in range(self.states):
row = n % self.size
col = n // self.size
self.screen.create_rectangle((self.gridsz * row + 1,
self.gridsz * col + 1,
self.gridsz * row + self.gridsz - 1,
self.gridsz * col + self.gridsz - 1),
fill=color[self.state[n]], outline="")
def bmtextpaste(self, event):
string = self.bmentry.get(1.0, END)
self.bmentry.delete(1.0, END)
string = string.replace("\t", "")
self.bmentry.insert(END, string)
def msktextpaste(self, event):
string = self.mskentry.get(1.0, END)
self.mskentry.delete(1.0, END)
string = string.replace("\t", "")
self.mskentry.insert(END, string)
def parsetext(self):
bmstring = self.bmentry.get(1.0, END)
bmstring = bmstring.replace(",", " ")
bmstring = bmstring.split()
mskstring = self.mskentry.get(1.0, END)
mskstring = mskstring.replace(",", " ")
mskstring = mskstring.split()
if len(bmstring) != len(mskstring):
print("Mismatched data. Bitmap and mask must be same size,")
return
elif not (len(bmstring) == 32 or len(bmstring) == 128):
print("Size Error, input must be 32 or 128 hex bytes. ")
return
for n in range(self.states):
self.state[n] = 0
m = 0
for entry in bmstring:
e = int(entry, 16)
for bit in range(8):
self.state[m] = (e & 1)
e = e >> 1
m += 1
m = 0
for entry in mskstring:
e = int(entry, 16)
for bit in range(8):
if not (e & 1):
self.state[m] = 2
e = e >> 1
m += 1
self.updatescrn()
for n in range(self.states):
self.updateprev(n)
def clearit(self):
for n in range(self.states):
self.state[n] = 2
self.updateprev(n)
self.updatescrn()
self.bmentry.delete(0.0, END)
self.mskentry.delete(0.0, END)
def doit(self):
mask = []
bitmap = []
numbytes = self.size * self.size // 8
for i in range(numbytes):
m = 0
b = 0
for j in range(8):
m <<= 1
b <<= 1
if (self.state[(i * 8) + (7 - j)] != 2):
m |= 1
if (self.state[(i * 8) + (7 - j)] == 1):
b |= 1
# print((i * 8) + (7 - j), self.state[(i * 8) + (7 - j)], m)
mask.append(m)
bitmap.append(b)
print("\n\nstatic char bitmap[] = {", end=' ')
for i in range(numbytes):
b1 = bitmap[i]
if not (i % 8):
print("\n\t", end=' ')
print("0x%(b1)02x, " % vars(), end=' ')
print("\n};")
print("\nstatic char mask[] = {", end=' ')
for i in range(numbytes):
b1 = mask[i]
if not (i % 8):
print("\n\t", end=' ')
print("0x%(b1)02x, " % vars(), end=' ')
print("\n};")
################## Main App #######################
def main() -> int:
root = Tk()
_app = App(root)
root.title("Cursor Maker")
root.mainloop()
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,276 @@
#!/usr/bin/env python3
# SPDX-FileCopyrightText: 2025 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
"""Self-bootstrapping script to run the OpenAPI-to-dataclasses code generator.
Run this via `<your buildtool> generate_datamodels` in your build directory.
This script creates its own virtualenv, installs its dependencies, and then runs
the code generator. It processes OpenAPI spec files in YAML format (see
`YAML_PATHS` below) to generate Python source files. Each `xxx.yaml` file will
produce an `xxx.py` file in the same directory. These Python files also include
the OpenAPI spec, as a Python dict.
The generated Python files are tracked by Git. This generator is NOT part of the
regular Blender build process, and only needs to be run when any of the YAML
files change.
"""
__all__ = (
"main",
)
import argparse
from pathlib import Path
import sys
import time
# Paths of the OpenAPI YAML files to convert to Python code. These are relative
# to Blender's top level source directory.
#
# The generated Python files will be written to the same path, just with the
# `.py` suffix.
#
# When adding a file here, make sure it is named `..._openapi.yaml`. That way
# the corresponding `.py`` file is automatically marked as 'generated' in
# `.gitattributes`.
YAML_PATHS = [
"scripts/modules/_bpy_internal/assets/remote_library/blender_asset_library_openapi.yaml",
]
# Packages to install in the virtualenv. These are only necessary to run this
# generator. The generated code does not depend on these.
REQUIREMENTS = [
"datamodel-code-generator ~= 0.53.0",
"PyYAML ~= 6.0.2",
"docformatter ~= 1.7.8",
]
# These arguments are quite likely to be used for all code generated with this
# generator, also later when we use this approach in other areas.
COMMON_ARGS = [
# Because of the Blender code standard:
"--use-double-quotes",
# Make it strict unless there's a good reason not to:
"--strict-nullable",
# Ignore unknown fields in the parsed JSON. This way, the code generated now
# has a chance of being be compatible with future versions of the schema (at
# least, when that future version just adds new stuff).
"--allow-extra-fields",
# Automatically target the currently-running version of Python:
f"--target-python-version={sys.version_info.major}.{sys.version_info.minor}",
# Use `list[T]` instead of `typing.List[T]`:
"--use-standard-collections",
# Because we use dataclasses:
"--output-model-type", "dataclasses.dataclass",
# Work around https://github.com/koxudaxi/datamodel-code-generator/issues/1870#issuecomment-2775689249
"--use-annotated",
# Remove the "generated on" timestamp from the output, so that running the
# generator is idempotent.
"--disable-timestamp",
"--use-inline-field-description",
"--use-schema-description",
]
CUSTOM_FILE_HEADER = """
# SPDX-FileCopyrightText: {year!s} Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
#
# Generated by datamodel-codegen:
# source filename: {source_path.name!s}
"""
def main() -> None:
"""Run the datamodel code generator."""
argparser = argparse.ArgumentParser(description="Run the datamodel code generator.")
argparser.add_argument('source_root', type=Path, help="The root of Blender's source directory")
args = argparser.parse_args(sys.argv[1:])
root_path: Path = args.source_root.resolve()
if not root_path.is_dir():
raise SystemExit("Path {!s} should be a directory".format(root_path))
print("Generating data model files:")
py_paths: list[Path] = []
for yaml_relpath in YAML_PATHS:
yaml_path = root_path / yaml_relpath
py_path = yaml_path.with_suffix(".py")
py_paths.append(py_path)
print(f" {yaml_path.relative_to(root_path)} -> {py_path.name}")
_generate_datamodel(
in_path=yaml_path,
in_type="openapi",
out_path=py_path,
)
# Make sure that output from subprocesses is flushed, before outputting more
# below. This prevents stderr and stdout going out of sync, ensuring things
# are shown in chronological order (i.e. generating files before
# reformatting them).
sys.stderr.flush()
sys.stdout.flush()
# Format the generated Python code.
_docformatter(py_paths)
_make_format(root_path, py_paths)
print("Done generating data model files!")
def _generate_datamodel(in_path: Path, in_type: str, out_path: Path) -> None:
"""Run datamodel-codegen."""
# `type: ignore` to ignore warnings that this module cannot be imported. Python checkers
# won't understand this is run from a self-managed virtualenv.
from datamodel_code_generator.__main__ import main as codegen_main # type: ignore
from datamodel_code_generator.__main__ import Exit # type: ignore
header = CUSTOM_FILE_HEADER.strip().format(
year=time.localtime().tm_year,
source_path=in_path,
)
args = [
*COMMON_ARGS,
"--input", str(in_path),
"--input-file-type", in_type,
"--output", str(out_path),
"--custom-file-header", header,
]
status = codegen_main(args)
match status:
case Exit.OK:
return
case Exit.ERROR:
raise SystemExit("code generation failed")
case Exit.KeyboardInterrupt:
raise KeyboardInterrupt()
case _:
raise SystemExit(f"unknown result from code generation: {status}")
def _docformatter(py_paths: list[Path]) -> None:
"""Run 'docformatter' on generated Python files.
This is necessary because the generated docstrings are very long, and
'make format' doesn't automatically re-wrap them.
"""
from docformatter import format
from docformatter import configuration
print("Formatting docstrings")
argv = ["docformatter", "--in-place", *(str(path) for path in py_paths)]
cfg = configuration.Configurater(argv)
cfg.do_parse_arguments()
formatter = format.Formatter(
cfg.args,
stderror=sys.stderr,
stdin=sys.stdin,
stdout=sys.stdout,
)
result = formatter.do_format_files()
if result not in {format.FormatResult.ok, format.FormatResult.format_required}:
raise RuntimeError(f"Error {result} running docformatter")
def _make_format(root_path: Path, py_paths: list[Path]) -> None:
"""Run 'make format' on generated Python files."""
print("Formatting Python files")
py_paths_as_str = [str(path) for path in py_paths]
subprocess.run(
["make", "format", "PATHS={}".format(" ".join(py_paths_as_str))],
cwd=root_path,
check=True,
)
# --------- Below this point is the self-bootstrapping logic ---------
import importlib.util
import subprocess
import venv
# Name of a module to import, to test whether dependencies have been installed or not.
TEST_INSTALL_MODULE = "datamodel_code_generator"
# Directory for the virtualenv. This script is expected to run with Blender's
# build directory as its working directory.
VENV_DIR = Path("generate_datamodels_venv").resolve()
# Python executable inside the virtual environment.
VENV_PYTHON = VENV_DIR / "Scripts/python.exe" if sys.platform == "win32" else VENV_DIR / "bin/python"
def _create_virtualenv() -> None:
"""Create the virtual environment if it does not exist."""
if VENV_DIR.exists():
return
print(f"Creating virtual environment at {VENV_DIR}")
venv.create(VENV_DIR, with_pip=True)
def _install_dependencies() -> None:
"""Install required dependencies into the virtual environment."""
print("Installing dependencies")
# Pip doesn't like to be used as Python library, invoking it via the CLI is the best option.
_run_command(str(VENV_PYTHON), "-m", "pip", "install", "--upgrade", "pip")
_run_command(str(VENV_PYTHON), "-m", "pip", "install", "--upgrade", *REQUIREMENTS)
def _is_dependency_installed(package: str) -> bool:
"""Try importing a package to check if it is installed."""
return importlib.util.find_spec(package) is not None
def _is_running_in_virtualenv() -> bool:
"""Check if the script is running inside the virtual environment."""
return sys.prefix != sys.base_prefix # Virtualenv modifies `sys.prefix`
def _run_command(*cmd: str) -> None:
"""Run a shell command and handle errors."""
try:
subprocess.run(cmd, check=True, text=True)
except subprocess.CalledProcessError as e:
print(f"Error running command: {' '.join(cmd)}", file=sys.stderr)
print(f"Exit code: {e.returncode}", file=sys.stderr)
sys.exit(e.returncode)
if __name__ == "__main__":
_create_virtualenv()
if not _is_running_in_virtualenv():
print(f"Re-executing inside virtual environment at {VENV_DIR}")
_run_command(str(VENV_PYTHON), *sys.argv)
sys.exit()
if not _is_dependency_installed(TEST_INSTALL_MODULE):
_install_dependencies()
# The virtual environment is active, so run the main script logic.
main()

View File

@@ -0,0 +1,82 @@
# SPDX-FileCopyrightText: 2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
# Converts 32x32 XPM images written be the gimp to GL stipples
# takes XPM files as arguments, prints out C style definitions.
__all__ = (
"main",
)
import sys
import os
def main():
xpm_ls = [f for f in sys.argv[1:] if f.lower().endswith(".xpm")]
print("Converting:", " ".join(xpm_ls))
for xpm in xpm_ls:
with open(xpm, "r", encoding="utf-8") as fh:
data = fh.read()
# all after first {
data = data.split("{", 1)[1]
# all before first }
data = data.rsplit("}", 1)[0]
data = data.replace("\n", "")
data = data.split(",")
w, h, c, dummy = map(int, data[0].strip("\"").split())
if w != 32 or h != 32 or c != 2:
print("Skipping %r, expected 32x32, monochrome, got %s" %
(xpm, data[0]))
continue
# col_1 = data[1][1]
col_2 = data[2][1]
data = [d[1:-1] for d in data[3:]]
bits = []
for d in data:
for i, c in enumerate(d):
bits.append('01'[(c == col_2)])
if len(bits) != 1024:
print("Skipping %r, expected 1024 bits, got %d" %
(xpm, len(bits)))
continue
bits = "".join(bits)
chars = []
for i in range(0, len(bits), 8):
chars.append("0x%.2x" % int(bits[i:i + 8], 2))
fout = sys.stdout
var = os.path.basename(xpm)
var = os.path.splitext(var)[0]
fout.write("GLubyte stipple_%s[128] {\n\t" % var)
for i, c in enumerate(chars):
if i != 127:
fout.write("%s, " % c)
else:
fout.write("%s" % c)
if not ((i + 1) % 8):
fout.write("\n\t")
fout.write("};\n")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,150 @@
# SPDX-FileCopyrightText: 2023 Blender Authors
#
# SPDX-License-Identifier: Apache-2.0
'''
Created compact byte arrays which can be decoded into 2D shapes.
(See 'GPU_batch_from_poly_2d_encoded').
- Objects must use the prefix "shape_"
- Meshes and Curves are supported as input.
- C and Python output is written to "output/"
The format is simple: a series of (X, Y) locations one byte each.
Repeating the same value terminates the polygon, moving onto the next.
Example Use::
blender.bin -b --factory-startup my_shapes.blend --python make_shape_2d_from_blend.py
'''
__all__ = (
"main",
)
import bpy
import os
USE_C_STYLE = True
USE_PY_STYLE = True
WRAP_LIMIT = 79
TAB_WIDTH = 4
SUBDIR = "output"
def float_to_ubyte(f):
return max(0, min(255, int(round(f * 255.0))))
def curve_to_loops(ob):
import bmesh
cu = ob.data
me = ob.to_mesh()
bm = bmesh.new()
bm.from_mesh(me)
me = ob.to_mesh_clear()
bmesh.ops.beautify_fill(bm, faces=bm.faces, edges=bm.edges)
edges = bm.edges[:]
edges.sort(key=lambda e: e.calc_length(), reverse=True)
for e in edges:
if e.is_manifold:
f_a, f_b = [f for f in e.link_faces]
bmesh.utils.face_join((f_a, f_b), False)
edges = bm.edges[:]
for e in edges:
if e.is_wire:
bm.edges.remove(e)
bm.normal_update()
data_all = []
for f in bm.faces:
points = []
# Ensure all faces are pointing the correct direction
# Note, we may want to use polygon sign for a second color
# (via the material index).
if f.normal.z > 0.0:
loops = f.loops
else:
loops = reversed(f.loops)
for l in loops:
points.append(
tuple(float_to_ubyte(axis) for axis in l.vert.co.xy)
)
data_all.append((points, f.material_index))
bm.free()
return data_all
def write_c(ob):
cu = ob.data
name = ob.name
with open(os.path.join(SUBDIR, name + ".c"), 'w') as fh:
fw = fh.write
fw(f"/* {name} */\n")
fw(f"const uchar {name}[] = {{")
line_len = WRAP_LIMIT
line_is_first = True
array_len = 0
data_all = curve_to_loops(ob)
for (points, material_index) in data_all:
# TODO, material_index
for p in points + [points[-1]]:
line_len += 12
if line_len >= WRAP_LIMIT:
fw("\n\t")
line_len = TAB_WIDTH
line_is_first = True
if not line_is_first:
fw(" ")
fw(", ".join([f"0x{axis:02x}" for axis in p]) + ",")
line_is_first = False
array_len += (len(points) + 1) * 2
fw("\n};\n")
# fw(f"const int data_len = {array_len}\n")
def write_py(ob):
cu = ob.data
name = ob.name
with open(os.path.join(SUBDIR, name + ".py"), 'w') as fh:
fw = fh.write
fw(f"# {name}\n")
fw("data = (")
line_len = WRAP_LIMIT
fw = fh.write
data_all = curve_to_loops(ob)
for (points, material_index) in data_all:
# TODO, material_index
for p in points + [points[-1]]:
line_len += 8
if line_len >= WRAP_LIMIT:
if p is not points[0]:
fw("'")
fw("\n b'")
line_len = 6
fw("".join([f"\\x{axis:02x}" for axis in p]))
fw("'\n)\n")
def main():
os.makedirs(SUBDIR, exist_ok=True)
for ob in bpy.data.objects:
if ob.type not in {'MESH', 'CURVE'}:
continue
if not ob.name.startswith('shape_'):
continue
if USE_C_STYLE:
write_c(ob)
if USE_PY_STYLE:
write_py(ob)
if __name__ == "__main__":
main()