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
10 changes: 7 additions & 3 deletions docs/superpowers/plans/2026-06-01-pr13-frozen-setattr.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

- **REF-6**: Drop `frozen=True` from the instrument hierarchy. Python's dataclass rules force a cascade: dropping `frozen=True` from `LoggingInstrument` requires dropping it from `BaseInstrument`, which requires dropping it from every other instrument subclass. 23 dataclass declarations across 12 files lose `frozen=True`. In `LoggingInstrument` and `OpenTelemetryInstrument`, the four `object.__setattr__(self, "_x", value)` workarounds for cached runtime state become plain `self._x = value` assignments. **Configs stay frozen** — only instruments lose `frozen=True`.

- **LOW-4**: `FastAPIConfig.application` currently uses `default=None` + `# ty: ignore[invalid-assignment]` because the field is typed `fastapi.FastAPI` (non-Optional). Replace with a typed-sentinel pattern: `_UNSET_FASTAPI_APP: typing.Final = typing.cast("fastapi.FastAPI", object())`. The `__post_init__` checks `self.application is _UNSET_FASTAPI_APP` instead of `not self.application`. Drops the `# ty: ignore`. FastAPIConfig stays frozen (so the existing `object.__setattr__(self, "application", ...)` in `__post_init__` remains — only the default-and-sentinel-check changes).
- **LOW-4**: `FastAPIConfig.application` currently uses `default=None` + `# ty: ignore[invalid-assignment]` because the field is typed `fastapi.FastAPI` (non-Optional). Replace with a proper sentinel-type pattern: introduce `UnsetType` + `UNSET` in `lite_bootstrap/types.py` (a sentinel class with a singleton instance), type the field as `fastapi.FastAPI | UnsetType`, default to `UNSET`, and replace the truthiness check in `__post_init__` with `isinstance(self.application, UnsetType)`. Add a `_narrow_app(config)` helper at module scope that asserts the type and returns the narrowed value; FastAPI framework instruments call `_narrow_app(self.bootstrap_config)` instead of `self.bootstrap_config.application` directly. Drops the `# ty: ignore`. FastAPIConfig stays frozen `object.__setattr__(self, "application", ...)` in `__post_init__` remains because the freeze bypass is the only way to mutate a frozen field after construction; a code comment documents the rationale.

**Architecture:** Largest mechanical refactor in the deferred-refactors sequence. The cascade is purely mechanical — every change is `frozen=True` → (delete). The setattr replacements and LOW-4 sentinel are the only meaningful diffs.

Expand Down Expand Up @@ -48,16 +48,20 @@ The sequencing spec's PR13 section said "drop `frozen=True` from `LoggingInstrum
- `lite_bootstrap/instruments/swagger_instrument.py` — `SwaggerInstrument` loses `frozen=True`.

**Bootstrapper modules (3 files):**
- `lite_bootstrap/bootstrappers/fastapi_bootstrapper.py` — 5 framework instruments lose `frozen=True` + LOW-4 sentinel pattern on `FastAPIConfig.application`.
- `lite_bootstrap/bootstrappers/fastapi_bootstrapper.py` — 5 framework instruments lose `frozen=True` + LOW-4 sentinel-type pattern on `FastAPIConfig.application` + `_narrow_app` helper + every FastAPI instrument bootstrap calls `_narrow_app(self.bootstrap_config)` for the app reference.
- `lite_bootstrap/bootstrappers/litestar_bootstrapper.py` — 6 framework instruments lose `frozen=True`.
- `lite_bootstrap/bootstrappers/faststream_bootstrapper.py` — 4 framework instruments lose `frozen=True`.

**Shared types (1 file):**
- `lite_bootstrap/types.py` — add `UnsetType` class + `UNSET: typing.Final[UnsetType]` singleton. Reusable sentinel for fields that distinguish "not passed" from "explicitly None".

---

## Locked decisions

