Files
renderdoc/docs/verify-docstrings.py
T
baldurk 83c53890da Mark accessors/return values that can be None with Optional[]
* This helps with type checkers to know that things can be None or not (both to
  silence previous warnings about "unnecessary" checks that are actually
  necessary, and to ensure those checks aren't omitted by accident)
2026-09-11 15:04:03 +01:00

481 lines
19 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
#
import sys
import re
import os
import glob
import argparse
import inspect
from enum import EnumMeta
from typing import List
import struct
os.chdir(os.path.realpath(os.path.dirname(__file__)))
# path to module libraries for windows
if struct.calcsize("P") == 8:
binpath = '../x64/'
else:
binpath = '../Win32/'
# Prioritise release over development builds
sys.path.insert(0, os.path.abspath(binpath + 'Development/pymodules'))
sys.path.insert(0, os.path.abspath(binpath + 'Release/pymodules'))
# Add the build paths to PATH so renderdoc.dll can be located
os.environ["PATH"] = os.path.abspath(binpath + 'Development/') + os.pathsep + os.environ["PATH"]
os.environ["PATH"] = os.path.abspath(binpath + 'Release/') + os.pathsep + os.environ["PATH"]
if sys.platform == 'win32' and sys.version_info[1] >= 8:
for sub in ['Release', 'Development']:
path = os.path.abspath(binpath + sub)
if os.path.exists(path):
os.add_dll_directory(path)
# path to module libraries for linux
sys.path.insert(0, os.path.abspath('../build/lib'))
import renderdoc as rd
import qrenderdoc as qrd
parser = argparse.ArgumentParser()
parser.add_argument('-p', '--path', help="Add a path to interface files to search (can be used multiple times)", action='append')
parser.add_argument('-d', '--dump-combined', help="Set a path to dump the combined header that's being searched through", type=str)
parser.add_argument('--debug-mismatches', help="Set a path to a folder, each mismatch creates a file in here with the failure", type=str)
parser.add_argument('-v', '--verbose',
help="Run verbosely", action="store_true")
args = parser.parse_args()
paths = ['../renderdoc/api/replay', '../qrenderdoc/Code/Interface']
if args.path is not None:
paths += args.path
if args.verbose:
print("Searching for interface files in: {}".format(paths))
headers = ''
for path in paths:
for f in glob.glob(os.path.join(os.path.abspath(path), '**', '*.h'), recursive=True):
if args.verbose:
print("Adding interface file {}".format(f))
with open(f, 'r') as file:
headers += file.read()
if args.dump_combined is not None:
with open(args.dump_combined, 'w') as file:
file.write(headers)
if args.debug_mismatches is not None and os.path.isdir(args.debug_mismatches):
for f in glob.glob(os.path.join(os.path.abspath(args.debug_mismatches), '*')):
if args.verbose:
print("Removing {} in debug mismatches folder".format(f))
os.unlink(f)
def make_c_typeval(ret: str, pattern: bool, typelist: List[str]):
orig_type = ret
# strip namespace
if ret[0:10] == 'renderdoc.':
ret = ret[10:].replace('.', '::')
# Handle pipelines that are renamed
if ret == 'D3D11State':
ret = 'D3D11Pipe::State'
elif ret == 'D3D12State':
ret = 'D3D12Pipe::State'
elif ret == 'GLState':
ret = 'GLPipe::State'
elif ret == 'VKState':
ret = 'VKPipe::State'
if ret in ['bool', 'void']:
pass
elif ret == 'None':
ret = 'NULL'
elif ret == 'True':
ret = 'true'
elif ret == 'False':
ret = 'false'
elif ret == 'str':
ret = '(const )?rdc(inflexible)?str ?[&*]?' if pattern else 'rdcstr'
elif ret == 'int':
ret = '(u?int[163264]{2}|size)_t' if pattern else 'int' # ambiguous
elif ret == 'float':
ret = 'float|double' if pattern else 'float'
elif ret == 'bytes':
ret = '(const )?bytebuf ?[&*]?' if pattern else 'bytebuf'
elif ret == 'List[Tuple[str,str]]': # special case
ret = 'rdcstrpairs'
elif ret == 'Tuple[str,str]': # special case
ret = 'rdcstrpair'
elif ret[0:9] == 'Callable[':
ret = r'(std::function<void\(\)>|[A-Za-z_]+Callback)' if pattern else 'std::function/NamedCallback'
elif ret[0:5] == 'List[':
inner = make_c_typeval(ret[5:-1], pattern, typelist)
ret = '(const )?rdcarray<{}> ?[&*]?'.format(inner) if pattern else 'rdcarray<{}>'.format(inner)
elif ret[0:9] == 'Optional[':
inner = make_c_typeval(ret[9:-1], pattern, typelist)
if 'std::function' in inner or (inner.startswith("RENDERDOC_") and inner.endswith("Callback")):
return inner
ret = '(const )?{} ?\\*?'.format(inner) if pattern else '{} *'.format(inner)
elif ret[0:6] == 'Tuple[':
inners = [make_c_typeval(i.strip(), pattern, typelist) for i in ret[6:-1].split(',')]
if pattern:
inner = r',\s*'.join(inners)
else:
inner = ', '.join(inners)
tuple_len = len(inners)
if tuple_len > 2 and len(list(set(inners))) == 1:
inner = inners[0]
ret = '(const )?rdcfixedarray<{}, {}> ?[&*]?'.format(inner, tuple_len) if pattern else 'rdcfixedarray<{}, {}>'.format(inner, tuple_len)
else:
ret = '(const )?rdcpair<{}> ?[&*]?'.format(inner) if pattern else 'rdcpair<{}>'.format(inner)
elif pattern:
if ret[-8:] == 'Callback':
ret = '(RENDERDOC_)?{}'.format(ret)
else:
if orig_type not in typelist:
typelist.append(orig_type)
ret = '(const )?I?{} ?[&*]?'.format(ret)
return ret
RTYPE_PATTERN = re.compile(r":rtype: (.*)")
PARAM_PATTERN = re.compile(r":param ([^:=]*) ([^: =]+)(\s*=[^:]*)?:")
TYPE_PATTERN = re.compile(r":type: (.*)")
DATA_PATTERN = re.compile(r"\.\. data:: (.*)")
count = 0
def check_function(parent_name, objname, obj, source, global_func, typelist):
global count, args
if args.verbose:
print("Checking {} function {}.{}".format('global' if global_func else 'member', parent_name, objname))
if obj.__class__ is staticmethod:
obj = obj.__func__
docstring = obj.__doc__
params = PARAM_PATTERN.findall(docstring)
funcargs = ['', '']
default_val = ''
for p in params:
if len(funcargs[0]) > 0:
funcargs[0] += r',\s*'
funcargs[1] += ', '
default_val = p[2].lstrip()
if len(default_val) > 0 and default_val[0] == '=':
default_val = make_c_typeval(default_val[1:].strip(), False, typelist)
default_val = default_val.replace('(', '\\(').replace(')', '\\)')
funcargs[0] += make_c_typeval(p[0], True, typelist) + ' ?' + p[1]
funcargs[1] += make_c_typeval(p[0], False, typelist) + ' ' + p[1]
if default_val != "":
funcargs[0] += f"\\s*=\\s*{default_val}"
funcargs[1] += f" = {default_val}"
result = RTYPE_PATTERN.search(docstring)
if result is not None:
ret = result.group(1)
else:
ret = 'void'
global_pattern = ''
if global_func:
global_pattern = r'(RENDERDOC_CC\s*RENDERDOC_)?'
pattern = r'(?s){} ?{}{}\(\s*{}\)'.format(make_c_typeval(ret, True, typelist), global_pattern, objname, funcargs[0])
clean = '{} {}({})'.format(make_c_typeval(ret, False, typelist), objname, funcargs[1])
match = re.search(pattern, source, re.MULTILINE | re.DOTALL)
pattern2 = None
# global functions returning strings can't return an rdcstr, they have to return const char *
if match is None and ret == 'str':
pattern2 = r'(?s)const char \*{}{}\(\s*{}\)'.format(global_pattern, objname, funcargs[0])
match = re.search(pattern2, source, re.MULTILINE | re.DOTALL)
if match is None:
count += 1
print("Error {:3} in {}: {} couldn't be found in C++ headers.\nCould be wrong types or missing default values. Try --debug-mismatches mismatches/".format(count, parent_name, clean))
if args.debug_mismatches is not None and os.path.isdir(args.debug_mismatches):
with open(os.path.join(os.path.abspath(args.debug_mismatches), '{:03}-{}.{}.txt'.format(count, parent_name, objname)), 'w') as file:
file.write("# Failed to find matching declaration for {}.{}\n".format(parent_name, objname))
file.write("#\n")
file.write("# Matched against {}\n".format(pattern))
if pattern2 is not None:
file.write("# Matched against {}\n".format(pattern2))
file.write("#\n")
file.write("#\n")
file.write("\n")
file.write("\n")
file.write(source)
def check_used_types(objname, module, used_types):
global count
if args.verbose:
print('Checking {} referenced types: {}'.format(objname, module, used_types))
for t in used_types:
type_name = t
parent = module
while True:
if t in dir(parent):
break
# Allow some types that are opaque
if parent is rd and t in ['ANativeWindow', 'NSView', 'CALayer', 'wl_display', 'wl_surface', 'HWND', 'xcb_connection_t', 'xcb_window_t', 'Display', 'Drawable']:
break
if parent is qrd and t in ['QWidget']:
break
idx = t.find('.')
if idx >= 0:
parent_name = t[0:idx]
if parent_name in dir(parent):
parent = parent.__dict__[parent_name]
elif parent_name == 'renderdoc':
parent = rd
elif parent_name == 'qrenderdoc':
parent = qrd
t = t[idx+1:]
continue
count += 1
print("Error {:3} in {}: Unrecognised reference {}".format(count, objname, type_name))
if type_name in dir(rd):
print(" - Maybe missing namespace to refer to renderdoc.{}?".format(type_name))
break
check_mods = ['renderdoc', 'qrenderdoc']
for mod_name in check_mods:
mod = sys.modules[mod_name]
if args.verbose:
print("===== Checks for {} =====".format(mod_name))
for objname in dir(mod):
if re.search('__|SWIG|rdcarray_of|Structured.*List', objname):
continue
if "_" in objname:
segments = objname.split("_")
if hasattr(mod, segments[0]) and inspect.isclass(
getattr(mod, segments[0])
):
continue
# skip some functions that have special bindings and won't be easily found
if objname in ['CreateRemoteServerConnection', 'DumpObject', 'GetSupportedDeviceProtocols']:
if args.verbose:
print("Skipping {}".format(objname))
continue
obj = mod.__dict__[objname]
docstring = obj.__doc__
if 'INTERNAL:' in docstring:
continue
qualname = '{}.{}'.format(mod_name, objname)
if isinstance(obj, EnumMeta):
if args.verbose:
print("Skipping enum {}".format(qualname))
# don't check enums
continue
elif isinstance(obj, type):
if args.verbose:
print("Checking class {}".format(qualname))
# Grab the source to just this class to search in
source = re.search('(struct|class|union) I?' + objname + r'(\n|\s*:[^A-Za-z][\s:a-zA-Z]*\n)\{.*?^}', headers, re.MULTILINE | re.DOTALL)
namespace = None
if source is None and objname[0:2] in ['VK', 'GL']:
pipe = objname[0:2] + 'Pipe'
objname = objname[2:]
namespace = re.search('namespace ' + pipe + '.* namespace ' + pipe, headers, re.MULTILINE | re.DOTALL)
namespace = namespace.group(0)
if source is None and objname[0:5] in ['D3D11', 'D3D12']:
pipe = objname[0:5] + 'Pipe'
objname = objname[5:]
namespace = re.search('namespace ' + pipe + '.* namespace ' + pipe, headers, re.MULTILINE | re.DOTALL)
namespace = namespace.group(0)
if source is None and namespace is not None:
source = re.search('(struct|class|union) I?' + objname + r'[^{]*\{.*?^}', namespace, re.MULTILINE | re.DOTALL)
source = source.group(0)
instance = None
copyable = False
try:
instance = obj()
try:
dupe_instance = obj(instance)
copyable = True
except NotImplementedError:
pass
except TypeError:
pass
# a couple of manual cases that need parameters
if qualname == 'renderdoc.SDObject' and instance is None:
instance = obj("", "")
if qualname == 'renderdoc.SDChunk' and instance is None:
instance = obj("")
instance_warned = False
# for types that we can create, we expect by default to
# see a default constructor and a copy constructor,
# unless we see a note that the type is not copyable
if instance is not None:
lines = docstring.strip().splitlines()
if lines[0].strip() != f"{obj.__name__}()":
count += 1
print(
f"Error {count:3}: {obj.__name__} can be created, "
"expect default constructor as first real line of its docstring."
)
elif (
copyable
and lines[1].strip() != f"{obj.__name__}(other: {obj.__name__})"
):
count += 1
print(
f"Error {count:3}: {obj.__name__} can be copied, "
"expect copy constructor as second entry in its docstring:\n"
f"Actual > {lines[1]}\n"
f"Expected > {obj.__name__}(other: {obj.__name__})"
)
for member_name in obj.__dict__.keys():
if '__' in member_name or member_name in ['this', 'thisown']:
continue
member = obj.__dict__[member_name]
# Skip some known functions that cannot be easily matched this way
if '{}.{}'.format(objname, member_name) in [
# pointer output remapped to tuple return
'RemoteHost.Connect',
# class-local callbacks
'CaptureContext.EditShader',
# Renamed in the interface from DuplicateAndAddChild
'SDObject.AddChild',
# not defined in the actual code since we have typed AsInt32 etc.
# Instead defined in the swig interface
'SDObject.AsInt',
'SDObject.AsFloat',
'SDObject.AsString']:
if args.verbose:
print("Skipping {}.{}".format(objname, member_name))
continue
if callable(member) or inspect.ismethoddescriptor(member):
used_types = []
check_function(qualname, member_name, member, source, False, used_types)
check_used_types('{}.{}'.format(qualname, member_name), mod, used_types)
elif instance and '__get__' in dir(member):
value = getattr(instance, member_name)
type_name = type(value).__name__
if type(value).__module__ != mod_name:
type_name = type(value).__module__ + '.' + type_name
type_name = re.sub(r'(.*)rdcarray_of_(.*)', 'List[\\1\\2]', type_name)
type_name = re.sub(r'(renderdoc\.)?u?int[163264]{2}_t', 'int', type_name)
type_name = re.sub(r'(renderdoc\.)?rdcstr', 'str', type_name)
type_name = re.sub(r'Pipe_', '', type_name)
type_name = re.sub(r'StructuredBufferList', 'List[bytes]', type_name)
type_name = re.sub(r'StructuredObjectList', 'List[SDObject]', type_name)
type_name = re.sub(r'StructuredChunkList', 'List[SDChunk]', type_name)
type_name = re.sub(r'^builtins.', '', type_name)
if 'importlib._bootstrap' in type_name:
type_name = re.sub('^importlib._bootstrap.', '', type_name)
real_module = [
m for m in check_mods if type_name in sys.modules[m].__dict__
][0]
if real_module != mod_name:
type_name = f"{real_module}.{type_name}"
type_name = re.sub('datetime.datetime', 'datetime', type_name)
if type_name == 'NoneType':
if args.verbose:
print(f"Skipping {objname}.{member_name} as pointer-assumed from None")
continue
if objname == 'ResourceId' and member_name == 'Null' and 'function' in type_name:
if args.verbose:
print("Skipping ResourceId")
continue
if args.verbose:
print('Checking struct member {}.{}'.format(qualname, member_name))
result = TYPE_PATTERN.search(member.__doc__)
if result is not None:
type_decl = result.group(1)
else:
type_decl = None
if type_decl is None:
count += 1
print("Error {:3}: {}.{} is missing :type: declaration, should be {}".format(count, qualname, member_name, type_name))
else:
type_decl = re.sub(r'Tuple\[.*\]', 'tuple', type_decl)
if type_decl != type_name:
count += 1
print("Error {:3}: {}.{} has wrong :type: declaration {}, should be {}".format(count, qualname, member_name, type_decl, type_name))
elif instance is None and '__get__' in dir(member):
if not instance_warned:
print(f"WARNING: Couldn't create a {qualname} to check some members")
instance_warned = True
elif type(member) == int:
datas = DATA_PATTERN.findall(docstring)
if member_name not in datas:
count += 1
print("Error {:3}: {}.{} is missing a .. data: declaration in object docstring".format(count, qualname, member_name))
elif callable(obj):
used_types = []
# check the function in all headers globally
check_function(mod_name, objname, obj, headers, True, used_types)
check_used_types(qualname, mod, used_types)
if count > 0:
print("{} problems detected".format(count))
sys.exit(count)
print("No problems detected")
sys.exit(0)