diff --git a/.github/workflows/_checks.yml b/.github/workflows/_checks.yml index 3f45cbb..e349fa0 100644 --- a/.github/workflows/_checks.yml +++ b/.github/workflows/_checks.yml @@ -46,3 +46,26 @@ jobs: - run: uv python pin ${{ matrix.python-version }} - run: just install - run: just test . --cov=. --cov-report xml + + free-threaded: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - python-version: "3.13t" + extras: "logging,sentry,fastapi,faststream,fastapi-metrics,faststream-metrics" + - python-version: "3.14t" + extras: "logging,sentry,fastapi,litestar,faststream,fastapi-metrics,litestar-metrics,faststream-metrics" + steps: + - uses: actions/checkout@v6 + - uses: astral-sh/setup-uv@v8.2.0 + with: + enable-cache: true + cache-dependency-glob: "**/pyproject.toml" + - run: uv python install ${{ matrix.python-version }} + - run: uv venv --python ${{ matrix.python-version }} + - name: Install core + ft-ready extras (orjson/otl/pyroscope/fastmcp excluded) + run: uv pip install ".[${{ matrix.extras }}]" + - name: Free-threaded smoke test + run: .venv/bin/python scripts/ft_smoke.py diff --git a/.gitignore b/.gitignore index c73839d..09c64ce 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,4 @@ dist/ .venv uv.lock site/ +.superpowers/ diff --git a/CLAUDE.md b/CLAUDE.md index ecf4466..c8ab359 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,6 +65,7 @@ Capability index (all of `architecture/`): | Config model — `BaseConfig`, multiple-inheritance composition, `from_dict`/`from_object`, `UNSET`, `__post_init__` cascade | `architecture/config-model.md` | | Instruments — `BaseInstrument` lifecycle, catalog, optional-dep guard, non-frozen rationale, cross-instrument integrations (Logging↔Sentry, OTel↔Logging, Pyroscope↔OTel), OTel single-instance | `architecture/instruments.md` | | Bootstrappers — hierarchy, skip ordering, registry + idempotent teardown, summary logging, teardown-attach seam, app-tagging sentinels | `architecture/bootstrappers.md` | +| Free-threading — nogil support matrix, orjson fallback, single-threaded-init invariant | `architecture/free-threading.md` | Recent design context, bugs, and rationale: bug-audit findings in `planning/audits/`, post-work reflections in `planning/retros/`, and the audit diff --git a/README.md b/README.md index baecc5d..d039ae6 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ The following ideas were borrowed: The following intentionally differ: - **Configuration**: `lite-bootstrap` uses frozen `dataclass` configs (no `pydantic` / `pydantic-settings` runtime dependency), which is what makes it "lite". `microbootstrap` configures everything through `pydantic-settings` models. -- **Granular extras**: `lite-bootstrap` ships only `orjson` as a runtime dependency; every instrument (`sentry`, `otl`, `logging`, `pyroscope`) and every framework (`fastapi`, `litestar`, `faststream`) is its own extra, with per-pair combos (`fastapi-sentry`, `litestar-otl`, `faststream-metrics`, …) and `*-all` rollups. You install only what you actually use. `microbootstrap` bundles the full observability stack (opentelemetry, sentry-sdk, structlog, pyroscope-io, rich, pydantic-settings, …) as base dependencies and only splits framework packages into extras. +- **Granular extras**: `lite-bootstrap` has **no** mandatory runtime dependencies; every instrument (`sentry`, `otl`, `logging`, `pyroscope`) and every framework (`fastapi`, `litestar`, `faststream`) is its own extra, plus an opt-in `orjson` speedup for logging, per-pair combos (`fastapi-sentry`, `litestar-otl`, `faststream-metrics`, …) and `*-all` rollups. You install only what you actually use. It also runs on free-threaded CPython (3.13t/3.14t) — see [`architecture/free-threading.md`](architecture/free-threading.md). `microbootstrap` bundles the full observability stack (opentelemetry, sentry-sdk, structlog, pyroscope-io, rich, pydantic-settings, …) as base dependencies and only splits framework packages into extras. - **Scope**: `lite-bootstrap` is deliberately narrow — only instrument wiring. It does not include a Granian server runner or a console writer. ## 📚 [Documentation](https://lite-bootstrap.modern-python.org) diff --git a/architecture/bootstrappers.md b/architecture/bootstrappers.md index 3f46f43..6c3e436 100644 --- a/architecture/bootstrappers.md +++ b/architecture/bootstrappers.md @@ -87,6 +87,14 @@ The guard is uniform: the same marker and warning apply to all four app-bearing frameworks. `attach` is typed `Callable[[], object]` because some hooks (FastStream's `on_shutdown`) return the callback. +## Single-threaded init (free-threading) + +`bootstrap()`/`teardown()` are startup/shutdown, main-thread operations; their +cached state (`is_bootstrapped`, the teardown-attach marker) carries no locks and +is not safe to drive concurrently on one bootstrapper. This is intentional: under +free-threaded CPython the parallelism is in request handling, not bootstrap. See +[`free-threading.md`](free-threading.md). + ## App-tagging sentinel convention When a bootstrapper must tag a user-supplied framework app (FastAPI, FastMCP, diff --git a/architecture/free-threading.md b/architecture/free-threading.md new file mode 100644 index 0000000..c13444f --- /dev/null +++ b/architecture/free-threading.md @@ -0,0 +1,51 @@ +# Free-threaded Python (nogil) + +`lite-bootstrap` runs on free-threaded CPython (PEP 703): 3.14t (officially +supported per PEP 779) and 3.13t (experimental). The library is pure Python; +what blocks a given surface on ft is always a native dependency somewhere in +its extra, not `lite-bootstrap` itself. + +## Support matrix + +| Surface | 3.13t | 3.14t | Notes | +|---|---|---|---| +| core, `logging`, `sentry` | ✅ | ✅ | pure Python; `logging` uses the stdlib-json serializer fallback when `orjson` is absent | +| `fastapi`/`faststream` (+ `-sentry`/`-logging`/`-metrics`) | ✅ | ✅ | pure Python + `pydantic-core` ft wheels | +| `litestar` (+ `litestar-metrics`) | ❌ | ✅ | `msgspec` gates `Py_GIL_DISABLED` to Python 3.14+ — its `_core.c` contains `#error "Py_GIL_DISABLED is only supported in Python 3.14+"` (v0.21.1), so the source build fails on 3.13t. See [`planning/deferred.md`](../planning/deferred.md) | +| `fastmcp` (+ `fastmcp-metrics`) | ❌ | ❌ | 3.13t: `cffi` (via `fastmcp`→`cryptography`) refuses to build free-threaded. 3.14t: `fastmcp` pulls bare `opentelemetry-api` without `opentelemetry-sdk`, so `import lite_bootstrap` still fails. See [`planning/deferred.md`](../planning/deferred.md) | +| `orjson` (opt-in speedup) | ❌ | ❌ | no ft wheels, build refuses ft ([ijl/orjson#530](https://github.com/ijl/orjson/issues/530)). Omit it on ft; the serializer falls back to stdlib json | +| `otl` (gRPC exporter) | ❌ | ❌ | needs `grpcio`, no ft wheels ([grpc/grpc#38762](https://github.com/grpc/grpc/issues/38762)). An HTTP-exporter path is deferred (`planning/deferred.md`) | +| `pyroscope` | ❌ | ❌ | `pyroscope-io` is abi3-only, unmaintained, no ft wheels ([`planning/deferred.md`](../planning/deferred.md)) | + +The CI matrix (`.github/workflows/_checks.yml`, `free-threaded` job) installs +per Python version to match this table exactly: the 3.13t leg's extras stop at +`logging,sentry,fastapi,faststream,fastapi-metrics,faststream-metrics`; the +3.14t leg adds `litestar,litestar-metrics`. `orjson`, `otl`, `pyroscope`, and +`fastmcp`/`fastmcp-metrics` are excluded from both legs. + +## The `orjson` fallback + +`orjson` is used only by the logging serializer. It is an opt-in extra +(`lite-bootstrap[orjson]`), preferred when present (GIL-build output unchanged); +otherwise `logging_factory` serializes with the stdlib `json` accelerator using +compact separators and `ensure_ascii=False`, so output stays byte-identical for +JSON-native values. Trade-off on the fallback: ~2-5x slower JSON, and non-JSON-native +types in log `extra` (datetime/UUID) render via repr instead of orjson's native +encoding. Reverts to the native path automatically once `orjson` ships ft wheels. + +## Single-threaded-init invariant + +`bootstrap()`/`teardown()` are startup/shutdown, main-thread operations. The +cached mutable state (`is_bootstrapped`, `OpenTelemetryInstrument._tracer_provider`, +the `_lite_bootstrap_teardown_attached` marker) is **not** guarded for concurrent +calls on one bootstrapper — by design. Free-threading parallelizes request +handling, where `lite-bootstrap` does not sit. Do not call `bootstrap()`/`teardown()` +on the same bootstrapper from multiple threads. + +## Proof + +`.github/workflows/_checks.yml` runs `scripts/ft_smoke.py` on 3.13t and 3.14t: +it asserts a free-threaded interpreter, `orjson` absent, the serializer fallback +round-trips, and a FastAPI bootstrap/teardown succeeds. It is a standalone +script rather than a `just test` run because `tests/conftest.py` hard-imports +`opentelemetry`, which the ft leg does not install. diff --git a/architecture/instruments.md b/architecture/instruments.md index 75c46a1..109ffde 100644 --- a/architecture/instruments.md +++ b/architecture/instruments.md @@ -53,6 +53,35 @@ so the runtime invariant holds even though static analyzers that don't model the guard may report spurious "possibly unbound" diagnostics. The project uses `ty`, which handles the pattern correctly. +**Guarding dotted `find_spec` checks.** `find_spec` imports a dotted name's +parent package first, so a present-but-incomplete namespace — e.g. +`opentelemetry-api` installed without `opentelemetry-instrumentation` — raised +`ModuleNotFoundError` instead of returning `False`, crashing `import +lite_bootstrap`. `import_checker._safe_find_spec` wraps `find_spec` for dotted +names and treats that exception as absent; `is_fastapi_opentelemetry_installed` +and `is_litestar_opentelemetry_installed` both route through it. + +**`is_otlp_grpc_exporter_installed` is a separate guard from +`is_opentelemetry_installed`.** `is_opentelemetry_installed` only means +`opentelemetry-api` resolves — it says nothing about the SDK or the gRPC OTLP +exporter package. `opentelemetry_instrument.py` used to import the gRPC +exporter unconditionally under that coarse guard, crashing `import +lite_bootstrap` in any environment with bare `opentelemetry-api` present (e.g. +`lite-bootstrap[fastmcp]`, which pulls it in transitively without the rest of +the stack). The exporter import — and its use in `bootstrap()` — now sit behind +their own `import_checker.is_otlp_grpc_exporter_installed` guard +(`_safe_find_spec("opentelemetry.exporter.otlp.proto.grpc.trace_exporter")`). +The honest trade-off: `check_dependencies()` still gates OpenTelemetry +activation on `is_opentelemetry_installed` alone, so setting +`opentelemetry_endpoint` in an environment where `opentelemetry-api` is present +but the exporter package is not now **silently skips OTLP export** — +`bootstrap()`'s exporter block simply omits the span processor when +`is_otlp_grpc_exporter_installed` is False, with none of the +`InstrumentDependencyMissingWarning` a configured-but-missing dependency +normally gets. Making `check_dependencies()` distinguish api/sdk/exporter +presence, so this case gets the standard warning instead of silence, is +deferred — see `planning/deferred.md`. + ## Why instruments are not frozen All `*Config` classes are frozen, but `*Instrument` classes drop `frozen=True` diff --git a/lite_bootstrap/bootstrappers/faststream_bootstrapper.py b/lite_bootstrap/bootstrappers/faststream_bootstrapper.py index 557ab20..d42b5b1 100644 --- a/lite_bootstrap/bootstrappers/faststream_bootstrapper.py +++ b/lite_bootstrap/bootstrappers/faststream_bootstrapper.py @@ -43,6 +43,11 @@ def __init__( include_messages_counters: bool = True, ) -> None: ... + # A constructed instance is passed to broker.add_middleware, which expects a + # faststream BrokerMiddleware — a builder callable. Declared with Any to keep + # lite-bootstrap decoupled from faststream's internal middleware types. + def __call__(self, msg: typing.Any, /, *, context: typing.Any) -> typing.Any: ... # noqa: ANN401 + @typing.runtime_checkable class FastStreamPrometheusMiddlewareProtocol(typing.Protocol): @@ -55,6 +60,9 @@ def __init__( received_messages_size_buckets: typing.Sequence[float] | None = None, ) -> None: ... + # See FastStreamTelemetryMiddlewareProtocol.__call__. + def __call__(self, msg: typing.Any, /, *, context: typing.Any) -> typing.Any: ... # noqa: ANN401 + def _make_asgi_faststream() -> "AsgiFastStream": return AsgiFastStream() diff --git a/lite_bootstrap/import_checker.py b/lite_bootstrap/import_checker.py index 238bab4..8ed7f6e 100644 --- a/lite_bootstrap/import_checker.py +++ b/lite_bootstrap/import_checker.py @@ -1,6 +1,19 @@ from importlib.util import find_spec +def _safe_find_spec(module_name: str) -> bool: + """Return whether a dotted module is importable, treating a missing parent as absent. + + find_spec imports the dotted name's parent first; a present-but-incomplete + namespace (e.g. opentelemetry-api installed without opentelemetry-instrumentation) + otherwise raises ModuleNotFoundError, crashing `import lite_bootstrap`. + """ + try: + return find_spec(module_name) is not None + except ModuleNotFoundError: + return False + + is_opentelemetry_installed = find_spec("opentelemetry") is not None is_sentry_installed = find_spec("sentry_sdk") is not None is_structlog_installed = find_spec("structlog") is not None @@ -9,11 +22,13 @@ is_litestar_installed = find_spec("litestar") is not None is_faststream_installed = find_spec("faststream") is not None is_prometheus_fastapi_instrumentator_installed = find_spec("prometheus_fastapi_instrumentator") is not None -is_fastapi_opentelemetry_installed = ( - is_opentelemetry_installed and find_spec("opentelemetry.instrumentation.fastapi") is not None +is_fastapi_opentelemetry_installed = is_opentelemetry_installed and _safe_find_spec( + "opentelemetry.instrumentation.fastapi" ) is_litestar_opentelemetry_installed = ( - is_opentelemetry_installed and is_litestar_installed and find_spec("opentelemetry.instrumentation.asgi") is not None + is_opentelemetry_installed and is_litestar_installed and _safe_find_spec("opentelemetry.instrumentation.asgi") ) +is_otlp_grpc_exporter_installed = _safe_find_spec("opentelemetry.exporter.otlp.proto.grpc.trace_exporter") is_pyroscope_installed = find_spec("pyroscope") is not None is_fastmcp_installed = find_spec("fastmcp") is not None +is_orjson_installed = find_spec("orjson") is not None diff --git a/lite_bootstrap/instruments/logging_factory.py b/lite_bootstrap/instruments/logging_factory.py index df93af0..1121739 100644 --- a/lite_bootstrap/instruments/logging_factory.py +++ b/lite_bootstrap/instruments/logging_factory.py @@ -1,14 +1,17 @@ import dataclasses +import json import logging import logging.handlers import sys import typing -import orjson - from lite_bootstrap import import_checker +if import_checker.is_orjson_installed: + import orjson + + ScopeType = typing.MutableMapping[str, typing.Any] @@ -31,10 +34,22 @@ class _MemoryLoggerFactoryConfig: log_stream: typing.Any = sys.stdout -def _serialize_log_with_orjson_to_string(value: typing.Any, **kwargs: typing.Any) -> str: # noqa: ANN401 +def _dumps_orjson(value: typing.Any, **kwargs: typing.Any) -> str: # noqa: ANN401 return orjson.dumps(value, **kwargs).decode() +def _dumps_stdlib(value: typing.Any, **kwargs: typing.Any) -> str: # noqa: ANN401 + # Match orjson's compact, UTF-8 output shape so log lines stay byte-identical. + return json.dumps(value, separators=(",", ":"), ensure_ascii=False, **kwargs) + + +# orjson has no free-threaded wheels and refuses to build on ft; fall back to the +# stdlib json accelerator (always ft-native) when it is absent. +# See architecture/free-threading.md. +_serialize_log_to_string = _dumps_orjson if import_checker.is_orjson_installed else _dumps_stdlib +_json_loads = orjson.loads if import_checker.is_orjson_installed else json.loads + + # Meta-keys the producer's structlog processor chain emits at the top level of every # rendered line (see lite_bootstrap/instruments/logging_instrument.py). They are not # user-supplied extra and are stripped from StructuredLogPayload.extra. If you add a @@ -68,8 +83,8 @@ def parse(cls, formatted: str) -> "StructuredLogPayload | None": if not formatted.startswith("{"): return None try: - loaded = orjson.loads(formatted) - except orjson.JSONDecodeError: + loaded = _json_loads(formatted) + except json.JSONDecodeError: # orjson.JSONDecodeError subclasses this return None if not isinstance(loaded, dict): # pragma: no cover - JSON starting with "{" is always an object return None diff --git a/lite_bootstrap/instruments/logging_instrument.py b/lite_bootstrap/instruments/logging_instrument.py index 799661f..a979892 100644 --- a/lite_bootstrap/instruments/logging_instrument.py +++ b/lite_bootstrap/instruments/logging_instrument.py @@ -11,7 +11,7 @@ RequestProtocol, ScopeType, _MemoryLoggerFactoryConfig, - _serialize_log_with_orjson_to_string, + _serialize_log_to_string, ) @@ -117,7 +117,7 @@ def structlog_processors(self) -> list[typing.Any]: structlog.stdlib.filter_by_level, *self.structlog_pre_chain_processors, *self.bootstrap_config.logging_extra_processors, - structlog.processors.JSONRenderer(serializer=_serialize_log_with_orjson_to_string), + structlog.processors.JSONRenderer(serializer=_serialize_log_to_string), ] @property @@ -152,7 +152,7 @@ def _configure_foreign_loggers(self) -> None: processors=[ structlog.stdlib.ProcessorFormatter.remove_processors_meta, *self.bootstrap_config.logging_extra_processors, - structlog.processors.JSONRenderer(serializer=_serialize_log_with_orjson_to_string), + structlog.processors.JSONRenderer(serializer=_serialize_log_to_string), ], logger=root_logger, ) diff --git a/lite_bootstrap/instruments/opentelemetry_instrument.py b/lite_bootstrap/instruments/opentelemetry_instrument.py index e4071f4..9d3fa9d 100644 --- a/lite_bootstrap/instruments/opentelemetry_instrument.py +++ b/lite_bootstrap/instruments/opentelemetry_instrument.py @@ -15,12 +15,17 @@ if import_checker.is_opentelemetry_installed: from opentelemetry.context import Context - from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter from opentelemetry.sdk import resources from opentelemetry.sdk.trace import ReadableSpan, SpanProcessor, TracerProvider from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter, SimpleSpanProcessor from opentelemetry.trace import Span, format_span_id, set_tracer_provider +if import_checker.is_otlp_grpc_exporter_installed: + # opentelemetry-api can be present without the grpc otlp exporter package (e.g. + # lite-bootstrap[fastmcp] pulls bare opentelemetry-api transitively); this must + # stay a separate guard from is_opentelemetry_installed above. + from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter + if import_checker.is_pyroscope_installed: import pyroscope @@ -176,7 +181,9 @@ def bootstrap(self) -> None: tracer_provider.add_span_processor(PyroscopeSpanProcessor()) if self.bootstrap_config.opentelemetry_log_traces: tracer_provider.add_span_processor(SimpleSpanProcessor(ConsoleSpanExporter(formatter=_format_span))) - if self.bootstrap_config.opentelemetry_endpoint: # pragma: no cover + if ( + self.bootstrap_config.opentelemetry_endpoint and import_checker.is_otlp_grpc_exporter_installed + ): # pragma: no cover tracer_provider.add_span_processor( BatchSpanProcessor( OTLPSpanExporter( diff --git a/planning/changes/2026-07-18.01-free-threaded-python-support.md b/planning/changes/2026-07-18.01-free-threaded-python-support.md new file mode 100644 index 0000000..c1bac85 --- /dev/null +++ b/planning/changes/2026-07-18.01-free-threaded-python-support.md @@ -0,0 +1,141 @@ +--- +summary: Make orjson an opt-in extra with a stdlib-json fallback so core + pure extras install on free-threaded CPython; add a per-version 3.13t/3.14t CI leg and a free-threading capability page. litestar lands on 3.14t only (msgspec's ft gate); fastmcp is excluded from both legs and stays deferred (cffi on 3.13t, a partial opentelemetry stack on 3.14t). Fixed two pre-existing import-safety bugs surfaced while verifying fastmcp: an incomplete-opentelemetry-namespace crash in import_checker, and an unconditional gRPC OTLP exporter import. Native-blocked extras (otl-grpc, pyroscope) documented, not fixed. +--- + +# Design: Free-threaded Python (nogil) support + +## Summary + +Free-threaded CPython (3.13t experimental, 3.14t officially supported per PEP +779) runs pure-Python packages with no changes. `lite-bootstrap` is pure Python, +but one **mandatory** dependency — `orjson` — hard-blocks it: `orjson` ships no +free-threaded wheels and its build script *refuses* to compile on a free-threaded +interpreter (`orjson v3.11.9 does not support free-threaded Python`, verified +locally on 3.14t). Because `orjson` is in `[project] dependencies`, **nothing** +installs on ft today — not even the pure extras. This change makes `orjson` +opt-in (its own extra) with a stdlib-`json` fallback in the one place it is used +(the logging serializer), so core and every pure extra install and pass tests on +ft. A per-version CI leg proves it; a capability page documents the support +matrix and the extras that remain ecosystem-blocked (`otl`, `pyroscope`, +`fastmcp`; `litestar` on 3.13t only). + +## Motivation + +Verified empirically on a local `3.14t` (`sys._is_gil_enabled()` False): + +- `uv pip install lite-bootstrap[logging,sentry,fastapi]` → **fails** building + `orjson` from sdist (maturin: "does not support free-threaded Python"). +- Installing the pure deps directly (`structlog`, `sentry-sdk`, `fastapi` + + `pydantic-core`'s ft wheels, `httptools`, `prometheus-client`) → **installs and + imports cleanly**. `orjson` is the sole blocker. + +`orjson` is used only by the logging path (`instruments/logging_factory.py`) yet +sits in mandatory core deps — a dependency-hygiene defect independent of ft. + +## Design + +### 1. `orjson` → opt-in extra + stdlib fallback + +`pyproject.toml`: remove `orjson` from `[project] dependencies` (core becomes +zero-dep, pure-Python) and add a standalone extra: + +```toml +[project.optional-dependencies] +orjson = ["orjson"] +``` + +`orjson` is deliberately **not** folded into `logging` or any `*-all` bundle: +no PEP 508 marker can express "GIL builds only" (see +`decisions/2026-07-18-orjson-opt-in-for-free-threading.md`), so bundling it would +re-break those extras on ft. Uniform rule: every extra installs on ft; `orjson` +is a per-build opt-in speedup (`pip install lite-bootstrap[fastapi,orjson]`). + +`import_checker.py`: add `is_orjson_installed = find_spec("orjson") is not None`. + +`instruments/logging_factory.py`: guard the import (matching the file's existing +`if import_checker.is_structlog_installed:` pattern) and select the serializer at +import. Two explicit implementations so both branches are unit-testable on a +normal (orjson-present) run: + +```python +def _dumps_orjson(value, **kw): return orjson.dumps(value, **kw).decode() +def _dumps_stdlib(value, **kw): return json.dumps(value, separators=(",", ":"), ensure_ascii=False, **kw) +_serialize_log_to_string = _dumps_orjson if import_checker.is_orjson_installed else _dumps_stdlib +``` + +`separators`/`ensure_ascii` reproduce orjson's compact, UTF-8 output shape; +structlog passes only `default=`, which both encoders accept. `StructuredLogPayload.parse` +selects `orjson.loads` vs `json.loads` the same way and catches `json.JSONDecodeError` +(orjson's subclasses it), so one `except` covers both. + +The GIL-build path is byte-for-byte unchanged (fallback runs only when `orjson` +is absent). `logging_instrument.py`'s import updates to the new name. + +### 2. CI leg + +Add a `free-threaded` job matrixed per Python version (`astral-sh/setup-uv` → +`uv python install `), each leg installing its own ft-ready extra set: +3.13t gets `logging,sentry,fastapi,faststream,fastapi-metrics,faststream-metrics`; +3.14t adds `litestar,litestar-metrics` (msgspec's ft gate — see §Non-goals). +`orjson`, `otl`, `pyroscope`, and `fastmcp`/`fastmcp-metrics` are excluded from +both legs. Each leg runs the standalone `scripts/ft_smoke.py`, not `just test`: +`tests/conftest.py` hard-imports `opentelemetry`, which the ft legs don't +install, so pytest can't collect there. The smoke script asserts a +free-threaded interpreter, the `orjson`-absent serializer fallback round-trips, +and a FastAPI bootstrap/teardown succeeds. This leg is the "runs correctly on +ft" proof; coverage stays 100% on the normal (non-ft) CI run per §Testing. + +### 3. Capability page + +New `architecture/free-threading.md`: the per-extra support matrix, the +single-threaded-init invariant (below), and the ecosystem-blocked extras with +upstream links — `otl` (grpcio), `pyroscope` (abi3-only), `fastmcp` (cffi on +3.13t, a partial opentelemetry stack on 3.14t), and `litestar` (msgspec, +3.13t only). Linked from `README.md` and the bootstrappers invariant list. +Promoted in this PR. + +### 4. Single-threaded-init invariant (documented, no code) + +`bootstrap()`/`teardown()` are startup/shutdown, main-thread operations; the +cached mutable state (`is_bootstrapped`, `_tracer_provider`, the +`_lite_bootstrap_teardown_attached` marker) is **not** guarded for concurrent +calls on one bootstrapper, by design. ft parallelizes *request handling*, where +`lite-bootstrap` does not sit. Recorded in `architecture/bootstrappers.md`. + +## Non-goals + +- **otl on ft.** The gRPC OTLP exporter needs `grpcio`, which has no ft wheels + ([grpc/grpc#38762](https://github.com/grpc/grpc/issues/38762)). An HTTP-exporter + path would fix it but is a code + extras change → `deferred.md`. +- **pyroscope on ft.** `pyroscope-io` is abi3-only, unmaintained, no ft path and + no pure fallback → `deferred.md`. +- **fastmcp on ft.** Excluded from both legs (cffi on 3.13t, a partial `opentelemetry` + stack on 3.14t) → `deferred.md`. Verifying it did surface two pre-existing, + not-ft-specific import-safety bugs, fixed in this change: `import_checker.py`'s + dotted `find_spec` calls raised `ModuleNotFoundError` instead of returning `False` + on an incomplete `opentelemetry` parent namespace, and `opentelemetry_instrument.py` + imported the gRPC OTLP exporter unconditionally under the coarse + `is_opentelemetry_installed` guard. See `architecture/instruments.md`. +- **Concurrent-bootstrap safety / locks.** See §4. +- **Switching the serializer to msgspec/ujson.** Rejected in the decision file — + a permanent new dep for a temporary orjson gap ([ijl/orjson#530](https://github.com/ijl/orjson/issues/530)). + +## Testing + +- Unit (normal CI): `_dumps_orjson` and `_dumps_stdlib` each called directly; a + parity test asserts identical output for a representative event dict (incl. + non-ASCII); `parse()` round-trips under both. 100% coverage without ft. +- ft CI leg: `scripts/ft_smoke.py` (import + serializer fallback + FastAPI + bootstrap/teardown) green on 3.13t and 3.14t (per-version extras). +- `just lint-ci` clean (`ty`, planning validator). + +## Risk + +- **Silent perf change for logging users who don't add `[orjson]` (low × low).** + stdlib `json` is ~2-5x slower, correctness unchanged. Documented in the release + note and capability page. +- **Exotic types in log `extra` (datetime/UUID) render via repr on the fallback + (low × low).** ft-only, orjson-absent-only; structlog's timestamper already + stringifies timestamps upstream of the renderer. Documented. +- **Transitive `import lite_bootstrap`-pulls-orjson consumers (very low).** They + should depend on `orjson` directly; noted in the release note. diff --git a/planning/decisions/2026-07-18-orjson-opt-in-for-free-threading.md b/planning/decisions/2026-07-18-orjson-opt-in-for-free-threading.md new file mode 100644 index 0000000..e016d21 --- /dev/null +++ b/planning/decisions/2026-07-18-orjson-opt-in-for-free-threading.md @@ -0,0 +1,57 @@ +--- +status: accepted +summary: orjson becomes an opt-in extra with a stdlib-json fallback (not bundled in logging/*-all, not replaced by msgspec) so every extra installs on free-threaded CPython. +supersedes: null +superseded_by: null +--- + +# orjson is opt-in for free-threaded support + +**Decision:** Make `orjson` its own opt-in extra with a stdlib-`json` fallback in +the logging serializer, rather than (B) keeping it bundled with `logging`/`*-all` +behind parallel ft-variant extras, or (C) replacing it with an ft-ready native +encoder (msgspec/ujson/rapidjson). + +## Context + +`orjson` is a mandatory core dep but hard-blocks free-threaded installs: no ft +wheels, and its build refuses to compile on ft (verified on 3.14t). It is used +only by the logging serializer. Three ways to unblock ft: + +- **A (chosen):** `orjson` → opt-in extra; `logging` = `structlog`-only; serializer + falls back to stdlib `json`. +- **B:** keep `orjson` reachable by default on GIL builds (left in core, or moved + into the `logging` extra) and add parallel ft-variant extras (`*-ft`) that omit + it — zero behaviour change for GIL users. +- **C:** replace `orjson` with msgspec (ft wheels ready today, litestar already + uses it) or ujson/rapidjson as the single serializer. + +PEP 508 has **no environment marker for "GIL enabled"**, so `orjson` cannot be +conditionally required only on GIL builds — the constraint that forces the choice. + +## Decision & rationale + +**A** keeps one coherent rule — *every* extra installs on ft, `orjson` is a +per-build opt-in speedup — with no combinatorial extras sprawl. It also fixes a +standing hygiene defect (a JSON encoder had no business being a mandatory core +dep). The GIL-build fast path is byte-for-byte unchanged when `[orjson]` is +present; the only cost is a documented, opt-in perf change for logging users who +don't add the extra (stdlib `json`, ~2-5x slower, same correctness). + +**B rejected:** bundling `orjson` in `logging` means every logging-bearing and +`*-all` extra needs an ft twin (`fastapi-logging`, `free-all`, …) — the extras +matrix the maintainer explicitly wanted to avoid. Zero GIL change is not worth +that sprawl. + +**C rejected:** a permanent new mandatory dependency to paper over a *temporary* +orjson gap ([ijl/orjson#530](https://github.com/ijl/orjson/issues/530) tracks ft +wheels toward the 3.15/abi3t timeframe). msgspec's encoder API differs (`enc_hook`, +not orjson's `default=`) and its output shape differs, forcing a serializer +rewrite and test re-baseline. The stdlib fallback is fully reversible — it simply +stops being exercised once `orjson` ships ft wheels. + +## Revisit trigger + +`orjson` ships free-threaded wheels (resolves #530). At that point `orjson` may +return to `logging`/core as a hard dep and the fallback branch retired — reopen +to decide whether the simplification is worth removing the opt-in extra. diff --git a/planning/deferred.md b/planning/deferred.md index ace4bb0..24cc3ea 100644 --- a/planning/deferred.md +++ b/planning/deferred.md @@ -8,4 +8,102 @@ change file in [`changes/active/`](changes/active/); see [CLAUDE.md](../CLAUDE.m ## Open -_None._ +### OTLP export on free-threaded Python (HTTP exporter path) + +The `otl` extra pulls `grpcio` (via the gRPC OTLP exporter), which has no +free-threaded wheels, so `otl` is uninstallable on ft. `opentelemetry_instrument.py` +hardwires the gRPC exporter (`from opentelemetry.exporter.otlp.proto.grpc...`). +Fix would add an `opentelemetry_exporter_protocol` field (grpc|http) + an +`otl-http` extra on `opentelemetry-exporter-otlp-proto-http` (no grpcio; protobuf +falls back to pure-python). Deferred from the 2026-07-18 free-threading change to +keep that change orjson-only. +**Trigger:** a user needs OTLP trace export on ft, **or** `grpcio` ships ft wheels +([grpc/grpc#38762](https://github.com/grpc/grpc/issues/38762)) making the swap moot. + +### Pyroscope on free-threaded Python + +`pyroscope-io` is abi3-only, unmaintained, ships no ft wheels and has no pure +fallback, so the `pyroscope` extra cannot install on ft. No action possible from +this repo. +**Trigger:** `pyroscope-io` ships ft wheels (or a maintained ft-capable +replacement appears). + +### fastmcp on free-threaded Python + +`fastmcp` and `fastmcp-metrics` are excluded from both ft CI legs +(`.github/workflows/_checks.yml`) and from `scripts/ft_smoke.py`'s local +verification commands, for two separate, independent reasons — one per leg: + +- **3.13t (install-time, upstream, not fixable here):** `fastmcp` → + `fastmcp-slim[server]` → `joserfc` → `cryptography` → `cffi`, and `cffi` + (v2.1.0) refuses to build on free-threaded 3.13: "CFFI does not support the + free-threaded build of CPython 3.13. Upgrade to free-threaded 3.14 or newer + to use CFFI with the free-threaded build." — an upstream gate, not a + build-environment problem, the same shape as msgspec's 3.13t gate below. + Reproduced 2026-07-18: `uv pip install --python <3.13t venv> ".[fastmcp]"` + fails building `cffi`. +- **3.14t (import-time, partially fixed, one blocker remains):** `fastmcp` + transitively pulls bare `opentelemetry-api` with no other `opentelemetry-*` + package (independent of lite-bootstrap's own `otl` extra, which bundles + api+sdk+exporter+instrumentation together and is excluded from ft for the + unrelated grpcio reason above). Two import-safety bugs this exposed are now + fixed: `import_checker.py`'s dotted `find_spec` calls previously raised + `ModuleNotFoundError` instead of returning `False` when a parent namespace + was incomplete, and `opentelemetry_instrument.py` previously imported the + grpc otlp exporter unconditionally whenever bare `opentelemetry-api` was + present. A third, deeper issue remains, found while verifying the above: + `opentelemetry_instrument.py` also imports several `opentelemetry.sdk.*` + names under the same `is_opentelemetry_installed` guard, and + `check_dependencies()` uses that same flag — but `opentelemetry-sdk` is a + separate PyPI distribution that `fastmcp` does not pull in, so `uv pip + install ".[fastmcp]"` followed by `import lite_bootstrap` still raises + `ModuleNotFoundError: No module named 'opentelemetry.sdk'` (reproduced + 2026-07-18 on 3.14t). See "OTel-stack dependency model" below for the fix + shape and a second, independent symptom of the same root cause. + +**Trigger:** `cffi` ships free-threaded 3.13 wheels (unblocks 3.13t), or fastmcp +support on the 3.14t leg is wanted (needs the `opentelemetry-sdk`-vs-api +activation fix below first). + +### OTel-stack dependency model (api vs sdk vs exporter granularity) + +`is_opentelemetry_installed` is one `find_spec("opentelemetry")` check, but +`opentelemetry-api`, `opentelemetry-sdk`, and the OTLP exporter packages are +three independent PyPI distributions — a real environment can have any subset +installed. `check_dependencies()`/activation only sees that one coarse flag, +which produces two distinct symptoms: + +- **Import-time:** `opentelemetry_instrument.py` imports several + `opentelemetry.sdk.*` names under the same `is_opentelemetry_installed` + guard used for the now-fixed exporter-import bug (see the fastmcp entry + above), so `import lite_bootstrap` still crashes with `ModuleNotFoundError: + No module named 'opentelemetry.sdk'` whenever `opentelemetry-api` is present + without `opentelemetry-sdk` — the case for `lite-bootstrap[fastmcp]`. +- **Activation-time:** `check_dependencies()` gates OpenTelemetry activation on + `is_opentelemetry_installed` alone, so an environment with `opentelemetry-api` + present but the OTLP exporter package absent passes `check_dependencies()` + cleanly, and `bootstrap()`'s `opentelemetry_endpoint`-gated span-processor + block silently no-ops once `is_otlp_grpc_exporter_installed` is False — + skipping OTLP export without the `InstrumentDependencyMissingWarning` a + configured-but-missing dependency normally gets elsewhere. See + `architecture/instruments.md`'s "Optional-dependency guard" section. + +Proper fix: an exporter/sdk-aware `check_dependencies()` (and matching +warning) that distinguishes api/sdk/exporter presence instead of one bool — +an activation-semantics change, not just another import guard. +**Trigger:** `[fastmcp]`-without-`otl` support is wanted (needs this first), or +a user reports the silent OTLP-export skip. + +### litestar on free-threaded Python 3.13 (msgspec gates Py_GIL_DISABLED to 3.14+) + +`litestar` unconditionally requires `msgspec`, which has no free-threaded wheel for +3.13t and fails to build from source there: `msgspec`'s own `_core.c` contains +`#error "Py_GIL_DISABLED is only supported in Python 3.14+"` (msgspec v0.21.1) — an +intentional upstream gate, not a build-environment problem. Reproduced 2026-07-18: +`uv pip install --python <3.13t venv> "litestar>=2.9"` fails compiling +`msgspec._core`; the same install against a 3.14t venv succeeds cleanly and +`scripts/ft_smoke.py`-equivalent checks pass. The ft CI leg +(`.github/workflows/_checks.yml`) now installs per Python version, so `litestar` +and `litestar-metrics` run on the 3.14t leg and are excluded only from 3.13t. +**Trigger:** `msgspec` extends `Py_GIL_DISABLED` support to 3.13, at which point +`litestar` / `litestar-metrics` can be added to the 3.13t leg too. diff --git a/planning/releases/1.3.0.md b/planning/releases/1.3.0.md new file mode 100644 index 0000000..fdfae98 --- /dev/null +++ b/planning/releases/1.3.0.md @@ -0,0 +1,71 @@ +# lite-bootstrap 1.3.0 — free-threaded Python support, orjson becomes opt-in + +**1.3.0 is a minor release. Backward compatible with 1.2.3, with one dependency +change** (`orjson` moves from a mandatory core dependency to an opt-in extra). +It lands free-threaded CPython (3.13t/3.14t) support for core and most extras, +plus two import-safety fixes surfaced while verifying it. + +## Features + +- **Free-threaded CPython (3.13t/3.14t) support.** `lite-bootstrap` is pure + Python; the only thing that ever blocked it on a free-threaded interpreter + was the mandatory `orjson` dependency (below). Core, `logging`, `sentry`, + `fastapi`, and `faststream` (plus their `-sentry`/`-logging`/`-metrics` + combos) now install and run on both 3.13t and 3.14t. `litestar` (+ + `litestar-metrics`) lands on **3.14t only** — `msgspec`, which `litestar` + requires unconditionally, gates free-threaded support to Python 3.14+ and + fails to build from source on 3.13t. `fastmcp` (+ `fastmcp-metrics`), `otl` + (the gRPC OTLP exporter needs `grpcio`, which has no ft wheels), and + `pyroscope` (`pyroscope-io` is abi3-only and unmaintained) remain + unavailable on free-threaded builds pending upstream fixes. See + [`architecture/free-threading.md`](../../architecture/free-threading.md) for + the full support matrix and [`planning/deferred.md`](../deferred.md) for the + ecosystem blockers. + +- **`orjson` is now an opt-in extra** (`lite-bootstrap[orjson]`) instead of a + mandatory core dependency — it shipped no free-threaded wheels and its build + refuses to compile under a free-threaded interpreter, which meant nothing, + not even the pure extras, could install on ft before this change. The + logging serializer falls back to the stdlib `json` accelerator when `orjson` + is absent (byte-identical output for JSON-native values; ~2-5x slower; + non-JSON-native types in log `extra`, e.g. datetime/UUID, render via repr + instead of orjson's native encoding). Add `[orjson]` to keep the fast path + on a standard (GIL) build. If your code relied on `import lite_bootstrap` + pulling `orjson` in transitively, depend on `orjson` directly. + +## Bug fixes + +- **`import_checker`'s dotted `find_spec` checks no longer crash on an + incomplete namespace.** `find_spec` imports a dotted name's parent package + first, so a present-but-incomplete `opentelemetry` install (e.g. + `opentelemetry-api` without `opentelemetry-instrumentation`) previously + raised `ModuleNotFoundError` instead of returning `False`, crashing `import + lite_bootstrap`. Affected dotted checks now go through a + `ModuleNotFoundError`-safe helper. +- **The gRPC OTLP exporter is no longer imported unconditionally.** + `opentelemetry_instrument.py` used to import + `opentelemetry.exporter.otlp.proto.grpc.trace_exporter` whenever bare + `opentelemetry-api` resolved, crashing `import lite_bootstrap` in any + environment with `opentelemetry-api` present but the exporter package + absent (e.g. `lite-bootstrap[fastmcp]`, which pulls in bare + `opentelemetry-api` transitively). The exporter import — and its use in + `bootstrap()` — now sit behind their own `is_otlp_grpc_exporter_installed` + guard. Note the trade-off this leaves open: setting `opentelemetry_endpoint` + in an environment with `opentelemetry-api` but not the exporter package now + silently skips OTLP export rather than warning; see + [`architecture/instruments.md`](../../architecture/instruments.md) and + [`planning/deferred.md`](../deferred.md). + +Both bugs are not ft-specific — they affect any environment with a partial +`opentelemetry` stack — but ft verification work is what surfaced them. + +## Backwards compatibility + +Fully backward compatible with 1.2.3 at the API level. The one dependency +change is the `orjson` move to opt-in described above; everything that imports +or configures `lite-bootstrap` continues to work unchanged on a standard (GIL) +build once `orjson` is installed (directly, or via the `[orjson]` extra). + +## References + +- Design bundle: `planning/changes/2026-07-18.01-free-threaded-python-support.md` diff --git a/pyproject.toml b/pyproject.toml index fde75ae..1cea818 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,9 +20,7 @@ classifiers = [ "Topic :: Software Development :: Libraries", ] version = "0" -dependencies = [ - "orjson", -] +dependencies = [] [project.urls] Homepage = "https://lite-bootstrap.modern-python.org" @@ -32,6 +30,9 @@ Issues = "https://github.com/modern-python/lite-bootstrap/issues" Changelog = "https://github.com/modern-python/lite-bootstrap/releases" [project.optional-dependencies] +orjson = [ + "orjson", +] sentry = [ "sentry-sdk", ] @@ -170,6 +171,9 @@ ignore = [ isort.lines-after-imports = 2 isort.no-lines-before = ["standard-library", "local-folder"] +[tool.ruff.lint.per-file-ignores] +"scripts/*.py" = ["INP001"] # standalone scripts, not an importable package + [tool.pytest.ini_options] addopts = "--cov=. --cov-report term-missing --cov-fail-under=100" asyncio_mode = "auto" diff --git a/scripts/ft_smoke.py b/scripts/ft_smoke.py new file mode 100644 index 0000000..78ed04e --- /dev/null +++ b/scripts/ft_smoke.py @@ -0,0 +1,49 @@ +"""Free-threaded (nogil) smoke test. + +Run under a free-threaded interpreter with the ft-ready extras installed (no +orjson). Exits non-zero on failure. Proves: the interpreter is free-threaded, +orjson is absent, the logging serializer's stdlib-json fallback produces +parseable output, and a FastAPI bootstrap runs bootstrap()/teardown() clean. +Not a pytest test (conftest.py hard-imports opentelemetry, which the ft leg +does not install). See architecture/free-threading.md. +""" + +import sys + +from lite_bootstrap import FastAPIBootstrapper, FastAPIConfig, import_checker +from lite_bootstrap.instruments.logging_factory import StructuredLogPayload, _serialize_log_to_string + + +def main() -> None: + assert sys._is_gil_enabled() is False, "expected a free-threaded interpreter" # noqa: SLF001 # ty: ignore[unresolved-attribute] + assert import_checker.is_orjson_installed is False, "orjson must be absent on the ft leg" + + formatted = _serialize_log_to_string({"event": "ft ok", "level": "info", "n": 1}) + payload = StructuredLogPayload.parse(formatted) + assert payload is not None + assert payload.message == "ft ok" + assert payload.extra == {"n": 1} + + bootstrapper = FastAPIBootstrapper( + bootstrap_config=FastAPIConfig( + service_name="ft-smoke", + service_debug=False, + logging_buffer_capacity=0, + health_checks_path="/health/", + ) + ) + application = bootstrapper.bootstrap() + assert bootstrapper.is_bootstrapped + # FastAPI >=0.137 wraps include_router()'d routes in an internal _IncludedRouter node, so + # application.routes no longer exposes a flat .path per route; url_path_for is the public, + # version-stable way to confirm the health route was registered. + assert application.url_path_for("health_check_handler") == "/health/" + + bootstrapper.teardown() + assert not bootstrapper.is_bootstrapped + + print("ft smoke OK:", sys.version) # noqa: T201 + + +if __name__ == "__main__": + main() diff --git a/tests/instruments/test_opentelemetry_instrument.py b/tests/instruments/test_opentelemetry_instrument.py index 66433e2..b4c5f26 100644 --- a/tests/instruments/test_opentelemetry_instrument.py +++ b/tests/instruments/test_opentelemetry_instrument.py @@ -1,4 +1,5 @@ import logging +import sys import typing import warnings from unittest.mock import patch @@ -6,13 +7,14 @@ import pytest from opentelemetry.instrumentation.instrumentor import BaseInstrumentor +from lite_bootstrap import import_checker from lite_bootstrap.exceptions import TeardownError from lite_bootstrap.instruments.opentelemetry_instrument import ( InstrumentorWithParams, OpenTelemetryConfig, OpenTelemetryInstrument, ) -from tests.conftest import CustomInstrumentor +from tests.conftest import CustomInstrumentor, emulate_package_missing_with_module_reload def test_opentelemetry_instrument() -> None: @@ -183,3 +185,31 @@ def _uninstrument(self, **_kwargs: object) -> None: assert any("shutdown boom" in m for m in error_msgs), error_msgs assert instrument._tracer_provider is None # noqa: SLF001 assert instrument._prior_logger_disabled == {} # noqa: SLF001 + + +# The grpc otlp exporter's dotted submodules are already cached in sys.modules from +# earlier imports of this very module (both in normal test collection and by the +# reload below), so a plain "opentelemetry.exporter" -> None emulation would take the +# "already in sys.modules" fast path and never exercise the parent-import bug; evict +# the cached leaf/intermediate entries first to force a real resolution. +_GRPC_EXPORTER_SUBMODULES = ( + "opentelemetry.exporter.otlp.proto.grpc.trace_exporter", + "opentelemetry.exporter.otlp.proto.grpc", + "opentelemetry.exporter.otlp.proto", + "opentelemetry.exporter.otlp", +) + + +def test_opentelemetry_instrument_survives_missing_grpc_exporter() -> None: + # opentelemetry-api (+sdk) present but the grpc otlp exporter package absent must + # not crash importing opentelemetry_instrument (e.g. lite-bootstrap[fastmcp], + # which pulls bare opentelemetry-api transitively without any exporter package). + saved_submodules = {name: sys.modules.pop(name) for name in _GRPC_EXPORTER_SUBMODULES if name in sys.modules} + try: + with emulate_package_missing_with_module_reload( + "opentelemetry.exporter", + ["lite_bootstrap.instruments.opentelemetry_instrument"], + ): + assert import_checker.is_otlp_grpc_exporter_installed is False + finally: + sys.modules.update(saved_submodules) diff --git a/tests/instruments/test_structured_log_payload.py b/tests/instruments/test_structured_log_payload.py index 68435b8..001b383 100644 --- a/tests/instruments/test_structured_log_payload.py +++ b/tests/instruments/test_structured_log_payload.py @@ -1,7 +1,9 @@ from lite_bootstrap.instruments.logging_factory import ( STRUCTLOG_META_KEYS, StructuredLogPayload, - _serialize_log_with_orjson_to_string, + _dumps_orjson, + _dumps_stdlib, + _serialize_log_to_string, ) @@ -70,10 +72,27 @@ def test_round_trip_through_real_serializer_strips_every_meta_key() -> None: "path": "/health", } - formatted = _serialize_log_with_orjson_to_string(event_dict) + formatted = _serialize_log_to_string(event_dict) payload = StructuredLogPayload.parse(formatted) assert payload is not None assert payload.message == "request handled" assert payload.extra == {"user_id": 42, "path": "/health"} assert not (payload.extra.keys() & STRUCTLOG_META_KEYS) + + +def test_dumps_stdlib_matches_orjson_output() -> None: + event = {"event": "hello café", "level": "info", "n": 1, "ok": True, "nested": {"a": [1, 2]}} + assert _dumps_stdlib(event) == _dumps_orjson(event) + + +def test_dumps_stdlib_is_compact_utf8() -> None: + assert _dumps_stdlib({"k": "café", "n": 1}) == '{"k":"café","n":1}' + + +def test_parse_round_trips_under_active_loader() -> None: + formatted = _serialize_log_to_string({"event": "hi", "level": "info", "x": 1}) + payload = StructuredLogPayload.parse(formatted) + assert payload is not None + assert payload.message == "hi" + assert payload.extra == {"x": 1} diff --git a/tests/test_import_checker.py b/tests/test_import_checker.py new file mode 100644 index 0000000..bbae3d6 --- /dev/null +++ b/tests/test_import_checker.py @@ -0,0 +1,25 @@ +import sys + +import opentelemetry.instrumentation.asgi +import opentelemetry.instrumentation.fastapi # noqa: F401 + +from lite_bootstrap import import_checker +from tests.conftest import emulate_package_missing + + +_DOTTED_SUBMODULES = ("opentelemetry.instrumentation.fastapi", "opentelemetry.instrumentation.asgi") + + +def test_import_checker_survives_incomplete_opentelemetry_namespace() -> None: + # opentelemetry-api present but opentelemetry.instrumentation absent must not + # crash import_checker (find_spec on the dotted name imports the missing parent). + # By the time this test runs, lite_bootstrap has already imported these dotted + # submodules, so find_spec would take the "already in sys.modules" fast path and + # never hit the parent-import bug; evict them so find_spec must resolve for real. + saved_submodules = {name: sys.modules.pop(name) for name in _DOTTED_SUBMODULES if name in sys.modules} + try: + with emulate_package_missing("opentelemetry.instrumentation"): + assert import_checker.is_fastapi_opentelemetry_installed is False + assert import_checker.is_litestar_opentelemetry_installed is False + finally: + sys.modules.update(saved_submodules)