diff --git a/.github/scripts/report-scheduled-failure.sh b/.github/scripts/report-scheduled-failure.sh index 7065008..099daad 100755 --- a/.github/scripts/report-scheduled-failure.sh +++ b/.github/scripts/report-scheduled-failure.sh @@ -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 '.[]'\` then \`python scripts/floor_smoke.py \`, 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 diff --git a/.github/workflows/_checks.yml b/.github/workflows/_checks.yml index a11e202..a0666e6 100644 --- a/.github/workflows/_checks.yml +++ b/.github/workflows/_checks.yml @@ -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: @@ -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 }} diff --git a/.github/workflows/scheduled.yml b/.github/workflows/scheduled.yml index b1fe433..528763c 100644 --- a/.github/workflows/scheduled.yml +++ b/.github/workflows/scheduled.yml @@ -11,6 +11,8 @@ concurrency: jobs: checks: uses: ./.github/workflows/_checks.yml + with: + lowest-direct: true report-failure: needs: checks diff --git a/pyproject.toml b/pyproject.toml index 78742ed..4952e05 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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, @@ -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 @@ -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]", diff --git a/scripts/floor_smoke.py b/scripts/floor_smoke.py new file mode 100644 index 0000000..717fe68 --- /dev/null +++ b/scripts/floor_smoke.py @@ -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()