mirror of
				https://github.com/python/cpython.git
				synced 2025-10-30 05:01:30 +00:00 
			
		
		
		
	
		
			
				
	
	
		
			484 lines
		
	
	
	
		
			17 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
			
		
		
	
	
			484 lines
		
	
	
	
		
			17 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
| """Check extension modules
 | |
| 
 | |
| The script checks shared and built-in extension modules. It verifies that the
 | |
| modules have been built and that they can be imported successfully. Missing
 | |
| modules and failed imports are reported to the user. Shared extension
 | |
| files are renamed on failed import.
 | |
| 
 | |
| Module information is parsed from several sources:
 | |
| 
 | |
| - core modules hard-coded in Modules/config.c.in
 | |
| - Windows-specific modules that are hard-coded in PC/config.c
 | |
| - MODULE_{name}_STATE entries in Makefile (provided through sysconfig)
 | |
| - Various makesetup files:
 | |
|   - $(srcdir)/Modules/Setup
 | |
|   - Modules/Setup.[local|bootstrap|stdlib] files, which are generated
 | |
|     from $(srcdir)/Modules/Setup.*.in files
 | |
| 
 | |
| See --help for more information
 | |
| """
 | |
| import argparse
 | |
| import collections
 | |
| import enum
 | |
| import logging
 | |
| import os
 | |
| import pathlib
 | |
| import re
 | |
| import sys
 | |
| import sysconfig
 | |
| import warnings
 | |
| 
 | |
| from importlib._bootstrap import _load as bootstrap_load
 | |
| from importlib.machinery import BuiltinImporter, ExtensionFileLoader, ModuleSpec
 | |
| from importlib.util import spec_from_file_location, spec_from_loader
 | |
| from typing import Iterable
 | |
| 
 | |
| SRC_DIR = pathlib.Path(__file__).parent.parent.parent
 | |
| 
 | |
| # core modules, hard-coded in Modules/config.h.in
 | |
| CORE_MODULES = {
 | |
|     "_ast",
 | |
|     "_imp",
 | |
|     "_string",
 | |
|     "_tokenize",
 | |
|     "_warnings",
 | |
|     "builtins",
 | |
|     "gc",
 | |
|     "marshal",
 | |
|     "sys",
 | |
| }
 | |
| 
 | |
| # Windows-only modules
 | |
| WINDOWS_MODULES = {
 | |
|     "_msi",
 | |
|     "_overlapped",
 | |
|     "_testconsole",
 | |
|     "_winapi",
 | |
|     "msvcrt",
 | |
|     "nt",
 | |
|     "winreg",
 | |
|     "winsound",
 | |
| }
 | |
| 
 | |
| 
 | |
| logger = logging.getLogger(__name__)
 | |
| 
 | |
| parser = argparse.ArgumentParser(
 | |
|     prog="check_extension_modules",
 | |
|     description=__doc__,
 | |
|     formatter_class=argparse.RawDescriptionHelpFormatter,
 | |
| )
 | |
| 
 | |
| parser.add_argument(
 | |
|     "--verbose",
 | |
|     action="store_true",
 | |
|     help="Verbose, report builtin, shared, and unavailable modules",
 | |
| )
 | |
| 
 | |
| parser.add_argument(
 | |
|     "--debug",
 | |
|     action="store_true",
 | |
|     help="Enable debug logging",
 | |
| )
 | |
| 
 | |
| parser.add_argument(
 | |
|     "--strict",
 | |
|     action=argparse.BooleanOptionalAction,
 | |
|     help=(
 | |
|         "Strict check, fail when a module is missing or fails to import"
 | |
|         "(default: no, unless env var PYTHONSTRICTEXTENSIONBUILD is set)"
 | |
|     ),
 | |
|     default=bool(os.environ.get("PYTHONSTRICTEXTENSIONBUILD")),
 | |
| )
 | |
| 
 | |
| parser.add_argument(
 | |
|     "--cross-compiling",
 | |
|     action=argparse.BooleanOptionalAction,
 | |
|     help=(
 | |
|         "Use cross-compiling checks "
 | |
|         "(default: no, unless env var _PYTHON_HOST_PLATFORM is set)."
 | |
|     ),
 | |
|     default="_PYTHON_HOST_PLATFORM" in os.environ,
 | |
| )
 | |
| 
 | |
| parser.add_argument(
 | |
|     "--list-module-names",
 | |
|     action="store_true",
 | |
|     help="Print a list of module names to stdout and exit",
 | |
| )
 | |
| 
 | |
| 
 | |
