From e189ca7923367fb4da755b506cc0d2ffb5bdbefd Mon Sep 17 00:00:00 2001 From: Hanjeong Lee Date: Sun, 20 Sep 2026 18:39:44 +0900 Subject: [PATCH] Compare native signatures and docstrings in whats_left.py (#8751) * Record a module's docstring mismatches on their own They were only kept when the module also had missing items or signature mismatches, so annotationlib, base64, ntpath and _ctypes reported none. Whether a module counts as implemented still depends on missing items and signatures alone. Assisted-by: Claude Code:claude-opus-5 * Report a builtin type method that differs as a mismatch A method whose signature or docstring differed was listed under "builtin items" as if it were missing, with both extra_info dicts as the reason. That section feeds the website's builtin_items.csv. Differences now go to mismatched_items and mismatched_doc_items, keyed by type name, like module items. Nothing reaches this path yet because builtin signatures are still skipped; without this change, lifting that skip turns the one "builtin items" line into 455. Assisted-by: Claude Code:claude-opus-5 * Compare signatures and docstrings of native callables extra_info() skipped anything inspect treats as builtin since cc2c46b1b (#2410), when RustPython had no __text_signature__ for builtins. It has one now, so --signature and --doc cover functions and methods written in Rust. Modules with a mismatched signature go from 45 to 134, and 160 of the 710 mismatched items are ones CPython itself cannot parse, which the --signature listing already skips. __builtins__ is no longer compared. inspect.signature() evals defaults in the owning module's namespace and eval() adds __builtins__ there, so CPython's builtins module gained one after list.index was inspected and it showed up as missing. Assisted-by: Claude Code:claude-opus-5 * Scan the members of native classes in each module A module was only compared one level deep, so a native class counted as present whatever its methods were. Classes a module owns and that are not Python-defined are now opened too, and their own members are compared as "Class.member": 438 classes and 4,833 members, 1,033 of which have a signature --signature reports. A class is native when it has no __firstlineno__, which a class statement records, and no Python functions of its own, which rules out namedtuple-style classes made by type(). BUILTIN_TYPES moves to module level so the builtin types are not reported twice, an alias like array.ArrayType is skipped, and CPython's __annotations_cache__ and __vectorcalloffset__ are left out. RustPython follows the CPython list instead of deciding again. A member only inherited is listed as missing with "(inherited)", as builtin type methods are. A class missing here, or implemented as something else such as _sha3's hash functions, is reported by its own line only. Assisted-by: Claude Code:claude-opus-5 * Report a module that exists but cannot be imported dir_of_mod_or_error() ran dir() on the exception import_module() returns, so a failed import looked like a module whose every item is missing: sqlite3 alone filled 198 of those lines, and CPython's own _ios_support was compared as a module holding ImportError's attributes. Returning the exception reaches the "exists but not importable" line that was already written for it, and drops 529 lines from "stdlib items". Assisted-by: Claude Code:claude-opus-5 * Compare a module's own docstring extra_info() reports a docstring for callables, and a module is not one, so the __doc__ item both sides record was None and a module docstring never counted as a mismatch. Reading it with getdoc(), the way a callable's is read, brings 221 module docstrings into the comparison. sys is the one that differs; base64 and imaplib differ here only because their Lib copies track 3.14.7 while this run used 3.14.6. Non-callables keep reporting None. getdoc() on a plain value answers with its type's docstring, which would turn every string constant into a mismatch. Assisted-by: Claude Code:claude-opus-5 * Decide a member's ownership by the class dictionary attr_is_not_inherited() compared the attribute's value with each base's. That took an inherited classmethod for the class's own, since a bound method compares unequal across classes, and dropped an own attribute whose value matched a base's default None: 394 of CPython's in all, ast.FunctionDef.returns among them. On the RustPython side it called AttributeError.name, a class attribute set to None, inherited. Ownership now means the class dictionary holds the name. An inherited member is reported only when what it resolves to differs from CPython's own, so dict_items.__doc__, a slot CPython fills with None that RustPython resolves to None without one, stays quiet. "(inherited)" lines drop from 196 to 54, and ast.alias.end_lineno is among the real gaps that appear. Assisted-by: Claude Code:claude-fable-5-1 --- scripts/whats_left.py | 204 +++++++++++++++++++++++++++++------------- 1 file changed, 144 insertions(+), 60 deletions(-) diff --git a/scripts/whats_left.py b/scripts/whats_left.py index 28eb19bb7..f79442aa9 100755 --- a/scripts/whats_left.py +++ b/scripts/whats_left.py @@ -5,7 +5,8 @@ # This script generates Lib/snippets/whats_left_data.py with these variables defined: # expected_methods - a dictionary mapping builtin objects to their methods -# cpymods - a dictionary mapping module names to their contents +# cpymods - a dictionary mapping module names to their contents, including +# the members of native classes as "Class.member" # libdir - the location of RustPython's Lib/ directory. # @@ -24,6 +25,7 @@ import platform import re import subprocess import sys +import types import warnings from pydoc import ModuleScanner @@ -109,12 +111,11 @@ def attr_is_not_inherited(type_, attr): """ returns True if type_'s attr is not inherited from any of its base classes """ - bases = type_.__mro__[1:] - return getattr(type_, attr) not in (getattr(base, attr, None) for base in bases) + return attr in type_.__dict__ def extra_info(obj): - if callable(obj) and not inspect._signature_is_builtin(obj): + if callable(obj): doc = inspect.getdoc(obj) try: sig = str(inspect.signature(obj)) @@ -151,36 +152,70 @@ def name_sort_key(name): return name + "2" +BUILTIN_TYPES = [ + bool, + bytearray, + bytes, + complex, + dict, + enumerate, + filter, + float, + frozenset, + int, + list, + map, + memoryview, + range, + set, + slice, + str, + super, + tuple, + object, + zip, + classmethod, + staticmethod, + property, + Exception, + BaseException, +] + + +# CPython's per-class annotations cache and its vectorcall C API offset +CPYTHON_INTERNAL_ATTRS = {"__annotations_cache__", "__vectorcalloffset__"} + + +def own_attrs(typ): + attrs = [] + for attr in dir(typ): + if attr in CPYTHON_INTERNAL_ATTRS: + continue + # Skip attributes in dir() but not actually accessible (e.g., descriptor that raises) + if not hasattr(typ, attr): + continue + if attr_is_not_inherited(typ, attr): + attrs.append((attr, extra_info(getattr(typ, attr)))) + return attrs + + +def is_native_class(obj): + # A class statement records __firstlineno__. A class made by calling + # type(), like a namedtuple, still has Python functions. + if not isinstance(obj, type) or "__firstlineno__" in obj.__dict__: + return False + for value in obj.__dict__.values(): + if isinstance(value, (classmethod, staticmethod)): + value = value.__func__ + if isinstance(value, property): + value = value.fget + if isinstance(value, types.FunctionType): + return False + return True + + def gen_methods(): - types = [ - bool, - bytearray, - bytes, - complex, - dict, - enumerate, - filter, - float, - frozenset, - int, - list, - map, - memoryview, - range, - set, - slice, - str, - super, - tuple, - object, - zip, - classmethod, - staticmethod, - property, - Exception, - BaseException, - ] - objects = [t.__name__ for t in types] + objects = [t.__name__ for t in BUILTIN_TYPES] objects.append("type(None)") iters = [ @@ -202,14 +237,7 @@ def gen_methods(): methods = {} for typ_code in objects + iters: typ = eval(typ_code) - attrs = [] - for attr in dir(typ): - # Skip attributes in dir() but not actually accessible (e.g., descriptor that raises) - if not hasattr(typ, attr): - continue - if attr_is_not_inherited(typ, attr): - attrs.append((attr, extra_info(getattr(typ, attr)))) - methods[typ.__name__] = (typ_code, extra_info(typ), attrs) + methods[typ.__name__] = (typ_code, extra_info(typ), own_attrs(typ)) output = "expected_methods = {\n" for name in sorted(methods.keys(), key=name_sort_key): @@ -272,9 +300,21 @@ def is_child(module, item): def dir_of_mod_or_error(module_name, keep_other=True): module = import_module(module_name) + if isinstance(module, Exception): + return module item_names = sorted(set(dir(module))) result = {} for item_name in item_names: + # eval() adds __builtins__ to its globals, and inspect.signature() evals + # defaults in the module's namespace, so whether it exists depends on + # what was inspected before. + if item_name == "__builtins__": + continue + if item_name == "__doc__": + # extra_info() reports a docstring for a callable only, and a module + # is not one. getdoc() matches how a callable's is normalized. + result[item_name] = {"sig": None, "doc": inspect.getdoc(module)} + continue item = getattr(module, item_name) # don't repeat items imported from other modules if keep_other or is_child(module, item) or inspect.getmodule(item) is None: @@ -297,6 +337,17 @@ def gen_modules(): file=sys.stderr, ) continue + module = import_module(mod_name) + for item_name in list(dir_result): + item = getattr(module, item_name) + # an alias such as array.ArrayType is scanned under its real name + if ( + is_native_class(item) + and item not in BUILTIN_TYPES + and item.__name__ == item_name + ): + for attr, info in own_attrs(item): + dir_result[f"{item_name}.{attr}"] = info modules[mod_name] = dir_result return modules @@ -352,22 +403,34 @@ def compare(): return "" is_inherited = not attr_is_not_inherited(typ, method_name) - if is_inherited: + # Inheriting something that reads the same as CPython's own member + # leaves nothing to implement. + if is_inherited and extra_info(getattr(typ, method_name)) != real_method_value: return "(inherited)" - value = extra_info(getattr(typ, method_name)) - if value != real_method_value: - return f"{value} != {real_method_value}" - return None not_implementeds = {} + mismatched_methods = {} + mismatched_method_docs = {} for name, (typ, real_value, methods) in expected_methods.items(): missing_methods = {} for method, real_method_value in methods: reason = method_incompatibility_reason(typ, method, real_method_value) if reason is not None: missing_methods[method] = reason + continue + # A method that exists but differs is a mismatch, not a missing one. + value = extra_info(getattr(typ, method)) + item = f"{name}.{method}" + if value["sig"] != real_method_value["sig"]: + mismatched_methods.setdefault(name, []).append( + (item, value["sig"], real_method_value["sig"]) + ) + if value["doc"] != real_method_value["doc"]: + mismatched_method_docs.setdefault(name, []).append( + (item, value["doc"], real_method_value["doc"]) + ) if missing_methods: not_implementeds[name] = missing_methods @@ -394,22 +457,44 @@ def compare(): "not_implemented": {}, "failed_to_import": {}, "missing_items": {}, - "mismatched_items": {}, - "mismatched_doc_items": {}, + # builtin types first, keyed by type name, then modules + "mismatched_items": mismatched_methods, + "mismatched_doc_items": mismatched_method_docs, } for modname, cpymod in cpymods.items(): rustpymod = rustpymods.get(modname) if rustpymod is None: result["not_implemented"][modname] = None elif isinstance(rustpymod, Exception): - result["failed_to_import"][modname] = rustpymod.__class__.__name__ + str( - rustpymod + result["failed_to_import"][modname] = ( + f"{rustpymod.__class__.__name__}: {rustpymod}" ) else: + module = import_module(modname) + skipped = set() + inherited = set() + for item in cpymod: + cls_name, dot, attr = item.partition(".") + if not dot: + continue + cls = getattr(module, cls_name, None) + if not isinstance(cls, type): + # The class line already reports a missing class or one + # implemented as something else. + skipped.add(item) + elif not hasattr(cls, attr): + continue + elif attr_is_not_inherited(cls, attr): + rustpymod[item] = extra_info(getattr(cls, attr)) + elif extra_info(getattr(cls, attr)) == cpymod[item]: + rustpymod[item] = cpymod[item] + else: + inherited.add(item) implemented_items = sorted(set(cpymod) & set(rustpymod)) - mod_missing_items = set(cpymod) - set(rustpymod) + mod_missing_items = set(cpymod) - set(rustpymod) - skipped mod_missing_items = sorted( - f"{modname}.{item}" for item in mod_missing_items + f"{modname}.{item}" + (" (inherited)" if item in inherited else "") + for item in mod_missing_items ) mod_mismatched_items = [ (f"{modname}.{item}", rustpymod[item]["sig"], cpymod[item]["sig"]) @@ -422,14 +507,13 @@ def compare(): for item in implemented_items if rustpymod[item]["doc"] != cpymod[item]["doc"] ] - if mod_missing_items or mod_mismatched_items: - if mod_missing_items: - result["missing_items"][modname] = mod_missing_items - if mod_mismatched_items: - result["mismatched_items"][modname] = mod_mismatched_items - if mod_mismatched_doc_items: - result["mismatched_doc_items"][modname] = mod_mismatched_doc_items - else: + if mod_missing_items: + result["missing_items"][modname] = mod_missing_items + if mod_mismatched_items: + result["mismatched_items"][modname] = mod_mismatched_items + if mod_mismatched_doc_items: + result["mismatched_doc_items"][modname] = mod_mismatched_doc_items + if not (mod_missing_items or mod_mismatched_items): result["implemented"][modname] = None result["cpython_modules"] = cpymods