diff --git a/docs/superpowers/plans/2026-06-01-pr13-frozen-setattr.md b/docs/superpowers/plans/2026-06-01-pr13-frozen-setattr.md index 7d1d648..e79c9d7 100644 --- a/docs/superpowers/plans/2026-06-01-pr13-frozen-setattr.md +++ b/docs/superpowers/plans/2026-06-01-pr13-frozen-setattr.md @@ -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. @@ -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). --- diff --git a/docs/superpowers/specs/2026-06-01-deferred-refactors-sequencing.md b/docs/superpowers/specs/2026-06-01-deferred-refactors-sequencing.md index cf3a1a3..90900d1 100644 --- a/docs/superpowers/specs/2026-06-01-deferred-refactors-sequencing.md +++ b/docs/superpowers/specs/2026-06-01-deferred-refactors-sequencing.md @@ -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. --- diff --git a/lite_bootstrap/bootstrappers/fastapi_bootstrapper.py b/lite_bootstrap/bootstrappers/fastapi_bootstrapper.py index 39a371d..345516f 100644 --- a/lite_bootstrap/bootstrappers/fastapi_bootstrapper.py +++ b/lite_bootstrap/bootstrappers/fastapi_bootstrapper.py @@ -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: @@ -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) @@ -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, @@ -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 @@ -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" @@ -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"]): @@ -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, ) @@ -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) diff --git a/lite_bootstrap/bootstrappers/faststream_bootstrapper.py b/lite_bootstrap/bootstrappers/faststream_bootstrapper.py index 3baaabd..a794dcb 100644 --- a/lite_bootstrap/bootstrappers/faststream_bootstrapper.py +++ b/lite_bootstrap/bootstrappers/faststream_bootstrapper.py @@ -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 @@ -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 @@ -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" @@ -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( diff --git a/lite_bootstrap/bootstrappers/litestar_bootstrapper.py b/lite_bootstrap/bootstrappers/litestar_bootstrapper.py index bad4066..b99f075 100644 --- a/lite_bootstrap/bootstrappers/litestar_bootstrapper.py +++ b/lite_bootstrap/bootstrappers/litestar_bootstrapper.py @@ -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 @@ -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 @@ -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 @@ -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 @@ -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" @@ -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" diff --git a/lite_bootstrap/instruments/base.py b/lite_bootstrap/instruments/base.py index e5fd6f5..5c2304c 100644 --- a/lite_bootstrap/instruments/base.py +++ b/lite_bootstrap/instruments/base.py @@ -29,7 +29,7 @@ def from_object(cls, obj: object) -> typing_extensions.Self: ConfigT = typing.TypeVar("ConfigT", bound=BaseConfig) -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) +@dataclasses.dataclass(kw_only=True, slots=True) class BaseInstrument(typing.Generic[ConfigT]): bootstrap_config: ConfigT not_ready_message = "" diff --git a/lite_bootstrap/instruments/cors_instrument.py b/lite_bootstrap/instruments/cors_instrument.py index 22b7384..1b16de4 100644 --- a/lite_bootstrap/instruments/cors_instrument.py +++ b/lite_bootstrap/instruments/cors_instrument.py @@ -14,7 +14,7 @@ class CorsConfig(BaseConfig): cors_max_age: int = 600 -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) +@dataclasses.dataclass(kw_only=True, slots=True) class CorsInstrument(BaseInstrument[CorsConfig]): not_ready_message = "cors_allowed_origins or cors_allowed_origin_regex must be provided" diff --git a/lite_bootstrap/instruments/healthchecks_instrument.py b/lite_bootstrap/instruments/healthchecks_instrument.py index 1116826..9d815d4 100644 --- a/lite_bootstrap/instruments/healthchecks_instrument.py +++ b/lite_bootstrap/instruments/healthchecks_instrument.py @@ -26,7 +26,7 @@ def health_check_data(self) -> HealthCheckTypedDict: } -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) +@dataclasses.dataclass(kw_only=True, slots=True) class HealthChecksInstrument(BaseInstrument[HealthChecksConfig]): not_ready_message = "health_checks_enabled is False" diff --git a/lite_bootstrap/instruments/logging_instrument.py b/lite_bootstrap/instruments/logging_instrument.py index 2c4ec79..0443384 100644 --- a/lite_bootstrap/instruments/logging_instrument.py +++ b/lite_bootstrap/instruments/logging_instrument.py @@ -72,7 +72,7 @@ class LoggingConfig(BaseConfig): logging_enabled: bool = True -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) +@dataclasses.dataclass(kw_only=True, slots=True) class LoggingInstrument(BaseInstrument[LoggingConfig]): not_ready_message = "logging_enabled is False" missing_dependency_message = "structlog is not installed" @@ -125,7 +125,7 @@ def memory_logger_factory(self) -> "MemoryLoggerFactory": logging_log_level=self.bootstrap_config.logging_log_level, ), ) - object.__setattr__(self, "_logger_factory", cached) + self._logger_factory = cached return cached def _configure_structlog_loggers(self) -> None: @@ -174,4 +174,4 @@ def teardown(self) -> None: try: self._logger_factory.close_handlers() finally: - object.__setattr__(self, "_logger_factory", None) + self._logger_factory = None diff --git a/lite_bootstrap/instruments/opentelemetry_instrument.py b/lite_bootstrap/instruments/opentelemetry_instrument.py index cf4d8f9..05e972a 100644 --- a/lite_bootstrap/instruments/opentelemetry_instrument.py +++ b/lite_bootstrap/instruments/opentelemetry_instrument.py @@ -77,7 +77,7 @@ def force_flush(self, timeout_millis: int = 30000) -> bool: # pragma: no cover return True -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) +@dataclasses.dataclass(kw_only=True, slots=True) class OpenTelemetryInstrument(BaseInstrument[OpentelemetryConfig]): not_ready_message = "opentelemetry_endpoint is empty and opentelemetry_log_traces is False" missing_dependency_message = "opentelemetry is not installed" @@ -119,7 +119,7 @@ def bootstrap(self) -> None: ) tracer_provider = TracerProvider(resource=resource) set_tracer_provider(tracer_provider) - object.__setattr__(self, "_tracer_provider", tracer_provider) + self._tracer_provider = tracer_provider if import_checker.is_pyroscope_installed and getattr(self.bootstrap_config, "pyroscope_endpoint", None): tracer_provider.add_span_processor(PyroscopeSpanProcessor()) if self.bootstrap_config.opentelemetry_log_traces: @@ -152,4 +152,4 @@ def teardown(self) -> None: try: self._tracer_provider.shutdown() finally: - object.__setattr__(self, "_tracer_provider", None) + self._tracer_provider = None diff --git a/lite_bootstrap/instruments/prometheus_instrument.py b/lite_bootstrap/instruments/prometheus_instrument.py index 8bc81c8..6d1b11e 100644 --- a/lite_bootstrap/instruments/prometheus_instrument.py +++ b/lite_bootstrap/instruments/prometheus_instrument.py @@ -12,7 +12,7 @@ class PrometheusConfig(BaseConfig): prometheus_metrics_include_in_schema: bool = False -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) +@dataclasses.dataclass(kw_only=True, slots=True) class PrometheusInstrument(BaseInstrument[PrometheusConfig]): not_ready_message = "prometheus_metrics_path is empty or not valid" diff --git a/lite_bootstrap/instruments/pyroscope_instrument.py b/lite_bootstrap/instruments/pyroscope_instrument.py index d64d0c1..04938f2 100644 --- a/lite_bootstrap/instruments/pyroscope_instrument.py +++ b/lite_bootstrap/instruments/pyroscope_instrument.py @@ -18,7 +18,7 @@ class PyroscopeConfig(OpenTelemetryServiceFieldsConfig): pyroscope_additional_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 PyroscopeInstrument(BaseInstrument[PyroscopeConfig]): not_ready_message = "pyroscope_endpoint is empty" missing_dependency_message = "pyroscope is not installed" diff --git a/lite_bootstrap/instruments/sentry_instrument.py b/lite_bootstrap/instruments/sentry_instrument.py index 8ef1247..d7faa6e 100644 --- a/lite_bootstrap/instruments/sentry_instrument.py +++ b/lite_bootstrap/instruments/sentry_instrument.py @@ -91,7 +91,7 @@ def run_before_send( return run_before_send -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) +@dataclasses.dataclass(kw_only=True, slots=True) class SentryInstrument(BaseInstrument[SentryConfig]): not_ready_message = "sentry_dsn is empty" missing_dependency_message = "sentry_sdk is not installed" diff --git a/lite_bootstrap/instruments/swagger_instrument.py b/lite_bootstrap/instruments/swagger_instrument.py index f222a98..91eea86 100644 --- a/lite_bootstrap/instruments/swagger_instrument.py +++ b/lite_bootstrap/instruments/swagger_instrument.py @@ -12,6 +12,6 @@ class SwaggerConfig(BaseConfig): swagger_offline_docs: bool = False -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) +@dataclasses.dataclass(kw_only=True, slots=True) class SwaggerInstrument(BaseInstrument[SwaggerConfig]): pass diff --git a/lite_bootstrap/types.py b/lite_bootstrap/types.py index 19ebd88..e43792a 100644 --- a/lite_bootstrap/types.py +++ b/lite_bootstrap/types.py @@ -3,3 +3,17 @@ BootstrapObjectT = typing.TypeVar("BootstrapObjectT", bound=typing.Any) ApplicationT = typing.TypeVar("ApplicationT", bound=typing.Any) + + +class UnsetType: + """Sentinel type for parameters that distinguish 'not passed' from 'explicitly None'. + + The :data:`UNSET` module-level instance is the canonical sentinel. Use + ``isinstance(value, UnsetType)`` or ``value is UNSET`` to detect it. + """ + + def __repr__(self) -> str: + return "UNSET" + + +UNSET: typing.Final[UnsetType] = UnsetType() diff --git a/pyproject.toml b/pyproject.toml index 2d60649..4132e19 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -121,7 +121,7 @@ dev = [ "pytest", "pytest-cov", "pytest-asyncio", - "httpx", # for test client + "httpx2", # for test client "redis>=5.2.1", ] lint = [ diff --git a/tests/test_litestar_bootstrap.py b/tests/test_litestar_bootstrap.py index 210cfe0..14bb201 100644 --- a/tests/test_litestar_bootstrap.py +++ b/tests/test_litestar_bootstrap.py @@ -5,6 +5,7 @@ import structlog from litestar import status_codes from litestar.config.app import AppConfig +from litestar.params import FromPath from litestar.testing import TestClient from opentelemetry.sdk.trace import TracerProvider as SDKTracerProvider from opentelemetry.sdk.trace.export import SimpleSpanProcessor @@ -91,7 +92,7 @@ def test_litestar_bootstrapper_with_missing_instrument_dependency( def test_litestar_otel_span_naming(litestar_config: LitestarConfig) -> None: @litestar.get("/items/{item_id:int}") - async def get_item(item_id: int) -> dict[str, int]: + async def get_item(item_id: FromPath[int]) -> dict[str, int]: return {"item_id": item_id} config = dataclasses.replace(litestar_config, application_config=AppConfig(route_handlers=[get_item]))