Repository navigation
Expand file tree
/
Copy pathwhats_left.py
More file actions
executable file
·735 lines (626 loc) · 23.6 KB
/
Copy pathwhats_left.py
File metadata and controls
executable file
·735 lines (626 loc) · 23.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
#!/usr/bin/env -S python3 -I
# /// script
# requires-python = ">=3.14"
# ///
# 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, including
# the members of native classes as "Class.member"
# libdir - the location of RustPython's Lib/ directory.
#
# TODO: include this:
# which finds all modules it has available and
# creates a Python dictionary mapping module names to their contents, which is
# in turn used to generate a second Python script that also finds which modules
# it has available and compares that against the first dictionary we generated.
# We then run this second generated script with RustPython.
import argparse
import inspect
import json
import os
import platform
import re
import subprocess
import sys
import types
import warnings
from pydoc import ModuleScanner
if not sys.flags.isolated:
print("running without -I option.")
print("python -I scripts/whats_left.py")
exit(1)
GENERATED_FILE = "extra_tests/not_impl.py"
implementation = platform.python_implementation()
if implementation != "CPython":
sys.exit(f"whats_left.py must be run under CPython, got {implementation} instead")
if sys.version_info[:2] < (3, 14):
sys.exit(
f"whats_left.py must be run under CPython 3.14 or newer, got {implementation} {sys.version} instead. If you have uv, try `uv run python -I scripts/whats_left.py` to select a proper Python interpreter easier."
)
def parse_args():
parser = argparse.ArgumentParser(description="Process some integers.")
parser.add_argument(
"--signature",
action="store_true",
help="print functions whose signatures don't match CPython's",
)
parser.add_argument(
"--doc",
action="store_true",
help="print elements whose __doc__ don't match CPython's",
)
parser.add_argument(
"--json",
action="store_true",
help="print output as JSON (instead of line by line)",
)
parser.add_argument(
"--no-default-features",
action="store_true",
help="disable default features when building RustPython",
)
parser.add_argument(
"--features",
action="append",
help="which features to enable when building RustPython (default: [])",
default=[],
)
parser.add_argument(
"--rustpython",
help="use this RustPython executable instead of building one with cargo",
)
args = parser.parse_args()
return args
args = parse_args()
# CPython specific modules (mostly consisting of templates/tests)
CPYTHON_SPECIFIC_MODS = {
"xxmodule",
"xxsubtype",
"xxlimited",
"_xxtestfuzz",
"_testbuffer",
"_testcapi",
"_testimportmultiple",
"_testinternalcapi",
"_testmultiphase",
"_testlimitedcapi",
}
IGNORED_MODULES = {"this", "antigravity"} | CPYTHON_SPECIFIC_MODS
sys.path = [
path
for path in sys.path
if ("site-packages" not in path and "dist-packages" not in path)
]
def attr_is_not_inherited(type_, attr):
"""
returns True if type_'s attr is not inherited from any of its base classes
"""
return attr in type_.__dict__
def extra_info(obj):
import sys
class _OrderedSet:
"""Set repr with sorted elements, so hash order does not change it."""
def __init__(self, items, braces):
self.items = items
self.braces = braces
def __repr__(self):
if not self.items:
return "set()" if self.braces else "frozenset()"
body = ", ".join(sorted(repr(item) for item in self.items))
if self.braces:
return "{" + body + "}"
return "frozenset({" + body + "})"
def canon_default(param):
default = param.default
# The interpreter path, version tuple, and install scheme differ per process.
if isinstance(default, str) and default == sys.executable:
return "<sys.executable>"
if default is sys.version_info:
return "<sys.version_info>"
if param.name == "scheme" and isinstance(default, str):
import sysconfig
if default == sysconfig.get_default_scheme():
return "<sysconfig.get_default_scheme()>"
if isinstance(default, frozenset):
return _OrderedSet(default, False)
if isinstance(default, set):
return _OrderedSet(default, True)
return default
def stable_signature(target):
sig = inspect.signature(target)
params = []
changed = False
for param in sig.parameters.values():
if param.default is not inspect.Parameter.empty:
default = canon_default(param)
if default is not param.default:
param = param.replace(default=default)
changed = True
params.append(param)
if changed:
sig = sig.replace(parameters=params)
# remove function memory addresses
return re.sub(r" at 0x[0-9A-Fa-f]+", " at 0xdeadbeef", str(sig))
if callable(obj):
doc = inspect.getdoc(obj)
try:
return {
"sig": stable_signature(obj),
"doc": doc,
}
except Exception as e:
exception = repr(e)
# CPython uses ' RustPython uses "
if exception.replace('"', "'").startswith("ValueError('no signature found"):
return {
"sig": "ValueError('no signature found')",
"doc": doc,
}
return {
"sig": exception,
"doc": doc,
}
return {
"sig": None,
"doc": None,
}
def name_sort_key(name):
if name == "builtins":
return ""
if name[0] == "_":
return name[1:] + "1"
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():
objects = [t.__name__ for t in BUILTIN_TYPES]
objects.append("type(None)")
iters = [
"type(bytearray().__iter__())",
"type(bytes().__iter__())",
"type(dict().__iter__())",
"type(dict().values().__iter__())",
"type(dict().items().__iter__())",
"type(dict().values())",
"type(dict().items())",
"type(set().__iter__())",
"type(list().__iter__())",
"type(range(0).__iter__())",
"type(str().__iter__())",
"type(tuple().__iter__())",
"type(memoryview(bytearray(b'0')).__iter__())",
]
methods = {}
for typ_code in objects + iters:
typ = eval(typ_code)
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):
typ_code, extra, attrs = methods[name]
output += f" '{name}': ({typ_code}, {extra!r}, [\n"
for attr, attr_extra in attrs:
output += f" ({attr!r}, {attr_extra!r}),\n"
output += " ]),\n"
if typ_code != objects[-1]:
output += "\n"
output += "}\n\n"
return output
def scan_modules():
"""taken from the source code of help('modules')
https://github.com/python/cpython/blob/63298930fb531ba2bb4f23bc3b915dbf1e17e9e1/Lib/pydoc.py#L2178"""
modules = {}
def callback(path, modname, desc, modules=modules):
if modname and modname[-9:] == ".__init__":
modname = modname[:-9] + " (package)"
if modname.find(".") < 0:
modules[modname] = 1
def onerror(modname):
callback(None, modname, None)
with warnings.catch_warnings():
# ignore warnings from importing deprecated modules
warnings.simplefilter("ignore")
ModuleScanner().run(callback, onerror=onerror)
return list(modules.keys())
def import_module(module_name):
import io
from contextlib import redirect_stdout
# Importing modules causes ('Constant String', 2, None, 4) and
# "Hello world!" to be printed to stdout.
f = io.StringIO()
with warnings.catch_warnings(), redirect_stdout(f):
# ignore warnings caused by importing deprecated modules
warnings.filterwarnings("ignore", category=DeprecationWarning)
try:
module = __import__(module_name)
except Exception as e:
return e
return module
def is_child(module, item):
import inspect
item_mod = inspect.getmodule(item)
return item_mod is module
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:
result[item_name] = extra_info(item)
return result
def _is_decimal_fallback_item(item):
"""Items that belong to the pure decimal module, not an imported helper."""
if inspect.ismodule(item):
return False
declared = getattr(item, "__module__", None)
if declared in {"decimal", "_pydecimal"}:
return True
return declared is None and inspect.getmodule(item) is None
def _expand_class_members(dir_result, module):
for item_name in list(dir_result):
if "." in item_name:
continue
item = getattr(module, item_name)
if (
isinstance(item, type)
and item not in BUILTIN_TYPES
and getattr(item, "__name__", None) == item_name
):
for attr, info in own_attrs(item):
dir_result[f"{item_name}.{attr}"] = info
def _pure_decimal_baseline(wrapper):
"""Signatures and docs from `_pydecimal`, with the wrapper module docstring."""
import _pydecimal
result = {}
for item_name in sorted(set(dir(_pydecimal))):
if item_name == "__builtins__":
continue
if item_name == "__doc__":
result[item_name] = {"sig": None, "doc": inspect.getdoc(wrapper)}
continue
item = getattr(_pydecimal, item_name)
if not _is_decimal_fallback_item(item):
continue
result[item_name] = extra_info(item)
_expand_class_members(result, _pydecimal)
return result
def gen_modules():
# check name because modules listed have side effects on import,
# e.g. printing something or opening a webpage
modules = {}
for mod_name in sorted(scan_modules(), key=name_sort_key):
if mod_name in IGNORED_MODULES:
continue
# decimal's accelerated types are not the implementation under test.
if mod_name == "decimal":
module = import_module(mod_name)
if isinstance(module, Exception):
print(
f"!!! {mod_name} skipped because {type(module).__name__}: {str(module)}",
file=sys.stderr,
)
continue
modules[mod_name] = _pure_decimal_baseline(module)
continue
# when generating CPython list, ignore items defined by other modules
dir_result = dir_of_mod_or_error(mod_name, keep_other=False)
if isinstance(dir_result, Exception):
print(
f"!!! {mod_name} skipped because {type(dir_result).__name__}: {str(dir_result)}",
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
output = """\
# WARNING: THIS IS AN AUTOMATICALLY GENERATED FILE
# EDIT extra_tests/not_impl_gen.sh, NOT THIS FILE.
# RESULTS OF THIS TEST DEPEND ON THE CPYTHON
# VERSION AND PYTHON ENVIRONMENT USED
# TO RUN not_impl_mods_gen.py
"""
output += gen_methods()
output += f"""
cpymods = {gen_modules()!r}
libdir = {os.path.abspath("Lib/").encode("utf8")!r}
"""
# Copy the source code of functions we will reuse in the generated script
REUSED = [
attr_is_not_inherited,
extra_info,
dir_of_mod_or_error,
import_module,
is_child,
]
for fn in REUSED:
output += "".join(inspect.getsourcelines(fn)[0]) + "\n\n"
# Prevent missing variable linter errors from compare()
expected_methods = {}
cpymods = {}
libdir = ""
# This function holds the source code that will be run under RustPython
def compare():
import inspect
import io
import json
import os
import platform
import re
import sys
import warnings
from contextlib import redirect_stdout
def sig_mismatch(rustpy_sig, cpython_sig):
# A signature CPython cannot produce is never a mismatch; having one is fine.
if cpython_sig is None or cpython_sig.startswith("ValueError("):
return False
return rustpy_sig != cpython_sig
def method_incompatibility_reason(typ, method_name, real_method_value):
has_method = hasattr(typ, method_name)
if not has_method:
return ""
is_inherited = not attr_is_not_inherited(typ, method_name)
# 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)"
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 sig_mismatch(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
if platform.python_implementation() == "CPython":
if not_implementeds:
sys.exit(
f"ERROR: CPython should have all the methods but missing: {not_implementeds}"
)
mod_names = [
name.decode()
for name, ext in map(os.path.splitext, os.listdir(libdir))
if ext == b".py" or os.path.isdir(os.path.join(libdir, name))
]
mod_names += list(sys.builtin_module_names)
# Remove easter egg modules
mod_names = sorted(set(mod_names) - {"this", "antigravity"})
rustpymods = {mod: dir_of_mod_or_error(mod) for mod in mod_names}
result = {
"cpython_modules": {},
"implemented": {},
"not_implemented": {},
"failed_to_import": {},
"missing_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] = (
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) - skipped
mod_missing_items = sorted(
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"])
for item in implemented_items
if sig_mismatch(rustpymod[item]["sig"], cpymod[item]["sig"])
]
mod_mismatched_doc_items = [
(f"{modname}.{item}", rustpymod[item]["doc"], cpymod[item]["doc"])
for item in implemented_items
if rustpymod[item]["doc"] != cpymod[item]["doc"]
]
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
result["not_implementeds"] = not_implementeds
print(json.dumps(result))
def remove_one_indent(s):
indent = " "
return s[len(indent) :] if s.startswith(indent) else s
compare_src = inspect.getsourcelines(compare)[0][1:]
output += "".join(remove_one_indent(line) for line in compare_src)
with open(GENERATED_FILE, "w", encoding="utf-8") as f:
f.write(output + "\n")
def resolve_rustpython(path):
if os.path.isfile(path):
return path
if os.name == "nt" and not path.lower().endswith(".exe"):
exe = path + ".exe"
if os.path.isfile(exe):
return exe
sys.exit(f"RustPython executable not found: {path}")
if args.rustpython:
rustpython_run_command = [resolve_rustpython(args.rustpython), GENERATED_FILE]
else:
cargo_build_command = ["cargo", "build", "--release"]
if args.no_default_features:
cargo_build_command.append("--no-default-features")
joined_features = ",".join(args.features)
if args.features:
cargo_build_command.extend(["--features", joined_features])
subprocess.run(cargo_build_command, check=True)
rustpython_run_command = ["cargo", "run", "--release"]
if args.no_default_features:
rustpython_run_command.append("--no-default-features")
if args.features:
rustpython_run_command.extend(["--features", joined_features])
rustpython_run_command.extend(["-q", "--", GENERATED_FILE])
result = subprocess.run(
rustpython_run_command,
env={**os.environ.copy(), "RUSTPYTHONPATH": "Lib"},
text=True,
capture_output=True,
)
# The last line should be json output, the rest of the lines can contain noise
# because importing certain modules can print stuff to stdout/stderr
print(result.stderr, file=sys.stderr)
result = json.loads(result.stdout.splitlines()[-1])
if args.json:
print(json.dumps(result))
sys.exit()
# missing entire modules
print("# modules")
for modname in result["not_implemented"]:
print(modname, "(entire module)")
for modname, exception in result["failed_to_import"].items():
print(f"{modname} (exists but not importable: {exception})")
# missing from builtins
print("\n# builtin items")
for module, missing_methods in result["not_implementeds"].items():
for method, reason in missing_methods.items():
print(f"{module}.{method}" + (f" {reason}" if reason else ""))
# missing from modules
print("\n# stdlib items")
for modname, missing in result["missing_items"].items():
for item in missing:
print(item)
if args.signature:
print("\n# mismatching signatures (warnings)")
for modname, mismatched in result["mismatched_items"].items():
for i, (item, rustpy_value, cpython_value) in enumerate(mismatched):
if rustpy_value is None or rustpy_value.startswith("ValueError("):
rustpy_value = f" {rustpy_value}"
print(f"{item}{rustpy_value}")
if cpython_value is None:
cpython_value = f" {cpython_value}"
print(f"{' ' * len(item)}{cpython_value}")
if i < len(mismatched) - 1:
print()
if args.doc:
print("\n# mismatching `__doc__`s (warnings)")
for modname, mismatched in result["mismatched_doc_items"].items():
for item, rustpy_doc, cpython_doc in mismatched:
print(f"{item} {repr(rustpy_doc)} != {repr(cpython_doc)}")
print()
print("# summary")
for error_type, modules in result.items():
print("# ", error_type, len(modules))