Add opt-in django-aqueduct settings module - #3979
Conversation
|
django-aqueduct 0.6.0 is now published on PyPI, including the KV v1 fix mitxpro's Vault mount needs. Removed the |
There was a problem hiding this comment.
Pull request overview
Adds an opt-in django-aqueduct (codegen v2) settings model alongside the existing mitxpro.settings module, plus production/dev shims and documentation, without changing the default DJANGO_SETTINGS_MODULE.
Changes:
- Introduces
AqueductSettings/DevAqueductSettings(generated + preserved regions) and two opt-in shims (settings_aqueduct*) to configure Django settings via django-aqueduct. - Adds
[tool.aqueduct]generation/parity configuration and excludes the generated settings file from Ruff formatting to prevent regen drift. - Updates docs/README and pins
django-aqueduct[mitol,vault,derivations]>=0.9.0(plus lockfile updates).
Reviewed changes
Copilot reviewed 7 out of 8 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
uv.lock |
Locks django-aqueduct and its transitive deps (e.g., hvac, pydantic-settings). |
README.md |
Adds a documentation link to the new aqueduct guide. |
pyproject.toml |
Adds the dependency pin plus [tool.ruff] exclude and [tool.aqueduct] config. |
mitxpro/settings.py |
Adds django_aqueduct to INSTALLED_APPS for management commands only. |
mitxpro/settings_aqueduct.py |
New opt-in production shim: init Sentry then configure_django_settings(AqueductSettings). |
mitxpro/settings_aqueduct_dev.py |
New opt-in dev shim using DevAqueductSettings (optional Vault layering). |
mitxpro/aqueduct_settings.py |
New generated+preserved pydantic settings model with derived/validator logic and Vault dev subclass. |
docs/aqueduct.md |
New documentation for regeneration workflow, parity, and Vault dev usage. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| BASE_DIR: str = Field(default_factory=lambda: BASE_DIR) | ||
| # Generated as `str` (the annotation follows the default expr, not the | ||
| # `get_int` reader); legacy `SITE_ID = MITXPRO_SITE_ID` is an int. | ||
| SITE_ID: int = Field(default=1, validation_alias="MITXPRO_SITE_ID") | ||
| ENVIRONMENT: str = Field( |
| `check_aqueduct_settings` reports parity with 17 ignored keys (see | ||
| `[tool.aqueduct] parity_ignore` for the annotated list): | ||
|
|
||
| - **Raw env inputs** the model carries as fields but the legacy module reads | ||
| inline without exposing as settings (`DATABASE_URL`, `REDIS_URL`, | ||
| `HOST_IP`, the `MITXPRO_DB_*` toggles, `REFRESH_TOKEN_EXPIRE_SECONDS`, | ||
| `OAUTH2_PROVIDER_ALLOWED_REDIRECT_URI_SCHEMES`, `SHEETS_DATE_TIMEZONE_NAME`, | ||
| the `HUBSPOT_*` form GUID inputs). | ||
| - **`AWS_S3_CUSTOM_DOMAIN`** — only assigned in legacy when `USE_S3` + | ||
| `CLOUDFRONT_DIST` are set; otherwise the model's always-present `None` field | ||
| has no legacy counterpart. | ||
| - **`DATABASES` / `DEFAULT_DATABASE_CONFIG`** — Django's settings harness adds | ||
| `ATOMIC_REQUESTS`/`AUTOCOMMIT`/`TIME_ZONE`/`TEST` to the live dict after | ||
| load, and the model omits the SQLite `sslmode` OPTION the legacy module | ||
| applies unconditionally. Both are load-time/derivation artifacts, not drift. |
Adds a parallel, pydantic-typed settings module for mitxpro without touching the existing mitxpro/settings.py, which remains the default everywhere (web, worker, management commands, tests). - pyproject.toml: add django-aqueduct[mitol] pointed at the local worktree via [tool.uv.sources] until it's published. - mitxpro/aqueduct_settings.py: AqueductSettings(BaseSettings) model generated via `generate_aqueduct_settings --include-envparser` and hand-refined with model_validators for FEATURES (FEATURE_* env scan), the S3 cross-field check, CELERY_BEAT_SCHEDULE (crontab / OffsettingSchedule objects), the Redis URL fallback chain, SOCIAL_AUTH_ALLOWED_REDIRECT_HOSTS, INSTALLED_APPS/MIDDLEWARE ENVIRONMENT/DEBUG conditionals, DATABASES, CACHES, STORAGES, and the handful of settings mitxpro.settings exposes under a different attribute name than the env var that configures them (e.g. SITE_BASE_URL from MITXPRO_BASE_URL). - mitxpro/settings_aqueduct.py: thin shim that initializes Sentry in the same relative order as mitxpro/settings.py, then calls configure_django_settings(AqueductSettings). - docs/aqueduct.md: usage, what changed, and why a Vault-backed dev settings class is deliberately deferred (secret-xpro is KV v1; django-aqueduct now supports it, but this pass stays validate-only). Verified `DJANGO_SETTINGS_MODULE=mitxpro.settings_aqueduct manage.py check` passes (including --deploy), and that every resolved setting value (INSTALLED_APPS, MIDDLEWARE, DATABASES, CACHES, CELERY_BEAT_SCHEDULE, FEATURES, etc.) matches mitxpro.settings exactly for the same environment, in both DEBUG=False and DEBUG=True. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Pins the floor to the version that fixes Vault KV-v1 support, lowers the Django floor to 4.2, and fixes two codegen bugs (secret-leaking module inspection, missing datetime import) — landed upstream ahead of PR #2 merging. The local path override in [tool.uv.sources] stays until that PR merges and a release is tagged.
django-aqueduct 0.6.0 is now published on PyPI, including the KV v1 fix mitxpro's Vault mount needs. No reason to keep resolving it from a local worktree path anymore.
django-aqueduct 0.7.0 is a ground-up codegen v2 rewrite: static AST discovery recovers env-var aliases, required-ness, and defaults without importing (or resolving secrets from) the settings module, and emits into mergeable managed regions. Why this rework: - The v1 model carried a dual set of fields for every setting mitxpro configures from a differently-named env var (SITE_BASE_URL from MITXPRO_BASE_URL, etc.), stitched by two hand-written copy validators. v2 recovers those as validation_alias declarations on a single field, so the ~30 placeholder fields and both copy validators are gone and the model file shrinks from 1549 to 1078 lines. - Redis fallback, DATABASES, CACHES, ADMINS, and the FEATURE_* scan now use django_aqueduct.derivations instead of re-implemented boilerplate. This fixes two shipped divergences for free: database_config omits sslmode for SQLite, and feature collection raises on non-true/false values (via mitol get_features) where the old scan silently dropped them, while also seeing Vault-sourced FEATURE_* flags. - Adds the previously-deferred dev Vault story: DevAqueductSettings builds a KV v1 source over the secret-xpro mount from VAULT_* env vars (graceful no-Vault fallback), with a mitxpro.settings_aqueduct_dev shim mirroring the production one. Generation is pinned in [tool.aqueduct]; the EnvParser inspector is disabled so fields key off the Django-facing names rather than reintroducing the raw env-name dual set. get_delimited_list fields use NoDecode + a before-validator so pydantic-settings does not JSON-decode comma-separated env values. aqueduct_settings.py is excluded from ruff so pre-commit formatting does not fight generate_aqueduct_settings --check. Parity is clean with 17 annotated parity_ignore keys (raw env inputs, S3-conditional domain, Django-mutated DB dicts). The classic mitxpro.settings remains the default everywhere. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UD5EECVR7wmBse7D49Ff1Y
0.7.1 taught the codegen v2 renderer to emit `Annotated[..., NoDecode]` plus an `_aqueduct_decode_list_fields` before-validator (in a new `aqueduct:generated:container_decoders` region) for every list/dict field, so a comma/JSON/Python-literal env value decodes without the hand-written workaround. Removed the `Annotated[list[str], NoDecode]` overrides and the `_split_delimited` validator from the three fully-generated fields (CSRF_TRUSTED_ORIGINS, SHEETS_ADMIN_EMAILS, EXTERNAL_COURSE_SYNC_EMAIL_RECIPIENTS) and rely on the generated handling; verified they still parse comma strings. Two fields keep a small hand refinement, for reasons unrelated to decoding: OAUTH2_PROVIDER_ALLOWED_REDIRECT_URI_SCHEMES is read only inside the OAUTH2_PROVIDER dict in legacy (never a module-level setting), so static discovery emits no field and the generated decoder does not cover it — it keeps NoDecode plus a dedicated one-field `_split_oauth_schemes` validator; DIGITAL_CREDENTIALS_SUPPORTED_RUNS is REDACTED to default=None by name, so its override only restores the legacy `[]` default (the generated decoder still handles its env parsing by name). Enrichment: `--enrich-usage` over the app source found no closed-value-set or range-check comparison sites, so it added no Literal/Constraint promotions (a no-op — flagless `--check` stays drift-free). 0.8.0 also unconditionally promotes every `*_URL`/`*_URI` str field to `pydantic.AnyUrl`. mitxpro injects these values straight into django.conf.settings and consumes them as plain strings (urlparse/urljoin, redis client and cache LOCATION, string concat, the PostHog client, str-vs-str legacy parity), and several defaults are relative paths (/signin, /media/, login) that AnyUrl rejects outright. Neutralized the promotion back to str/str|None in the preserved region so runtime behavior and parity are unchanged. include_envparser stays false (the MITXPRO_*-vs-Django-name dual-field-set / alias-collapse reason, which the decode fix does not affect). Parity remains clean with the existing 17-entry parity_ignore; the ruff extend-exclude on the generated file is unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UD5EECVR7wmBse7D49Ff1Y
0.9.0 fixes upstream the three things mitxpro was working around locally. - AnyUrl: the 0.8.0 `str`→`AnyUrl` promotion was unconditional and fired on the `*_URL`/`*_URI` name alone, so this model carried a ~22-field block reverting every promotion back to `str`. 0.9.0 makes the promotion opt-in (`[tool.aqueduct] enrich_url_types`, left OFF here), denylists the relative-URL Django settings, only promotes a field whose own default actually validates as an absolute URL, and pairs each promotion with a `field_serializer` so `model_dump()` still emits `str`. Removed the entire neutralization block; the generated `*_URL` fields render as plain `str` again. Enabling the flag was tested and rejected: mitxpro's validators read these in-instance as strings (`urlparse(self.SITE_BASE_URL)`, `first_url(...).strip()`, redis LOCATION nested in the CACHES/OAUTH2_PROVIDER dicts) and the top-level serializer does not cover in-validator access or nested-dict values, so instantiation failed with "'AnyUrl' object has no attribute 'decode'". - parity_ignore: dropped DATABASES and DEFAULT_DATABASE_CONFIG. 0.9.0's check_aqueduct_settings does a one-way subset comparison for dict-valued settings, so the Django-injected ATOMIC_REQUESTS/AUTOCOMMIT/TIME_ZONE/TEST keys on the live DATABASES dict no longer read as divergences. The remaining ignores (inline-only env inputs, S3-conditional AWS_S3_CUSTOM_DOMAIN) stay. Parity is clean with 15 ignored (was 17). Kept, with reasons unchanged by 0.9.0: - OAUTH2_PROVIDER_ALLOWED_REDIRECT_URI_SCHEMES override + one-field `_split_oauth_schemes`: legacy reads this only inside the OAUTH2_PROVIDER dict, never as a module-level setting, so static discovery emits no field for it — an inline-read limitation, not the `get_list_of_str` recognition gap 0.9.0 fixed. Confirmed still absent from generated output. - `[tool.ruff] extend-exclude` on the generated file: tried removing it, but `ruff format` still reflows two generated constructs — the captured HUBSPOT_CONFIG dict-literal default (embedded at its original source indentation) and an over-long `AliasChoices(...)` line — which would reintroduce `generate_aqueduct_settings --check` drift. Kept per the 0.8.1 documented fallback. Also dropped a now-duplicate `get_features` import (the generated imports region provides it). include_envparser stays false (alias reason, unchanged). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UD5EECVR7wmBse7D49Ff1Y
Rebased onto master. master added `health_check` to INSTALLED_APPS, bumped VERSION to 0.195.2, and rewrote the AUTHENTICATION_BACKENDS comment, so the generated regions are regenerated to match and the hand-written `_build_installed_apps_and_middleware` validator gains `health_check` in the same position (the validator reconstructs the tuple, so the generated field alone was not enough). The module-level `VERSION` constant the shims read to init Sentry is bumped to 0.195.2 to match settings.py. Review feedback (Copilot): - VOUCHER_COMPANY_ID was generated `int` but with a string default `"1"` (static discovery followed the default expr, not the `get_int` reader, like the earlier SITE_ID case). Added a preserved-region override with an int default so the declared type and default agree. - docs/aqueduct.md described the parity_ignore list as 17 entries; it is 15 after the 0.9.0 removal of DATABASES/DEFAULT_DATABASE_CONFIG. Updated the doc to match the current list and explain the subset-comparison change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UD5EECVR7wmBse7D49Ff1Y
2a49c40 to
cae6c52
Compare
What are the relevant tickets?
N/A
Description (What does it do?)
Adds an opt-in, non-disruptive django-aqueduct-based
settings model alongside the classic one, built on django-aqueduct 0.9.0
(codegen v2). Nothing about the existing deployment changes unless
DJANGO_SETTINGS_MODULEis explicitly repointed — the classicmitxpro/settings.pyremains the default everywhere.mitxpro/aqueduct_settings.py— a typed pydanticAqueductSettings(BaseSettings)model mirroring
mitxpro/settings.py. Codegen v2 discovers settings bystatic AST analysis (the settings module is never imported and no secrets
are resolved), emitting field declarations into machine-owned
# >>> aqueduct:generated:*regions. Hand-written refinements (validators,derived settings, secret/required overrides) live in the
# >>> aqueduct:preserved:*region and survive regeneration.mitxpro/settings_aqueduct.py— production shim: init Sentry, thenconfigure_django_settings(AqueductSettings).mitxpro/settings_aqueduct_dev.py— new local-dev shim constructingDevAqueductSettings(Vault KV v1, see below).docs/aqueduct.md— rewritten for the v2 regeneration workflow, the aliascollapse, derivations, the now-shipped dev Vault story, and the intentional
parity divergences.
The alias collapse. mitxpro systematically configures a Django setting from
a differently-named env var (
SITE_BASE_URLfromMITXPRO_BASE_URL,EMAIL_HOSTfromMITXPRO_EMAIL_HOST,ENVIRONMENTfromMITXPRO_ENVIRONMENT,~30 total). The v1 model carried a dual set of fields — a raw
MITXPRO_*field plus a Django-facing placeholder — stitched by two hand-written copy
validators (
_apply_env_name_aliases,_build_derived_aliases). v2 recoversthose relationships from the env-reader call sites and emits them as
validation_alias=AliasChoices('MITXPRO_BASE_URL')on a single field. Theplaceholder fields and both copy validators are gone; the model file shrinks
from 1549 to 1078 lines.
Derivations. Redis fallback,
DATABASES,CACHES,ADMINS, and theFEATURE_*scan now usedjango_aqueduct.derivationsinstead of re-implementedboilerplate. This fixes two previously-shipped divergences:
database_configomits
sslmodefor SQLite (which its driver rejects), and feature collection(via mitol
get_features) raises on non-true/falsevalues where the oldhand-written scan silently dropped them — while also seeing Vault-sourced
FEATURE_*flags.New dev / Vault subclass (KV v1). mitxpro's Vault mount
secret-xprorunsthe KV v1 engine, which is why a Vault dev class was deferred originally.
django-aqueduct's
VaultSettingsSourcenow supportskv_version=1, soDevAqueductSettingsships here: it builds a Vault source fromVAULT_*envvars via
django_aqueduct.sources.dev.vault_source_from_env, defaultingVAULT_MOUNTtosecret-xproandVAULT_KV_VERSIONto1. WithVAULT_ADDRunset it runs on plain env/
.env, so the dev shim is safe without a runningVault.
Generation config.
[tool.aqueduct]pins the source modules, output,parity model/legacy, and
parity_ignore. The EnvParser inspector is disabled(
include_envparser = false) so fields key off Django-facing names rather thanreintroducing the raw env-name dual set.
aqueduct_settings.pyis excluded fromruff (see the ruff note below).
List/dict decoding (generated). The generator emits
Annotated[..., NoDecode]plus an_aqueduct_decode_list_fieldsbefore-validator (in the
aqueduct:generated:container_decodersregion) forevery list/dict field, so a comma / JSON / Python-literal env value decodes
automatically — no hand-written
_split_delimitedneeded for the generatedfields (
CSRF_TRUSTED_ORIGINS,SHEETS_ADMIN_EMAILS,EXTERNAL_COURSE_SYNC_EMAIL_RECIPIENTS; verified they still parse commastrings). Two fields keep a small refinement, for reasons unrelated to
decoding:
OAUTH2_PROVIDER_ALLOWED_REDIRECT_URI_SCHEMESis read only inside theOAUTH2_PROVIDERdict in legacy (never a module-level setting), so staticdiscovery emits no field for it — an inline-read limitation, not the
get_list_of_strgap 0.9.0 fixed — and it keeps a dedicated one-fieldbefore-validator;
DIGITAL_CREDENTIALS_SUPPORTED_RUNSis name-redacted todefault=None, so its override only restores the legacy[]default.AnyUrl promotion — left off. 0.9.0 makes the
str→AnyUrlpromotionopt-in (
[tool.aqueduct] enrich_url_types), denylists relative-URL Djangosettings, only promotes when the field's own default validates as an absolute
URL, and pairs each promotion with a
field_serializersomodel_dump()stillemits
str. It is left off here: mitxpro reads these values in-instance asstrings (
urlparse(self.SITE_BASE_URL),first_url(...).strip(), redisLOCATIONnested in the CACHES/OAUTH2_PROVIDER dicts), and the top-levelserializer covers neither in-validator access nor nested-dict values — enabling
it was tested and raised
'AnyUrl' object has no attribute 'decode'atinstantiation. With the flag off the generated
*_URLfields render as plainstr, so the ~22-field neutralization block earlier revisions carried is gone.--enrich-usagefound no closed-value-set/range comparison sites (noLiteral/Field(gt=…)promotions);--enrich-runtimeis not used (it is theone flag that imports the settings module).
Ruff exclude retained. 0.8.1 made the generator's output
ruff format-stable and dropped the blanket
# ruff: noqa. Removing the[tool.ruff] extend-excludewas tried, butruff formatstill reflows twogenerated constructs — the captured
HUBSPOT_CONFIGdict-literal default(embedded at its original source indentation) and an over-long
AliasChoices(...)line — which would reintroducegenerate_aqueduct_settings --checkdrift, so the exclude onmitxpro/aqueduct_settings.pystays (the 0.8.1 documented fallback).Dependency note:
django-aqueduct[mitol,vault,derivations]is now pinned to>=0.9.0(published to PyPI), resolving from the release rather than a localworktree override.
Screenshots (if appropriate):
N/A — no UI changes.
How can this be tested?
uv lockresolvesdjango-aqueductto0.9.0.manage.py generate_aqueduct_settings --checkreports no drift.manage.py check_aqueduct_settingsreports parity (15 annotatedparity_ignorekeys: raw env inputs the legacy module reads inline, andthe S3-conditional
AWS_S3_CUSTOM_DOMAIN.DATABASES/DEFAULT_DATABASE_CONFIGare no longer listed — 0.9.0 subset-comparesdict settings, so Django's runtime-injected keys no longer diverge).
manage.py checkpasses under bothmitxpro.settings_aqueductandmitxpro.settings_aqueduct_dev(dev shim withVAULT_ADDRunset), withthe required env vars mitxpro already needs (
MITXPRO_BASE_URL,SECRET_KEY,MAILGUN_SENDER_DOMAIN,MAILGUN_KEY,OPENEDX_API_CLIENT_ID,OPENEDX_API_CLIENT_SECRET,EXTERNAL_COURSE_SYNC_API_KEY) — confirmed locally.mitxpro/settings.pychanges only adddjango_aqueducttoINSTALLED_APPS(for the management commands); no runtime behaviorchanges.
DJANGO_SETTINGS_MODULE(web/worker/tests/Procfile/Kubernetes) is unaffected — nothing switches to an aqueduct shim
automatically.
mitxpro/settings_test.pypasses (7 passed, 1 skipped).Additional Context
Both the validate-only production shim and the KV v1 dev Vault subclass now
ship together (the dev subclass was the deferred item in the original pass).
The rollout remains fully opt-in.