Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ project uses it.
The user-facing entry point, one class per framework. It owns exactly one application for the life
of the process; it is not a builder you can run twice. A second bootstrapper on the same application (or,
for Litestar, the same `AppConfig`) warns at construction and raises `ConfigurationError` from
`bootstrap()`.
`bootstrap()`. Once torn down, a bootstrapper cannot bootstrap again: `bootstrap()` after
`teardown()` raises `ConfigurationError`.

**Instrument**:
One observability or middleware concern: logging, tracing, metrics, profiling, CORS, Swagger, or
Expand Down Expand Up @@ -55,7 +56,7 @@ possible probe.

**Free**:
Without a web framework: `FreeBootstrapper`, `FreeConfig`, the `free-all` extra. It has no
application, so nothing to attach teardown to and no double-bootstrap guard.
application, so nothing to attach teardown to and no guard against a second bootstrapper.
_Avoid_: bare "free" for PEP 703 free-threaded CPython, which this project also supports. Write
"free-threaded" in full.

Expand Down
9 changes: 9 additions & 0 deletions lite_bootstrap/bootstrappers/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ class BaseBootstrapper(abc.ABC, typing.Generic[ApplicationT]):

def __init__(self, bootstrap_config: BaseConfig) -> None:
self.is_bootstrapped = False
self._torn_down = False
# Set when another bootstrapper already owns this application; bootstrap() then refuses.
self._attach_skipped = False
if not self.is_ready():
Expand Down Expand Up @@ -131,6 +132,13 @@ def bootstrap(self) -> ApplicationT:
f"{type(self).__name__} per application."
)
raise ConfigurationError(msg)
if self._torn_down:
msg = (
f"{type(self).__name__} was torn down and cannot bootstrap again: its instruments "
f"already changed the application. To bootstrap again, construct a new "
f"{type(self).__name__}, with a new application if it takes one."
)
raise ConfigurationError(msg)
if self.is_bootstrapped:
return self._application
self.is_bootstrapped = True
Expand All @@ -143,6 +151,7 @@ def teardown(self) -> None:
if not self.is_bootstrapped:
return
self.is_bootstrapped = False
self._torn_down = True
with collect_teardown_errors(logger) as teardown_errors:
for one_instrument in reversed(self.instruments):
with teardown_errors.capture(type(one_instrument).__name__):
Expand Down
8 changes: 8 additions & 0 deletions tests/test_fastapi_bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,14 @@ def test_fastapi_config_inherits_otel_insecure_warning() -> None:
assert matching, [str(w.message) for w in caught]


def test_fastapi_bootstrap_after_teardown_raises() -> None:
bootstrapper = FastAPIBootstrapper(bootstrap_config=FastAPIConfig())
bootstrapper.bootstrap()
bootstrapper.teardown()
with pytest.raises(ConfigurationError, match="construct a new FastAPIBootstrapper"):
bootstrapper.bootstrap()


def test_second_fastapi_bootstrapper_bootstrap_raises(fastapi_config: FastAPIConfig) -> None:
application = fastapi.FastAPI()
first = FastAPIBootstrapper(bootstrap_config=dataclasses.replace(fastapi_config, application=application))
Expand Down
8 changes: 8 additions & 0 deletions tests/test_fastmcp_bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,14 @@ def test_fastmcp_bootstrap_without_structlog() -> None:
bootstrapper.teardown()


def test_fastmcp_bootstrap_after_teardown_raises() -> None:
bootstrapper = FastMcpBootstrapper(bootstrap_config=FastMcpConfig())
bootstrapper.bootstrap()
bootstrapper.teardown()
with pytest.raises(ConfigurationError, match="construct a new FastMcpBootstrapper"):
bootstrapper.bootstrap()


def test_second_fastmcp_bootstrapper_bootstrap_raises() -> None:
application = FastMCP()
first = FastMcpBootstrapper(bootstrap_config=FastMcpConfig(application=application, service_name="a"))
Expand Down
8 changes: 8 additions & 0 deletions tests/test_faststream_bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,14 @@ def _raise_on_write(_self: object, _value: object) -> None:
structlog.get_logger("verify-reset")


def test_faststream_bootstrap_after_teardown_raises() -> None:
bootstrapper = FastStreamBootstrapper(bootstrap_config=FastStreamConfig())
bootstrapper.bootstrap()
bootstrapper.teardown()
with pytest.raises(ConfigurationError, match="construct a new FastStreamBootstrapper"):
bootstrapper.bootstrap()


def test_second_faststream_bootstrapper_bootstrap_raises(broker: RedisBroker) -> None:
config_a = build_faststream_config(broker=broker)
first = FastStreamBootstrapper(bootstrap_config=config_a)
Expand Down
9 changes: 9 additions & 0 deletions tests/test_free_bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from opentelemetry.sdk.trace import sampling

from lite_bootstrap import (
ConfigurationError,
FreeBootstrapper,
FreeConfig,
InstrumentDependencyMissingWarning,
Expand Down Expand Up @@ -35,6 +36,14 @@ def free_bootstrapper_config() -> FreeConfig:
)


def test_free_bootstrap_after_teardown_raises() -> None:
bootstrapper = FreeBootstrapper(bootstrap_config=FreeConfig())
bootstrapper.bootstrap()
bootstrapper.teardown()
with pytest.raises(ConfigurationError, match="construct a new FreeBootstrapper"):
bootstrapper.bootstrap()


def test_free_bootstrap(free_bootstrapper_config: FreeConfig) -> None:
bootstrapper = FreeBootstrapper(bootstrap_config=free_bootstrapper_config)
bootstrapper.bootstrap()
Expand Down
11 changes: 5 additions & 6 deletions tests/test_litestar_bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,13 +124,12 @@ def test_litestar_bootstrap_twice_returns_the_same_application() -> None:
assert bootstrapper.bootstrap() is bootstrapper.bootstrap()


def test_litestar_bootstrap_after_teardown_builds_a_new_application() -> None:
bootstrapper = LitestarBootstrapper(
LitestarConfig(logging_enabled=False, health_checks_enabled=False, prometheus_metrics_path="")
)
first = bootstrapper.bootstrap()
def test_litestar_bootstrap_after_teardown_raises() -> None:
bootstrapper = LitestarBootstrapper(bootstrap_config=LitestarConfig())
bootstrapper.bootstrap()
bootstrapper.teardown()
assert bootstrapper.bootstrap() is not first
with pytest.raises(ConfigurationError, match="construct a new LitestarBootstrapper"):
bootstrapper.bootstrap()


def test_litestar_bootstrapper_not_ready() -> None:
Expand Down
Loading