diff --git a/conformance/test_mathutils.py b/conformance/test_mathutils.py new file mode 100644 index 0000000..3838d69 --- /dev/null +++ b/conformance/test_mathutils.py @@ -0,0 +1,18 @@ +import mathutils +from math import radians + +vec = mathutils.Vector((1.0, 2.0, 3.0)) + +mat_rot = mathutils.Matrix.Rotation(radians(90.0), 4, "X") +mat_trans = mathutils.Matrix.Translation(vec) + +mat = mat_trans @ mat_rot +mat.invert() + +mat3 = mat.to_3x3() +quat1 = mat.to_quaternion() +quat2 = mat3.to_quaternion() + +quat_diff = quat1.rotation_difference(quat2) + +print(quat_diff.angle) diff --git a/introspect.py b/introspect.py index dcc7f87..38d0518 100644 --- a/introspect.py +++ b/introspect.py @@ -959,7 +959,9 @@ def introspect_callable(func: Callable[..., object], name: str) -> FunctionData # C functions often use generic names like "object" in __text_signature__ # while docstrings use descriptive names like "string", "cls", etc. doc_param_list = list(param_types.items()) - sig_param_list = list(sig.parameters.items()) + sig_param_list = [ + (n, p) for n, p in sig.parameters.items() if n != "self" + ] params = [] for i, (pname, param) in enumerate(sig_param_list): @@ -1223,6 +1225,17 @@ def _fix_dunder_signatures( pass continue + # __iter__: return Iterator[element_type] based on __getitem__ + if name == "__iter__": + try: + result = instance[0] + etype = _type_name(result) + method["return_type"] = f"Iterator[{etype}]" + method["params"] = [] + except Exception: + pass + continue + # __setitem__: refine value type from __getitem__ return type if name == "__setitem__": try: @@ -1381,6 +1394,22 @@ def introspect_class(cls: type[object], module_name: str) -> StructData: if dunder_methods: _fix_dunder_signatures(cls, dunder_methods) + # Synthesize __iter__ if the class supports iteration via __getitem__ + # but doesn't define __iter__ explicitly (old-style C iteration protocol). + method_names = {m["name"] for m in methods} + if "__getitem__" in method_names and "__iter__" not in method_names: + getitem = next(m for m in methods if m["name"] == "__getitem__") + elem_type = getitem["return_type"] or "object" + methods.append( + { + "name": "__iter__", + "doc": "", + "params": [], + "return_type": f"Iterator[{elem_type}]", + "is_classmethod": False, + } + ) + return { "name": cls.__name__, "doc": class_doc, diff --git a/main.py b/main.py index fec6aa7..2190ade 100644 --- a/main.py +++ b/main.py @@ -396,6 +396,68 @@ def typecheck_stubs(versions: list[str] | None = None) -> None: sys.exit(1) +def conformance_check(versions: list[str] | None = None) -> None: + """Type-check conformance test files against generated stubs.""" + dist_dir = SCRIPT_DIR / "dist" + conformance_dir = SCRIPT_DIR / "conformance" + + if not conformance_dir.exists(): + print("No conformance/ directory found.") + sys.exit(1) + + if versions: + missing = [v for v in versions if not (dist_dir / v).is_dir()] + if missing: + print(f"Missing stubs for: {', '.join(missing)}") + sys.exit(1) + else: + versions = sorted( + d.name + for d in dist_dir.iterdir() + if d.is_dir() and not d.name.startswith(".") + ) + + if not versions: + print("No generated stubs found in dist/.") + sys.exit(1) + + failed = False + for version in versions: + version_dir = dist_dir / version + print(f"=== Conformance check against Blender {version} stubs ===") + + python_version_file = version_dir / ".python-version" + python_version = ( + python_version_file.read_text().strip() + if python_version_file.exists() + else "3.11" + ) + + config = version_dir / "pyrightconfig.conformance.json" + config.write_text( + json.dumps( + { + "include": [str(conformance_dir)], + "extraPaths": [str(version_dir)], + "typeCheckingMode": "strict", + "pythonVersion": python_version, + } + ) + ) + + result = subprocess.run( + ["basedpyright", "--project", str(config)], + ) + + config.unlink() + + if result.returncode != 0: + failed = True + + if failed: + sys.exit(1) + + if __name__ == "__main__": parser = argparse.ArgumentParser(description="Blender type stubs generator") parser.add_argument( @@ -403,6 +465,11 @@ if __name__ == "__main__": action="store_true", help="Type-check generated stubs instead of generating", ) + parser.add_argument( + "--conformance", + action="store_true", + help="Run conformance tests against generated stubs", + ) parser.add_argument( "versions", nargs="*", @@ -412,6 +479,8 @@ if __name__ == "__main__": if args.typecheck_stubs: typecheck_stubs(args.versions or None) + elif args.conformance: + conformance_check(args.versions or None) else: if not args.versions: parser.error("versions are required for generation") diff --git a/overrides/5.1/mathutils.json b/overrides/5.1/mathutils.json new file mode 100644 index 0000000..f6b59c2 --- /dev/null +++ b/overrides/5.1/mathutils.json @@ -0,0 +1,7 @@ +{ + "Matrix.Translation": { + "params": { + "vector": "Sequence[float] | Vector" + } + } +} diff --git a/pyproject.toml b/pyproject.toml index 76ad3f8..5c56dec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,7 @@ target-version = ["py311"] [tool.basedpyright] typeCheckingMode = "strict" extraPaths = ["stubs"] -exclude = ["downloads/", ".venv/", "dist/"] +exclude = ["downloads/", ".venv/", "dist/", "conformance/"] [tool.ruff] exclude = ["dist/"] @@ -40,6 +40,7 @@ format = { cmd = "black .", help = "Format all Python files" } lint = { cmd = "ruff check .", help = "Lint all Python files" } typecheck = { cmd = "basedpyright", help = "Run type checker on source code" } typecheck-stubs = { cmd = "python -m main --typecheck-stubs", help = "Type-check generated stubs (e.g., poe typecheck-stubs 4.0 4.1)" } +conformance = { cmd = "python -m main --conformance", help = "Run conformance tests against generated stubs (e.g., poe conformance 5.0)" } test = { cmd = "python -m unittest discover -s tests -v", help = "Run unit tests" } [tool.poe.tasks.generate]