Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
allendowney
GitHub Repository: allendowney/cpython
Path: blob/main/Tools/build/stable_abi.py
12 views
1
"""Check the stable ABI manifest or generate files from it
2
3
By default, the tool only checks existing files/libraries.
4
Pass --generate to recreate auto-generated files instead.
5
6
For actions that take a FILENAME, the filename can be left out to use a default
7
(relative to the manifest file, as they appear in the CPython codebase).
8
"""
9
10
from functools import partial
11
from pathlib import Path
12
import dataclasses
13
import subprocess
14
import sysconfig
15
import argparse
16
import textwrap
17
import tomllib
18
import difflib
19
import pprint
20
import sys
21
import os
22
import os.path
23
import io
24
import re
25
import csv
26
27
SCRIPT_NAME = 'Tools/build/stable_abi.py'
28
MISSING = object()
29
30
EXCLUDED_HEADERS = {
31
"bytes_methods.h",
32
"cellobject.h",
33
"classobject.h",
34
"code.h",
35
"compile.h",
36
"datetime.h",
37
"dtoa.h",
38
"frameobject.h",
39
"genobject.h",
40
"longintrepr.h",
41
"parsetok.h",
42
"pyatomic.h",
43
"token.h",
44
"ucnhash.h",
45
}
46
MACOS = (sys.platform == "darwin")
47
UNIXY = MACOS or (sys.platform == "linux") # XXX should this be "not Windows"?
48
49
50
# The stable ABI manifest (Misc/stable_abi.toml) exists only to fill the
51
# following dataclasses.
52
# Feel free to change its syntax (and the `parse_manifest` function)
53
# to better serve that purpose (while keeping it human-readable).
54
55
class Manifest:
56
"""Collection of `ABIItem`s forming the stable ABI/limited API."""
57
def __init__(self):
58
self.contents = dict()
59
60
def add(self, item):
61
if item.name in self.contents:
62
# We assume that stable ABI items do not share names,
63
# even if they're different kinds (e.g. function vs. macro).
64
raise ValueError(f'duplicate ABI item {item.name}')
65
self.contents[item.name] = item
66
67
def select(self, kinds, *, include_abi_only=True, ifdef=None):
68
"""Yield selected items of the manifest
69
70
kinds: set of requested kinds, e.g. {'function', 'macro'}
71
include_abi_only: if True (default), include all items of the
72
stable ABI.
73
If False, include only items from the limited API
74
(i.e. items people should use today)
75
ifdef: set of feature macros (e.g. {'HAVE_FORK', 'MS_WINDOWS'}).
76
If None (default), items are not filtered by this. (This is
77
different from the empty set, which filters out all such
78
conditional items.)
79
"""
80
for name, item in sorted(self.contents.items()):
81
if item.kind not in kinds:
82
continue
83
if item.abi_only and not include_abi_only:
84
continue
85
if (ifdef is not None
86
and item.ifdef is not None
87
and item.ifdef not in ifdef):
88
continue
89
yield item
90
91
def dump(self):
92
"""Yield lines to recreate the manifest file (sans comments/newlines)"""
93
for item in self.contents.values():
94
fields = dataclasses.fields(item)
95
yield f"[{item.kind}.{item.name}]"
96
for field in fields:
97
if field.name in {'name', 'value', 'kind'}:
98
continue
99
value = getattr(item, field.name)
100
if value == field.default:
101
pass
102
elif value is True:
103
yield f" {field.name} = true"
104
elif value:
105
yield f" {field.name} = {value!r}"
106
107
108
itemclasses = {}
109
def itemclass(kind):
110
"""Register the decorated class in `itemclasses`"""
111
def decorator(cls):
112
itemclasses[kind] = cls
113
return cls
114
return decorator
115
116
@itemclass('function')
117
@itemclass('macro')
118
@itemclass('data')
119
@itemclass('const')
120
@itemclass('typedef')
121
@dataclasses.dataclass
122
class ABIItem:
123
"""Information on one item (function, macro, struct, etc.)"""
124
125
name: str
126
kind: str
127
added: str = None
128
abi_only: bool = False
129
ifdef: str = None
130
131
@itemclass('feature_macro')
132
@dataclasses.dataclass(kw_only=True)
133
class FeatureMacro(ABIItem):
134
name: str
135
doc: str
136
windows: bool = False
137
abi_only: bool = True
138
139
@itemclass('struct')
140
@dataclasses.dataclass(kw_only=True)
141
class Struct(ABIItem):
142
struct_abi_kind: str
143
members: list = None
144
145
146
def parse_manifest(file):
147
"""Parse the given file (iterable of lines) to a Manifest"""
148
149
manifest = Manifest()
150
151
data = tomllib.load(file)
152
153
for kind, itemclass in itemclasses.items():
154
for name, item_data in data[kind].items():
155
try:
156
item = itemclass(name=name, kind=kind, **item_data)
157
manifest.add(item)
158
except BaseException as exc:
159
exc.add_note(f'in {kind} {name}')
160
raise
161
162
return manifest
163
164
# The tool can run individual "actions".
165
# Most actions are "generators", which generate a single file from the
166
# manifest. (Checking works by generating a temp file & comparing.)
167
# Other actions, like "--unixy-check", don't work on a single file.
168
169
generators = []
170
def generator(var_name, default_path):
171
"""Decorates a file generator: function that writes to a file"""
172
def _decorator(func):
173
func.var_name = var_name
174
func.arg_name = '--' + var_name.replace('_', '-')
175
func.default_path = default_path
176
generators.append(func)
177
return func
178
return _decorator
179
180
181
@generator("python3dll", 'PC/python3dll.c')
182
def gen_python3dll(manifest, args, outfile):
183
"""Generate/check the source for the Windows stable ABI library"""
184
write = partial(print, file=outfile)
185
content = f"""\
186
/* Re-export stable Python ABI */
187
188
/* Generated by {SCRIPT_NAME} */
189
"""
190
content += r"""
191
#ifdef _M_IX86
192
#define DECORATE "_"
193
#else
194
#define DECORATE
195
#endif
196
197
#define EXPORT_FUNC(name) \
198
__pragma(comment(linker, "/EXPORT:" DECORATE #name "=" PYTHON_DLL_NAME "." #name))
199
#define EXPORT_DATA(name) \
200
__pragma(comment(linker, "/EXPORT:" DECORATE #name "=" PYTHON_DLL_NAME "." #name ",DATA"))
201
"""
202
write(textwrap.dedent(content))
203
204
def sort_key(item):
205
return item.name.lower()
206
207
windows_feature_macros = {
208
item.name for item in manifest.select({'feature_macro'}) if item.windows
209
}
210
for item in sorted(
211
manifest.select(
212
{'function'},
213
include_abi_only=True,
214
ifdef=windows_feature_macros),
215
key=sort_key):
216
write(f'EXPORT_FUNC({item.name})')
217
218
write()
219
220
for item in sorted(
221
manifest.select(
222
{'data'},
223
include_abi_only=True,
224
ifdef=windows_feature_macros),
225
key=sort_key):
226
write(f'EXPORT_DATA({item.name})')
227
228
REST_ROLES = {
229
'function': 'function',
230
'data': 'var',
231
'struct': 'type',
232
'macro': 'macro',
233
# 'const': 'const', # all undocumented
234
'typedef': 'type',
235
}
236
237
@generator("doc_list", 'Doc/data/stable_abi.dat')
238
def gen_doc_annotations(manifest, args, outfile):
239
"""Generate/check the stable ABI list for documentation annotations"""
240
writer = csv.DictWriter(
241
outfile,
242
['role', 'name', 'added', 'ifdef_note', 'struct_abi_kind'],
243
lineterminator='\n')
244
writer.writeheader()
245
for item in manifest.select(REST_ROLES.keys(), include_abi_only=False):
246
if item.ifdef:
247
ifdef_note = manifest.contents[item.ifdef].doc
248
else:
249
ifdef_note = None
250
row = {
251
'role': REST_ROLES[item.kind],
252
'name': item.name,
253
'added': item.added,
254
'ifdef_note': ifdef_note}
255
rows = [row]
256
if item.kind == 'struct':
257
row['struct_abi_kind'] = item.struct_abi_kind
258
for member_name in item.members or ():
259
rows.append({
260
'role': 'member',
261
'name': f'{item.name}.{member_name}',
262
'added': item.added})
263
writer.writerows(rows)
264
265
@generator("ctypes_test", 'Lib/test/test_stable_abi_ctypes.py')
266
def gen_ctypes_test(manifest, args, outfile):
267
"""Generate/check the ctypes-based test for exported symbols"""
268
write = partial(print, file=outfile)
269
write(textwrap.dedent(f'''\
270
# Generated by {SCRIPT_NAME}
271
272
"""Test that all symbols of the Stable ABI are accessible using ctypes
273
"""
274
275
import sys
276
import unittest
277
from test.support.import_helper import import_module
278
from _testcapi import get_feature_macros
279
280
feature_macros = get_feature_macros()
281
ctypes_test = import_module('ctypes')
282
283
class TestStableABIAvailability(unittest.TestCase):
284
def test_available_symbols(self):
285
286
for symbol_name in SYMBOL_NAMES:
287
with self.subTest(symbol_name):
288
ctypes_test.pythonapi[symbol_name]
289
290
def test_feature_macros(self):
291
self.assertEqual(
292
set(get_feature_macros()), EXPECTED_FEATURE_MACROS)
293
294
# The feature macros for Windows are used in creating the DLL
295
# definition, so they must be known on all platforms.
296
# If we are on Windows, we check that the hardcoded data matches
297
# the reality.
298
@unittest.skipIf(sys.platform != "win32", "Windows specific test")
299
def test_windows_feature_macros(self):
300
for name, value in WINDOWS_FEATURE_MACROS.items():
301
if value != 'maybe':
302
with self.subTest(name):
303
self.assertEqual(feature_macros[name], value)
304
305
SYMBOL_NAMES = (
306
'''))
307
items = manifest.select(
308
{'function', 'data'},
309
include_abi_only=True,
310
)
311
optional_items = {}
312
for item in items:
313
if item.name in (
314
# Some symbols aren't exported on all platforms.
315
# This is a bug: https://bugs.python.org/issue44133
316
'PyModule_Create2', 'PyModule_FromDefAndSpec2',
317
):
318
continue
319
if item.ifdef:
320
optional_items.setdefault(item.ifdef, []).append(item.name)
321
else:
322
write(f' "{item.name}",')
323
write(")")
324
for ifdef, names in optional_items.items():
325
write(f"if feature_macros[{ifdef!r}]:")
326
write(f" SYMBOL_NAMES += (")
327
for name in names:
328
write(f" {name!r},")
329
write(" )")
330
write("")
331
feature_macros = list(manifest.select({'feature_macro'}))
332
feature_names = sorted(m.name for m in feature_macros)
333
write(f"EXPECTED_FEATURE_MACROS = set({pprint.pformat(feature_names)})")
334
335
windows_feature_macros = {m.name: m.windows for m in feature_macros}
336
write(f"WINDOWS_FEATURE_MACROS = {pprint.pformat(windows_feature_macros)}")
337
338
339
@generator("testcapi_feature_macros", 'Modules/_testcapi_feature_macros.inc')
340
def gen_testcapi_feature_macros(manifest, args, outfile):
341
"""Generate/check the stable ABI list for documentation annotations"""
342
write = partial(print, file=outfile)
343
write(f'// Generated by {SCRIPT_NAME}')
344
write()
345
write('// Add an entry in dict `result` for each Stable ABI feature macro.')
346
write()
347
for macro in manifest.select({'feature_macro'}):
348
name = macro.name
349
write(f'#ifdef {name}')
350
write(f' res = PyDict_SetItemString(result, "{name}", Py_True);')
351
write('#else')
352
write(f' res = PyDict_SetItemString(result, "{name}", Py_False);')
353
write('#endif')
354
write('if (res) {')
355
write(' Py_DECREF(result); return NULL;')
356
write('}')
357
write()
358
359
360
def generate_or_check(manifest, args, path, func):
361
"""Generate/check a file with a single generator
362
363
Return True if successful; False if a comparison failed.
364
"""
365
366
outfile = io.StringIO()
367
func(manifest, args, outfile)
368
generated = outfile.getvalue()
369
existing = path.read_text()
370
371
if generated != existing:
372
if args.generate:
373
path.write_text(generated)
374
else:
375
print(f'File {path} differs from expected!')
376
diff = difflib.unified_diff(
377
generated.splitlines(), existing.splitlines(),
378
str(path), '<expected>',
379
lineterm='',
380
)
381
for line in diff:
382
print(line)
383
return False
384
return True
385
386
387
def do_unixy_check(manifest, args):
388
"""Check headers & library using "Unixy" tools (GCC/clang, binutils)"""
389
okay = True
390
391
# Get all macros first: we'll need feature macros like HAVE_FORK and
392
# MS_WINDOWS for everything else
393
present_macros = gcc_get_limited_api_macros(['Include/Python.h'])
394
feature_macros = set(m.name for m in manifest.select({'feature_macro'}))
395
feature_macros &= present_macros
396
397
# Check that we have all needed macros
398
expected_macros = set(
399
item.name for item in manifest.select({'macro'})
400
)
401
missing_macros = expected_macros - present_macros
402
okay &= _report_unexpected_items(
403
missing_macros,
404
'Some macros from are not defined from "Include/Python.h"'
405
+ 'with Py_LIMITED_API:')
406
407
expected_symbols = set(item.name for item in manifest.select(
408
{'function', 'data'}, include_abi_only=True, ifdef=feature_macros,
409
))
410
411
# Check the static library (*.a)
412
LIBRARY = sysconfig.get_config_var("LIBRARY")
413
if not LIBRARY:
414
raise Exception("failed to get LIBRARY variable from sysconfig")
415
if os.path.exists(LIBRARY):
416
okay &= binutils_check_library(
417
manifest, LIBRARY, expected_symbols, dynamic=False)
418
419
# Check the dynamic library (*.so)
420
LDLIBRARY = sysconfig.get_config_var("LDLIBRARY")
421
if not LDLIBRARY:
422
raise Exception("failed to get LDLIBRARY variable from sysconfig")
423
okay &= binutils_check_library(
424
manifest, LDLIBRARY, expected_symbols, dynamic=False)
425
426
# Check definitions in the header files
427
expected_defs = set(item.name for item in manifest.select(
428
{'function', 'data'}, include_abi_only=False, ifdef=feature_macros,
429
))
430
found_defs = gcc_get_limited_api_definitions(['Include/Python.h'])
431
missing_defs = expected_defs - found_defs
432
okay &= _report_unexpected_items(
433
missing_defs,
434
'Some expected declarations were not declared in '
435
+ '"Include/Python.h" with Py_LIMITED_API:')
436
437
# Some Limited API macros are defined in terms of private symbols.
438
# These are not part of Limited API (even though they're defined with
439
# Py_LIMITED_API). They must be part of the Stable ABI, though.
440
private_symbols = {n for n in expected_symbols if n.startswith('_')}
441
extra_defs = found_defs - expected_defs - private_symbols
442
okay &= _report_unexpected_items(
443
extra_defs,
444
'Some extra declarations were found in "Include/Python.h" '
445
+ 'with Py_LIMITED_API:')
446
447
return okay
448
449
450
def _report_unexpected_items(items, msg):
451
"""If there are any `items`, report them using "msg" and return false"""
452
if items:
453
print(msg, file=sys.stderr)
454
for item in sorted(items):
455
print(' -', item, file=sys.stderr)
456
return False
457
return True
458
459
460
def binutils_get_exported_symbols(library, dynamic=False):
461
"""Retrieve exported symbols using the nm(1) tool from binutils"""
462
# Only look at dynamic symbols
463
args = ["nm", "--no-sort"]
464
if dynamic:
465
args.append("--dynamic")
466
args.append(library)
467
proc = subprocess.run(args, stdout=subprocess.PIPE, universal_newlines=True)
468
if proc.returncode:
469
sys.stdout.write(proc.stdout)
470
sys.exit(proc.returncode)
471
472
stdout = proc.stdout.rstrip()
473
if not stdout:
474
raise Exception("command output is empty")
475
476
for line in stdout.splitlines():
477
# Split line '0000000000001b80 D PyTextIOWrapper_Type'
478
if not line:
479
continue
480
481
parts = line.split(maxsplit=2)
482
if len(parts) < 3:
483
continue
484
485
symbol = parts[-1]
486
if MACOS and symbol.startswith("_"):
487
yield symbol[1:]
488
else:
489
yield symbol
490
491
492
def binutils_check_library(manifest, library, expected_symbols, dynamic):
493
"""Check that library exports all expected_symbols"""
494
available_symbols = set(binutils_get_exported_symbols(library, dynamic))
495
missing_symbols = expected_symbols - available_symbols
496
if missing_symbols:
497
print(textwrap.dedent(f"""\
498
Some symbols from the limited API are missing from {library}:
499
{', '.join(missing_symbols)}
500
501
This error means that there are some missing symbols among the
502
ones exported in the library.
503
This normally means that some symbol, function implementation or
504
a prototype belonging to a symbol in the limited API has been
505
deleted or is missing.
506
"""), file=sys.stderr)
507
return False
508
return True
509
510
511
def gcc_get_limited_api_macros(headers):
512
"""Get all limited API macros from headers.
513
514
Runs the preprocessor over all the header files in "Include" setting
515
"-DPy_LIMITED_API" to the correct value for the running version of the
516
interpreter and extracting all macro definitions (via adding -dM to the
517
compiler arguments).
518
519
Requires Python built with a GCC-compatible compiler. (clang might work)
520
"""
521
522
api_hexversion = sys.version_info.major << 24 | sys.version_info.minor << 16
523
524
preprocesor_output_with_macros = subprocess.check_output(
525
sysconfig.get_config_var("CC").split()
526
+ [
527
# Prevent the expansion of the exported macros so we can
528
# capture them later
529
"-DSIZEOF_WCHAR_T=4", # The actual value is not important
530
f"-DPy_LIMITED_API={api_hexversion}",
531
"-I.",
532
"-I./Include",
533
"-dM",
534
"-E",
535
]
536
+ [str(file) for file in headers],
537
text=True,
538
)
539
540
return {
541
target
542
for target in re.findall(
543
r"#define (\w+)", preprocesor_output_with_macros
544
)
545
}
546
547
548
def gcc_get_limited_api_definitions(headers):
549
"""Get all limited API definitions from headers.
550
551
Run the preprocessor over all the header files in "Include" setting
552
"-DPy_LIMITED_API" to the correct value for the running version of the
553
interpreter.
554
555
The limited API symbols will be extracted from the output of this command
556
as it includes the prototypes and definitions of all the exported symbols
557
that are in the limited api.
558
559
This function does *NOT* extract the macros defined on the limited API
560
561
Requires Python built with a GCC-compatible compiler. (clang might work)
562
"""
563
api_hexversion = sys.version_info.major << 24 | sys.version_info.minor << 16
564
preprocesor_output = subprocess.check_output(
565
sysconfig.get_config_var("CC").split()
566
+ [
567
# Prevent the expansion of the exported macros so we can capture
568
# them later
569
"-DPyAPI_FUNC=__PyAPI_FUNC",
570
"-DPyAPI_DATA=__PyAPI_DATA",
571
"-DEXPORT_DATA=__EXPORT_DATA",
572
"-D_Py_NO_RETURN=",
573
"-DSIZEOF_WCHAR_T=4", # The actual value is not important
574
f"-DPy_LIMITED_API={api_hexversion}",
575
"-I.",
576
"-I./Include",
577
"-E",
578
]
579
+ [str(file) for file in headers],
580
text=True,
581
stderr=subprocess.DEVNULL,
582
)
583
stable_functions = set(
584
re.findall(r"__PyAPI_FUNC\(.*?\)\s*(.*?)\s*\(", preprocesor_output)
585
)
586
stable_exported_data = set(
587
re.findall(r"__EXPORT_DATA\((.*?)\)", preprocesor_output)
588
)
589
stable_data = set(
590
re.findall(r"__PyAPI_DATA\(.*?\)[\s\*\(]*([^);]*)\)?.*;", preprocesor_output)
591
)
592
return stable_data | stable_exported_data | stable_functions
593
594
def check_private_names(manifest):
595
"""Ensure limited API doesn't contain private names
596
597
Names prefixed by an underscore are private by definition.
598
"""
599
for name, item in manifest.contents.items():
600
if name.startswith('_') and not item.abi_only:
601
raise ValueError(
602
f'`{name}` is private (underscore-prefixed) and should be '
603
+ 'removed from the stable ABI list or or marked `abi_only`')
604
605
def check_dump(manifest, filename):
606
"""Check that manifest.dump() corresponds to the data.
607
608
Mainly useful when debugging this script.
609
"""
610
dumped = tomllib.loads('\n'.join(manifest.dump()))
611
with filename.open('rb') as file:
612
from_file = tomllib.load(file)
613
if dumped != from_file:
614
print(f'Dump differs from loaded data!', file=sys.stderr)
615
diff = difflib.unified_diff(
616
pprint.pformat(dumped).splitlines(),
617
pprint.pformat(from_file).splitlines(),
618
'<dumped>', str(filename),
619
lineterm='',
620
)
621
for line in diff:
622
print(line, file=sys.stderr)
623
return False
624
else:
625
return True
626
627
def main():
628
parser = argparse.ArgumentParser(
629
description=__doc__,
630
formatter_class=argparse.RawDescriptionHelpFormatter,
631
)
632
parser.add_argument(
633
"file", type=Path, metavar='FILE',
634
help="file with the stable abi manifest",
635
)
636
parser.add_argument(
637
"--generate", action='store_true',
638
help="generate file(s), rather than just checking them",
639
)
640
parser.add_argument(
641
"--generate-all", action='store_true',
642
help="as --generate, but generate all file(s) using default filenames."
643
+ " (unlike --all, does not run any extra checks)",
644
)
645
parser.add_argument(
646
"-a", "--all", action='store_true',
647
help="run all available checks using default filenames",
648
)
649
parser.add_argument(
650
"-l", "--list", action='store_true',
651
help="list available generators and their default filenames; then exit",
652
)
653
parser.add_argument(
654
"--dump", action='store_true',
655
help="dump the manifest contents (used for debugging the parser)",
656
)
657
658
actions_group = parser.add_argument_group('actions')
659
for gen in generators:
660
actions_group.add_argument(
661
gen.arg_name, dest=gen.var_name,
662
type=str, nargs="?", default=MISSING,
663
metavar='FILENAME',
664
help=gen.__doc__,
665
)
666
actions_group.add_argument(
667
'--unixy-check', action='store_true',
668
help=do_unixy_check.__doc__,
669
)
670
args = parser.parse_args()
671
672
base_path = args.file.parent.parent
673
674
if args.list:
675
for gen in generators:
676
print(f'{gen.arg_name}: {base_path / gen.default_path}')
677
sys.exit(0)
678
679
run_all_generators = args.generate_all
680
681
if args.generate_all:
682
args.generate = True
683
684
if args.all:
685
run_all_generators = True
686
if UNIXY:
687
args.unixy_check = True
688
689
try:
690
file = args.file.open('rb')
691
except FileNotFoundError as err:
692
if args.file.suffix == '.txt':
693
# Provide a better error message
694
suggestion = args.file.with_suffix('.toml')
695
raise FileNotFoundError(
696
f'{args.file} not found. Did you mean {suggestion} ?') from err
697
raise
698
with file:
699
manifest = parse_manifest(file)
700
701
check_private_names(manifest)
702
703
# Remember results of all actions (as booleans).
704
# At the end we'll check that at least one action was run,
705
# and also fail if any are false.
706
results = {}
707
708
if args.dump:
709
for line in manifest.dump():
710
print(line)
711
results['dump'] = check_dump(manifest, args.file)
712
713
for gen in generators:
714
filename = getattr(args, gen.var_name)
715
if filename is None or (run_all_generators and filename is MISSING):
716
filename = base_path / gen.default_path
717
elif filename is MISSING:
718
continue
719
720
results[gen.var_name] = generate_or_check(manifest, args, filename, gen)
721
722
if args.unixy_check:
723
results['unixy_check'] = do_unixy_check(manifest, args)
724
725
if not results:
726
if args.generate:
727
parser.error('No file specified. Use --help for usage.')
728
parser.error('No check specified. Use --help for usage.')
729
730
failed_results = [name for name, result in results.items() if not result]
731
732
if failed_results:
733
raise Exception(f"""
734
These checks related to the stable ABI did not succeed:
735
{', '.join(failed_results)}
736
737
If you see diffs in the output, files derived from the stable
738
ABI manifest the were not regenerated.
739
Run `make regen-limited-abi` to fix this.
740
741
Otherwise, see the error(s) above.
742
743
The stable ABI manifest is at: {args.file}
744
Note that there is a process to follow when modifying it.
745
746
You can read more about the limited API and its contracts at:
747
748
https://docs.python.org/3/c-api/stable.html
749
750
And in PEP 384:
751
752
https://peps.python.org/pep-0384/
753
""")
754
755
756
if __name__ == "__main__":
757
main()
758
759