Skip to content

Commit ffbd378

Browse files
authored
feat: exercise the declared dependency floors in CI (#221)
* feat: exercise the declared dependency floors in CI Adds a lowest-direct job that pins every direct dependency to its declared floor and bootstraps against it, per framework rather than all-extras at once. Gated behind a workflow_call input so it runs from scheduled.yml, where an upstream release breaking the floor opens a tracking issue rather than blocking an unrelated PR. Writing it found two floors that import and bootstrap cleanly but fail on a call further in: pyroscope.add_thread_tag changed signature in 1.0.1 and PyroscopeSpanProcessor only reaches it when a span starts, and litestar's StructlogPlugin needs structlog 23.2 to build its stdlib formatter. * fix: orjson 3.11.0 ships no cp314 wheel; the floor is 3.11.1
1 parent a7c9e98 commit ffbd378

5 files changed

Lines changed: 286 additions & 8 deletions

File tree

‎.github/scripts/report-scheduled-failure.sh‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,11 @@ gh label create "$LABEL" \
1616
existing=$(gh issue list --label "$LABEL" --state open --json number --jq '.[0].number // empty')
1717

1818
if [ -z "$existing" ]; then
19-
body=$(printf '%s\n\n%s\n\n%s\n\n%s' \
19+
body=$(printf '%s\n\n%s\n\n%s\n\n%s\n\n%s' \
2020
"The weekly scheduled dependency check failed." \
2121
"First failing run: ${RUN_URL}" \
22-
"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\`." \
22+
"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\`." \
23+
"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." \
2324
"Close this issue once fixed. The next scheduled failure will open a fresh issue.")
2425
gh issue create --title "$TITLE" --label "$LABEL" --body "$body"
2526
else

‎.github/workflows/_checks.yml‎

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
11
name: checks
22
on:
3-
workflow_call: {}
3+
workflow_call:
4+
inputs:
5+
lowest-direct:
6+
description: >-
7+
Run the declared dependency floors. Off for PRs: the job resolves direct dependencies at
8+
their floor and transitives at their newest, so a release published upstream today can
9+
turn it red for reasons no PR here caused. scheduled.yml turns it on, where that failure
10+
opens a tracking issue instead of blocking a merge.
11+
type: boolean
12+
default: false
413

514
jobs:
615
lint:
@@ -121,3 +130,38 @@ jobs:
121130
done
122131
if [ -n "$failed" ]; then echo "::error::extras failed isolated install+import:$failed"; exit 1; fi
123132
echo "all extras install and import in isolation"
133+
134+
lowest-direct:
135+
# Every other job resolves newest, so a declared floor is a claim nothing checks. This one
136+
# pins each direct dependency to the floor pyproject.toml declares and bootstraps against it.
137+
# Per framework rather than all-extras at once: a floor declared in one extra pulls the shared
138+
# stack up for the others, so a combined resolution tests a set no user installs.
139+
if: inputs.lowest-direct
140+
runs-on: ubuntu-latest
141+
strategy:
142+
fail-fast: false
143+
matrix:
144+
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
145+
target: [free, fastapi, litestar, faststream, fastmcp]
146+
# These `include` entries name an existing `target`, so each adds `extras` to that
147+
# target's five combinations rather than creating a job of its own. `free` carries
148+
# orjson because it belongs to no *-all extra and its floor is a wheel-availability
149+
# one, which only an interpreter matrix can check.
150+
include:
151+
- {target: free, extras: "free-all,orjson"}
152+
- {target: fastapi, extras: "fastapi-all"}
153+
- {target: litestar, extras: "litestar-all"}
154+
- {target: faststream, extras: "faststream-all"}
155+
- {target: fastmcp, extras: "fastmcp-all"}
156+
steps:
157+
- uses: actions/checkout@v6
158+
- uses: astral-sh/setup-uv@v8.2.0
159+
with:
160+
enable-cache: true
161+
cache-dependency-glob: "**/pyproject.toml"
162+
- run: uv python install ${{ matrix.python-version }}
163+
- run: uv venv --python ${{ matrix.python-version }}
164+
- name: Install at the declared floors
165+
run: uv pip install --resolution lowest-direct ".[${{ matrix.extras }}]"
166+
- name: Bootstrap at the floors
167+
run: .venv/bin/python scripts/floor_smoke.py ${{ matrix.target }}

‎.github/workflows/scheduled.yml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ concurrency:
1111
jobs:
1212
checks:
1313
uses: ./.github/workflows/_checks.yml
14+
with:
15+
lowest-direct: true
1416

1517
report-failure:
1618
needs: checks

‎pyproject.toml‎

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,11 @@ Changelog = "https://github.com/modern-python/lite-bootstrap/releases"
3939
[project.optional-dependencies]
4040
orjson = [
4141
# No API forces this floor: only `orjson.dumps(...).decode()` and `orjson.loads`, both original.
42-
# >=3.11 is the first release with cp314 wheels, and orjson builds from source only with a Rust
43-
# toolchain, so anything older is uninstallable on the newest supported interpreter.
44-
"orjson>=3.11",
42+
# >=3.11.1 is the first release with cp314 wheels, and orjson builds from source only with a
43+
# Rust toolchain (a nightly one, on 3.14), so anything older is uninstallable on the newest
44+
# supported interpreter. 3.11.0 ships none: a machine with cargo installed can still build it,
45+
# which is how #218 came to declare >=3.11.
46+
"orjson>=3.11.1",
4547
]
4648
sentry = [
4749
# >=1.31 for the `max_value_length` init option; >=2.1 for sentry_sdk.set_tags (plural,
@@ -50,8 +52,10 @@ sentry = [
5052
]
5153
pyroscope = [
5254
# >=0.7.2 for pyroscope.shutdown() (PyroscopeInstrument.teardown); 0.7.1 and below carry
53-
# configure() without it, so teardown raises rather than failing at import.
54-
"pyroscope-io>=0.7.2",
55+
# configure() without it, so teardown raises rather than failing at import. >=1.0.1 because
56+
# add_thread_tag took (thread_id, key, value) until then and PyroscopeSpanProcessor calls it
57+
# with (key, value) -- reached only when a span starts, so import and bootstrap both look fine.
58+
"pyroscope-io>=1.0.1",
5559
]
5660
otl = [
5761
# One release train: api/sdk/exporters 1.28 pair with every instrumentation package at
@@ -125,6 +129,10 @@ litestar-otl = [
125129
]
126130
litestar-logging = [
127131
"lite-bootstrap[litestar,logging]",
132+
# litestar's StructlogPlugin builds a stdlib logging config whose 'standard' formatter
133+
# structlog cannot supply before 23.2, so LitestarBootstrapper raises ValueError during
134+
# bootstrap. Higher than the 21.3 the `logging` extra needs on its own.
135+
"structlog>=23.2",
128136
]
129137
litestar-metrics = [
130138
"lite-bootstrap[litestar]",

‎scripts/floor_smoke.py‎

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
"""Bootstrap smoke at the declared dependency floors.
2+
3+
Run against an environment resolved with `uv pip install --resolution lowest-direct`,
4+
so every direct dependency sits at the floor `pyproject.toml` declares. Takes one
5+
target naming the bootstrapper to exercise. Exits non-zero on failure.
6+
7+
An import check is not enough: half the floors this repo carries were found by a call
8+
made during bootstrap or teardown, not by a missing module. So each target builds a
9+
config with every instrument its extras can supply, bootstraps, exercises the calls
10+
that set a floor, and tears down.
11+
12+
`InstrumentSkippedWarning` is escalated to an error, because an instrument that
13+
degrades to a silent skip would let a too-low floor pass as success.
14+
15+
Not a pytest test: conftest.py hard-imports opentelemetry, sentry_sdk and structlog,
16+
which most of these targets do not install.
17+
"""
18+
19+
import asyncio
20+
import sys
21+
import typing
22+
import warnings
23+
24+
from lite_bootstrap import (
25+
FastAPIBootstrapper,
26+
FastAPIConfig,
27+
FastMcpBootstrapper,
28+
FastMcpConfig,
29+
FastStreamBootstrapper,
30+
FastStreamConfig,
31+
FreeBootstrapper,
32+
FreeConfig,
33+
InstrumentSkippedWarning,
34+
LitestarBootstrapper,
35+
LitestarConfig,
36+
import_checker,
37+
)
38+
from lite_bootstrap.instruments.logging_factory import StructuredLogPayload, _serialize_log_to_string
39+
40+
41+
if import_checker.is_opentelemetry_installed:
42+
from opentelemetry import trace
43+
44+
if import_checker.is_sentry_installed:
45+
import sentry_sdk
46+
47+
class DroppingTransport(sentry_sdk.Transport):
48+
"""Keep sentry configured without a server: teardown's flush would otherwise retry for minutes."""
49+
50+
def capture_envelope(self, envelope: object) -> None:
51+
"""Drop the envelope."""
52+
53+
54+
OTLP_ENDPOINT: typing.Final = "localhost:4317"
55+
PYROSCOPE_ENDPOINT: typing.Final = "http://localhost:4040"
56+
SENTRY_DSN: typing.Final = "https://testdsn@localhost/1"
57+
SENTRY_PARAMS: typing.Final = {"transport": DroppingTransport()} if import_checker.is_sentry_installed else {}
58+
HEALTH_PATH: typing.Final = "/floor-health/"
59+
60+
61+
def _emit_span() -> None:
62+
"""Force _format_span -> ReadableSpan.to_json(indent=None) through the console exporter."""
63+
with trace.get_tracer("floor-smoke").start_as_current_span("floor-smoke-span"):
64+
pass
65+
66+
67+
def _check_orjson_serializer() -> None:
68+
payload = StructuredLogPayload.parse(_serialize_log_to_string({"event": "floor ok", "level": "info", "n": 1}))
69+
assert payload is not None
70+
assert payload.message == "floor ok"
71+
assert payload.extra == {"n": 1}
72+
73+
74+
async def _drive_lifespan(application: typing.Any) -> None: # noqa: ANN401
75+
"""Run the ASGI lifespan through startup and shutdown without a test client."""
76+
queue: asyncio.Queue[dict] = asyncio.Queue()
77+
await queue.put({"type": "lifespan.startup"})
78+
79+
async def receive() -> dict:
80+
return await queue.get()
81+
82+
async def send(message: dict) -> None:
83+
if message["type"] == "lifespan.startup.complete":
84+
await queue.put({"type": "lifespan.shutdown"})
85+
elif message["type"].endswith(".failed"):
86+
raise RuntimeError(message)
87+
88+
await application({"type": "lifespan", "state": {}}, receive, send)
89+
90+
91+
def _free() -> None:
92+
bootstrapper = FreeBootstrapper(
93+
bootstrap_config=FreeConfig(
94+
service_name="floor-smoke",
95+
service_version="1.0.0",
96+
service_environment="test",
97+
logging_buffer_capacity=0,
98+
sentry_dsn=SENTRY_DSN,
99+
sentry_additional_params=SENTRY_PARAMS,
100+
sentry_tags={"floor": "smoke"},
101+
pyroscope_endpoint=PYROSCOPE_ENDPOINT,
102+
opentelemetry_endpoint=OTLP_ENDPOINT,
103+
opentelemetry_log_traces=True,
104+
)
105+
)
106+
bootstrapper.bootstrap()
107+
try:
108+
_emit_span()
109+
if import_checker.is_orjson_installed:
110+
_check_orjson_serializer()
111+
finally:
112+
bootstrapper.teardown()
113+
114+
115+
def _fastapi() -> None:
116+
bootstrapper = FastAPIBootstrapper(
117+
bootstrap_config=FastAPIConfig(
118+
service_name="floor-smoke",
119+
service_version="1.0.0",
120+
service_debug=False,
121+
logging_buffer_capacity=0,
122+
cors_allowed_origins=["http://test"],
123+
health_checks_path=HEALTH_PATH,
124+
health_checks_include_in_schema=True,
125+
swagger_offline_docs=True,
126+
sentry_dsn=SENTRY_DSN,
127+
sentry_additional_params=SENTRY_PARAMS,
128+
pyroscope_endpoint=PYROSCOPE_ENDPOINT,
129+
opentelemetry_endpoint=OTLP_ENDPOINT,
130+
opentelemetry_log_traces=True,
131+
)
132+
)
133+
application = bootstrapper.bootstrap()
134+
# Generating the schema puts HealthCheckTypedDict through pydantic as a response model,
135+
# which is the typing-extensions use ADR-0007 records.
136+
assert application.openapi()["paths"]
137+
assert application.url_path_for("health_check_handler") == HEALTH_PATH
138+
_emit_span()
139+
# Teardown runs inside the lifespan the bootstrapper chained onto the application.
140+
asyncio.run(_drive_lifespan(application))
141+
142+
143+
def _litestar() -> None:
144+
bootstrapper = LitestarBootstrapper(
145+
bootstrap_config=LitestarConfig(
146+
service_name="floor-smoke",
147+
service_version="1.0.0",
148+
service_debug=False,
149+
logging_buffer_capacity=0,
150+
cors_allowed_origins=["http://test"],
151+
health_checks_path=HEALTH_PATH,
152+
sentry_dsn=SENTRY_DSN,
153+
sentry_additional_params=SENTRY_PARAMS,
154+
pyroscope_endpoint=PYROSCOPE_ENDPOINT,
155+
opentelemetry_endpoint=OTLP_ENDPOINT,
156+
opentelemetry_log_traces=True,
157+
)
158+
)
159+
bootstrapper.bootstrap()
160+
try:
161+
_emit_span()
162+
finally:
163+
bootstrapper.teardown()
164+
165+
166+
def _faststream() -> None:
167+
bootstrapper = FastStreamBootstrapper(
168+
bootstrap_config=FastStreamConfig(
169+
service_name="floor-smoke",
170+
service_version="1.0.0",
171+
logging_buffer_capacity=0,
172+
health_checks_path=HEALTH_PATH,
173+
sentry_dsn=SENTRY_DSN,
174+
sentry_additional_params=SENTRY_PARAMS,
175+
pyroscope_endpoint=PYROSCOPE_ENDPOINT,
176+
opentelemetry_endpoint=OTLP_ENDPOINT,
177+
opentelemetry_log_traces=True,
178+
)
179+
)
180+
bootstrapper.bootstrap()
181+
try:
182+
_emit_span()
183+
finally:
184+
bootstrapper.teardown()
185+
186+
187+
def _fastmcp() -> None:
188+
bootstrapper = FastMcpBootstrapper(
189+
bootstrap_config=FastMcpConfig(
190+
service_name="floor-smoke",
191+
service_version="1.0.0",
192+
logging_buffer_capacity=0,
193+
health_checks_path=HEALTH_PATH,
194+
sentry_dsn=SENTRY_DSN,
195+
sentry_additional_params=SENTRY_PARAMS,
196+
pyroscope_endpoint=PYROSCOPE_ENDPOINT,
197+
)
198+
)
199+
bootstrapper.bootstrap()
200+
bootstrapper.teardown()
201+
202+
203+
TARGETS: typing.Final = {
204+
"free": _free,
205+
"fastapi": _fastapi,
206+
"litestar": _litestar,
207+
"faststream": _faststream,
208+
"fastmcp": _fastmcp,
209+
}
210+
211+
212+
def main() -> None:
213+
expected_argv_len = 2
214+
if len(sys.argv) != expected_argv_len or sys.argv[1] not in TARGETS:
215+
sys.exit(f"usage: floor_smoke.py {{{'|'.join(TARGETS)}}}")
216+
warnings.filterwarnings("error", category=InstrumentSkippedWarning)
217+
target = sys.argv[1]
218+
TARGETS[target]()
219+
print(f"floor smoke OK: {target} on {sys.version}") # noqa: T201
220+
221+
222+
if __name__ == "__main__":
223+
main()

0 commit comments

Comments
 (0)