| class ModuleState(enum.Enum):
 | |
|     # Makefile state "yes"
 | |
|     BUILTIN = "builtin"
 | |
|     SHARED = "shared"
 | |
| 
 | |
|     DISABLED = "disabled"
 | |
|     MISSING = "missing"
 | |
|     NA = "n/a"
 | |
|     # disabled by Setup / makesetup rule
 | |
|     DISABLED_SETUP = "disabled_setup"
 | |
| 
 | |
|     def __bool__(self):
 | |
|         return self.value in {"builtin", "shared"}
 | |
| 
 | |
| 
 | |
| ModuleInfo = collections.namedtuple("ModuleInfo", "name state")
 | |
| 
 | |
| 
 | |
| class ModuleChecker:
 | |
|     pybuilddir_txt = "pybuilddir.txt"
 | |
| 
 | |
|     setup_files = (
 | |
|         # see end of configure.ac
 | |
|         "Modules/Setup.local",
 | |
|         "Modules/Setup.stdlib",
 | |
|         "Modules/Setup.bootstrap",
 | |
|         SRC_DIR / "Modules/Setup",
 | |
|     )
 | |
| 
 | |
|     def __init__(self, cross_compiling: bool = False, strict: bool = False):
 | |
|         self.cross_compiling = cross_compiling
 | |
|         self.strict_extensions_build = strict
 | |
|         self.ext_suffix = sysconfig.get_config_var("EXT_SUFFIX")
 | |
|         self.platform = sysconfig.get_platform()
 | |
|         self.builddir = self.get_builddir()
 | |
|         self.modules = self.get_modules()
 | |
| 
 | |
|         self.builtin_ok = []
 | |
|         self.shared_ok = []
 | |
|         self.failed_on_import = []
 | |
|         self.missing = []
 | |
|         self.disabled_configure = []
 | |
|         self.disabled_setup = []
 | |
|         self.notavailable = []
 | |
| 
 | |
|     def check(self):
 | |
|         for modinfo in self.modules:
 | |
|             logger.debug("Checking '%s' (%s)", modinfo.name, self.get_location(modinfo))
 | |
|             if modinfo.state == ModuleState.DISABLED:
 | |
|                 self.disabled_configure.append(modinfo)
 | |
|             elif modinfo.state == ModuleState.DISABLED_SETUP:
 | |
|                 self.disabled_setup.append(modinfo)
 | |
|             elif modinfo.state == ModuleState.MISSING:
 | |
|                 self.missing.append(modinfo)
 | |
|             elif modinfo.state == ModuleState.NA:
 | |
|                 self.notavailable.append(modinfo)
 | |
|             else:
 | |
|                 try:
 | |
|                     if self.cross_compiling:
 | |
|                         self.check_module_cross(modinfo)
 | |
|                     else:
 | |
|                         self.check_module_import(modinfo)
 | |
|                 except (ImportError, FileNotFoundError):
 | |
|                     self.rename_module(modinfo)
 | |
|                     self.failed_on_import.append(modinfo)
 | |
|                 else:
 | |
|                     if modinfo.state == ModuleState.BUILTIN:
 | |
|                         self.builtin_ok.append(modinfo)
 | |
|                     else:
 | |
|                         assert modinfo.state == ModuleState.SHARED
 | |
|                         self.shared_ok.append(modinfo)
 | |
| 
 | |
|     def summary(self, *, verbose: bool = False):
 | |
|         longest = max([len(e.name) for e in self.modules], default=0)
 | |
| 
 | |
|         def print_three_column(modinfos: list[ModuleInfo]):
 | |
|             names = [modinfo.name for modinfo in modinfos]
 | |
|             names.sort(key=str.lower)
 | |
|             # guarantee zip() doesn't drop anything
 | |
|             while len(names) % 3:
 | |
|                 names.append("")
 | |
|             for l, m, r in zip(names[::3], names[1::3], names[2::3]):
 | |
|                 print("%-*s   %-*s   %-*s" % (longest, l, longest, m, longest, r))
 | |
| 
 | |
|         if verbose and self.builtin_ok:
 | |
|             print("The following *built-in* modules have been successfully built:")
 | |
|             print_three_column(self.builtin_ok)
 | |
|             print()
 | |
| 
 | |
|         if verbose and self.shared_ok:
 | |
|             print("The following *shared* modules have been successfully built:")
 | |
|             print_three_column(self.shared_ok)
 | |
|             print()
 | |
| 
 | |
|         if self.disabled_configure:
 | |
|             print("The following modules are *disabled* in configure script:")
 | |
