Skip to content
Merged
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ BaseConfig (frozen dataclass, kw_only)
└── Framework configs compose multiple instrument configs via multiple inheritance

BaseInstrument[ConfigT] (generic, non-frozen dataclass with slots)
└── Instrument subclasses: lifecycle via bootstrap() / teardown() / is_ready()
└── Instrument subclasses: lifecycle via bootstrap() / teardown(); skip check via is_configured() classmethod

BaseBootstrapper (abc.ABC)
├── FastAPIBootstrapper
Expand All @@ -39,6 +39,7 @@ BaseBootstrapper (abc.ABC)
### Key design decisions

- **Optional dependencies**: Each instrument checks for its optional package via `import_checker.py` (`importlib.util.find_spec`). Instruments are skipped silently if the package is absent. Optional packages are imported inside `if import_checker.is_X_installed:` blocks; static analyzers that don't model this guard will report spurious "possibly unbound" diagnostics — the project uses `ty` which handles the pattern correctly.
- **Instrument skip ordering**: `BaseBootstrapper.__init__` runs `instrument_type.is_configured(config)` first (silent skip if the user's config indicates the instrument shouldn't run — populates `bootstrapper.skipped_instruments: list[tuple[type, str]]`); then `check_dependencies()` (emits `InstrumentDependencyMissingWarning` only for configured-but-dep-missing — the genuine deployment surprise); then instantiates. One `logger.info` summary line at the end lists configured + skipped instruments via `BaseBootstrapper.build_summary()`; that method is also publicly callable for post-construction debugging. Uses stdlib `logging` so it composes cleanly with the user's logging setup and with pytest's `caplog`.
- **Frozen configs, non-frozen instruments**: All `*Config` classes are `@dataclasses.dataclass(kw_only=True, frozen=True)`. All `*Instrument` classes lose `frozen=True` because two instruments (`LoggingInstrument`, `OpenTelemetryInstrument`) cache mutable runtime state (`_logger_factory`, `_tracer_provider`); Python's dataclass rules require the whole hierarchy to be non-frozen. `from_dict()` and `from_object()` filter unknown keys before constructing.
- **`FastAPIConfig.application` uses an `UnsetType` sentinel**: shared in `lite_bootstrap/types.py` as `UnsetType` + `UNSET` (singleton). `FastAPIConfig.__post_init__` checks `isinstance(self.application, UnsetType)` and replaces with a constructed `FastAPI()` via `object.__setattr__` (config stays frozen for user-facing immutability). A one-line comment in `__post_init__` documents the freeze bypass.
- **Instrument registry**: `BaseBootstrapper` holds a list of instrument instances; it calls `bootstrap()` on each in order and `teardown()` in reverse during shutdown.
Expand Down Expand Up @@ -78,7 +79,7 @@ Install via `pip install lite-bootstrap[<group>]` or `uv add lite-bootstrap[<gro
- **No `# noqa: PLR2004`**: extract magic values to named locals. Example: `expected_max_age = 600; assert config.cors_max_age == expected_max_age` (not `assert config.cors_max_age == 600 # noqa: PLR2004`).
- **Backward-compat aliases for renames**: when renaming a public class, add a silent module-level alias (`OldName = NewName`) at the end of the file. Re-export both names from `__init__.py` if the old name was publicly exported. Aliases are class assignments, not subclasses — same class object, so `isinstance` behavior is preserved.
- **Frozen-config bypass in `__post_init__`**: it's acceptable to use `object.__setattr__(self, "field", value)` inside a frozen config's `__post_init__` to set a field that requires other config values to construct. Document with a one-line comment naming the trade-off (user-facing immutability vs. construction-time mutation).
- **Optional-import guard pattern**: top-level conditional imports (`if import_checker.is_X_installed: import X`) keep optional dependencies actually optional. Code that references `X` is only reached when `check_dependencies()` has already returned True; the runtime invariant is maintained by `BaseBootstrapper._register_or_skip`. See "Type checking" below for why Pyright dislikes this pattern.
- **Optional-import guard pattern**: top-level conditional imports (`if import_checker.is_X_installed: import X`) keep optional dependencies actually optional. Code that references `X` is only reached when `check_dependencies()` has already returned True; the runtime invariant is maintained by the inline `is_configured → check_dependencies → instantiate` flow in `BaseBootstrapper.__init__`. See "Type checking" below.

### Type checking

Expand Down
31 changes: 18 additions & 13 deletions docs/introduction/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,28 +193,33 @@ Additional params:
- `health_checks_path`
- `health_checks_include_in_schema`

## Skipped instrument warnings
## Skipped instruments

When a bootstrapper is constructed, each registered instrument is checked. If it can't run, the instrument is skipped and a `UserWarning` subclass is emitted so the skip is visible at the call site:
When a bootstrapper is constructed, each registered instrument is checked twice:

- `InstrumentDependencyMissingWarning` — the instrument's optional package is not installed (e.g. `[sentry]` extra missing).
- `InstrumentNotReadyWarning` — the instrument's required config is missing or disabled (e.g. `sentry_dsn` not set, `logging_enabled=False`, `pyroscope_endpoint` empty).
- `InstrumentSkippedWarning` — base class for both, useful if you want to filter every skip with one rule.
1. **`is_configured(config)`** (classmethod, runs before instantiation) — returns False if the user's config indicates this instrument shouldn't run (e.g. `sentry_dsn` empty, `logging_enabled=False`, `pyroscope_endpoint` empty). When False, the instrument is **silently skipped** and recorded in `bootstrapper.skipped_instruments: list[tuple[type, str]]` — each entry is the instrument class plus its `not_ready_message`.

Both go through Python's `warnings` module, so they show up in stderr by default and can be filtered, captured, or escalated like any other warning. Example — silence intentional opt-outs but keep dependency-missing warnings loud:
2. **`check_dependencies()`** — runs only if `is_configured()` returned True. If the instrument's optional package is missing, an `InstrumentDependencyMissingWarning` is emitted. This is a real "configured but dependency missing" deployment surprise.

After the loop, the bootstrapper emits one INFO-level summary log listing configured + skipped instruments. Default Python logging suppresses INFO; opt in via `logging.basicConfig(level=logging.INFO)`.

Filter the dep-missing warning the same way as any `UserWarning`:

```python
import warnings
from lite_bootstrap import InstrumentNotReadyWarning
from lite_bootstrap import InstrumentDependencyMissingWarning

warnings.filterwarnings("ignore", category=InstrumentNotReadyWarning)
warnings.filterwarnings("ignore", category=InstrumentDependencyMissingWarning)
```

Or treat any skip as an error in CI:
`InstrumentSkippedWarning` is kept as the base class for forward-compatibility (additional skip categories may emerge); today, `InstrumentDependencyMissingWarning` is its only concrete subclass.

```python
import warnings
from lite_bootstrap import InstrumentSkippedWarning
To inspect skipped instruments programmatically:

warnings.filterwarnings("error", category=InstrumentSkippedWarning)
```python
bootstrapper = FastAPIBootstrapper(bootstrap_config=config)
for cls, reason in bootstrapper.skipped_instruments:
print(f"{cls.__name__}: {reason}")
```

To get a human-readable view of the same information at any later point (e.g. for debugging from a REPL or a health endpoint), call `bootstrapper.build_summary()`. It returns the multi-line string that the INFO summary log emits — useful when log levels are filtered or when you want to render the bootstrapper state inline.
Loading
Loading