diff --git a/lite_bootstrap/__init__.py b/lite_bootstrap/__init__.py index 372f3ed..b2fc8f9 100644 --- a/lite_bootstrap/__init__.py +++ b/lite_bootstrap/__init__.py @@ -1,8 +1,6 @@ -from lite_bootstrap.bootstrappers.fastapi_bootstrapper import FastAPIBootstrapper, FastAPIConfig -from lite_bootstrap.bootstrappers.fastmcp_bootstrapper import FastMcpBootstrapper, FastMcpConfig -from lite_bootstrap.bootstrappers.faststream_bootstrapper import FastStreamBootstrapper, FastStreamConfig -from lite_bootstrap.bootstrappers.free_bootstrapper import FreeBootstrapper, FreeConfig -from lite_bootstrap.bootstrappers.litestar_bootstrapper import LitestarBootstrapper, LitestarConfig +import typing +from importlib import import_module + from lite_bootstrap.exceptions import ( BootstrapperNotReadyError, ConfigurationError, @@ -10,7 +8,15 @@ LiteBootstrapError, TeardownError, ) -from lite_bootstrap.instruments.pyroscope_instrument import PyroscopeConfig, PyroscopeInstrument + + +if typing.TYPE_CHECKING: + from lite_bootstrap.bootstrappers.fastapi_bootstrapper import FastAPIBootstrapper, FastAPIConfig + from lite_bootstrap.bootstrappers.fastmcp_bootstrapper import FastMcpBootstrapper, FastMcpConfig + from lite_bootstrap.bootstrappers.faststream_bootstrapper import FastStreamBootstrapper, FastStreamConfig + from lite_bootstrap.bootstrappers.free_bootstrapper import FreeBootstrapper, FreeConfig + from lite_bootstrap.bootstrappers.litestar_bootstrapper import LitestarBootstrapper, LitestarConfig + from lite_bootstrap.instruments.pyroscope_instrument import PyroscopeConfig, PyroscopeInstrument __all__ = [ @@ -32,3 +38,33 @@ "PyroscopeInstrument", "TeardownError", ] + + +_LAZY_EXPORTS = { + "FastAPIBootstrapper": "bootstrappers.fastapi_bootstrapper", + "FastAPIConfig": "bootstrappers.fastapi_bootstrapper", + "FastMcpBootstrapper": "bootstrappers.fastmcp_bootstrapper", + "FastMcpConfig": "bootstrappers.fastmcp_bootstrapper", + "FastStreamBootstrapper": "bootstrappers.faststream_bootstrapper", + "FastStreamConfig": "bootstrappers.faststream_bootstrapper", + "FreeBootstrapper": "bootstrappers.free_bootstrapper", + "FreeConfig": "bootstrappers.free_bootstrapper", + "LitestarBootstrapper": "bootstrappers.litestar_bootstrapper", + "LitestarConfig": "bootstrappers.litestar_bootstrapper", + "PyroscopeConfig": "instruments.pyroscope_instrument", + "PyroscopeInstrument": "instruments.pyroscope_instrument", +} + + +def __getattr__(name: str) -> typing.Any: # noqa: ANN401 + module_name = _LAZY_EXPORTS.get(name) + if module_name is None: + msg = f"module {__name__!r} has no attribute {name!r}" + raise AttributeError(msg) + exported = getattr(import_module(f".{module_name}", __name__), name) + globals()[name] = exported + return exported + + +def __dir__() -> list[str]: + return sorted(set(globals()) | set(__all__)) diff --git a/tests/test_core_import_surface.py b/tests/test_core_import_surface.py index 14ccb56..1fca672 100644 --- a/tests/test_core_import_surface.py +++ b/tests/test_core_import_surface.py @@ -4,6 +4,7 @@ import subprocess import sys +import pytest import typing_extensions import lite_bootstrap @@ -95,3 +96,89 @@ def test_import_with_bare_opentelemetry_namespace(tmp_path: pathlib.Path) -> Non ) assert result.returncode == 0, result.stderr + + +@pytest.mark.parametrize( + "statement", + [ + "import lite_bootstrap", + "from lite_bootstrap import ConfigurationError", + "from lite_bootstrap import PyroscopeConfig", + "from lite_bootstrap import FastAPIBootstrapper", + ], +) +def test_public_imports_do_not_load_unrelated_frameworks(statement: str) -> None: + result = subprocess.run( # noqa: S603 + [ + sys.executable, + "-c", + statement + + """ +import sys +assert not {"litestar", "faststream", "fastmcp"}.intersection(sys.modules) +""", + ], + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stderr + + +def test_every_public_export_resolves_in_a_fresh_process() -> None: + result = subprocess.run( + [ + sys.executable, + "-c", + """ +import lite_bootstrap + +for name in lite_bootstrap.__all__: + exported = getattr(lite_bootstrap, name) + assert exported.__name__ == name, name + assert exported is getattr(lite_bootstrap, name), name + +try: + getattr(lite_bootstrap, "not_an_export") +except AttributeError as error: + assert "not_an_export" in str(error) +else: + raise AssertionError("unknown export resolved") +""", + ], + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stderr + + +def test_unknown_public_export_raises_attribute_error() -> None: + name = "not_an_export" + with pytest.raises(AttributeError, match=name): + getattr(lite_bootstrap, name) + + +def test_public_exports_are_discoverable_without_loading_frameworks() -> None: + result = subprocess.run( + [ + sys.executable, + "-c", + """ +import sys +import lite_bootstrap + +before = set(sys.modules) +names = dir(lite_bootstrap) +assert set(lite_bootstrap.__all__).issubset(names) +assert "__name__" in names +assert set(sys.modules) == before +assert not {"fastapi", "litestar", "faststream", "fastmcp"}.intersection(sys.modules) +""", + ], + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stderr + assert set(lite_bootstrap.__all__).issubset(dir(lite_bootstrap))