|             print_three_column(self.disabled_configure)
 | |
|             print()
 | |
| 
 | |
|         if self.disabled_setup:
 | |
|             print("The following modules are *disabled* in Modules/Setup files:")
 | |
|             print_three_column(self.disabled_setup)
 | |
|             print()
 | |
| 
 | |
|         if verbose and self.notavailable:
 | |
|             print(
 | |
|                 f"The following modules are not available on platform '{self.platform}':"
 | |
|             )
 | |
|             print_three_column(self.notavailable)
 | |
|             print()
 | |
| 
 | |
|         if self.missing:
 | |
|             print("The necessary bits to build these optional modules were not found:")
 | |
|             print_three_column(self.missing)
 | |
|             print("To find the necessary bits, look in configure.ac and config.log.")
 | |
|             print()
 | |
| 
 | |
|         if self.failed_on_import:
 | |
|             print(
 | |
|                 "Following modules built successfully "
 | |
|                 "but were removed because they could not be imported:"
 | |
|             )
 | |
|             print_three_column(self.failed_on_import)
 | |
|             print()
 | |
| 
 | |
|         if any(
 | |
|             modinfo.name == "_ssl" for modinfo in self.missing + self.failed_on_import
 | |
|         ):
 | |
|             print("Could not build the ssl module!")
 | |
|             print("Python requires a OpenSSL 1.1.1 or newer")
 | |
|             if sysconfig.get_config_var("OPENSSL_LDFLAGS"):
 | |
|                 print("Custom linker flags may require --with-openssl-rpath=auto")
 | |
|             print()
 | |
| 
 | |
|         disabled = len(self.disabled_configure) + len(self.disabled_setup)
 | |
|         print(
 | |
|             f"Checked {len(self.modules)} modules ("
 | |
|             f"{len(self.builtin_ok)} built-in, "
 | |
|             f"{len(self.shared_ok)} shared, "
 | |
|             f"{len(self.notavailable)} n/a on {self.platform}, "
 | |
|             f"{disabled} disabled, "
 | |
|             f"{len(self.missing)} missing, "
 | |
|             f"{len(self.failed_on_import)} failed on import)"
 | |
|         )
 | |
| 
 | |
|     def check_strict_build(self):
 | |
|         """Fail if modules are missing and it's a strict build"""
 | |
|         if self.strict_extensions_build and (self.failed_on_import or self.missing):
 | |
|             raise RuntimeError("Failed to build some stdlib modules")
 | |
| 
 | |
|     def list_module_names(self, *, all: bool = False) -> set:
 | |
|         names = {modinfo.name for modinfo in self.modules}
 | |
|         if all:
 | |
|             names.update(WINDOWS_MODULES)
 | |
|         return names
 | |
| 
 | |
|     def get_builddir(self) -> pathlib.Path:
 | |
|         try:
 | |
|             with open(self.pybuilddir_txt, encoding="utf-8") as f:
 | |
|                 builddir = f.read()
 | |
|         except FileNotFoundError:
 | |
|             logger.error("%s must be run from the top build directory", __file__)
 | |
|             raise
 | |
|         builddir = pathlib.Path(builddir)
 | |
|         logger.debug("%s: %s", self.pybuilddir_txt, builddir)
 | |
|         return builddir
 | |
| 
 | |
|     def get_modules(self) -> list[ModuleInfo]:
 | |
|         """Get module info from sysconfig and Modules/Setup* files"""
 | |
|         seen = set()
 | |
|         modules = []
 | |
|         # parsing order is important, first entry wins
 | |
|         for modinfo in self.get_core_modules():
 | |
|             modules.append(modinfo)
 | |
|             seen.add(modinfo.name)
 | |
|         for setup_file in self.setup_files:
 | |
|             for modinfo in self.parse_setup_file(setup_file):
 | |
|                 if modinfo.name not in seen:
 | |
|                     modules.append(modinfo)
 | |
|                     seen.add(modinfo.name)
 | |
|         for modinfo in self.get_sysconfig_modules():
 | |
|             if modinfo.name not in seen:
 | |
|                 modules.append(modinfo)
 | |
|                 seen.add(modinfo.name)
 | |
|         logger.debug("Found %i modules in total", len(modules))
 | |
|         modules.sort()
 | |
|         return modules
 | |
| 
 | |
|     def get_core_modules(self) -> Iterable[ModuleInfo]:
 | |
|         """Get hard-coded core modules"""
 | |
|         for name in CORE_MODULES:
 | |
|             modinfo = ModuleInfo(name, ModuleState.BUILTIN)
 | |
|             logger.debug("Found core module %s", modinfo)
 | |
|             yield modinfo
 | |
| 
 | |
|     def get_sysconfig_modules(self) -> Iterable[ModuleInfo]:
 | |
|         """Get modules defined in Makefile through sysconfig
 | |
| 
 | |
|         MODBUILT_NAMES: modules in *static* block
 | |
|         MODSHARED_NAMES: modules in *shared* block
 | |
|         MODDISABLED_NAMES: modules in *disabled* block
 | |
|         """
 | |
|         moddisabled = set(sysconfig.get_config_var("MODDISABLED_NAMES").split())
 | |
|         if self.cross_compiling:
 | |
|             modbuiltin = set(sysconfig.get_config_var("MODBUILT_NAMES").split())
 | |
|         else:
 | |
|             modbuiltin = set(sys.builtin_module_names)
 | |
| 
 | |
|         for key, value in sysconfig.get_config_vars().items():
 | |
|             if not key.startswith("MODULE_") or not key.endswith("_STATE"):
 | |
|                 continue
 | |
|             if value not in {"yes", "disabled", "missing", "n/a"}:
 | |
|                 raise ValueError(f"Unsupported value '{value}' for {key}")
 | |
| 
 | |
|             modname = key[7:-6].lower()
 | |
|             if modname in moddisabled:
 | |
|                 # Setup "*disabled*" rule
 | |
|                 state = ModuleState.DISABLED_SETUP
 | |
|             elif value in {"disabled", "missing", "n/a"}:
 | |
|                 state = ModuleState(value)
 | |
|             elif modname in modbuiltin:
 | |
|                 assert value == "yes"
 | |
|                 state = ModuleState.BUILTIN
 | |
|             else:
 | |
|                 assert value == "yes"
 | |
|                 state = ModuleState.SHARED
 | |
| 
 | |
|             modinfo = ModuleInfo(modname, state)
 | |
|             logger.debug("Found %s in Makefile", modinfo)
 | |
|             yield modinfo
 | |
| 
 | |
|     def parse_setup_file(self, setup_file: pathlib.Path) -> Iterable[ModuleInfo]:
 | |
|         """Parse a Modules/Setup file"""
 | |
|         assign_var = re.compile(r"^\w+=")  # EGG_SPAM=foo
 | |
|         # default to static module
 | |
|         state = ModuleState.BUILTIN
 | |
|         logger.debug("Parsing Setup file %s", setup_file)
 | |
|         with open(setup_file, encoding="utf-8") as f:
 | |
|             for line in f:
 | |
|                 line = line.strip()
 | |
|                 if not line or line.startswith("#") or assign_var.match(line):
 | |
|                     continue
 | |
|                 match line.split():
 | |
|                     case ["*shared*"]:
 | |
|                         state = ModuleState.SHARED
 | |
|                     case ["*static*"]:
 | |
|                         state = ModuleState.BUILTIN
 | |
|                     case ["*disabled*"]:
 | |
|                         state = ModuleState.DISABLED
 | |
|                     case ["*noconfig*"]:
 | |
|                         state = None
 | |
|                     case [*items]:
 | |
|                         if state == ModuleState.DISABLED:
 | |
|                             # *disabled* can disable multiple modules per line
 | |
|                             for item in items:
 | |
|                                 modinfo = ModuleInfo(item, state)
 | |
|                                 logger.debug("Found %s in %s", modinfo, setup_file)
 | |
|                                 yield modinfo
 | |
|                         elif state in {ModuleState.SHARED, ModuleState.BUILTIN}:
 | |
|                             # *shared* and *static*, first item is the name of the module.
 | |
|                             modinfo = ModuleInfo(items[0], state)
 | |
|                             logger.debug("Found %s in %s", modinfo, setup_file)
 | |
|                             yield modinfo
 | |
| 
 | |
|     def get_spec(self, modinfo: ModuleInfo) -> ModuleSpec:
 | |
|         """Get ModuleSpec for builtin or extension module"""
 | |
|         if modinfo.state == ModuleState.SHARED:
 | |
|             location = os.fspath(self.get_location(modinfo))
 | |
|             loader = ExtensionFileLoader(modinfo.name, location)
 | |
|             return spec_from_file_location(modinfo.name, location, loader=loader)
 | |
|         elif modinfo.state == ModuleState.BUILTIN:
 | |
|             return spec_from_loader(modinfo.name, loader=BuiltinImporter)
 | |
|         else:
 | |
|             raise ValueError(modinfo)
 | |
