Skip to content

Declare a floor for every bare direct dependency #213

Closed

Description

@lesnik512

Twelve of the eighteen direct runtime requirements in [project.optional-dependencies]
declare no floor at all. Six do — litestar>=2.15, prometheus-fastapi-instrumentator>=8.0.1,
prometheus-client>=0.20, opentelemetry-instrumentation-asgi>=0.46b0, faststream>=0.6,
fastmcp>=3.0 — and the rest are bare:

  • core: typing-extensions
  • orjson, sentry-sdk, pyroscope-io, structlog, fastapi
  • opentelemetry-api, opentelemetry-sdk, opentelemetry-instrumentation,
    opentelemetry-exporter-otlp-proto-grpc, opentelemetry-exporter-otlp-proto-http,
    opentelemetry-instrumentation-fastapi

A bare requirement is not a wider range than a floored one; it is a claim that every release
back to the first one works. uv pip install --resolution lowest-direct reads it that way and
resolves fastapi==0.1.0, structlog==0.1.0, sentry-sdk==0.1.0, opentelemetry-{api,sdk}==1.0.0
with opentelemetry-instrumentation==0.19b0, pyroscope-io==0.3.0, orjson==2.0.7.

Core is the case that settles it, because it needs no extra to reproduce. A bare install at
lowest-direct resolves typing-extensions==3.6.2 and does not import:

ImportError: cannot import name 'GenericMeta' from 'typing'
  at lite_bootstrap/instruments/base.py:4

typing_extensions.Self (BaseConfig.from_dict/from_object) landed in 4.0.0, and 4.0.0 imports
clean. ADR-0007
argues that dependency is load-bearing and names both uses; the floor those uses imply was never
written down. That is a real undeclared minimum today, independent of any CI work.

Establish the lowest version each bare requirement is actually tested against and declare it, the
way litestar>=2.15 names the two APIs that forced it and #211 established faststream>=0.6 and
fastmcp>=3.0 release by release. A floor gets a comment when a specific API forced it, and none
when it is simply the oldest release the suite passes on.

Two things to decide while doing it:

  • Whether a floor is derived from the library or from the suite. tests/conftest.py imports
    sentry_sdk.envelope.Envelope and structlog.typing at module level, and the FastStream tests
    import faststream._internal.logger.params_storage.ManualLoggerStorage. A floor established by
    running the suite is therefore at least as high as the test helpers need, which can exceed the
    library's own minimum and over-constrains downstream services. The litestar and fastmcp
    floors were derived from library APIs; picking one rule and applying it to all twelve keeps the
    set coherent.
  • The dev and lint groups. They are unfloored too (redis>=5.2.1 is the only exception), which
    is why uv lock --resolution lowest-direct fails outright: pytest resolves to 2.0.0 and its
    sdist 403s on a 2011 setuptools bootstrap URL. uv lock has no --no-dev or
    --no-default-groups, so the lock form of Exercise declared dependency floors in CI #210 needs floors here as well, while the per-extra
    uv pip install form does not. Worth settling alongside, since it decides which form Exercise declared dependency floors in CI #210 can use.

#210 is the job that holds these floors once they exist; it cannot go green before this lands.

Note that nothing enforces a floor at runtime either: import_checker.py is presence-only
(find_spec), so a too-old-but-present dependency passes every guard and fails at attribute access
inside bootstrap() rather than as InstrumentDependencyMissingWarning. The declaration is the
only defence, which is what makes an unwritten one costly.

Revisit trigger: a new call into one of these packages from an instrument or bootstrapper, which
raises the real minimum whether or not the declared floor moves with it.

Activity

  1. lesnik512 commented on Sep 13, 2026

    @lesnik512
    MemberAuthor

    Taking the first open decision, since it sets the method for the remaining eleven and is cheap to
    settle before any of them are bisected.

    The gap is real, and it is measurable

    structlog is the case that shows it. Same repo, same interpreter (3.12), two oracles:

    oracle floor what sets it
    the library: [logging] installed, bootstrap a FreeBootstrapper and emit a line 21.3.0 structlog.stdlib.ProcessorFormatter.remove_processors_meta (logging_instrument.py:158)
    the suite: tests/instruments/test_logging_instrument.py + test_structured_log_payload.py 22.2.0 structlog.typing, imported at runtime by tests/conftest.py:11

    Five releases and about eleven months apart. The library imports structlog.typing only under
    if typing.TYPE_CHECKING: (logging_instrument.py:19), so nothing in lite_bootstrap needs that
    module to exist at runtime. A suite-derived floor would publish structlog>=22.2 and refuse an
    environment the library runs on perfectly well.

    The same shape appears wherever a test reaches for something the library does not:
    sentry_sdk.envelope.Envelope and sentry_sdk.Transport (conftest builds a capture transport; the
    library only calls init, set_tags, flush), faststream._internal.broker.BrokerUsecase and
    faststream.redis.{RedisBroker,TestRedisBroker}, opentelemetry.sdk.trace.export.in_memory_span_exporter,
    starlette.testclient.TestClient and litestar.testing.TestClient.

    Not all of those bite. The divergence only matters where the test reach is newer than the library
    reach, which is why it has to be measured per package rather than assumed. structlog is the one
    measured instance so far.

    Proposal: floors are library-derived

    A declared floor is a promise to someone who installs lite-bootstrap[logging] and never sees this
    repo's tests. Deriving it from the suite makes every test helper a downstream constraint, silently,
    and the constraint moves whenever a test is rewritten. ManualLoggerStorage is the useful contrast:
    it looks like a test detail but faststream_bootstrapper.py:17 imports it at runtime, so it is
    library-derived and genuinely forced faststream>=0.6 in #211.

    Concretely:

    • The oracle for a floor is install the extra, import, and bootstrap. For typing-extensions in
      feat: declare minimum compatible version for typing-extensions #214 that was bare import, because the two uses are reached at import time. For a framework extra
      it is constructing that bootstrapper and calling bootstrap() / teardown().
    • Where a test-only import outruns the library floor, fix the test if it is cheap. structlog.typing
      in conftest.py can move behind typing.TYPE_CHECKING exactly as the library has it, which
      collapses this particular gap to nothing.
    • Where the test genuinely needs the newer symbol, it constrains the dev group, not the extra.
      A test client or an envelope inspector is not something a downstream service installs. Putting
      structlog>=22.2 in [dependency-groups] dev while the extra declares >=21.3 states both facts
      accurately, and a downstream user never installs the dev group. It also gives the dev group the
      floors the lock form of Exercise declared dependency floors in CI #210 needs anyway.

    Cost, stated plainly

    This decision has a consequence for #210. If floors are library-derived but the CI job runs the full
    pytest suite under --resolution lowest-direct, the job fails on the declared floor whenever the two
    diverge: 21.3.0 resolves, then conftest.py cannot import. So either the residual divergence is
    carried in the dev group as above, which keeps the suite installable while the extra stays honest,
    or #210's lowest-direct oracle is a per-framework bootstrap smoke rather than pytest.

    The dev-group route is the better one. It keeps one oracle for the declaration and one for CI without
    them contradicting each other, and the smoke-test alternative would be a second, weaker test path to
    maintain alongside the suite.

    What this settles

    Derive every remaining floor from library API use. Measure it by bootstrapping, not by running the
    suite. Carry any test-only excess in [dependency-groups] dev.

    If that reads right, the OTel cluster is the next piece of work: six of the eleven, and they share a
    release train, so they want one decision rather than six.

  2. lesnik512 commented on Sep 15, 2026

    @lesnik512
    MemberAuthor

    Addendum to the method, before the remaining four are swept in one pass.

    Where no API forces a floor

    The rule settled above derives a floor from library API use. Two of the four remaining requirements
    have no such use to derive from:

    • orjson: orjson.dumps(value, **kwargs).decode() and orjson.loads (logging_factory.py:40,51).
      Both have existed since 1.x.
    • pyroscope-io: configure, shutdown, add_thread_tag, remove_thread_tag. All original API.

    Reading the rule literally would floor them at their first release, which is exactly the claim #210
    exists to stop anyone making. What actually fails there is installation, not the call: pyroscope-io
    0.3.0 ships an sdist whose pyproject.toml has no project.name, and no current build backend
    accepts it. That is the error already blocking [fastapi-all] under lowest-direct today.

    So, extending the rule by one clause: where no API use forces a floor, the floor is the lowest
    release that installs and imports across the supported interpreter matrix, and the comment says that
    is the reason.
    A floor with a packaging rationale reads differently from one that names a symbol,
    and it should, because a later release of the same package could legitimately move it.

    This is not new ground. #215 already landed a floor of this shape: the OpenTelemetry train sits at
    1.28 / 0.49b0 not because lite-bootstrap reaches for anything newer, but because
    opentelemetry-instrumentation below 0.49b0 imports pkg_resources under a stale setuptools>=16.0
    declaration and no longer installs. The clause names what that PR already did.

    structlog and the conftest import

    The structlog.typing divergence measured above (21.3.0 library, 22.2.0 suite) resolves to the
    dev-group route, not to fixing the test.

    tests/conftest.py:11 imports EventDict and WrappedLogger for annotations only, so a
    typing.TYPE_CHECKING guard would work mechanically. But [tool.ruff.lint] ignores TCH repo-wide,
    on the stated grounds that "imports stay real; TYPE_CHECKING-only imports break runtime
    introspection". Rewriting the test to satisfy a floor would cut against that, for a package where the
    library genuinely does not need the module at runtime.

    So: structlog>=21.3 in the logging extra, structlog>=22.2 in [dependency-groups] dev. The
    extra states what a downstream service needs; the dev group states what running this repo's suite
    needs. Nobody installs the second.

    Scope of the sweep

    The four runtime requirements, in one PR: structlog, sentry-sdk, orjson, pyroscope-io.

    The seven bare entries in [dependency-groups] stay out. They matter only if #210 chooses
    uv lock --resolution lowest-direct, and the comment on that issue argues for the per-extra
    uv pip install form instead. Declaring them now would be pre-deciding that.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions