Files
renpy/scripts/generate_pyi.py
T
2022-05-26 13:54:16 +02:00

327 lines
7.4 KiB
Python
Executable File

#!/usr/bin/env python3
TYPING_IMPORTS = "List, Dict, Tuple, Set, Optional, Union, Any, Callable, Type".split(", ")
from typing import List, Dict, Tuple, Set, Optional, Union, Any, Callable, Type, TextIO
import sys
import pathlib
import textwrap
import inspect
import types
ROOT = pathlib.Path(__file__).parent.parent.absolute()
sys.path.insert(0, str(ROOT))
import _renpy
import renpy
renpy.import_all()
import pygame_sdl2
pygame_sdl2.import_as_pygame()
# Patch renpy.script so the Lexer can be used.
class FakeScript:
all_pyexpr = None
renpy.game.script = FakeScript()
def python_signature(o):
"""
Given a callabale object, try to return a python-style type signature.
Returns the signature as a string if it can be determined, or None if
no signature can be determined.
This first uses inspect.signature to analyze the object. If that doesn't
work, it looks for a signature on the first line of the docstring.
If the signature containst Cython typing, it's converted to Python.
"""
if not callable(o):
return None
# If this is pure-python, then just inspect the signature.
try:
sig = inspect.signature(o)
return (str(sig))
except Exception:
pass
# Otherwise, look at the docstring.
s = getattr(o, "__doc__", "")
renpy.game.script.all_pyexpr = [ ]
s = s.split("\n\n")[0]
if "(" not in s:
return None
if ")" not in s:
return None
s = s.replace("-> void", "")
lines = renpy.parser.list_logical_lines('<test>', s, 1, add_lines=True)
nested = renpy.parser.group_logical_lines(lines)
l = renpy.parser.Lexer(nested)
l.advance()
l.word()
while l.match(r"\."):
l.word()
rv = ""
def consume(pattern):
nonlocal rv
m = l.match(pattern)
if m:
rv += m
return m
consume(r'\(')
first = True
while True:
consume(",")
if consume(r'\)'):
break
consume(r'\**')
if not first:
rv += " "
else:
first = False
argname = l.word()
while True:
n = l.word()
if n is not None:
argname = n
else:
break
rv += argname # type: ignore
rv += l.delimited_python(",)")
rv += " "
rv += l.rest()
return rv
def generate_namespace(out : TextIO, prefix : str, namespace : types.ModuleType|type):
"""
This generates type information for a module or class namespace.
"""
# Imports.
for k, v in sorted(namespace.__dict__.items()):
if not isinstance(v, types.ModuleType):
continue
name = v.__name__
if name == k:
out.write(prefix + f"import {name}\n")
elif isinstance(namespace, types.ModuleType) and name == f"{namespace.__name__}.{k}":
out.write(prefix + f"from . import {k}\n")
else:
out.write(prefix + f"import {name} as {k}\n")
out.write("\n")
generated = False
# Classes, methods, and functions.
for k, v in sorted(namespace.__dict__.items()):
if k in TYPING_IMPORTS:
continue
if k in [ "__new__", "__init__" ]:
continue
if isinstance(v, type):
if v.__module__ != namespace.__name__:
if v.__module__.startswith("renpy") or v.__module__.startswith("pygame_sdl2"):
out.write(prefix + f"{k} = {v.__module__}.{v.__name__}\n")
else:
out.write(prefix + f"from {v.__module__} import {v.__name__}\n")
out.write("\n")
generated = True
continue
# Bases and class name.
bases = [ i.__module__ + "." + i.__name__ for i in v.__bases__ if i != object ]
if bases:
bases_clause = f"({', '.join(bases)})"
else:
bases_clause = ""
out.write(prefix + f"class {v.__name__}{bases_clause}:\n")
# Figure out the signature for init.
init_sig = None
try:
init_sig = python_signature(v)
except Exception:
pass
if not init_sig:
try:
init_sig = python_signature(v.__init__)
except Exception:
pass
if init_sig:
if "(self" not in init_sig:
init_sig = "(self, " + init_sig[1:]
out.write(prefix + f" def __init__{init_sig}: ...\n")
# Methods and other contents.
generate_namespace(out, prefix + " ", v)
generated = True
continue
try:
sig = python_signature(v)
except Exception:
sig = None
if sig is None:
continue
if prefix and ("(self" not in sig):
out.write(prefix + "@staticmethod\n")
out.write(prefix + f"def {k}{sig}: ...\n")
out.write("\n")
generated = True
# Variables.
for k, v in sorted(namespace.__dict__.items()):
if isinstance(v, int):
out.write(prefix + f"{k} : int\n")
generated = True
_types = getattr(namespace, "_types", "")
for l in textwrap.dedent(_types.strip("\n")).split("\n"):
if l:
out.write(prefix + l + "\n")
generated = True
if not generated:
out.write(prefix + "pass\n\n")
def generate_module(module : types.ModuleType, package : bool):
"""
This generates type information for a module.
"""
if module.__name__.startswith("renpy."):
base = ROOT
else:
base = ROOT / "typings"
modfn = module.__name__.replace(".", "/")
if package:
fn = base / modfn / "__init__.pyi"
else:
fn = base / f"{modfn}.pyi"
print(fn)
fn.parent.mkdir(parents=True, exist_ok=True)
with open(fn, "w") as f:
f.write(f"from typing import {', '.join(TYPING_IMPORTS)}\n\n")
f.write("import builtins\n")
f.write("import renpy\n")
f.write("import pygame_sdl2\n\n")
generate_namespace(f, "", module)
def is_extension(m : types.ModuleType):
"""
Returns true if m is an extension module, and False otherwise.
"""
if m.__file__ == "built-in":
return True
if m.__file__.endswith(".so"):
return True
if m.__file__.endswith(".pyd"):
return True
return False
def should_generate(name, m : types.ModuleType):
"""
Returns true if we should generate the type information for the module with
`name`.
"""
prefix = name.partition(".")[0]
if prefix == "renpy":
return is_extension(m)
if prefix in [ "pygame", "pygame_sdl2" ]:
return True
return False
def main():
packages = set()
for name in tuple(sys.modules):
package = name.rpartition(".")[0]
packages.add(package)
for k, v in sorted(sys.modules.items()):
if not should_generate(k, v):
continue
generate_module(v, k in packages)
for fn in (ROOT / "scripts" / "pyi").glob("**/*.pyi"):
dfn = ROOT / "typings" / fn.relative_to(ROOT / "scripts" / "pyi")
text = fn.read_text()
dfn.parent.mkdir(parents=True, exist_ok=True)
dfn.write_text(text)
if __name__ == "__main__":
main()