From 3a686d6bdac705b1b4ce721843d46a78a17ded26 Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Wed, 17 Jun 2026 09:23:06 -0400 Subject: [PATCH] feat: bridge FEATURES proxy reads to @override_settings overrides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets @override_settings(KEY=val) be seen through both settings.KEY and settings.FEATURES['KEY']/.get('KEY'), so tests can migrate to the flat setting without first updating every production reader that still does settings.FEATURES['KEY']. We walk only the UserSettingsHolder chain that @override_settings pushes onto django.conf.settings._wrapped, NOT the bottom Settings layer. Django's bottom Settings is a *snapshot* of the settings module's globals taken at init time, so it doesn't reflect runtime mutations of the module's globals — which is exactly what legacy patch.dict(settings.FEATURES, ...) does via proxy.ns. Reading the snapshot would mask those mutations and break legacy tests; walking only the explicit override layers lets both paths coexist during the migration. ns kept as-is. A stateless redesign (drop ns, route everything through django.conf.settings, set FEATURES = FeaturesProxy() in each env file) was considered but rejected for this step because FEATURES.copy() and __iter__ are widely used to snapshot the full set of feature flags; without ns there is no clean source of truth for "which keys are features". Once all callers have moved off the dict pattern the proxy can be deleted outright (every settings.FEATURES['X'] becomes settings.X), making the stateless refactor unnecessary. Co-authored-by: Claude Opus 4.7 (1M context) --- openedx/core/lib/features_setting_proxy.py | 38 ++++++++++++++++++++-- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/openedx/core/lib/features_setting_proxy.py b/openedx/core/lib/features_setting_proxy.py index eb38ad7f9100..95c61203306e 100644 --- a/openedx/core/lib/features_setting_proxy.py +++ b/openedx/core/lib/features_setting_proxy.py @@ -24,8 +24,37 @@ def __init__(self, namespace=None): """Store the namespace (as a dict)""" self.ns = namespace or {} + _MISSING = object() + + def _resolve(self, key): + """Return key's value if it's been overridden via @override_settings, else self._MISSING. + + We deliberately walk only the UserSettingsHolder chain (the layers that + @override_settings pushes onto django.conf.settings._wrapped) rather + than reading django.conf.settings.X directly. Django's bottom Settings + layer is a *snapshot* taken at init time, so it doesn't reflect runtime + mutations of the settings module's globals (which is what + proxy.ns mutations and legacy patch.dict(settings.FEATURES, ...) do). + Walking only the explicit override layers lets the new + @override_settings(X=Y) path work while leaving the legacy patch.dict + path untouched. + """ + from django.conf import settings as django_settings + wrapped = django_settings._wrapped # pylint: disable=protected-access + # UserSettingsHolder has a `default_settings` attribute and stores + # explicit overrides in its own __dict__; the bottom Settings has no + # default_settings, so the loop terminates there. + while hasattr(wrapped, 'default_settings'): + if key in wrapped.__dict__: + return wrapped.__dict__[key] + wrapped = wrapped.default_settings + return self._MISSING + def __getitem__(self, key): - """Retrieve a feature flag by key""" + """Retrieve a feature flag by key, preferring @override_settings overrides.""" + value = self._resolve(key) + if value is not self._MISSING: + return value return self.ns[key] def __setitem__(self, key, value): @@ -49,14 +78,17 @@ def __len__(self): return len(self.ns) def __contains__(self, key): - return key in self.ns + return self._resolve(key) is not self._MISSING or key in self.ns def clear(self): """Remove all feature flags from the namespace.""" self.ns.clear() def get(self, key, default=None): - """Standard dict-style get with default""" + """Standard dict-style get with default; prefers @override_settings overrides.""" + value = self._resolve(key) + if value is not self._MISSING: + return value return self.ns.get(key, default) def update(self, other=(), /, **kwds):