• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1import contextlib
2import faulthandler
3import locale
4import math
5import os.path
6import platform
7import random
8import re
9import shlex
10import signal
11import subprocess
12import sys
13import sysconfig
14import tempfile
15import textwrap
16from collections.abc import Callable, Iterable
17
18from test import support
19from test.support import os_helper
20from test.support import threading_helper
21
22
23# All temporary files and temporary directories created by libregrtest should
24# use TMP_PREFIX so cleanup_temp_dir() can remove them all.
25TMP_PREFIX = 'test_python_'
26WORK_DIR_PREFIX = TMP_PREFIX
27WORKER_WORK_DIR_PREFIX = WORK_DIR_PREFIX + 'worker_'
28
29# bpo-38203: Maximum delay in seconds to exit Python (call Py_Finalize()).
30# Used to protect against threading._shutdown() hang.
31# Must be smaller than buildbot "1200 seconds without output" limit.
32EXIT_TIMEOUT = 120.0
33
34
35ALL_RESOURCES = ('audio', 'curses', 'largefile', 'network',
36                 'decimal', 'cpu', 'subprocess', 'urlfetch', 'gui', 'walltime')
37
38# Other resources excluded from --use=all:
39#
40# - extralagefile (ex: test_zipfile64): really too slow to be enabled
41#   "by default"
42# - tzdata: while needed to validate fully test_datetime, it makes
43#   test_datetime too slow (15-20 min on some buildbots) and so is disabled by
44#   default (see bpo-30822).
45RESOURCE_NAMES = ALL_RESOURCES + ('extralargefile', 'tzdata')
46
47
48# Types for types hints
49StrPath = str
50TestName = str
51StrJSON = str
52TestTuple = tuple[TestName, ...]
53TestList = list[TestName]
54# --match and --ignore options: list of patterns
55# ('*' joker character can be used)
56TestFilter = list[tuple[TestName, bool]]
57FilterTuple = tuple[TestName, ...]
58FilterDict = dict[TestName, FilterTuple]
59
60
61def format_duration(seconds):
62    ms = math.ceil(seconds * 1e3)
63    seconds, ms = divmod(ms, 1000)
64    minutes, seconds = divmod(seconds, 60)
65    hours, minutes = divmod(minutes, 60)
66
67    parts = []
68    if hours:
69        parts.append('%s hour' % hours)
70    if minutes:
71        parts.append('%s min' % minutes)
72    if seconds:
73        if parts:
74            # 2 min 1 sec
75            parts.append('%s sec' % seconds)
76        else:
77            # 1.0 sec
78            parts.append('%.1f sec' % (seconds + ms / 1000))
79    if not parts:
80        return '%s ms' % ms
81
82    parts = parts[:2]
83    return ' '.join(parts)
84
85
86def strip_py_suffix(names: list[str] | None) -> None:
87    if not names:
88        return
89    for idx, name in enumerate(names):
90        basename, ext = os.path.splitext(name)
91        if ext == '.py':
92            names[idx] = basename
93
94
95def plural(n, singular, plural=None):
96    if n == 1:
97        return singular
98    elif plural is not None:
99        return plural
100    else:
101        return singular + 's'
102
103
104def count(n, word):
105    if n == 1:
106        return f"{n} {word}"
107    else:
108        return f"{n} {word}s"
109
110
111def printlist(x, width=70, indent=4, file=None):
112    """Print the elements of iterable x to stdout.
113
114    Optional arg width (default 70) is the maximum line length.
115    Optional arg indent (default 4) is the number of blanks with which to
116    begin each line.
117    """
118
119    blanks = ' ' * indent
120    # Print the sorted list: 'x' may be a '--random' list or a set()
121    print(textwrap.fill(' '.join(str(elt) for elt in sorted(x)), width,
122                        initial_indent=blanks, subsequent_indent=blanks),
123          file=file)
124
125
126def print_warning(msg):
127    support.print_warning(msg)
128
129
130orig_unraisablehook = None
131
132
133def regrtest_unraisable_hook(unraisable):
134    global orig_unraisablehook
135    support.environment_altered = True
136    support.print_warning("Unraisable exception")
137    old_stderr = sys.stderr
138    try:
139        support.flush_std_streams()
140        sys.stderr = support.print_warning.orig_stderr
141        orig_unraisablehook(unraisable)
142        sys.stderr.flush()
143    finally:
144        sys.stderr = old_stderr
145
146
147def setup_unraisable_hook():
148    global orig_unraisablehook
149    orig_unraisablehook = sys.unraisablehook
150    sys.unraisablehook = regrtest_unraisable_hook
151
152
153orig_threading_excepthook = None
154
155
156def regrtest_threading_excepthook(args):
157    global orig_threading_excepthook
158    support.environment_altered = True
159    support.print_warning(f"Uncaught thread exception: {args.exc_type.__name__}")
160    old_stderr = sys.stderr
161    try:
162        support.flush_std_streams()
163        sys.stderr = support.print_warning.orig_stderr
164        orig_threading_excepthook(args)
165        sys.stderr.flush()
166    finally:
167        sys.stderr = old_stderr
168
169
170def setup_threading_excepthook():
171    global orig_threading_excepthook
172    import threading
173    orig_threading_excepthook = threading.excepthook
174    threading.excepthook = regrtest_threading_excepthook
175
176
177def clear_caches():
178    # Clear the warnings registry, so they can be displayed again
179    for mod in sys.modules.values():
180        if hasattr(mod, '__warningregistry__'):
181            del mod.__warningregistry__
182
183    # Flush standard output, so that buffered data is sent to the OS and
184    # associated Python objects are reclaimed.
185    for stream in (sys.stdout, sys.stderr, sys.__stdout__, sys.__stderr__):
186        if stream is not None:
187            stream.flush()
188
189    try:
190        re = sys.modules['re']
191    except KeyError:
192        pass
193    else:
194        re.purge()
195
196    try:
197        _strptime = sys.modules['_strptime']
198    except KeyError:
199        pass
200    else:
201        _strptime._regex_cache.clear()
202
203    try:
204        urllib_parse = sys.modules['urllib.parse']
205    except KeyError:
206        pass
207    else:
208        urllib_parse.clear_cache()
209
210    try:
211        urllib_request = sys.modules['urllib.request']
212    except KeyError:
213        pass
214    else:
215        urllib_request.urlcleanup()
216
217    try:
218        linecache = sys.modules['linecache']
219    except KeyError:
220        pass
221    else:
222        linecache.clearcache()
223
224    try:
225        mimetypes = sys.modules['mimetypes']
226    except KeyError:
227        pass
228    else:
229        mimetypes._default_mime_types()
230
231    try:
232        filecmp = sys.modules['filecmp']
233    except KeyError:
234        pass
235    else:
236        filecmp._cache.clear()
237
238    try:
239        struct = sys.modules['struct']
240    except KeyError:
241        pass
242    else:
243        struct._clearcache()
244
245    try:
246        doctest = sys.modules['doctest']
247    except KeyError:
248        pass
249    else:
250        doctest.master = None
251
252    try:
253        ctypes = sys.modules['ctypes']
254    except KeyError:
255        pass
256    else:
257        ctypes._reset_cache()
258
259    try:
260        typing = sys.modules['typing']
261    except KeyError:
262        pass
263    else:
264        for f in typing._cleanups:
265            f()
266
267        import inspect
268        abs_classes = filter(inspect.isabstract, typing.__dict__.values())
269        for abc in abs_classes:
270            for obj in abc.__subclasses__() + [abc]:
271                obj._abc_caches_clear()
272
273    try:
274        fractions = sys.modules['fractions']
275    except KeyError:
276        pass
277    else:
278        fractions._hash_algorithm.cache_clear()
279
280    try:
281        inspect = sys.modules['inspect']
282    except KeyError:
283        pass
284    else:
285        inspect._shadowed_dict_from_weakref_mro_tuple.cache_clear()
286        inspect._filesbymodname.clear()
287        inspect.modulesbyfile.clear()
288
289    try:
290        importlib_metadata = sys.modules['importlib.metadata']
291    except KeyError:
292        pass
293    else:
294        importlib_metadata.FastPath.__new__.cache_clear()
295
296
297def get_build_info():
298    # Get most important configure and build options as a list of strings.
299    # Example: ['debug', 'ASAN+MSAN'] or ['release', 'LTO+PGO'].
300
301    config_args = sysconfig.get_config_var('CONFIG_ARGS') or ''
302    cflags = sysconfig.get_config_var('PY_CFLAGS') or ''
303    cflags += ' ' + (sysconfig.get_config_var('PY_CFLAGS_NODIST') or '')
304    ldflags_nodist = sysconfig.get_config_var('PY_LDFLAGS_NODIST') or ''
305
306    build = []
307
308    # --disable-gil
309    if sysconfig.get_config_var('Py_GIL_DISABLED'):
310        if not sys.flags.ignore_environment:
311            PYTHON_GIL = os.environ.get('PYTHON_GIL', None)
312            if PYTHON_GIL:
313                PYTHON_GIL = (PYTHON_GIL == '1')
314        else:
315            PYTHON_GIL = None
316
317        free_threading = "free_threading"
318        if PYTHON_GIL is not None:
319            free_threading = f"{free_threading} GIL={int(PYTHON_GIL)}"
320        build.append(free_threading)
321
322    if hasattr(sys, 'gettotalrefcount'):
323        # --with-pydebug
324        build.append('debug')
325
326        if '-DNDEBUG' in cflags:
327            build.append('without_assert')
328    else:
329        build.append('release')
330
331        if '--with-assertions' in config_args:
332            build.append('with_assert')
333        elif '-DNDEBUG' not in cflags:
334            build.append('with_assert')
335
336    # --enable-experimental-jit
337    tier2 = re.search('-D_Py_TIER2=([0-9]+)', cflags)
338    if tier2:
339        tier2 = int(tier2.group(1))
340
341    if not sys.flags.ignore_environment:
342        PYTHON_JIT = os.environ.get('PYTHON_JIT', None)
343        if PYTHON_JIT:
344            PYTHON_JIT = (PYTHON_JIT != '0')
345    else:
346        PYTHON_JIT = None
347
348    if tier2 == 1:  # =yes
349        if PYTHON_JIT == False:
350            jit = 'JIT=off'
351        else:
352            jit = 'JIT'
353    elif tier2 == 3:  # =yes-off
354        if PYTHON_JIT:
355            jit = 'JIT'
356        else:
357            jit = 'JIT=off'
358    elif tier2 == 4:  # =interpreter
359        if PYTHON_JIT == False:
360            jit = 'JIT-interpreter=off'
361        else:
362            jit = 'JIT-interpreter'
363    elif tier2 == 6:  # =interpreter-off (Secret option!)
364        if PYTHON_JIT:
365            jit = 'JIT-interpreter'
366        else:
367            jit = 'JIT-interpreter=off'
368    elif '-D_Py_JIT' in cflags:
369        jit = 'JIT'
370    else:
371        jit = None
372    if jit:
373        build.append(jit)
374
375    # --enable-framework=name
376    framework = sysconfig.get_config_var('PYTHONFRAMEWORK')
377    if framework:
378        build.append(f'framework={framework}')
379
380    # --enable-shared
381    shared = int(sysconfig.get_config_var('PY_ENABLE_SHARED') or '0')
382    if shared:
383        build.append('shared')
384
385    # --with-lto
386    optimizations = []
387    if '-flto=thin' in ldflags_nodist:
388        optimizations.append('ThinLTO')
389    elif '-flto' in ldflags_nodist:
390        optimizations.append('LTO')
391
392    if support.check_cflags_pgo():
393        # PGO (--enable-optimizations)
394        optimizations.append('PGO')
395
396    if support.check_bolt_optimized():
397        # BOLT (--enable-bolt)
398        optimizations.append('BOLT')
399
400    if optimizations:
401        build.append('+'.join(optimizations))
402
403    # --with-address-sanitizer
404    sanitizers = []
405    if support.check_sanitizer(address=True):
406        sanitizers.append("ASAN")
407    # --with-memory-sanitizer
408    if support.check_sanitizer(memory=True):
409        sanitizers.append("MSAN")
410    # --with-undefined-behavior-sanitizer
411    if support.check_sanitizer(ub=True):
412        sanitizers.append("UBSAN")
413    # --with-thread-sanitizer
414    if support.check_sanitizer(thread=True):
415        sanitizers.append("TSAN")
416    if sanitizers:
417        build.append('+'.join(sanitizers))
418
419    # --with-trace-refs
420    if hasattr(sys, 'getobjects'):
421        build.append("TraceRefs")
422    # --enable-pystats
423    if hasattr(sys, '_stats_on'):
424        build.append("pystats")
425    # --with-valgrind
426    if sysconfig.get_config_var('WITH_VALGRIND'):
427        build.append("valgrind")
428    # --with-dtrace
429    if sysconfig.get_config_var('WITH_DTRACE'):
430        build.append("dtrace")
431
432    return build
433
434
435def get_temp_dir(tmp_dir: StrPath | None = None) -> StrPath:
436    if tmp_dir:
437        tmp_dir = os.path.expanduser(tmp_dir)
438    else:
439        # When tests are run from the Python build directory, it is best practice
440        # to keep the test files in a subfolder.  This eases the cleanup of leftover
441        # files using the "make distclean" command.
442        if sysconfig.is_python_build():
443            if not support.is_wasi:
444                tmp_dir = sysconfig.get_config_var('abs_builddir')
445                if tmp_dir is None:
446                    tmp_dir = sysconfig.get_config_var('abs_srcdir')
447                    if not tmp_dir:
448                        # gh-74470: On Windows, only srcdir is available. Using
449                        # abs_builddir mostly matters on UNIX when building
450                        # Python out of the source tree, especially when the
451                        # source tree is read only.
452                        tmp_dir = sysconfig.get_config_var('srcdir')
453                        if not tmp_dir:
454                            raise RuntimeError(
455                                "Could not determine the correct value for tmp_dir"
456                            )
457                tmp_dir = os.path.join(tmp_dir, 'build')
458            else:
459                # WASI platform
460                tmp_dir = sysconfig.get_config_var('projectbase')
461                if not tmp_dir:
462                    raise RuntimeError(
463                        "sysconfig.get_config_var('projectbase') "
464                        f"unexpectedly returned {tmp_dir!r} on WASI"
465                    )
466                tmp_dir = os.path.join(tmp_dir, 'build')
467
468                # When get_temp_dir() is called in a worker process,
469                # get_temp_dir() path is different than in the parent process
470                # which is not a WASI process. So the parent does not create
471                # the same "tmp_dir" than the test worker process.
472                os.makedirs(tmp_dir, exist_ok=True)
473        else:
474            tmp_dir = tempfile.gettempdir()
475
476    return os.path.abspath(tmp_dir)
477
478
479def fix_umask():
480    if support.is_emscripten:
481        # Emscripten has default umask 0o777, which breaks some tests.
482        # see https://github.com/emscripten-core/emscripten/issues/17269
483        old_mask = os.umask(0)
484        if old_mask == 0o777:
485            os.umask(0o027)
486        else:
487            os.umask(old_mask)
488
489
490def get_work_dir(parent_dir: StrPath, worker: bool = False) -> StrPath:
491    # Define a writable temp dir that will be used as cwd while running
492    # the tests. The name of the dir includes the pid to allow parallel
493    # testing (see the -j option).
494    # Emscripten and WASI have stubbed getpid(), Emscripten has only
495    # millisecond clock resolution. Use randint() instead.
496    if support.is_emscripten or support.is_wasi:
497        nounce = random.randint(0, 1_000_000)
498    else:
499        nounce = os.getpid()
500
501    if worker:
502        work_dir = WORK_DIR_PREFIX + str(nounce)
503    else:
504        work_dir = WORKER_WORK_DIR_PREFIX + str(nounce)
505    work_dir += os_helper.FS_NONASCII
506    work_dir = os.path.join(parent_dir, work_dir)
507    return work_dir
508
509
510@contextlib.contextmanager
511def exit_timeout():
512    try:
513        yield
514    except SystemExit as exc:
515        # bpo-38203: Python can hang at exit in Py_Finalize(), especially
516        # on threading._shutdown() call: put a timeout
517        if threading_helper.can_start_thread:
518            faulthandler.dump_traceback_later(EXIT_TIMEOUT, exit=True)
519        sys.exit(exc.code)
520
521
522def remove_testfn(test_name: TestName, verbose: int) -> None:
523    # Try to clean up os_helper.TESTFN if left behind.
524    #
525    # While tests shouldn't leave any files or directories behind, when a test
526    # fails that can be tedious for it to arrange.  The consequences can be
527    # especially nasty on Windows, since if a test leaves a file open, it
528    # cannot be deleted by name (while there's nothing we can do about that
529    # here either, we can display the name of the offending test, which is a
530    # real help).
531    name = os_helper.TESTFN
532    if not os.path.exists(name):
533        return
534
535    nuker: Callable[[str], None]
536    if os.path.isdir(name):
537        import shutil
538        kind, nuker = "directory", shutil.rmtree
539    elif os.path.isfile(name):
540        kind, nuker = "file", os.unlink
541    else:
542        raise RuntimeError(f"os.path says {name!r} exists but is neither "
543                           f"directory nor file")
544
545    if verbose:
546        print_warning(f"{test_name} left behind {kind} {name!r}")
547        support.environment_altered = True
548
549    try:
550        import stat
551        # fix possible permissions problems that might prevent cleanup
552        os.chmod(name, stat.S_IRWXU | stat.S_IRWXG | stat.S_IRWXO)
553        nuker(name)
554    except Exception as exc:
555        print_warning(f"{test_name} left behind {kind} {name!r} "
556                      f"and it couldn't be removed: {exc}")
557
558
559def abs_module_name(test_name: TestName, test_dir: StrPath | None) -> TestName:
560    if test_name.startswith('test.') or test_dir:
561        return test_name
562    else:
563        # Import it from the test package
564        return 'test.' + test_name
565
566
567# gh-90681: When rerunning tests, we might need to rerun the whole
568# class or module suite if some its life-cycle hooks fail.
569# Test level hooks are not affected.
570_TEST_LIFECYCLE_HOOKS = frozenset((
571    'setUpClass', 'tearDownClass',
572    'setUpModule', 'tearDownModule',
573))
574
575def normalize_test_name(test_full_name, *, is_error=False):
576    short_name = test_full_name.split(" ")[0]
577    if is_error and short_name in _TEST_LIFECYCLE_HOOKS:
578        if test_full_name.startswith(('setUpModule (', 'tearDownModule (')):
579            # if setUpModule() or tearDownModule() failed, don't filter
580            # tests with the test file name, don't use use filters.
581            return None
582
583        # This means that we have a failure in a life-cycle hook,
584        # we need to rerun the whole module or class suite.
585        # Basically the error looks like this:
586        #    ERROR: setUpClass (test.test_reg_ex.RegTest)
587        # or
588        #    ERROR: setUpModule (test.test_reg_ex)
589        # So, we need to parse the class / module name.
590        lpar = test_full_name.index('(')
591        rpar = test_full_name.index(')')
592        return test_full_name[lpar + 1: rpar].split('.')[-1]
593    return short_name
594
595
596def adjust_rlimit_nofile():
597    """
598    On macOS the default fd limit (RLIMIT_NOFILE) is sometimes too low (256)
599    for our test suite to succeed. Raise it to something more reasonable. 1024
600    is a common Linux default.
601    """
602    try:
603        import resource
604    except ImportError:
605        return
606
607    fd_limit, max_fds = resource.getrlimit(resource.RLIMIT_NOFILE)
608
609    desired_fds = 1024
610
611    if fd_limit < desired_fds and fd_limit < max_fds:
612        new_fd_limit = min(desired_fds, max_fds)
613        try:
614            resource.setrlimit(resource.RLIMIT_NOFILE,
615                               (new_fd_limit, max_fds))
616            print(f"Raised RLIMIT_NOFILE: {fd_limit} -> {new_fd_limit}")
617        except (ValueError, OSError) as err:
618            print_warning(f"Unable to raise RLIMIT_NOFILE from {fd_limit} to "
619                          f"{new_fd_limit}: {err}.")
620
621
622def get_host_runner():
623    if (hostrunner := os.environ.get("_PYTHON_HOSTRUNNER")) is None:
624        hostrunner = sysconfig.get_config_var("HOSTRUNNER")
625    return hostrunner
626
627
628def is_cross_compiled():
629    return ('_PYTHON_HOST_PLATFORM' in os.environ)
630
631
632def format_resources(use_resources: Iterable[str]):
633    use_resources = set(use_resources)
634    all_resources = set(ALL_RESOURCES)
635
636    # Express resources relative to "all"
637    relative_all = ['all']
638    for name in sorted(all_resources - use_resources):
639        relative_all.append(f'-{name}')
640    for name in sorted(use_resources - all_resources):
641        relative_all.append(f'{name}')
642    all_text = ','.join(relative_all)
643    all_text = f"resources: {all_text}"
644
645    # List of enabled resources
646    text = ','.join(sorted(use_resources))
647    text = f"resources ({len(use_resources)}): {text}"
648
649    # Pick the shortest string (prefer relative to all if lengths are equal)
650    if len(all_text) <= len(text):
651        return all_text
652    else:
653        return text
654
655
656def display_header(use_resources: tuple[str, ...],
657                   python_cmd: tuple[str, ...] | None):
658    # Print basic platform information
659    print("==", platform.python_implementation(), *sys.version.split())
660    print("==", platform.platform(aliased=True),
661                  "%s-endian" % sys.byteorder)
662    print("== Python build:", ' '.join(get_build_info()))
663    print("== cwd:", os.getcwd())
664
665    cpu_count: object = os.cpu_count()
666    if cpu_count:
667        # The function is new in Python 3.13; mypy doesn't know about it yet:
668        process_cpu_count = os.process_cpu_count()  # type: ignore[attr-defined]
669        if process_cpu_count and process_cpu_count != cpu_count:
670            cpu_count = f"{process_cpu_count} (process) / {cpu_count} (system)"
671        print("== CPU count:", cpu_count)
672    print("== encodings: locale=%s FS=%s"
673          % (locale.getencoding(), sys.getfilesystemencoding()))
674
675    if use_resources:
676        text = format_resources(use_resources)
677        print(f"== {text}")
678    else:
679        print("== resources: all test resources are disabled, "
680              "use -u option to unskip tests")
681
682    cross_compile = is_cross_compiled()
683    if cross_compile:
684        print("== cross compiled: Yes")
685    if python_cmd:
686        cmd = shlex.join(python_cmd)
687        print(f"== host python: {cmd}")
688
689        get_cmd = [*python_cmd, '-m', 'platform']
690        proc = subprocess.run(
691            get_cmd,
692            stdout=subprocess.PIPE,
693            text=True,
694            cwd=os_helper.SAVEDCWD)
695        stdout = proc.stdout.replace('\n', ' ').strip()
696        if stdout:
697            print(f"== host platform: {stdout}")
698        elif proc.returncode:
699            print(f"== host platform: <command failed with exit code {proc.returncode}>")
700    else:
701        hostrunner = get_host_runner()
702        if hostrunner:
703            print(f"== host runner: {hostrunner}")
704
705    # This makes it easier to remember what to set in your local
706    # environment when trying to reproduce a sanitizer failure.
707    asan = support.check_sanitizer(address=True)
708    msan = support.check_sanitizer(memory=True)
709    ubsan = support.check_sanitizer(ub=True)
710    tsan = support.check_sanitizer(thread=True)
711    sanitizers = []
712    if asan:
713        sanitizers.append("address")
714    if msan:
715        sanitizers.append("memory")
716    if ubsan:
717        sanitizers.append("undefined behavior")
718    if tsan:
719        sanitizers.append("thread")
720    if sanitizers:
721        print(f"== sanitizers: {', '.join(sanitizers)}")
722        for sanitizer, env_var in (
723            (asan, "ASAN_OPTIONS"),
724            (msan, "MSAN_OPTIONS"),
725            (ubsan, "UBSAN_OPTIONS"),
726            (tsan, "TSAN_OPTIONS"),
727        ):
728            options= os.environ.get(env_var)
729            if sanitizer and options is not None:
730                print(f"== {env_var}={options!r}")
731
732    print(flush=True)
733
734
735def cleanup_temp_dir(tmp_dir: StrPath):
736    import glob
737
738    path = os.path.join(glob.escape(tmp_dir), TMP_PREFIX + '*')
739    print("Cleanup %s directory" % tmp_dir)
740    for name in glob.glob(path):
741        if os.path.isdir(name):
742            print("Remove directory: %s" % name)
743            os_helper.rmtree(name)
744        else:
745            print("Remove file: %s" % name)
746            os_helper.unlink(name)
747
748WINDOWS_STATUS = {
749    0xC0000005: "STATUS_ACCESS_VIOLATION",
750    0xC00000FD: "STATUS_STACK_OVERFLOW",
751    0xC000013A: "STATUS_CONTROL_C_EXIT",
752}
753
754def get_signal_name(exitcode):
755    if exitcode < 0:
756        signum = -exitcode
757        try:
758            return signal.Signals(signum).name
759        except ValueError:
760            pass
761
762    # Shell exit code (ex: WASI build)
763    if 128 < exitcode < 256:
764        signum = exitcode - 128
765        try:
766            return signal.Signals(signum).name
767        except ValueError:
768            pass
769
770    try:
771        return WINDOWS_STATUS[exitcode]
772    except KeyError:
773        pass
774
775    return None
776
777
778ILLEGAL_XML_CHARS_RE = re.compile(
779    '['
780    # Control characters; newline (\x0A and \x0D) and TAB (\x09) are legal
781    '\x00-\x08\x0B\x0C\x0E-\x1F'
782    # Surrogate characters
783    '\uD800-\uDFFF'
784    # Special Unicode characters
785    '\uFFFE'
786    '\uFFFF'
787    # Match multiple sequential invalid characters for better efficiency
788    ']+')
789
790def _sanitize_xml_replace(regs):
791    text = regs[0]
792    return ''.join(f'\\x{ord(ch):02x}' if ch <= '\xff' else ascii(ch)[1:-1]
793                   for ch in text)
794
795def sanitize_xml(text):
796    return ILLEGAL_XML_CHARS_RE.sub(_sanitize_xml_replace, text)
797