diff --git a/lite_bootstrap/bootstrappers/base.py b/lite_bootstrap/bootstrappers/base.py index 1e49bba..9dd997b 100644 --- a/lite_bootstrap/bootstrappers/base.py +++ b/lite_bootstrap/bootstrappers/base.py @@ -42,14 +42,18 @@ def __init__(self, bootstrap_config: BaseConfig) -> None: self.instruments.append(instrument) def _register_or_skip(self, instrument_type: type[BaseInstrument]) -> BaseInstrument | None: - instrument = instrument_type(bootstrap_config=self.bootstrap_config) - if not instrument.check_dependencies(): + # Check dependencies before instantiation: an instrument's __init__ + # may reference symbols gated behind an optional import (e.g. a + # default_factory that calls into the missing package), which would + # raise NameError before the check_dependencies skip could run. + if not instrument_type.check_dependencies(): warnings.warn( - instrument.missing_dependency_message, + instrument_type.missing_dependency_message, category=InstrumentDependencyMissingWarning, stacklevel=4, ) return None + instrument = instrument_type(bootstrap_config=self.bootstrap_config) if not instrument.is_ready(): warnings.warn( f"{instrument_type.__name__} is not ready: {instrument.not_ready_message}", diff --git a/lite_bootstrap/bootstrappers/faststream_bootstrapper.py b/lite_bootstrap/bootstrappers/faststream_bootstrapper.py index f4a3fdd..6287b02 100644 --- a/lite_bootstrap/bootstrappers/faststream_bootstrapper.py +++ b/lite_bootstrap/bootstrappers/faststream_bootstrapper.py @@ -87,7 +87,10 @@ async def check_health(_: object) -> "AsgiResponse": else AsgiResponse(b"Service is unhealthy", 500, headers={"content-type": "text/plain"}) ) - if self.bootstrap_config.opentelemetry_generate_health_check_spans: + if ( + self.bootstrap_config.opentelemetry_generate_health_check_spans + and import_checker.is_opentelemetry_installed + ): check_health = tracer.start_as_current_span(f"GET {self.bootstrap_config.health_checks_path}")( check_health, ) diff --git a/tests/conftest.py b/tests/conftest.py index 6fc2dd5..959fb99 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -58,3 +58,39 @@ def emulate_package_missing(package_name: str) -> typing.Iterator[None]: finally: sys.modules[package_name] = old_module reload(import_checker) + + +@contextlib.contextmanager +def emulate_package_missing_with_module_reload( + package_name: str, module_names: typing.Iterable[str] +) -> typing.Iterator[None]: + # Reload listed modules under emulate_package_missing so their + # `if import_checker.is_X_installed: import X` blocks re-evaluate against + # the patched flag. `importlib.reload` preserves existing module globals, + # so we wipe non-dunder names first to truly simulate a fresh import where + # the conditional import never ran. + module_names = list(module_names) + snapshots: dict[str, dict[str, typing.Any]] = {} + for name in module_names: + if name in sys.modules: + snapshots[name] = dict(sys.modules[name].__dict__) + + def _wipe_and_reload() -> None: + for name in module_names: + if name in sys.modules: + mod_dict = sys.modules[name].__dict__ + for key in [k for k in mod_dict if not k.startswith("__")]: + del mod_dict[key] + reload(sys.modules[name]) + + with emulate_package_missing(package_name): + _wipe_and_reload() + try: + yield + finally: + for name, snap in snapshots.items(): + if name in sys.modules: + mod_dict = sys.modules[name].__dict__ + for key in [k for k in mod_dict if not k.startswith("__")]: + del mod_dict[key] + mod_dict.update({k: v for k, v in snap.items() if not k.startswith("__")}) diff --git a/tests/test_faststream_bootstrap.py b/tests/test_faststream_bootstrap.py index d0dcf1b..2cf6211 100644 --- a/tests/test_faststream_bootstrap.py +++ b/tests/test_faststream_bootstrap.py @@ -13,7 +13,12 @@ from starlette.testclient import TestClient from lite_bootstrap import FastStreamBootstrapper, FastStreamConfig -from tests.conftest import CustomInstrumentor, SentryTestTransport, emulate_package_missing +from tests.conftest import ( + CustomInstrumentor, + SentryTestTransport, + emulate_package_missing, + emulate_package_missing_with_module_reload, +) logger = structlog.getLogger(__name__) @@ -127,3 +132,30 @@ def test_faststream_bootstrapper_with_missing_instrument_dependency(broker: Redi bootstrap_config = build_faststream_config(broker=broker) with emulate_package_missing(package_name), pytest.warns(UserWarning, match=package_name): FastStreamBootstrapper(bootstrap_config=bootstrap_config) + + +def test_faststream_bootstrap_without_prometheus_client(broker: RedisBroker) -> None: + # Regression: issue #87 bug 1 — FastStreamPrometheusInstrument's + # default_factory called prometheus_client.CollectorRegistry() during + # dataclass __init__, raising NameError before check_dependencies() ran. + bootstrap_config = build_faststream_config(broker=broker) + with emulate_package_missing_with_module_reload( + "prometheus_client", + ["lite_bootstrap.bootstrappers.faststream_bootstrapper"], + ): + with pytest.warns(UserWarning, match="prometheus_client"): + bootstrapper = FastStreamBootstrapper(bootstrap_config=bootstrap_config) + bootstrapper.bootstrap() + + +def test_faststream_bootstrap_without_opentelemetry(broker: RedisBroker) -> None: + # Regression: issue #87 bug 2 — FastStreamHealthChecksInstrument.bootstrap + # referenced unbound `tracer` when opentelemetry was absent and + # opentelemetry_generate_health_check_spans defaulted to True. + bootstrap_config = build_faststream_config(broker=broker) + with emulate_package_missing_with_module_reload( + "opentelemetry", + ["lite_bootstrap.bootstrappers.faststream_bootstrapper"], + ): + bootstrapper = FastStreamBootstrapper(bootstrap_config=bootstrap_config) + bootstrapper.bootstrap()