Skip to content

Commit 650206f

Browse files
lesnik512claude
andauthored
Free-threaded Python (nogil) support (#153)
* docs(planning): spec free-threaded Python (nogil) support orjson is the sole blocker for free-threaded CPython: no ft wheels and its build refuses ft (verified on 3.14t). Make orjson an opt-in extra with a stdlib-json fallback so core + pure extras install and test on ft; add a 3.13t/3.14t CI leg and a free-threading capability page. otl-grpc and pyroscope remain ecosystem-blocked (deferred with revisit triggers). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: git-ignore .superpowers scratch dir Ephemeral plans and subagent-driven-development scratch live here per the planning convention; keep them out of version control. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat: make orjson an opt-in extra with a stdlib-json fallback orjson ships no free-threaded wheels and its build refuses ft, yet it was a mandatory core dep used only by the logging serializer. Move it to an opt-in `orjson` extra and fall back to the stdlib json accelerator when absent, so core and the pure extras install on free-threaded CPython. GIL-build output is byte-identical when orjson is present. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci: prove free-threaded support on 3.13t/3.14t via a smoke leg Add a standalone ft smoke script (bootstrap + serializer fallback) and a CI matrix leg installing the ft-ready extras. conftest.py hard-imports opentelemetry, so the ft leg runs the script, not the coverage suite. orjson/otl/pyroscope are excluded as planned. fastmcp is also excluded: it transitively pulls in opentelemetry-api, which crashes import_checker.py's find_spec calls once opentelemetry-instrumentation isn't also present. litestar is excluded because msgspec gates Py_GIL_DISABLED support to Python 3.14+, so it fails to build on the 3.13t leg (the CI job shares one install step across the {3.13t, 3.14t} matrix). Both gaps are tracked in planning/deferred.md. Also adds a scripts/*.py per-file INP001 ignore since the smoke script is a standalone entry point, not an importable package. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: guard import_checker and OTel exporter import against a partial opentelemetry stack find_spec on a dotted name imports its parent first, so find_spec("opentelemetry.instrumentation.fastapi") raised ModuleNotFoundError (crashing import lite_bootstrap) when opentelemetry-api was installed without opentelemetry-instrumentation. Guard both dotted checks with a _safe_find_spec helper. Separately, opentelemetry_instrument.py unconditionally imported the grpc otlp exporter under the coarse is_opentelemetry_installed guard, which only means opentelemetry-api resolves, not that the exporter package is present. Give it its own is_otlp_grpc_exporter_installed guard, at both the import and the bootstrap() use site, so an endpoint configured without the exporter installed no longer crashes. Both bugs surface for any environment with opentelemetry-api installed but not the rest of the stack (e.g. lite-bootstrap[fastmcp], which pulls bare opentelemetry-api transitively) - not ft-specific, though ft is what surfaced them. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci: split the free-threaded leg's install per Python version, add litestar to 3.14t msgspec still gates Py_GIL_DISABLED to 3.14+, so litestar can't install on 3.13t; move from one shared install step to a per-version extras matrix so litestar/litestar-metrics can run on 3.14t while 3.13t stays without them. fastmcp/fastmcp-metrics stay off both legs: cffi (pulled in via fastmcp -> cryptography) gates free-threaded 3.13 upstream, and on 3.14t a remaining opentelemetry-sdk-vs-opentelemetry-api activation gap (found while verifying the previous commit's exporter fix) still crashes import lite_bootstrap with fastmcp installed. Update planning/deferred.md with the corrected picture for both fastmcp and litestar. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: promote free-threading support (capability page, matrix, invariant) Add architecture/free-threading.md with the support matrix reconciled to what Tasks 1-2b actually shipped: litestar lands on 3.14t only (msgspec's ft gate), fastmcp stays unsupported on both legs. Update the README dependency claim, record the single-threaded-init invariant in bootstrappers.md, index the new page in CLAUDE.md, and add the 1.3.0 release note. Promote the is_otlp_grpc_exporter_installed guard into instruments.md, including the silent-OTLP-skip trade-off it leaves open, and fold that into a cohesive "OTel-stack dependency model" entry in deferred.md alongside the existing fastmcp entry. Finalize the change file's summary to match the shipped extra set and the two import-safety bug fixes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: correct free-threading matrix note and reconcile change-file body Drop the httptools claim (bare fastapi/faststream don't depend on it) and update the change file's §2/§3 to the shipped ft CI leg (per-version smoke script, not just test) and full blocked-extras list, plus the two import-safety bug fixes found during verification. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: fix remaining stale ft-leg claims in change-file body The Summary and Testing sections still said "two extras" blocked and "just test" on the ft leg; align them with the shipped per-version smoke-script leg and full blocked-extras list. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: drop the misplaced import-safety section from free-threading.md Those two fixes are not ft-specific (they hit any partial-opentelemetry environment on regular Python); their home is instruments.md and the change file, not the free-threading capability page. The one ft-relevant fact — the sdk-vs-api gap blocking fastmcp on 3.14t — already lives in the matrix row. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(faststream): give middleware protocols a __call__ so they satisfy BrokerMiddleware A newer ty flagged that FastStreamTelemetryMiddlewareProtocol / FastStreamPrometheusMiddlewareProtocol declared only __init__, so a constructed instance was not assignable to faststream's BrokerMiddleware[Any, Any] at broker.add_middleware(). Declare the builder __call__ (loosely typed as Any to stay decoupled from faststream internals). Pre-existing gap, surfaced by tool drift. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 7c2e22f commit 650206f

21 files changed

Lines changed: 673 additions & 21 deletions

‎.github/workflows/_checks.yml‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,26 @@ jobs:
4646
- run: uv python pin ${{ matrix.python-version }}
4747
- run: just install
4848
- run: just test . --cov=. --cov-report xml
49+
50+
free-threaded:
51+
runs-on: ubuntu-latest
52+
strategy:
53+
fail-fast: false
54+
matrix:
55+
include:
56+
- python-version: "3.13t"
57+
extras: "logging,sentry,fastapi,faststream,fastapi-metrics,faststream-metrics"
58+
- python-version: "3.14t"
59+
extras: "logging,sentry,fastapi,litestar,faststream,fastapi-metrics,litestar-metrics,faststream-metrics"
60+
steps:
61+
- uses: actions/checkout@v6
62+
- uses: astral-sh/setup-uv@v8.2.0
63+
with:
64+
enable-cache: true
65+
cache-dependency-glob: "**/pyproject.toml"
66+
- run: uv python install ${{ matrix.python-version }}
67+
- run: uv venv --python ${{ matrix.python-version }}
68+
- name: Install core + ft-ready extras (orjson/otl/pyroscope/fastmcp excluded)
69+
run: uv pip install ".[${{ matrix.extras }}]"
70+
- name: Free-threaded smoke test
71+
run: .venv/bin/python scripts/ft_smoke.py

‎.gitignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,4 @@ dist/
2020
.venv
2121
uv.lock
2222
site/
23+
.superpowers/

‎CLAUDE.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ Capability index (all of `architecture/`):
6565
| Config model — `BaseConfig`, multiple-inheritance composition, `from_dict`/`from_object`, `UNSET`, `__post_init__` cascade | `architecture/config-model.md` |
6666
| 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` |
6767
| Bootstrappers — hierarchy, skip ordering, registry + idempotent teardown, summary logging, teardown-attach seam, app-tagging sentinels | `architecture/bootstrappers.md` |
68+
| Free-threading — nogil support matrix, orjson fallback, single-threaded-init invariant | `architecture/free-threading.md` |
6869

6970
Recent design context, bugs, and rationale: bug-audit findings in
7071
`planning/audits/`, post-work reflections in `planning/retros/`, and the audit

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ The following ideas were borrowed:
6868
The following intentionally differ:
6969

7070
- **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.
71-
- **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.
71+
- **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.
7272
- **Scope**: `lite-bootstrap` is deliberately narrow — only instrument wiring. It does not include a Granian server runner or a console writer.
7373

7474
## 📚 [Documentation](https://lite-bootstrap.modern-python.org)

‎architecture/bootstrappers.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,14 @@ The guard is uniform: the same marker and warning apply to all four app-bearing
8787
frameworks. `attach` is typed `Callable[[], object]` because some hooks (FastStream's
8888
`on_shutdown`) return the callback.
8989

90+
## Single-threaded init (free-threading)
91+
92+
`bootstrap()`/`teardown()` are startup/shutdown, main-thread operations; their
93+
cached state (`is_bootstrapped`, the teardown-attach marker) carries no locks and
94+
is not safe to drive concurrently on one bootstrapper. This is intentional: under
95+
free-threaded CPython the parallelism is in request handling, not bootstrap. See
96+
[`free-threading.md`](free-threading.md).
97+
9098
## App-tagging sentinel convention
9199

92100
When a bootstrapper must tag a user-supplied framework app (FastAPI, FastMCP,

‎architecture/free-threading.md‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# Free-threaded Python (nogil)
2+
3+
`lite-bootstrap` runs on free-threaded CPython (PEP 703): 3.14t (officially
4+
supported per PEP 779) and 3.13t (experimental). The library is pure Python;
5+
what blocks a given surface on ft is always a native dependency somewhere in
6+
its extra, not `lite-bootstrap` itself.
7+
8+
## Support matrix
9+
10+
| Surface | 3.13t | 3.14t | Notes |
11+
|---|---|---|---|
12+
| core, `logging`, `sentry` | ✅ | ✅ | pure Python; `logging` uses the stdlib-json serializer fallback when `orjson` is absent |
13+
| `fastapi`/`faststream` (+ `-sentry`/`-logging`/`-metrics`) | ✅ | ✅ | pure Python + `pydantic-core` ft wheels |
14+
| `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) |
15+
| `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) |
16+
| `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 |
17+
| `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`) |
18+
| `pyroscope` | ❌ | ❌ | `pyroscope-io` is abi3-only, unmaintained, no ft wheels ([`planning/deferred.md`](../planning/deferred.md)) |
19+
20+
The CI matrix (`.github/workflows/_checks.yml`, `free-threaded` job) installs
21+
per Python version to match this table exactly: the 3.13t leg's extras stop at
22+
`logging,sentry,fastapi,faststream,fastapi-metrics,faststream-metrics`; the
23+
3.14t leg adds `litestar,litestar-metrics`. `orjson`, `otl`, `pyroscope`, and
24+
`fastmcp`/`fastmcp-metrics` are excluded from both legs.
25+
26+
## The `orjson` fallback
27+
28+
`orjson` is used only by the logging serializer. It is an opt-in extra
29+
(`lite-bootstrap[orjson]`), preferred when present (GIL-build output unchanged);
30+
otherwise `logging_factory` serializes with the stdlib `json` accelerator using
31+
compact separators and `ensure_ascii=False`, so output stays byte-identical for
32+
JSON-native values. Trade-off on the fallback: ~2-5x slower JSON, and non-JSON-native
33+
types in log `extra` (datetime/UUID) render via repr instead of orjson's native
34+
encoding. Reverts to the native path automatically once `orjson` ships ft wheels.
35+
36+
## Single-threaded-init invariant
37+
38+
`bootstrap()`/`teardown()` are startup/shutdown, main-thread operations. The
39+
cached mutable state (`is_bootstrapped`, `OpenTelemetryInstrument._tracer_provider`,
40+
the `_lite_bootstrap_teardown_attached` marker) is **not** guarded for concurrent
41+
calls on one bootstrapper — by design. Free-threading parallelizes request
42+
handling, where `lite-bootstrap` does not sit. Do not call `bootstrap()`/`teardown()`
43+
on the same bootstrapper from multiple threads.
44+
45+
## Proof
46+
47+
`.github/workflows/_checks.yml` runs `scripts/ft_smoke.py` on 3.13t and 3.14t:
48+
it asserts a free-threaded interpreter, `orjson` absent, the serializer fallback
49+
round-trips, and a FastAPI bootstrap/teardown succeeds. It is a standalone
50+
script rather than a `just test` run because `tests/conftest.py` hard-imports
51+
`opentelemetry`, which the ft leg does not install.

‎architecture/instruments.md‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,35 @@ so the runtime invariant holds even though static analyzers that don't model the
5353
guard may report spurious "possibly unbound" diagnostics. The project uses `ty`,
5454
which handles the pattern correctly.
5555

56+
**Guarding dotted `find_spec` checks.** `find_spec` imports a dotted name's
57+
parent package first, so a present-but-incomplete namespace — e.g.
58+
`opentelemetry-api` installed without `opentelemetry-instrumentation` — raised
59+
`ModuleNotFoundError` instead of returning `False`, crashing `import
60+
lite_bootstrap`. `import_checker._safe_find_spec` wraps `find_spec` for dotted
61+
names and treats that exception as absent; `is_fastapi_opentelemetry_installed`
62+
and `is_litestar_opentelemetry_installed` both route through it.
63+
64+
**`is_otlp_grpc_exporter_installed` is a separate guard from
65+
`is_opentelemetry_installed`.** `is_opentelemetry_installed` only means
66+
`opentelemetry-api` resolves — it says nothing about the SDK or the gRPC OTLP
67+
exporter package. `opentelemetry_instrument.py` used to import the gRPC
68+
exporter unconditionally under that coarse guard, crashing `import
69+
lite_bootstrap` in any environment with bare `opentelemetry-api` present (e.g.
70+
`lite-bootstrap[fastmcp]`, which pulls it in transitively without the rest of
71+
the stack). The exporter import — and its use in `bootstrap()` — now sit behind
72+
their own `import_checker.is_otlp_grpc_exporter_installed` guard
73+
(`_safe_find_spec("opentelemetry.exporter.otlp.proto.grpc.trace_exporter")`).
74+
The honest trade-off: `check_dependencies()` still gates OpenTelemetry
75+
activation on `is_opentelemetry_installed` alone, so setting
76+
`opentelemetry_endpoint` in an environment where `opentelemetry-api` is present
77+
but the exporter package is not now **silently skips OTLP export** —
78+
`bootstrap()`'s exporter block simply omits the span processor when
79+
`is_otlp_grpc_exporter_installed` is False, with none of the
80+
`InstrumentDependencyMissingWarning` a configured-but-missing dependency
81+
normally gets. Making `check_dependencies()` distinguish api/sdk/exporter
82+
presence, so this case gets the standard warning instead of silence, is
83+
deferred — see `planning/deferred.md`.
84+
5685
## Why instruments are not frozen
5786

5887
All `*Config` classes are frozen, but `*Instrument` classes drop `frozen=True`

‎lite_bootstrap/bootstrappers/faststream_bootstrapper.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,11 @@ def __init__(
4343
include_messages_counters: bool = True,
4444
) -> None: ...
4545

46+
# A constructed instance is passed to broker.add_middleware, which expects a
47+
# faststream BrokerMiddleware — a builder callable. Declared with Any to keep
48+
# lite-bootstrap decoupled from faststream's internal middleware types.
49+
def __call__(self, msg: typing.Any, /, *, context: typing.Any) -> typing.Any: ... # noqa: ANN401
50+
4651

4752
@typing.runtime_checkable
4853
class FastStreamPrometheusMiddlewareProtocol(typing.Protocol):
@@ -55,6 +60,9 @@ def __init__(
5560
received_messages_size_buckets: typing.Sequence[float] | None = None,
5661
) -> None: ...
5762

63+
# See FastStreamTelemetryMiddlewareProtocol.__call__.
64+
def __call__(self, msg: typing.Any, /, *, context: typing.Any) -> typing.Any: ... # noqa: ANN401
65+
5866

5967
def _make_asgi_faststream() -> "AsgiFastStream":
6068
return AsgiFastStream()

‎lite_bootstrap/import_checker.py‎

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,19 @@
11
from importlib.util import find_spec
22

33

4+
def _safe_find_spec(module_name: str) -> bool:
5+
"""Return whether a dotted module is importable, treating a missing parent as absent.
6+
7+
find_spec imports the dotted name's parent first; a present-but-incomplete
8+
namespace (e.g. opentelemetry-api installed without opentelemetry-instrumentation)
9+
otherwise raises ModuleNotFoundError, crashing `import lite_bootstrap`.
10+
"""
11+
try:
12+
return find_spec(module_name) is not None
13+
except ModuleNotFoundError:
14+
return False
15+
16+
417
is_opentelemetry_installed = find_spec("opentelemetry") is not None
518
is_sentry_installed = find_spec("sentry_sdk") is not None
619
is_structlog_installed = find_spec("structlog") is not None
@@ -9,11 +22,13 @@
922
is_litestar_installed = find_spec("litestar") is not None
1023
is_faststream_installed = find_spec("faststream") is not None
1124
is_prometheus_fastapi_instrumentator_installed = find_spec("prometheus_fastapi_instrumentator") is not None
12-
is_fastapi_opentelemetry_installed = (
13-
is_opentelemetry_installed and find_spec("opentelemetry.instrumentation.fastapi") is not None
25+
is_fastapi_opentelemetry_installed = is_opentelemetry_installed and _safe_find_spec(
26+
"opentelemetry.instrumentation.fastapi"
1427
)
1528
is_litestar_opentelemetry_installed = (
16-
is_opentelemetry_installed and is_litestar_installed and find_spec("opentelemetry.instrumentation.asgi") is not None
29+
is_opentelemetry_installed and is_litestar_installed and _safe_find_spec("opentelemetry.instrumentation.asgi")
1730
)
31+
is_otlp_grpc_exporter_installed = _safe_find_spec("opentelemetry.exporter.otlp.proto.grpc.trace_exporter")
1832
is_pyroscope_installed = find_spec("pyroscope") is not None
1933
is_fastmcp_installed = find_spec("fastmcp") is not None
34+
is_orjson_installed = find_spec("orjson") is not None

‎lite_bootstrap/instruments/logging_factory.py‎

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
import dataclasses
2+
import json
23
import logging
34
import logging.handlers
45
import sys
56
import typing
67

7-
import orjson
8-
98
from lite_bootstrap import import_checker
109

1110

11+
if import_checker.is_orjson_installed:
12+
import orjson
13+
14+
1215
ScopeType = typing.MutableMapping[str, typing.Any]
1316

1417

@@ -31,10 +34,22 @@ class _MemoryLoggerFactoryConfig:
3134
log_stream: typing.Any = sys.stdout
3235

3336

34-
def _serialize_log_with_orjson_to_string(value: typing.Any, **kwargs: typing.Any) -> str: # noqa: ANN401
37+
def _dumps_orjson(value: typing.Any, **kwargs: typing.Any) -> str: # noqa: ANN401
3538
return orjson.dumps(value, **kwargs).decode()
3639

3740

41+
def _dumps_stdlib(value: typing.Any, **kwargs: typing.Any) -> str: # noqa: ANN401
42+
# Match orjson's compact, UTF-8 output shape so log lines stay byte-identical.
43+
return json.dumps(value, separators=(",", ":"), ensure_ascii=False, **kwargs)
44+
45+
46+
# orjson has no free-threaded wheels and refuses to build on ft; fall back to the
47+
# stdlib json accelerator (always ft-native) when it is absent.
48+
# See architecture/free-threading.md.
49+
_serialize_log_to_string = _dumps_orjson if import_checker.is_orjson_installed else _dumps_stdlib
50+
_json_loads = orjson.loads if import_checker.is_orjson_installed else json.loads
51+
52+
3853
# Meta-keys the producer's structlog processor chain emits at the top level of every
3954
# rendered line (see lite_bootstrap/instruments/logging_instrument.py). They are not
4055
# user-supplied extra and are stripped from StructuredLogPayload.extra. If you add a
@@ -68,8 +83,8 @@ def parse(cls, formatted: str) -> "StructuredLogPayload | None":
6883
if not formatted.startswith("{"):
6984
return None
7085
try:
71-
loaded = orjson.loads(formatted)
72-
except orjson.JSONDecodeError:
86+
loaded = _json_loads(formatted)
87+
except json.JSONDecodeError: # orjson.JSONDecodeError subclasses this
7388
return None
7489
if not isinstance(loaded, dict): # pragma: no cover - JSON starting with "{" is always an object
7590
return None

0 commit comments

Comments
 (0)