• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1"""Check extension modules
2
3The script checks shared and built-in extension modules. It verifies that the
4modules have been built and that they can be imported successfully. Missing
5modules and failed imports are reported to the user. Shared extension
6files are renamed on failed import.
7
8Module information is parsed from several sources:
9
10- core modules hard-coded in Modules/config.c.in
11- Windows-specific modules that are hard-coded in PC/config.c
12- MODULE_{name}_STATE entries in Makefile (provided through sysconfig)
13- Various makesetup files:
14  - $(srcdir)/Modules/Setup
15  - Modules/Setup.[local|bootstrap|stdlib] files, which are generated
16    from $(srcdir)/Modules/Setup.*.in files
17
18See --help for more information
19"""
20import argparse
21import collections
22import enum
23import logging
24import os
25import pathlib
26import re
27import sys
28import sysconfig
29import warnings
30import _imp
31
32from importlib._bootstrap import _load as bootstrap_load
33from importlib.machinery import BuiltinImporter, ExtensionFileLoader, ModuleSpec
34from importlib.util import spec_from_file_location, spec_from_loader
35from typing import Iterable
36
37SRC_DIR = pathlib.Path(__file__).parent.parent.parent
38
39# core modules, hard-coded in Modules/config.h.in
40CORE_MODULES = {
41    "_ast",
42    "_imp",
43    "_string",
44    "_tokenize",
45    "_warnings",
46    "builtins",
47    "gc",
48    "marshal",
49    "sys",
50}
51
52# Windows-only modules
53WINDOWS_MODULES = {
54    "_overlapped",
55    "_testconsole",
56    "_winapi",
57    "_wmi",
58    "msvcrt",
59    "nt",
60    "winreg",
61    "winsound",
62}
63
64
65logger = logging.getLogger(__name__)
66
67parser = argparse.ArgumentParser(
68    prog="check_extension_modules",
69    description=__doc__,
70    formatter_class=argparse.RawDescriptionHelpFormatter,
71)
72
73parser.add_argument(
74    "--verbose",
75    action="store_true",
76    help="Verbose, report builtin, shared, and unavailable modules",
77)
78
79parser.add_argument(
80    "--debug",
81    action="store_true",
82    help="Enable debug logging",
83)
84
85parser.add_argument(
86    "--strict",
87    action=argparse.BooleanOptionalAction,
88    help=(
89        "Strict check, fail when a module is missing or fails to import"
90        "(default: no, unless env var PYTHONSTRICTEXTENSIONBUILD is set)"
91    ),
92    default=bool(os.environ.get("PYTHONSTRICTEXTENSIONBUILD")),
93)
94
95parser.add_argument(
96    "--cross-compiling",
97    action=argparse.BooleanOptionalAction,
98    help=(
99        "Use cross-compiling checks "
100        "(default: no, unless env var _PYTHON_HOST_PLATFORM is set)."
101    ),
102    default="_PYTHON_HOST_PLATFORM" in os.environ,
103)
104
105parser.add_argument(
106    "--list-module-names",
107    action="store_true",
108    help="Print a list of module names to stdout and exit",
109)
110
111
112class ModuleState(enum.Enum):
113    # Makefile state "yes"
114    BUILTIN = "builtin"
115    SHARED = "shared"
116
117    DISABLED = "disabled"
118    MISSING = "missing"
119    NA = "n/a"
120    # disabled by Setup / makesetup rule
121    DISABLED_SETUP = "disabled_setup"
122
123    def __bool__(self):
124        return self.value in {"builtin", "shared"}
125
126
127ModuleInfo = collections.namedtuple("ModuleInfo", "name state")
128
129
130class ModuleChecker:
131    pybuilddir_txt = "pybuilddir.txt"
132
133    setup_files = (
134        # see end of configure.ac
135        "Modules/Setup.local",
136        "Modules/Setup.stdlib",
137        "Modules/Setup.bootstrap",
138        SRC_DIR / "Modules/Setup",
139    )
140
141    def __init__(self, cross_compiling: bool = False, strict: bool = False):
142        self.cross_compiling = cross_compiling
143        self.strict_extensions_build = strict
144        self.ext_suffix = sysconfig.get_config_var("EXT_SUFFIX")
145        self.platform = sysconfig.get_platform()
146        self.builddir = self.get_builddir()
147        self.modules = self.get_modules()
148
149        self.builtin_ok = []
150        self.shared_ok = []
151        self.failed_on_import = []
152        self.missing = []
153        self.disabled_configure = []
154        self.disabled_setup = []
155        self.notavailable = []
156
157    def check(self):
158        if not hasattr(_imp, 'create_dynamic'):
159            logger.warning(
160                ('Dynamic extensions not supported '
161                 '(HAVE_DYNAMIC_LOADING not defined)'),
162            )
163        for modinfo in self.modules:
164            logger.debug("Checking '%s' (%s)", modinfo.name, self.get_location(modinfo))
165            if modinfo.state == ModuleState.DISABLED:
166                self.disabled_configure.append(modinfo)
167            elif modinfo.state == ModuleState.DISABLED_SETUP:
168                self.disabled_setup.append(modinfo)
169            elif modinfo.state == ModuleState.MISSING:
170                self.missing.append(modinfo)
171            elif modinfo.state == ModuleState.NA:
172                self.notavailable.append(modinfo)
173            else:
174                try:
175                    if self.cross_compiling:
176                        self.check_module_cross(modinfo)
177                    else:
178                        self.check_module_import(modinfo)
179                except (ImportError, FileNotFoundError):
180                    self.rename_module(modinfo)
181                    self.failed_on_import.append(modinfo)
182                else:
183                    if modinfo.state == ModuleState.BUILTIN:
184                        self.builtin_ok.append(modinfo)
185                    else:
186                        assert modinfo.state == ModuleState.SHARED
187                        self.shared_ok.append(modinfo)
188
189    def summary(self, *, verbose: bool = False):
190        longest = max([len(e.name) for e in self.modules], default=0)
191
192        def print_three_column(modinfos: list[ModuleInfo]):
193            names = [modinfo.name for modinfo in modinfos]
194            names.sort(key=str.lower)
195            # guarantee zip() doesn't drop anything
196            while len(names) % 3:
197                names.append("")
198            for l, m, r in zip(names[::3], names[1::3], names[2::3]):
199                print("%-*s   %-*s   %-*s" % (longest, l, longest, m, longest, r))
200
201        if verbose and self.builtin_ok:
202            print("The following *built-in* modules have been successfully built:")
203            print_three_column(self.builtin_ok)
204            print()
205
206        if verbose and self.shared_ok:
207            print("The following *shared* modules have been successfully built:")
208            print_three_column(self.shared_ok)
209            print()
210
211        if self.disabled_configure:
212            print("The following modules are *disabled* in configure script:")
213            print_three_column(self.disabled_configure)
214            print()
215
216        if self.disabled_setup:
217            print("The following modules are *disabled* in Modules/Setup files:")
218            print_three_column(self.disabled_setup)
219            print()
220
221        if verbose and self.notavailable:
222            print(
223                f"The following modules are not available on platform '{self.platform}':"
224            )
225            print_three_column(self.notavailable)
226            print()
227
228        if self.missing:
229            print("The necessary bits to build these optional modules were not found:")
230            print_three_column(self.missing)
231            print("To find the necessary bits, look in configure.ac and config.log.")
232            print()
233
234        if self.failed_on_import:
235            print(
236                "Following modules built successfully "
237                "but were removed because they could not be imported:"
238            )
239            print_three_column(self.failed_on_import)
240            print()
241
242        if any(
243            modinfo.name == "_ssl" for modinfo in self.missing + self.failed_on_import
244        ):
245            print("Could not build the ssl module!")
246            print("Python requires a OpenSSL 1.1.1 or newer")
247            if sysconfig.get_config_var("OPENSSL_LDFLAGS"):
248                print("Custom linker flags may require --with-openssl-rpath=auto")
249            print()
250
251        disabled = len(self.disabled_configure) + len(self.disabled_setup)
252        print(
253            f"Checked {len(self.modules)} modules ("
254            f"{len(self.builtin_ok)} built-in, "
255            f"{len(self.shared_ok)} shared, "
256            f"{len(self.notavailable)} n/a on {self.platform}, "
257            f"{disabled} disabled, "
258            f"{len(self.missing)} missing, "
259            f"{len(self.failed_on_import)} failed on import)"
260        )
261
262    def check_strict_build(self):
263        """Fail if modules are missing and it's a strict build"""
264        if self.strict_extensions_build and (self.failed_on_import or self.missing):
265            raise RuntimeError("Failed to build some stdlib modules")
266
267    def list_module_names(self, *, all: bool = False) -> set:
268        names = {modinfo.name for modinfo in self.modules}
269        if all:
270            names.update(WINDOWS_MODULES)
271        return names
272
273    def get_builddir(self) -> pathlib.Path:
274        try:
275            with open(self.pybuilddir_txt, encoding="utf-8") as f:
276                builddir = f.read()
277        except FileNotFoundError:
278            logger.error("%s must be run from the top build directory", __file__)
279            raise
280        builddir = pathlib.Path(builddir)
281        logger.debug("%s: %s", self.pybuilddir_txt, builddir)
282        return builddir
283
284    def get_modules(self) -> list[ModuleInfo]:
285        """Get module info from sysconfig and Modules/Setup* files"""
286        seen = set()
287        modules = []
288        # parsing order is important, first entry wins
289        for modinfo in self.get_core_modules():
290            modules.append(modinfo)
291            seen.add(modinfo.name)
292        for setup_file in self.setup_files:
293            for modinfo in self.parse_setup_file(setup_file):
294                if modinfo.name not in seen:
295                    modules.append(modinfo)
296                    seen.add(modinfo.name)
297        for modinfo in self.get_sysconfig_modules():
298            if modinfo.name not in seen:
299                modules.append(modinfo)
300                seen.add(modinfo.name)
301        logger.debug("Found %i modules in total", len(modules))
302        modules.sort()
303        return modules
304
305    def get_core_modules(self) -> Iterable[ModuleInfo]:
306        """Get hard-coded core modules"""
307        for name in CORE_MODULES:
308            modinfo = ModuleInfo(name, ModuleState.BUILTIN)
309            logger.debug("Found core module %s", modinfo)
310            yield modinfo
311
312    def get_sysconfig_modules(self) -> Iterable[ModuleInfo]:
313        """Get modules defined in Makefile through sysconfig
314
315        MODBUILT_NAMES: modules in *static* block
316        MODSHARED_NAMES: modules in *shared* block
317        MODDISABLED_NAMES: modules in *disabled* block
318        """
319        moddisabled = set(sysconfig.get_config_var("MODDISABLED_NAMES").split())
320        if self.cross_compiling:
321            modbuiltin = set(sysconfig.get_config_var("MODBUILT_NAMES").split())
322        else:
323            modbuiltin = set(sys.builtin_module_names)
324
325        for key, value in sysconfig.get_config_vars().items():
326            if not key.startswith("MODULE_") or not key.endswith("_STATE"):
327                continue
328            if value not in {"yes", "disabled", "missing", "n/a"}:
329                raise ValueError(f"Unsupported value '{value}' for {key}")
330
331            modname = key[7:-6].lower()
332            if modname in moddisabled:
333                # Setup "*disabled*" rule
334                state = ModuleState.DISABLED_SETUP
335            elif value in {"disabled", "missing", "n/a"}:
336                state = ModuleState(value)
337            elif modname in modbuiltin:
338                assert value == "yes"
339                state = ModuleState.BUILTIN
340            else:
341                assert value == "yes"
342                state = ModuleState.SHARED
343
344            modinfo = ModuleInfo(modname, state)
345            logger.debug("Found %s in Makefile", modinfo)
346            yield modinfo
347
348    def parse_setup_file(self, setup_file: pathlib.Path) -> Iterable[ModuleInfo]:
349        """Parse a Modules/Setup file"""
350        assign_var = re.compile(r"^\w+=")  # EGG_SPAM=foo
351        # default to static module
352        state = ModuleState.BUILTIN
353        logger.debug("Parsing Setup file %s", setup_file)
354        with open(setup_file, encoding="utf-8") as f:
355            for line in f:
356                line = line.strip()
357                if not line or line.startswith("#") or assign_var.match(line):
358                    continue
359                match line.split():
360                    case ["*shared*"]:
361                        state = ModuleState.SHARED
362                    case ["*static*"]:
363                        state = ModuleState.BUILTIN
364                    case ["*disabled*"]:
365                        state = ModuleState.DISABLED
366                    case ["*noconfig*"]:
367                        state = None
368                    case [*items]:
369                        if state == ModuleState.DISABLED:
370                            # *disabled* can disable multiple modules per line
371                            for item in items:
372                                modinfo = ModuleInfo(item, state)
373                                logger.debug("Found %s in %s", modinfo, setup_file)
374                                yield modinfo
375                        elif state in {ModuleState.SHARED, ModuleState.BUILTIN}:
376                            # *shared* and *static*, first item is the name of the module.
377                            modinfo = ModuleInfo(items[0], state)
378                            logger.debug("Found %s in %s", modinfo, setup_file)
379                            yield modinfo
380
381    def get_spec(self, modinfo: ModuleInfo) -> ModuleSpec:
382        """Get ModuleSpec for builtin or extension module"""
383        if modinfo.state == ModuleState.SHARED:
384            location = os.fspath(self.get_location(modinfo))
385            loader = ExtensionFileLoader(modinfo.name, location)
386            return spec_from_file_location(modinfo.name, location, loader=loader)
387        elif modinfo.state == ModuleState.BUILTIN:
388            return spec_from_loader(modinfo.name, loader=BuiltinImporter)
389        else:
390            raise ValueError(modinfo)
391
392    def get_location(self, modinfo: ModuleInfo) -> pathlib.Path:
393        """Get shared library location in build directory"""
394        if modinfo.state == ModuleState.SHARED:
395            return self.builddir / f"{modinfo.name}{self.ext_suffix}"
396        else:
397            return None
398
399    def _check_file(self, modinfo: ModuleInfo, spec: ModuleSpec):
400        """Check that the module file is present and not empty"""
401        if spec.loader is BuiltinImporter:
402            return
403        try:
404            st = os.stat(spec.origin)
405        except FileNotFoundError:
406            logger.error("%s (%s) is missing", modinfo.name, spec.origin)
407            raise
408        if not st.st_size:
409            raise ImportError(f"{spec.origin} is an empty file")
410
411    def check_module_import(self, modinfo: ModuleInfo):
412        """Attempt to import module and report errors"""
413        spec = self.get_spec(modinfo)
414        self._check_file(modinfo, spec)
415        try:
416            with warnings.catch_warnings():
417                # ignore deprecation warning from deprecated modules
418                warnings.simplefilter("ignore", DeprecationWarning)
419                bootstrap_load(spec)
420        except ImportError as e:
421            logger.error("%s failed to import: %s", modinfo.name, e)
422            raise
423        except Exception as e:
424            if not hasattr(_imp, 'create_dynamic'):
425                logger.warning("Dynamic extension '%s' ignored", modinfo.name)
426                return
427            logger.exception("Importing extension '%s' failed!", modinfo.name)
428            raise
429
430    def check_module_cross(self, modinfo: ModuleInfo):
431        """Sanity check for cross compiling"""
432        spec = self.get_spec(modinfo)
433        self._check_file(modinfo, spec)
434
435    def rename_module(self, modinfo: ModuleInfo) -> None:
436        """Rename module file"""
437        if modinfo.state == ModuleState.BUILTIN:
438            logger.error("Cannot mark builtin module '%s' as failed!", modinfo.name)
439            return
440
441        failed_name = f"{modinfo.name}_failed{self.ext_suffix}"
442        builddir_path = self.get_location(modinfo)
443        if builddir_path.is_symlink():
444            symlink = builddir_path
445            module_path = builddir_path.resolve().relative_to(os.getcwd())
446            failed_path = module_path.parent / failed_name
447        else:
448            symlink = None
449            module_path = builddir_path
450            failed_path = self.builddir / failed_name
451
452        # remove old failed file
453        failed_path.unlink(missing_ok=True)
454        # remove symlink
455        if symlink is not None:
456            symlink.unlink(missing_ok=True)
457        # rename shared extension file
458        try:
459            module_path.rename(failed_path)
460        except FileNotFoundError:
461            logger.debug("Shared extension file '%s' does not exist.", module_path)
462        else:
463            logger.debug("Rename '%s' -> '%s'", module_path, failed_path)
464
465
466def main():
467    args = parser.parse_args()
468    if args.debug:
469        args.verbose = True
470    logging.basicConfig(
471        level=logging.DEBUG if args.debug else logging.INFO,
472        format="[%(levelname)s] %(message)s",
473    )
474
475    checker = ModuleChecker(
476        cross_compiling=args.cross_compiling,
477        strict=args.strict,
478    )
479    if args.list_module_names:
480        names = checker.list_module_names(all=True)
481        for name in sorted(names):
482            print(name)
483    else:
484        checker.check()
485        checker.summary(verbose=args.verbose)
486        try:
487            checker.check_strict_build()
488        except RuntimeError as e:
489            parser.exit(1, f"\nError: {e}\n")
490
491
492if __name__ == "__main__":
493    main()
494