Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .github/scripts/report-scheduled-failure.sh
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,11 @@ gh label create "$LABEL" \
existing=$(gh issue list --label "$LABEL" --state open --json number --jq '.[0].number // empty')

if [ -z "$existing" ]; then
body=$(printf '%s\n\n%s\n\n%s\n\n%s' \
body=$(printf '%s\n\n%s\n\n%s\n\n%s\n\n%s' \
"The weekly scheduled dependency check failed." \
"First failing run: ${RUN_URL}" \
"Likely cause: a transitive dev or lint dependency (ruff, ty, eof-fixer, pytest, typing-extensions) released a breaking change. Reproduce locally with \`just install\` then \`just lint\` and \`just test\`." \
"Likely cause, if a lint or pytest job failed: a dev or lint dependency (ruff, ty, eof-fixer, pytest, typing-extensions) released a breaking change. Reproduce locally with \`just install\` then \`just lint\` and \`just test\`." \
"Likely cause, if a lowest-direct job failed: a declared floor in \`pyproject.toml\` no longer installs or bootstraps, usually because an upstream package changed metadata under it. Reproduce with \`uv pip install --resolution lowest-direct '.[<extras>]'\` then \`python scripts/floor_smoke.py <target>\`, reading both off the failing job's matrix." \
"Close this issue once fixed. The next scheduled failure will open a fresh issue.")
gh issue create --title "$TITLE" --label "$LABEL" --body "$body"
else
Expand Down
46 changes: 45 additions & 1 deletion .github/workflows/_checks.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
name: checks
on:
workflow_call: {}
workflow_call:
inputs:
lowest-direct:
description: >-
Run the declared dependency floors. Off for PRs: the job resolves direct dependencies at
their floor and transitives at their newest, so a release published upstream today can
turn it red for reasons no PR here caused. scheduled.yml turns it on, where that failure
opens a tracking issue instead of blocking a merge.
type: boolean
default: false

jobs:
lint:
Expand Down Expand Up @@ -121,3 +130,38 @@ jobs:
done
if [ -n "$failed" ]; then echo "::error::extras failed isolated install+import:$failed"; exit 1; fi
echo "all extras install and import in isolation"

lowest-direct:
# Every other job resolves newest, so a declared floor is a claim nothing checks. This one
# pins each direct dependency to the floor pyproject.toml declares and bootstraps against it.
# Per framework rather than all-extras at once: a floor declared in one extra pulls the shared
# stack up for the others, so a combined resolution tests a set no user installs.
if: inputs.lowest-direct
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
target: [free, fastapi, litestar, faststream, fastmcp]
# These `include` entries name an existing `target`, so each adds `extras` to that
# target's five combinations rather than creating a job of its own. `free` carries
# orjson because it belongs to no *-all extra and its floor is a wheel-availability
# one, which only an interpreter matrix can check.
include:
- {target: free, extras: "free-all,orjson"}
- {target: fastapi, extras: "fastapi-all"}
- {target: litestar, extras: "litestar-all"}
- {target: faststream, extras: "faststream-all"}
- {target: fastmcp, extras: "fastmcp-all"}
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 at the declared floors
run: uv pip install --resolution lowest-direct ".[${{ matrix.extras }}]"
- name: Bootstrap at the floors
run: .venv/bin/python scripts/floor_smoke.py ${{ matrix.target }}
2 changes: 2 additions & 0 deletions .github/workflows/scheduled.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ concurrency:
jobs:
checks:
uses: ./.github/workflows/_checks.yml
with:
lowest-direct: true

report-failure:
needs: checks
Expand Down
18 changes: 13 additions & 5 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,11 @@ Changelog = "https://github.com/modern-python/lite-bootstrap/releases"
[project.optional-dependencies]
orjson = [
# No API forces this floor: only `orjson.dumps(...).decode()` and `orjson.loads`, both original.
# >=3.11 is the first release with cp314 wheels, and orjson builds from source only with a Rust
# toolchain, so anything older is uninstallable on the newest supported interpreter.
"orjson>=3.11",
# >=3.11.1 is the first release with cp314 wheels, and orjson builds from source only with a
# Rust toolchain (a nightly one, on 3.14), so anything older is uninstallable on the newest
# supported interpreter. 3.11.0 ships none: a machine with cargo installed can still build it,
# which is how #218 came to declare >=3.11.
"orjson>=3.11.1",
]
sentry = [
# >=1.31 for the `max_value_length` init option; >=2.1 for sentry_sdk.set_tags (plural,
Expand All @@ -50,8 +52,10 @@ sentry = [
]
pyroscope = [
# >=0.7.2 for pyroscope.shutdown() (PyroscopeInstrument.teardown); 0.7.1 and below carry
# configure() without it, so teardown raises rather than failing at import.
"pyroscope-io>=0.7.2",
# configure() without it, so teardown raises rather than failing at import. >=1.0.1 because
# add_thread_tag took (thread_id, key, value) until then and PyroscopeSpanProcessor calls it
# with (key, value) -- reached only when a span starts, so import and bootstrap both look fine.
"pyroscope-io>=1.0.1",
]
otl = [
# One release train: api/sdk/exporters 1.28 pair with every instrumentation package at
Expand Down Expand Up @@ -125,6 +129,10 @@ litestar-otl = [
]
litestar-logging = [
"lite-bootstrap[litestar,logging]",
# litestar's StructlogPlugin builds a stdlib logging config whose 'standard' formatter
# structlog cannot supply before 23.2, so LitestarBootstrapper raises ValueError during
# bootstrap. Higher than the 21.3 the `logging` extra needs on its own.
"structlog>=23.2",
]
litestar-metrics = [
"lite-bootstrap[litestar]",
Expand Down
223 changes: 223 additions & 0 deletions scripts/floor_smoke.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
"""Bootstrap smoke at the declared dependency floors.

Run against an environment resolved with `uv pip install --resolution lowest-direct`,
so every direct dependency sits at the floor `pyproject.toml` declares. Takes one
target naming the bootstrapper to exercise. Exits non-zero on failure.

An import check is not enough: half the floors this repo carries were found by a call
made during bootstrap or teardown, not by a missing module. So each target builds a
config with every instrument its extras can supply, bootstraps, exercises the calls
that set a floor, and tears down.

`InstrumentSkippedWarning` is escalated to an error, because an instrument that
degrades to a silent skip would let a too-low floor pass as success.

Not a pytest test: conftest.py hard-imports opentelemetry, sentry_sdk and structlog,
which most of these targets do not install.
"""

import asyncio
import sys
import typing
import warnings

from lite_bootstrap import (
FastAPIBootstrapper,
FastAPIConfig,
FastMcpBootstrapper,
FastMcpConfig,
FastStreamBootstrapper,
FastStreamConfig,
FreeBootstrapper,
FreeConfig,
InstrumentSkippedWarning,
LitestarBootstrapper,
LitestarConfig,
import_checker,
)
from lite_bootstrap.instruments.logging_factory import StructuredLogPayload, _serialize_log_to_string


if import_checker.is_opentelemetry_installed:
from opentelemetry import trace

if import_checker.is_sentry_installed:
import sentry_sdk

class DroppingTransport(sentry_sdk.Transport):
"""Keep sentry configured without a server: teardown's flush would otherwise retry for minutes."""

def capture_envelope(self, envelope: object) -> None:
"""Drop the envelope."""


OTLP_ENDPOINT: typing.Final = "localhost:4317"
PYROSCOPE_ENDPOINT: typing.Final = "http://localhost:4040"
SENTRY_DSN: typing.Final = "https://testdsn@localhost/1"
SENTRY_PARAMS: typing.Final = {"transport": DroppingTransport()} if import_checker.is_sentry_installed else {}
HEALTH_PATH: typing.Final = "/floor-health/"


def _emit_span() -> None:
"""Force _format_span -> ReadableSpan.to_json(indent=None) through the console exporter."""
with trace.get_tracer("floor-smoke").start_as_current_span("floor-smoke-span"):
pass


def _check_orjson_serializer() -> None:
payload = StructuredLogPayload.parse(_serialize_log_to_string({"event": "floor ok", "level": "info", "n": 1}))
assert payload is not None
assert payload.message == "floor ok"
assert payload.extra == {"n": 1}


async def _drive_lifespan(application: typing.Any) -> None: # noqa: ANN401
"""Run the ASGI lifespan through startup and shutdown without a test client."""
queue: asyncio.Queue[dict] = asyncio.Queue()
await queue.put({"type": "lifespan.startup"})

async def receive() -> dict:
return await queue.get()

async def send(message: dict) -> None:
if message["type"] == "lifespan.startup.complete":
await queue.put({"type": "lifespan.shutdown"})
elif message["type"].endswith(".failed"):
raise RuntimeError(message)

await application({"type": "lifespan", "state": {}}, receive, send)


def _free() -> None:
bootstrapper = FreeBootstrapper(
bootstrap_config=FreeConfig(
service_name="floor-smoke",
service_version="1.0.0",
service_environment="test",
logging_buffer_capacity=0,
sentry_dsn=SENTRY_DSN,
sentry_additional_params=SENTRY_PARAMS,
sentry_tags={"floor": "smoke"},
pyroscope_endpoint=PYROSCOPE_ENDPOINT,
opentelemetry_endpoint=OTLP_ENDPOINT,
opentelemetry_log_traces=True,
)
)
bootstrapper.bootstrap()
try:
_emit_span()
if import_checker.is_orjson_installed:
_check_orjson_serializer()
finally:
bootstrapper.teardown()


def _fastapi() -> None:
bootstrapper = FastAPIBootstrapper(
bootstrap_config=FastAPIConfig(
service_name="floor-smoke",
service_version="1.0.0",
service_debug=False,
logging_buffer_capacity=0,
cors_allowed_origins=["http://test"],
health_checks_path=HEALTH_PATH,
health_checks_include_in_schema=True,
swagger_offline_docs=True,
sentry_dsn=SENTRY_DSN,
sentry_additional_params=SENTRY_PARAMS,
pyroscope_endpoint=PYROSCOPE_ENDPOINT,
opentelemetry_endpoint=OTLP_ENDPOINT,
opentelemetry_log_traces=True,
)
)
application = bootstrapper.bootstrap()
# Generating the schema puts HealthCheckTypedDict through pydantic as a response model,
# which is the typing-extensions use ADR-0007 records.
assert application.openapi()["paths"]
assert application.url_path_for("health_check_handler") == HEALTH_PATH
_emit_span()
# Teardown runs inside the lifespan the bootstrapper chained onto the application.
asyncio.run(_drive_lifespan(application))


def _litestar() -> None:
bootstrapper = LitestarBootstrapper(
bootstrap_config=LitestarConfig(
service_name="floor-smoke",
service_version="1.0.0",
service_debug=False,
logging_buffer_capacity=0,
cors_allowed_origins=["http://test"],
health_checks_path=HEALTH_PATH,
sentry_dsn=SENTRY_DSN,
sentry_additional_params=SENTRY_PARAMS,
pyroscope_endpoint=PYROSCOPE_ENDPOINT,
opentelemetry_endpoint=OTLP_ENDPOINT,
opentelemetry_log_traces=True,
)
)
bootstrapper.bootstrap()
try:
_emit_span()
finally:
bootstrapper.teardown()


def _faststream() -> None:
bootstrapper = FastStreamBootstrapper(
bootstrap_config=FastStreamConfig(
service_name="floor-smoke",
service_version="1.0.0",
logging_buffer_capacity=0,
health_checks_path=HEALTH_PATH,
sentry_dsn=SENTRY_DSN,
sentry_additional_params=SENTRY_PARAMS,
pyroscope_endpoint=PYROSCOPE_ENDPOINT,
opentelemetry_endpoint=OTLP_ENDPOINT,
opentelemetry_log_traces=True,
)
)
bootstrapper.bootstrap()
try:
_emit_span()
finally:
bootstrapper.teardown()


def _fastmcp() -> None:
bootstrapper = FastMcpBootstrapper(
bootstrap_config=FastMcpConfig(
service_name="floor-smoke",
service_version="1.0.0",
logging_buffer_capacity=0,
health_checks_path=HEALTH_PATH,
sentry_dsn=SENTRY_DSN,
sentry_additional_params=SENTRY_PARAMS,
pyroscope_endpoint=PYROSCOPE_ENDPOINT,
)
)
bootstrapper.bootstrap()
bootstrapper.teardown()


TARGETS: typing.Final = {
"free": _free,
"fastapi": _fastapi,
"litestar": _litestar,
"faststream": _faststream,
"fastmcp": _fastmcp,
}


def main() -> None:
expected_argv_len = 2
if len(sys.argv) != expected_argv_len or sys.argv[1] not in TARGETS:
sys.exit(f"usage: floor_smoke.py {{{'|'.join(TARGETS)}}}")
warnings.filterwarnings("error", category=InstrumentSkippedWarning)
target = sys.argv[1]
TARGETS[target]()
print(f"floor smoke OK: {target} on {sys.version}") # noqa: T201


if __name__ == "__main__":
main()
Loading