- **Cascade scope:** Drop `frozen=True` from `BaseInstrument` and ALL 22 instrument subclasses. Configs stay frozen. Confirmed by the user after the constraint surfaced.
- **LOW-4 pattern:** Typed sentinel via `typing.cast`. Cleaner than the current `# ty: ignore` once the cast obscures the type lie.
- **LOW-4 pattern:** Proper `UnsetType` sentinel class in `lite_bootstrap/types.py`, used via `isinstance(value, UnsetType)`. Honest to the type checker (no `typing.cast` lie). Adds a `_narrow_app` helper that wraps the assert/return narrowing for callers. Revised from the original plan's `typing.cast("fastapi.FastAPI", object())` pattern — the spec was updated retroactively to match what was built.
- **`FastAPIConfig` stays frozen:** Confirmed by user. The `object.__setattr__(self, "application", ...)` in `__post_init__` remains; a one-line code comment documents the rationale (frozen for user-facing immutability; bypass needed because `application` is constructed using other config fields).
- **No new tests.** The full existing test suite verifies behavior preservation; pure-refactor changes should not affect runtime semantics other than enabling future direct mutation (which we don't exercise).

---
Expand Down
84 changes: 33 additions & 51 deletions docs/superpowers/specs/2026-06-01-deferred-refactors-sequencing.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,60 +211,42 @@ suite passes against the new layout.

**Scope:**

- REF-6: Drop `frozen=True` from `LoggingInstrument` and `OpenTelemetryInstrument`
(the only two instruments that legitimately cache mutable runtime state via
`object.__setattr__`). Replace `object.__setattr__(self, "_x", value)` with plain
`self._x = value`. The exception-safety from PR3's `try/finally` shape stays
intact — the pattern simplifies from:

```python
if self._tracer_provider is not None:
try:
self._tracer_provider.shutdown()
finally:
object.__setattr__(self, "_tracer_provider", None)
```

to:

```python
if self._tracer_provider is not None:
try:
self._tracer_provider.shutdown()
finally:
self._tracer_provider = None
```

Configs stay frozen — only the two instruments with cached state lose `frozen`.
Apply to both `LoggingInstrument._logger_factory` resets and
`OpenTelemetryInstrument._tracer_provider` resets, including in their bootstrap
paths.

- LOW-4: Address `FastAPIConfig.application` declared with
`default=None` + `# ty: ignore[invalid-assignment]` and patched in `__post_init__`.
The minimum-risk cleanup is to use a sentinel instead of `None`:

```python
_UNSET: typing.Final = typing.cast("fastapi.FastAPI", object())
application: "fastapi.FastAPI" = _UNSET
```

Drop the `# ty: ignore`. The `__post_init__` check changes from `if not
self.application:` to `if self.application is _UNSET:`. Litestar's
`default_factory=lambda: AppConfig()` pattern doesn't work here because the
factory needs `service_name` from the config to set `app.title` etc.

**Files:** 3-4 — `logging_instrument.py`, `opentelemetry_instrument.py`,
`fastapi_bootstrapper.py`. Possibly `tests/instruments/test_*_instrument.py` if any
tests rely on instrument immutability (`dataclasses.replace`, `frozen` errors).
- REF-6: Python's dataclass rules forbid surgical unfreezing (a non-frozen
dataclass can't inherit from a frozen one). To drop `frozen=True` from
`LoggingInstrument` and `OpenTelemetryInstrument` (the two instruments with
`object.__setattr__` workarounds), `BaseInstrument` and all 22 instrument
subclasses must also lose `frozen=True`. Configs all keep `frozen=True`.
After the cascade, 4 `object.__setattr__(self, "_x", value)` call sites in
the two instruments become plain `self._x = value`. The `try/finally`
exception safety from PR3 is preserved.

- LOW-4: `FastAPIConfig.application` declared with `default=None` +
`# ty: ignore[invalid-assignment]`. Replaced with a proper sentinel-type
pattern: introduce `UnsetType` + `UNSET` singleton in
`lite_bootstrap/types.py`, type the field as `fastapi.FastAPI | UnsetType`,
default to `UNSET`, check via `isinstance(self.application, UnsetType)`. Add
a `_narrow_app(config)` helper that asserts the type and returns the
narrowed app; every FastAPI framework instrument calls it. Drops the
`# ty: ignore`. `FastAPIConfig` stays frozen — the existing
`object.__setattr__(self, "application", ...)` in `__post_init__` remains
(a code comment documents the rationale). Sibling configs (`LitestarConfig`,
`FastStreamConfig`) don't have this need because they use `default_factory`
for their app fields.

**Note:** the originally-planned `typing.cast("fastapi.FastAPI", object())`
sentinel was replaced during implementation with a proper `UnsetType` class.
This spec has been retroactively updated to match what was built.

**Files:** 13 — 9 instrument modules (`base.py` + 8 base instruments), 3
bootstrapper modules (`fastapi`, `litestar`, `faststream`), and `types.py`
(new `UnsetType` + `UNSET` sentinel).

**Test impact:** Existing tests should pass unchanged. Watch for any test that relied
on `FrozenInstanceError` being raised on instrument mutation — none expected, but
verify.
on `FrozenInstanceError` being raised on instrument mutation — none expected.

**Risk:** Medium. The `frozen` change is observable to user code that relied on
`dataclasses.replace` for `LoggingInstrument` / `OpenTelemetryInstrument`. Unlikely
in practice but worth noting.
**Risk:** Medium. The cascade is mechanical but missing one entry breaks the build
(TypeError at import). The `frozen` change is observable to user code that relied on
`dataclasses.replace` for instruments — unlikely in practice but worth noting.

---

Expand Down
69 changes: 39 additions & 30 deletions lite_bootstrap/bootstrappers/fastapi_bootstrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from lite_bootstrap.instruments.pyroscope_instrument import PyroscopeConfig, PyroscopeInstrument
from lite_bootstrap.instruments.sentry_instrument import SentryConfig, SentryInstrument
from lite_bootstrap.instruments.swagger_instrument import SwaggerConfig, SwaggerInstrument
from lite_bootstrap.types import UNSET, UnsetType


if import_checker.is_fastapi_installed:
Expand Down Expand Up @@ -47,7 +48,7 @@ class FastAPIConfig(
SentryConfig,
SwaggerConfig,
):
application: "fastapi.FastAPI" = dataclasses.field(default=None) # ty: ignore[invalid-assignment]
application: "fastapi.FastAPI | UnsetType" = UNSET
application_kwargs: dict[str, typing.Any] = dataclasses.field(default_factory=dict)
opentelemetry_excluded_urls: list[str] = dataclasses.field(default_factory=list)
prometheus_instrumentator_params: dict[str, typing.Any] = dataclasses.field(default_factory=dict)
Expand All @@ -59,24 +60,32 @@ def __post_init__(self) -> None:
msg = "fastapi is not installed"
raise ConfigurationError(msg)

if not self.application:
object.__setattr__(
self, "application", fastapi.FastAPI(docs_url=self.swagger_path, **self.application_kwargs)
)
elif self.application_kwargs:
warnings.warn("application_kwargs must be used without application", stacklevel=2)
if isinstance(self.application, UnsetType):
application = fastapi.FastAPI(docs_url=self.swagger_path, **self.application_kwargs)
# FastAPIConfig stays frozen for user-facing immutability; __post_init__ needs
# to set application after construction, so we bypass the freeze here.
object.__setattr__(self, "application", application)
else:
application = self.application
if self.application_kwargs:
warnings.warn("application_kwargs must be used without application", stacklevel=2)

self.application.title = self.service_name
self.application.debug = self.service_debug
self.application.version = self.service_version
application.title = self.service_name
application.debug = self.service_debug
application.version = self.service_version


@dataclasses.dataclass(kw_only=True, slots=True, frozen=True)
def _narrow_app(config: "FastAPIConfig") -> "fastapi.FastAPI":
assert not isinstance(config.application, UnsetType)
return config.application


@dataclasses.dataclass(kw_only=True, slots=True)
class FastAPICorsInstrument(CorsInstrument):
bootstrap_config: FastAPIConfig

def bootstrap(self) -> None:
self.bootstrap_config.application.add_middleware(
_narrow_app(self.bootstrap_config).add_middleware(
CORSMiddleware,
allow_origins=self.bootstrap_config.cors_allowed_origins,
allow_methods=self.bootstrap_config.cors_allowed_methods,
Expand All @@ -88,7 +97,7 @@ def bootstrap(self) -> None:
)


@dataclasses.dataclass(kw_only=True, slots=True, frozen=True)
@dataclasses.dataclass(kw_only=True, slots=True)
class FastAPIHealthChecksInstrument(HealthChecksInstrument):
bootstrap_config: FastAPIConfig

Expand All @@ -105,27 +114,27 @@ async def health_check_handler() -> HealthCheckTypedDict:
return fastapi_router

def bootstrap(self) -> None:
self.bootstrap_config.application.include_router(self.build_fastapi_health_check_router())
_narrow_app(self.bootstrap_config).include_router(self.build_fastapi_health_check_router())


@dataclasses.dataclass(kw_only=True, frozen=True)
@dataclasses.dataclass(kw_only=True)
class FastAPIOpenTelemetryInstrument(OpenTelemetryInstrument):
bootstrap_config: FastAPIConfig

def bootstrap(self) -> None:
super().bootstrap()
FastAPIInstrumentor.instrument_app(
app=self.bootstrap_config.application,
app=_narrow_app(self.bootstrap_config),
tracer_provider=get_tracer_provider(),
excluded_urls=",".join(self._build_excluded_urls()),
)

def teardown(self) -> None:
FastAPIInstrumentor.uninstrument_app(self.bootstrap_config.application)
FastAPIInstrumentor.uninstrument_app(_narrow_app(self.bootstrap_config))
super().teardown()


@dataclasses.dataclass(kw_only=True, frozen=True)
@dataclasses.dataclass(kw_only=True)
class FastAPIPrometheusInstrument(PrometheusInstrument):
bootstrap_config: FastAPIConfig
missing_dependency_message = "prometheus_fastapi_instrumentator is not installed"
Expand All @@ -135,32 +144,31 @@ def check_dependencies() -> bool:
return import_checker.is_prometheus_fastapi_instrumentator_installed

def bootstrap(self) -> None:
application = _narrow_app(self.bootstrap_config)
Instrumentator(**self.bootstrap_config.prometheus_instrumentator_params).instrument(
self.bootstrap_config.application,
application,
**self.bootstrap_config.prometheus_instrument_params,
).expose(
self.bootstrap_config.application,
application,
endpoint=self.bootstrap_config.prometheus_metrics_path,
include_in_schema=self.bootstrap_config.prometheus_metrics_include_in_schema,
**self.bootstrap_config.prometheus_expose_params,
)


@dataclasses.dataclass(kw_only=True, frozen=True)
@dataclasses.dataclass(kw_only=True)
class FastAPISwaggerInstrument(SwaggerInstrument):
bootstrap_config: FastAPIConfig

def bootstrap(self) -> None:
if self.bootstrap_config.swagger_path != self.bootstrap_config.application.docs_url:
application = _narrow_app(self.bootstrap_config)
if self.bootstrap_config.swagger_path != application.docs_url:
warnings.warn(
f"swagger_path differs from docs_url, "
f"{self.bootstrap_config.application.docs_url} will be used for docs path",
f"swagger_path differs from docs_url, {application.docs_url} will be used for docs path",
stacklevel=2,
)
if self.bootstrap_config.swagger_offline_docs:
enable_offline_docs(
self.bootstrap_config.application, static_path=self.bootstrap_config.swagger_static_path
)
enable_offline_docs(application, static_path=self.bootstrap_config.swagger_static_path)


class FastAPIBootstrapper(BaseBootstrapper["fastapi.FastAPI"]):
Expand Down Expand Up @@ -189,8 +197,9 @@ async def lifespan_manager(self, _: "fastapi.FastAPI") -> typing.AsyncIterator[d
def __init__(self, bootstrap_config: FastAPIConfig) -> None:
super().__init__(bootstrap_config)

old_lifespan_manager = self.bootstrap_config.application.router.lifespan_context
self.bootstrap_config.application.router.lifespan_context = _merge_lifespan_context(
application = _narrow_app(self.bootstrap_config)
old_lifespan_manager = application.router.lifespan_context
application.router.lifespan_context = _merge_lifespan_context(
old_lifespan_manager,
self.lifespan_manager,
)
Expand All @@ -199,4 +208,4 @@ def is_ready(self) -> bool:
return import_checker.is_fastapi_installed

def _prepare_application(self) -> "fastapi.FastAPI":
return self.bootstrap_config.application
return _narrow_app(self.bootstrap_config)
8 changes: 4 additions & 4 deletions lite_bootstrap/bootstrappers/faststream_bootstrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ class FastStreamConfig(
faststream_log_level: int = logging.WARNING


@dataclasses.dataclass(kw_only=True, slots=True, frozen=True)
@dataclasses.dataclass(kw_only=True, slots=True)
class FastStreamHealthChecksInstrument(HealthChecksInstrument):
bootstrap_config: FastStreamConfig

Expand Down Expand Up @@ -104,7 +104,7 @@ async def _define_health_status(self) -> bool:
return await self.bootstrap_config.application.broker.ping(timeout=5)


@dataclasses.dataclass(kw_only=True, frozen=True)
@dataclasses.dataclass(kw_only=True)
class FastStreamLoggingInstrument(LoggingInstrument):
bootstrap_config: FastStreamConfig

Expand All @@ -117,7 +117,7 @@ def bootstrap(self) -> None:
broker.config.logger.params_storage = ManualLoggerStorage(logger)


@dataclasses.dataclass(kw_only=True, frozen=True)
@dataclasses.dataclass(kw_only=True)
class FastStreamOpenTelemetryInstrument(OpenTelemetryInstrument):
bootstrap_config: FastStreamConfig
not_ready_message = OpenTelemetryInstrument.not_ready_message + " or opentelemetry_middleware_cls is empty"
Expand All @@ -136,7 +136,7 @@ def _make_collector_registry() -> "prometheus_client.CollectorRegistry":
return prometheus_client.CollectorRegistry()


@dataclasses.dataclass(kw_only=True, frozen=True)
@dataclasses.dataclass(kw_only=True)
class FastStreamPrometheusInstrument(PrometheusInstrument):
bootstrap_config: FastStreamConfig
collector_registry: "prometheus_client.CollectorRegistry" = dataclasses.field(
Expand Down
12 changes: 6 additions & 6 deletions lite_bootstrap/bootstrappers/litestar_bootstrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ class LitestarConfig(
swagger_extra_params: dict[str, typing.Any] = dataclasses.field(default_factory=dict)


@dataclasses.dataclass(kw_only=True, slots=True, frozen=True)
@dataclasses.dataclass(kw_only=True, slots=True)
class LitestarCorsInstrument(CorsInstrument):
bootstrap_config: LitestarConfig

Expand All @@ -132,7 +132,7 @@ def bootstrap(self) -> None:
)


@dataclasses.dataclass(kw_only=True, slots=True, frozen=True)
@dataclasses.dataclass(kw_only=True, slots=True)
class LitestarHealthChecksInstrument(HealthChecksInstrument):
bootstrap_config: LitestarConfig

Expand All @@ -152,7 +152,7 @@ def bootstrap(self) -> None:
self.bootstrap_config.application_config.route_handlers.append(self.build_litestar_health_check_router())


@dataclasses.dataclass(kw_only=True, frozen=True)
@dataclasses.dataclass(kw_only=True)
class LitestarLoggingInstrument(LoggingInstrument):
bootstrap_config: LitestarConfig

Expand All @@ -175,7 +175,7 @@ def bootstrap(self) -> None:
self._configure_foreign_loggers()


@dataclasses.dataclass(kw_only=True, frozen=True)
@dataclasses.dataclass(kw_only=True)
class LitestarOpenTelemetryInstrument(OpenTelemetryInstrument):
bootstrap_config: LitestarConfig

Expand All @@ -189,7 +189,7 @@ def bootstrap(self) -> None:
)


@dataclasses.dataclass(kw_only=True, frozen=True)
@dataclasses.dataclass(kw_only=True)
class LitestarPrometheusInstrument(PrometheusInstrument):
bootstrap_config: LitestarConfig
missing_dependency_message = "prometheus_client is not installed"
Expand All @@ -213,7 +213,7 @@ class LitestarPrometheusController(PrometheusController):
self.bootstrap_config.application_config.middleware.append(litestar_prometheus_config.middleware)


@dataclasses.dataclass(kw_only=True, frozen=True)
@dataclasses.dataclass(kw_only=True)
class LitestarSwaggerInstrument(SwaggerInstrument):
bootstrap_config: LitestarConfig
not_ready_message = "swagger_path is empty or not valid"
Expand Down
Loading
Loading