Skip to content

Add opt-in django-aqueduct settings module - #3979

Open
blarghmatey wants to merge 7 commits into
masterfrom
aqueduct-integration
Open

Add opt-in django-aqueduct settings module#3979
blarghmatey wants to merge 7 commits into
masterfrom
aqueduct-integration

Conversation

@blarghmatey

@blarghmatey blarghmatey commented Jul 1, 2026

Copy link
Copy Markdown
Member

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_MODULE is explicitly repointed — the classic
mitxpro/settings.py remains the default everywhere.

  • mitxpro/aqueduct_settings.py — a typed pydantic AqueductSettings(BaseSettings)
    model mirroring mitxpro/settings.py. Codegen v2 discovers settings by
    static 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, then
    configure_django_settings(AqueductSettings).
  • mitxpro/settings_aqueduct_dev.pynew local-dev shim constructing
    DevAqueductSettings (Vault KV v1, see below).
  • docs/aqueduct.md — rewritten for the v2 regeneration workflow, the alias
    collapse, 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_URL from MITXPRO_BASE_URL,
EMAIL_HOST from MITXPRO_EMAIL_HOST, ENVIRONMENT from MITXPRO_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 recovers
those relationships from the env-reader call sites and emits them as
validation_alias=AliasChoices('MITXPRO_BASE_URL') on a single field. The
placeholder fields and both copy validators are gone; the model file shrinks
from 1549 to 1078 lines.

Derivations. Redis fallback, DATABASES, CACHES, ADMINS, and the
FEATURE_* scan now use django_aqueduct.derivations instead of re-implemented
boilerplate. This fixes two previously-shipped divergences: database_config
omits sslmode for SQLite (which its driver rejects), and feature collection
(via mitol get_features) raises on non-true/false values where the old
hand-written scan silently dropped them — while also seeing Vault-sourced
FEATURE_* flags.

New dev / Vault subclass (KV v1). mitxpro's Vault mount secret-xpro runs
the KV v1 engine, which is why a Vault dev class was deferred originally.
django-aqueduct's VaultSettingsSource now supports kv_version=1, so
DevAqueductSettings ships here: it builds a Vault source from VAULT_* env
vars via django_aqueduct.sources.dev.vault_source_from_env, defaulting
VAULT_MOUNT to secret-xpro and VAULT_KV_VERSION to 1. With VAULT_ADDR
unset it runs on plain env/.env, so the dev shim is safe without a running
Vault.

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 than
reintroducing the raw env-name dual set. aqueduct_settings.py is excluded from
ruff (see the ruff note below).

List/dict decoding (generated). The generator emits
Annotated[..., NoDecode] plus an _aqueduct_decode_list_fields
before-validator (in the aqueduct:generated:container_decoders region) for
every list/dict field, so a comma / JSON / Python-literal env value decodes
automatically — no hand-written _split_delimited needed for the generated
fields (CSRF_TRUSTED_ORIGINS, SHEETS_ADMIN_EMAILS,
EXTERNAL_COURSE_SYNC_EMAIL_RECIPIENTS; verified they still parse comma
strings). Two fields keep a small 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 for it — an inline-read limitation, not the
get_list_of_str gap 0.9.0 fixed — and it keeps a dedicated one-field
before-validator; DIGITAL_CREDENTIALS_SUPPORTED_RUNS is name-redacted to
default=None, so its override only restores the legacy [] default.

AnyUrl promotion — left off. 0.9.0 makes the strAnyUrl promotion
opt-in ([tool.aqueduct] enrich_url_types), denylists relative-URL Django
settings, only promotes when the field's own default validates as an absolute
URL, and pairs each promotion with a field_serializer so model_dump() still
emits str. It is left off here: mitxpro reads these values 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 covers neither in-validator access nor nested-dict values — enabling
it was tested and raised 'AnyUrl' object has no attribute 'decode' at
instantiation. With the flag off the generated *_URL fields render as plain
str, so the ~22-field neutralization block earlier revisions carried is gone.
--enrich-usage found no closed-value-set/range comparison sites (no
Literal/Field(gt=…) promotions); --enrich-runtime is not used (it is the
one 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-exclude was tried, 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, so the exclude on
mitxpro/aqueduct_settings.py stays (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 local
worktree override.

Screenshots (if appropriate):

N/A — no UI changes.

How can this be tested?

  • uv lock resolves django-aqueduct to 0.9.0.
  • manage.py generate_aqueduct_settings --check reports no drift.
  • manage.py check_aqueduct_settings reports parity (15 annotated
    parity_ignore keys: raw env inputs the legacy module reads inline, and
    the S3-conditional AWS_S3_CUSTOM_DOMAIN. DATABASES/
    DEFAULT_DATABASE_CONFIG are no longer listed — 0.9.0 subset-compares
    dict settings, so Django's runtime-injected keys no longer diverge).
  • manage.py check passes under both mitxpro.settings_aqueduct and
    mitxpro.settings_aqueduct_dev (dev shim with VAULT_ADDR unset), with
    the 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.
  • Confirm mitxpro/settings.py changes only add django_aqueduct to
    INSTALLED_APPS (for the management commands); no runtime behavior
    changes.
  • Confirm the default DJANGO_SETTINGS_MODULE (web/worker/tests/Procfile/
    Kubernetes) is unaffected — nothing switches to an aqueduct shim
    automatically.
  • Existing mitxpro/settings_test.py passes (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.

Comment thread pyproject.toml Outdated
@blarghmatey

Copy link
Copy Markdown
Member Author

django-aqueduct 0.6.0 is now published on PyPI, including the KV v1 fix mitxpro's Vault mount needs. Removed the [tool.uv.sources] local-path override in pyproject.toml and re-locked so django-aqueduct now resolves from PyPI instead of the local worktree — no longer blocked on that TODO. manage.py check --settings=mitxpro.settings_aqueduct still passes against the published package. The dev-settings Vault subclass remains deliberately out of scope for this PR.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +1149 to +1153
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(
Comment thread docs/aqueduct.md Outdated
Comment on lines +154 to +168
`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.
blarghmatey and others added 7 commits July 8, 2026 16:22
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
@blarghmatey
blarghmatey force-pushed the aqueduct-integration branch from 2a49c40 to cae6c52 Compare July 8, 2026 20:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants