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