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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ for an adapter and an adapter fix should not drag the wheels behind it.

## [Unreleased]

## 0.1.0 — 2026-08-18

### Added

- **The shared core.** `Wiring` (load, watch, stop — idempotent, leased per
Expand Down
26 changes: 23 additions & 3 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,32 @@ lint:
ruff check .
ruff format --check .

# The types a caller sees. The frameworks are `ignore_missing_imports`, so
# this runs with or without them — and checks against the real thing when
# they are there.
# The types a caller sees, in both environments CI checks — with the
# frameworks installed, and without any. They are different checks: a
# decorator is typed in one and `Any` in the other, so an override that
# satisfies only the environment you happen to have is how a green local
# run becomes a red `the types a caller sees`.
types:
#!/usr/bin/env bash
set -euo pipefail

mypy --strict src/dynamic_config_web/

# And again resolving imports against an environment with no framework
# in it. `--python-executable` is what makes that cheap: one venv, and
# the mypy you already have does the checking.
bare=$(mktemp -d)
trap 'rm -rf "$bare"' EXIT
python -m venv "$bare"
# `[dev]` and no framework — the same install CI's second run makes.
# It matters that it is `[dev]` rather than bare: `pytest` is in there,
# and `dynamic_config_web.pytest` decorates fixtures with it.
# `--python-executable` then points the mypy you already have at that
# environment, so no second toolchain is built.
"$bare/bin/pip" install --quiet -e ".[dev]"
echo "→ and with no framework installed"
mypy --strict --python-executable "$bare/bin/python" src/dynamic_config_web/

# The shared half. Runs with no framework installed, which is the point:
# the core is what has to work on every interpreter this package claims.
core:
Expand Down
39 changes: 34 additions & 5 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -227,13 +227,39 @@ known-first-party = ["dynamic_config", "dynamic_config_web"]
# floor this package supports is checked by running the suite on it,
# not by asking a type checker to pretend.

# django-bolt registers routes with `Router.get(...)`, which is untyped —
# so every handler it decorates is "made untyped", which is a fact about
# django-bolt and not about this adapter. The handlers themselves are
# annotated; see the note in `router()` about why they answer `Any`.
# The adapters are type-checked twice: once with every framework installed,
# and once with none of them — the environment most users type-check in, and
# the one CI's second `mypy` run reproduces. Three of `--strict`'s rules
# cannot hold in both, because each depends on whether an optional import
# resolved:
#
# disallow_untyped_decorators `@app.get(...)` is typed when the framework
# is there and `Any` when it is not, so the
# handlers it decorates are "made untyped"
# only in the second environment. (For
# django-bolt it is untyped in *both*:
# `Router.get` carries no annotations.)
# warn_unused_ignores an ignore that silences a real complaint
# from the installed framework has nothing to
# silence when the import failed.
# disallow_subclassing_any `InitPlugin` is a class in one environment
# and `Any` in the other.
#
# Everything else stays strict, and the shared core — which imports no
# framework — keeps all three.
[[tool.mypy.overrides]]
module = ["dynamic_config_web.django_bolt"]
module = [
"dynamic_config_web.fastapi",
"dynamic_config_web.litestar",
"dynamic_config_web.flask",
"dynamic_config_web.quart",
"dynamic_config_web.robyn",
"dynamic_config_web.django_bolt",
"dynamic_config_web.django.*",
]
disallow_untyped_decorators = false
warn_unused_ignores = false
disallow_subclassing_any = false

# Every framework is optional by construction, so in any one environment
# most of them are missing — including the environment that type-checks
Expand All @@ -242,10 +268,13 @@ disallow_untyped_decorators = false
# against the real thing.
[[tool.mypy.overrides]]
module = [
"asgiref.*",
"django.*",
"django_bolt.*",
"fastapi.*",
"flask.*",
"litestar.*",
"ninja.*",
"quart.*",
"rest_framework.*",
"robyn.*",
Expand Down
14 changes: 13 additions & 1 deletion tests/django_bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

from __future__ import annotations

import importlib.util
from typing import Any

import django
Expand All @@ -23,6 +24,11 @@
urlpatterns: list[Any] = []


def _installed(*apps: str) -> list[str]:
"""The subset of `apps` this interpreter can actually import."""
return [app for app in apps if importlib.util.find_spec(app) is not None]


def bootstrap() -> None:
"""Configures Django, once per process."""
if settings.configured:
Expand All @@ -37,7 +43,13 @@ def bootstrap() -> None:
# `ready()` reads `DYNAMIC_CONFIG`, and each case wires a different
# configuration. `tests/test_django_app.py` exercises that path in a
# subprocess, where a settings module can name one target.
INSTALLED_APPS=["rest_framework", "ninja"],
# Only the ones actually installed. Each CI row installs a single
# extra — `[drf]` brings `rest_framework`, `[ninja]` brings `ninja`,
# `[django]` brings neither — and naming an absent app here fails
# `django.setup()` for every Django case in that row, not just the
# one that needs it. A developer with all three installed would
# never see it.
INSTALLED_APPS=_installed("rest_framework", "ninja"),
MIDDLEWARE=[
"dynamic_config_web.django.middleware.DynamicConfigMiddleware",
],
Expand Down
Loading