| 
 | |
|     def get_location(self, modinfo: ModuleInfo) -> pathlib.Path:
 | |
|         """Get shared library location in build directory"""
 | |
|         if modinfo.state == ModuleState.SHARED:
 | |
|             return self.builddir / f"{modinfo.name}{self.ext_suffix}"
 | |
|         else:
 | |
|             return None
 | |
| 
 | |
|     def _check_file(self, modinfo: ModuleInfo, spec: ModuleSpec):
 | |
|         """Check that the module file is present and not empty"""
 | |
|         if spec.loader is BuiltinImporter:
 | |
|             return
 | |
|         try:
 | |
|             st = os.stat(spec.origin)
 | |
|         except FileNotFoundError:
 | |
|             logger.error("%s (%s) is missing", modinfo.name, spec.origin)
 | |
|             raise
 | |
|         if not st.st_size:
 | |
|             raise ImportError(f"{spec.origin} is an empty file")
 | |
| 
 | |
|     def check_module_import(self, modinfo: ModuleInfo):
 | |
|         """Attempt to import module and report errors"""
 | |
|         spec = self.get_spec(modinfo)
 | |
|         self._check_file(modinfo, spec)
 | |
|         try:
 | |
|             with warnings.catch_warnings():
 | |
|                 # ignore deprecation warning from deprecated modules
 | |
|                 warnings.simplefilter("ignore", DeprecationWarning)
 | |
|                 bootstrap_load(spec)
 | |
|         except ImportError as e:
 | |
|             logger.error("%s failed to import: %s", modinfo.name, e)
 | |
|             raise
 | |
|         except Exception as e:
 | |
|             logger.exception("Importing extension '%s' failed!", modinfo.name)
 | |
|             raise
 | |
| 
 | |
|     def check_module_cross(self, modinfo: ModuleInfo):
 | |
|         """Sanity check for cross compiling"""
 | |
|         spec = self.get_spec(modinfo)
 | |
|         self._check_file(modinfo, spec)
 | |
| 
 | |
|     def rename_module(self, modinfo: ModuleInfo) -> None:
 | |
|         """Rename module file"""
 | |
|         if modinfo.state == ModuleState.BUILTIN:
 | |
|             logger.error("Cannot mark builtin module '%s' as failed!", modinfo.name)
 | |
|             return
 | |
| 
 | |
|         failed_name = f"{modinfo.name}_failed{self.ext_suffix}"
 | |
|         builddir_path = self.get_location(modinfo)
 | |
|         if builddir_path.is_symlink():
 | |
|             symlink = builddir_path
 | |
|             module_path = builddir_path.resolve().relative_to(os.getcwd())
 | |
|             failed_path = module_path.parent / failed_name
 | |
|         else:
 | |
|             symlink = None
 | |
|             module_path = builddir_path
 | |
|             failed_path = self.builddir / failed_name
 | |
| 
 | |
|         # remove old failed file
 | |
|         failed_path.unlink(missing_ok=True)
 | |
|         # remove symlink
 | |
|         if symlink is not None:
 | |
|             symlink.unlink(missing_ok=True)
 | |
|         # rename shared extension file
 | |
|         try:
 | |
|             module_path.rename(failed_path)
 | |
|         except FileNotFoundError:
 | |
|             logger.debug("Shared extension file '%s' does not exist.", module_path)
 | |
|         else:
 | |
|             logger.debug("Rename '%s' -> '%s'", module_path, failed_path)
 | |
| 
 | |
| 
 | |
| def main():
 | |
|     args = parser.parse_args()
 | |
|     if args.debug:
 | |
|         args.verbose = True
 | |
|     logging.basicConfig(
 | |
|         level=logging.DEBUG if args.debug else logging.INFO,
 | |
|         format="[%(levelname)s] %(message)s",
 | |
|     )
 | |
| 
 | |
|     checker = ModuleChecker(
 | |
|         cross_compiling=args.cross_compiling,
 | |
|         strict=args.strict,
 | |
|     )
 | |
|     if args.list_module_names:
 | |
|         names = checker.list_module_names(all=True)
 | |
|         for name in sorted(names):
 | |
|             print(name)
 | |
|     else:
 | |
|         checker.check()
 | |
|         checker.summary(verbose=args.verbose)
 | |
|         try:
 | |
|             checker.check_strict_build()
 | |
|         except RuntimeError as e:
 | |
|             parser.exit(1, f"\nError: {e}\n")
 | |
| 
 | |
| 
 | |
| if __name__ == "__main__":
 | |
|     main()
 | 
