diff --git a/tests/core/test_service_compiled_import_hint.py b/tests/core/test_service_compiled_import_hint.py new file mode 100644 index 00000000..a686a477 --- /dev/null +++ b/tests/core/test_service_compiled_import_hint.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from importlib.machinery import EXTENSION_SUFFIXES +from pathlib import Path + +from unshackle.core.services import compiled_import_hint + + +def _service(tmp_path: Path, *files: str) -> Path: + (tmp_path / "EXAMPLE").mkdir() + init = tmp_path / "EXAMPLE" / "__init__.py" + init.write_text("from .example import EXAMPLE\n") + for name in files: + (init.parent / name).touch() + return init + + +def test_hint_names_the_file_this_python_needs(tmp_path: Path) -> None: + init = _service(tmp_path, "example.cp999-win_amd64.pyd", "example.cpython-999-x86_64-linux-gnu.so") + assert f"needs example{EXTENSION_SUFFIXES[0]}" in compiled_import_hint("EXAMPLE.example", init) + + +def test_no_hint_when_this_python_can_load_it(tmp_path: Path) -> None: + init = _service(tmp_path, f"example{EXTENSION_SUFFIXES[0]}") + assert compiled_import_hint("EXAMPLE.example", init) == "" + + +def test_no_hint_for_a_pure_python_service(tmp_path: Path) -> None: + init = _service(tmp_path) + assert compiled_import_hint("EXAMPLE.example", init) == "" diff --git a/unshackle/core/services.py b/unshackle/core/services.py index f6df0e54..00dd92d5 100644 --- a/unshackle/core/services.py +++ b/unshackle/core/services.py @@ -2,10 +2,12 @@ from __future__ import annotations import inspect import logging +import platform import re import sys import threading import time +from importlib.machinery import EXTENSION_SUFFIXES from pathlib import Path from typing import Any, Iterable @@ -67,6 +69,22 @@ def discover_services() -> tuple[list[Path], list[str]]: SERVICES, SHADOWED = discover_services() +def compiled_import_hint(missing: str, path: Path) -> str: + """Say why a compiled service submodule did not import. + + Python loads a compiled service only from a file named for the exact interpreter + and platform that runs it, so name the build the service does not ship. + """ + stem = missing.rsplit(".", 1)[-1] + present = [f.name for f in path.parent.glob(f"{stem}.*") if f.suffix in (".so", ".pyd")] + if not present or any(f"{stem}{suffix}" in present for suffix in EXTENSION_SUFFIXES): + return "" + return ( + f" - this service ships no build for Python {platform.python_version()}" + f" on {platform.system()}, which needs {stem}{EXTENSION_SUFFIXES[0]}" + ) + + def load_service(path: Path) -> object: """Load one Service module, returning its tag-named class. @@ -77,7 +95,8 @@ def load_service(path: Path) -> object: try: module = import_module_by_path(path) except Exception as e: - raise RuntimeError(f"{tag}: failed to import - {type(e).__name__}: {e} ({path})") from e + hint = compiled_import_hint(e.name, path) if isinstance(e, ModuleNotFoundError) and e.name else "" + raise RuntimeError(f"{tag}: failed to import - {type(e).__name__}: {e}{hint} ({path})") from e try: return getattr(module, tag) except AttributeError as e: