ADR-0029: Runtime Dependency Boundary and Feature Extras
Status: Accepted
Date: August 2026
Context
A plain pip install protean used to pull a web server, an ASGI stack, a Jinja
template engine, an interactive REPL, and a project scaffolder, whether or not
the consumer ever ran a server, opened a shell, or generated a project. The
adapter ecosystem was already right: postgresql, redis, elasticsearch,
flask, and sendgrid are extras, installed only when the consumer talks to
that infrastructure. But several install-time-optional concerns were hard runtime
dependencies in [project].dependencies:
fastapi,uvicorn,jinja2: the HTTP/ASGI/observatory stack, used only byprotean observatoryandprotean.integrations.fastapi.ipython: the interactive shell, used only byprotean shell.copier: project scaffolding, used only byprotean new.
None of these are imported by import protean. The imports were already lazy,
gated behind a CLI subcommand or an opt-in integration module. So the cost was
not import coupling; it was install footprint and the dependency-conflict
surface every consumer inherited. FastAPI itself models the fix (it pushes
uvicorn into fastapi[standard]); Requests keeps its core at a handful of
dependencies.
Two more packages were candidates but stayed in core after the original review.
bleach (HTML sanitization) looked optional, used only for String/Text field
sanitization, until you notice that String() and Text() default to
sanitize=True. Nearly every domain has a string field, so bleach runs for
nearly every domain; moving it behind an extra would make that extra a de-facto
requirement and would silently stop sanitizing for anyone who missed it.
werkzeug was a genuine import-time need (it backed current_domain/
current_uow). Both stayed in core at the time. (Each of werkzeug, cffi,
and greenlet was later reviewed and moved out; see the amendments in
Consequences.)
Moving a package out of [project].dependencies is a Tier-1 breaking change
under ADR-0004: code that
worked on a fat install can fail on a lean one. The question was how to draw the
core/extras boundary, what happens when a feature is used without its extra, and
how to land the break without stranding upgraders.
Decision
Draw the core at the domain-modeling and message-processing essentials, and move the five install-time-optional packages behind feature extras.
Core (pip install protean) kept only what every consumer needed to define a
domain, persist through the memory adapter, and run the async engine:
inflection, marshmallow, python-dateutil, typer (the CLI framework
itself), structlog, werkzeug, bleach, pydantic, greenlet, and cffi
(at the time of the original decision; see the amendments in Consequences for
the current state).
The feature extras:
| Extra | Packages | Gates |
|---|---|---|
server |
fastapi, uvicorn, jinja2 |
protean observatory, protean.integrations.fastapi |
shell |
ipython |
protean shell |
scaffold |
copier |
protean new |
And two convenience bundles:
| Extra | Expands to | Purpose |
|---|---|---|
cli |
shell + scaffold |
the full interactive protean new / protean shell experience |
all |
server + cli |
everything that shipped in the pre-0.18 core; the one-line upgrade |
When a feature is used without its extra, fail with an actionable message that
names the extra to install, never a bare ModuleNotFoundError. The message
builder lives in protean.utils.dependencies.missing_dependency_message, and
FEATURE_EXTRA_MODULES in the same module maps each extra to the packages it
provides, so both facts live in one place. The CLI commands (new, shell,
observatory) import their optional stack lazily inside the command body; on
ImportError they hand off to abort_for_missing_dependency, which prints
Error: … requires the '<pkg>' package. Install it with 'pip install
"protean[<extra>]"'. and exits non-zero.
To decide whether a package is actually missing, the guard uses
importlib.util.find_spec over the extra's packages, not the ImportError's
name. An ImportError from a package that is installed but broken (an
incompatible version, a renamed symbol) names that same package, so a name-based
check would tell the user to install what they already have; and a package such
as jinja2 that is re-raised by a third party with no name at all would be
missed. When every package the extra provides is importable, the ImportError
is a real bug inside the feature and is re-raised unchanged. The FastAPI
integration applies the same find_spec guard at import, raising a clear
ImportError rather than a typer.Abort (a library import must not abort the
process).
Land the break cleanly in 0.18.0. Because the imports were already lazy,
there is no import protean code path on which to emit a pre-removal
DeprecationWarning; the deps simply were not loaded at import. A staged
"warn then move" runway would keep the fat install for a release while warning
about a change whose fix is a single word, adding cycle time for no real
protection. Instead: move to extras in 0.18.0, ship the all extra as the
one-line restore of the previous behavior, provide the actionable errors, and
document the change in the 0.18 migration guide. The actionable error plus the
all extra is the Tier-1 mitigation.
Consequences
- A plain
pip install proteanis markedly smaller and carries a smaller dependency-conflict surface. Consumers opt into the web stack, the shell, and the scaffolder only when they use them. - Consumers who ran
protean observatory,protean shell, orprotean new, or importedprotean.integrations.fastapi, must install the matching extra. They get a message that tells them exactly which one; a project that wants the old behavior wholesale addsprotean[all]. - CI and dev environments that already run
uv sync --all-extrasare unaffected; the new extras are picked up automatically. - Deriving the boundary per feature (rather than one big
clibucket) means more extra names to document, but each install stays minimal: a REPL user does not drag in the scaffolder. bleachstays in core because String/Text fields sanitize by default (see Context).werkzeug,cffi, andgreenletstayed in core at the time of the original decision, but each was later reviewed and moved out; see the amendments below.
Amended (August 2026, #1376): cffi and greenlet left core. Nothing in
src/protean imports either package on any runtime path (the SQLAlchemy
adapter uses the synchronous engine), so both left core. Their floors moved to
the extras whose trees pull them: greenlet to postgresql/sqlite/mssql
(SQLAlchemy requires it on common platforms but pins no floor), cffi to
sendgrid (its only consumer is cryptography, also floorless). The
ADR-0020 newest-Python-wheel floor still holds where each package is actually
installed.
Amended (August 2026, #1375): werkzeug left core. current_domain,
current_uow, and g are now backed by a small stdlib contextvars
implementation that preserves the push/pop nesting semantics of the old
LocalStack/LocalProxy. The public API of the three proxies is unchanged.
Alternatives Considered
- One coarse
cliextra covering shell + scaffold. Fewer names to document, but it couples unrelated concerns: a user who only wants a REPL would install the scaffolder. Rejected in favor of precise extras plus thecli/allbundles. - Move
bleachbehind a[sanitize]extra and fail fast when asanitize=Truefield is defined without it. Rejected:String()andText()default tosanitize=True, so bleach runs for nearly every domain. The extra would become a de-facto requirement, and the "fail fast" would fire on the default field declaration, not an opt-in one. Moving it out of core would also mean flipping thesanitizedefault toFalse, a separate and security-relevant behavioral break (string fields silently stop HTML-escaping) that does not belong in a packaging change. bleach stays in core; revisit if that default ever flips. - Detect the missing package from
ImportError.nameinstead offind_spec. Simpler, but wrong for the case that matters most: a package that is installed but broken raises anImportErrornaming itself, so a name check would tell the user to reinstall a package they already have, and ajinja2miss re-raised without anamewould be missed entirely.find_specdistinguishes absent from present-but-broken directly. - Replace
werkzeugwithcontextvarsin this change to shrink core further. Reimplementing the nested push/pop semantics of the domain-context and Unit-of-Work stacks is a real refactor with behavioral risk; folding it into a packaging change would put an unrelated correctness risk in the same PR. Deferred to its own issue. - A staged Tier-1 deprecation (warn in 0.18, move in 0.19). Rejected: with the imports already lazy there is no natural code path to warn on, and the fix is a one-word extra. The runway would add a release cycle without protecting anyone.