diff options
| author | Mike Edmunds <medmunds@gmail.com> | 2026-04-25 12:57:15 -0700 |
|---|---|---|
| committer | nessita <124304+nessita@users.noreply.github.com> | 2026-04-27 21:58:06 -0300 |
| commit | 32a3b4aa695e76ced1b2829b178e630b9ac62dce (patch) | |
| tree | 5a1f44b8ca052a143a25fd7612f70ef42ccaa6c3 | |
| parent | a49be25e6128a3272960ed4f6f6506d147596395 (diff) | |
Refs #35514 -- Added warn_about_external_use() deprecation utility.
Implemented a new `warn_about_external_use()` helper to conditionally
issue warnings depending on whether a deprecated feature is used from
within Django.
Fixed `LazySettings._show_deprecation_warning()` (Refs #26029) to work
correctly when called from anywhere in `LazySettings`. Previously, it
assumed a specific code path through `LazyObject.__getattribute__()` and
an `@property` getter on `LazySettings`.
| -rw-r--r-- | django/conf/__init__.py | 28 | ||||
| -rw-r--r-- | django/utils/deprecation.py | 79 | ||||
| -rw-r--r-- | tests/deprecation/internal.py | 66 | ||||
| -rw-r--r-- | tests/deprecation/test_warn_about_external_use.py | 178 |
4 files changed, 341 insertions, 10 deletions
diff --git a/django/conf/__init__.py b/django/conf/__init__.py index 25f5ffa305..dd0a158dfb 100644 --- a/django/conf/__init__.py +++ b/django/conf/__init__.py @@ -9,14 +9,16 @@ for a list of all possible variables. import importlib import os import time -import traceback import warnings from pathlib import Path -import django from django.conf import global_settings from django.core.exceptions import ImproperlyConfigured -from django.utils.deprecation import RemovedInDjango70Warning, django_file_prefixes +from django.utils.deprecation import ( + RemovedInDjango70Warning, + django_file_prefixes, + warn_about_external_use, +) from django.utils.functional import LazyObject, empty ENVIRONMENT_VARIABLE = "DJANGO_SETTINGS_MODULE" @@ -146,13 +148,19 @@ class LazySettings(LazyObject): return self._wrapped is not empty def _show_deprecation_warning(self, message, category): - stack = traceback.extract_stack() - # Show a warning if the setting is used outside of Django. - # Stack index: -1 this line, -2 the property, -3 the - # LazyObject __getattribute__(), -4 the caller. - filename, _, _, _ = stack[-4] - if not filename.startswith(os.path.dirname(django.__file__)): - warnings.warn(message, category, stacklevel=2) + """Issue a warning when external code uses a deprecated setting. + + Allow Django's own code to use it without emitting the warning. This + function should only be called from within LazySettings methods. + """ + warn_about_external_use( + message, + category, + skip_name_prefixes=( + "django.conf.LazySettings", + "django.utils.functional.LazyObject", + ), + ) class Settings: diff --git a/django/utils/deprecation.py b/django/utils/deprecation.py index 4aa1183216..89c9f71e55 100644 --- a/django/utils/deprecation.py +++ b/django/utils/deprecation.py @@ -30,6 +30,85 @@ class RemovedInDjango70Warning(PendingDeprecationWarning): RemovedAfterNextVersionWarning = RemovedInDjango70Warning +def warn_about_external_use( + message, + category, + *, + skip_name_prefixes=None, + skip_frames=0, + internal_modules=None, +): + """Issue a warning when a deprecated feature is used outside of Django. + + Skip the warning when called from within Django, to avoid cascading + warnings when one deprecated feature is implemented on top of another. + + Examine the stack to determine the "effective caller" (the code using the + deprecated feature). By default, this is the third frame from the top + (ignoring this helper plus the call site). + + Provide `skip_name_prefixes` (a string or tuple of strings) to skip + additional frames by prefix-matching each frame's fully qualified name + (dotted module path plus qualname). `skip_name_prefixes` can be used to + skip specific functions, all methods in a class, or everything in a module. + Skipping stops at the first non-matching frame. Useful when a shared helper + is called through multiple paths of varying depth with a common prefix. + + Provide `skip_frames` (an integer) to skip a fixed number of additional + frames (e.g., exactly one decorator or shared helper function). If both + options are provided, `skip_name_prefixes` is applied first. + + Provide `internal_modules` (a tuple of names, defaulting to ("django",)) to + customize what counts as "internal". A frame is internal when its fully + qualified name starts with one of these names followed by a dot. + + The warning is issued only if the effective caller (the first non-skipped + frame) is outside Django, attributed to that frame. If all frames are + skipped, it falls back to the base of the stack. + + Note: To unconditionally issue a warning identifying the first caller + outside Django as its source, don't use this function. Instead, use:: + + warnings.warn(..., skip_file_prefixes=django_file_prefixes()) + + to avoid unnecessary stack inspection overhead. + """ + + if internal_modules is None: + internal_modules = ("django",) + if not isinstance(internal_modules, tuple): + raise TypeError("internal_modules must be a tuple of module names") + internal_prefixes = tuple(f"{mod}." for mod in internal_modules) + + def get_fq_name(frame): + mod_name = frame.f_globals.get("__name__", "__main__") + return f"{mod_name}.{frame.f_code.co_qualname}" + + def back(frame, level): + if frame is not None: + return frame.f_back, level + 1 + return None, level + + frame, level = inspect.currentframe(), 0 + try: + # Back two frames: ignore warn_about_external_use() and its caller. + frame, level = back(*back(frame, level)) + + if skip_name_prefixes is not None: + while frame and get_fq_name(frame).startswith(skip_name_prefixes): + frame, level = back(frame, level) + + for _ in range(skip_frames): + frame, level = back(frame, level) + + is_internal = frame and get_fq_name(frame).startswith(internal_prefixes) + finally: + del frame + + if not is_internal: + warnings.warn(message, category=category, stacklevel=level + 1) + + class warn_about_renamed_method: def __init__( self, class_name, old_method_name, new_method_name, deprecation_warning diff --git a/tests/deprecation/internal.py b/tests/deprecation/internal.py new file mode 100644 index 0000000000..893a05d002 --- /dev/null +++ b/tests/deprecation/internal.py @@ -0,0 +1,66 @@ +# Simulated Django "internals" for WarnAboutExternalUseTests. +# +# Every function in this module ends up calling deprecated_function(), which +# calls warn_about_external_use(). The other functions provide various stack +# depths and qualnames for test purposes. All functions pass their arguments +# through to warn_about_external_use(). +# +# The tests set internal_modules to treat this module (only) as the "internal" +# Django code. Pass internal_modules=None for the original default behavior or +# internal_modules=tuple(...) to make some other modules "internal." + +from django.utils.deprecation import ( + RemovedAfterNextVersionWarning, + RemovedInNextVersionWarning, + deprecate_posargs, + warn_about_external_use, +) + + +def deprecated_function(message=None, category=None, **kwargs): + kwargs.setdefault("internal_modules", (__name__,)) + warn_about_external_use( + message or "Message", + category or RemovedInNextVersionWarning, + **kwargs, + ) + + +def one_indirection(*args, **kwargs): + deprecated_function(*args, **kwargs) + + +def two_indirections(*args, **kwargs): + one_indirection(*args, **kwargs) + + +def three_indirections(*args, **kwargs): + two_indirections(*args, **kwargs) + + +class Class: + def deprecated_method(self, *args, **kwargs): + deprecated_function(*args, **kwargs) + + def one_indirection(self, *args, **kwargs): + self.deprecated_method(*args, **kwargs) + + def two_indirections(self, *args, **kwargs): + self.one_indirection(*args, **kwargs) + + +@deprecate_posargs(RemovedAfterNextVersionWarning, ["a"]) +def decorated(message=None, category=None, *, a=None, **kwargs): + deprecated_function(message, category, **kwargs) + + +def call_decorated(*args, **kwargs): + decorated(*args, **kwargs) + + +def nested(*args, **kwargs): + # inner.__qualname__ is something like "nested.<locals>.inner". + def inner(*args, **kwargs): + deprecated_function(*args, **kwargs) + + inner(*args, **kwargs) diff --git a/tests/deprecation/test_warn_about_external_use.py b/tests/deprecation/test_warn_about_external_use.py new file mode 100644 index 0000000000..8bcdf5e729 --- /dev/null +++ b/tests/deprecation/test_warn_about_external_use.py @@ -0,0 +1,178 @@ +import inspect +import sys +import warnings +from contextlib import contextmanager + +from django.test import SimpleTestCase +from django.utils.deprecation import RemovedInNextVersionWarning + +from . import internal + + +class WarnAboutExternalUseTests(SimpleTestCase): + @contextmanager + def assertNotWarns(self, category, **kwargs): + with warnings.catch_warnings(record=True) as caught_warnings: + warnings.filterwarnings("always", category=category, **kwargs) + yield caught_warnings + self.assertEqual([str(warning) for warning in caught_warnings], []) + + def assertWarningPointsHere(self, warning, *, offset=-1): + caller_frame = inspect.currentframe().f_back + self.assertEqual(warning.filename, caller_frame.f_code.co_filename) + self.assertEqual(warning.lineno, caller_frame.f_lineno + offset) + + def test_external_use_warns(self): + msg = "This is deprecated." + with self.assertWarnsMessage(RemovedInNextVersionWarning, msg) as warning: + internal.deprecated_function(msg, RemovedInNextVersionWarning) + self.assertWarningPointsHere(warning) + + def test_internal_use_does_not_warn(self): + with self.assertNotWarns(RemovedInNextVersionWarning): + internal.one_indirection("This is deprecated.", RemovedInNextVersionWarning) + + def test_external_skip_frames_warns(self): + with self.assertWarnsMessage(RemovedInNextVersionWarning, "Message") as warning: + internal.one_indirection(skip_frames=1) + self.assertWarningPointsHere(warning) + + def test_internal_skip_frames_does_not_warn(self): + with self.assertNotWarns(RemovedInNextVersionWarning): + internal.two_indirections(skip_frames=1) + + def test_internal_skip_multiple_frames_does_not_warn(self): + with self.assertNotWarns(RemovedInNextVersionWarning): + internal.three_indirections(skip_frames=2) + + def test_external_skip_module_name_warns(self): + with self.assertWarnsMessage(RemovedInNextVersionWarning, "Message") as warning: + internal.two_indirections(skip_name_prefixes=internal.__name__) + self.assertWarningPointsHere(warning) + + def test_internal_skip_module_name_does_not_warn(self): + # Treat only the current test module as "internal" for this test. + with self.assertNotWarns(RemovedInNextVersionWarning): + internal.two_indirections( + skip_name_prefixes=internal.__name__, + internal_modules=(__name__,), + ) + + def test_skip_fully_qualified_name(self): + fqname = f"{internal.__name__}.Class" + instance = internal.Class() + for case in ("deprecated_method", "one_indirection", "two_indirections"): + method = getattr(instance, case) + with self.subTest(use="external warns", case=case): + with self.assertWarnsMessage( + RemovedInNextVersionWarning, "Message" + ) as warning: + method(skip_name_prefixes=fqname) + self.assertWarningPointsHere(warning) + with ( + self.subTest(use="internal does not warn", case=case), + self.assertNotWarns(RemovedInNextVersionWarning), + ): + # Treat only the current test module as "internal". + method(skip_name_prefixes=fqname, internal_modules=(__name__,)) + + def test_skip_name_prefixes_tuple(self): + prefixes = ( + internal.__name__, + "django.utils.deprecation.deprecate_posargs", + ) + with self.assertWarnsMessage(RemovedInNextVersionWarning, "Message") as warning: + internal.call_decorated(skip_name_prefixes=prefixes) + self.assertWarningPointsHere(warning) + + def test_skip_name_prefixes_is_applied_before_skip_frames(self): + with self.assertWarnsMessage(RemovedInNextVersionWarning, "Message") as warning: + # Stack frames: + # - deprecated_function() + # - one_indirection() -- ignored by skip_name_prefixes + # - two_indirections() -- ignored by skip_frames=1 + # - this test case -- effective caller, is external + internal.two_indirections( + skip_name_prefixes=f"{internal.__name__}.one_indirection", + skip_frames=1, + ) + self.assertWarningPointsHere(warning, offset=-4) + + def test_skip_name_prefixes_is_not_applied_after_skip_frames(self): + with self.assertNotWarns(RemovedInNextVersionWarning): + # Stack frames: + # - deprecated_function() + # - (skip_name_prefixes does not match here) + # - one_indirection() -- ignored by skip_frames=1 + # - two_indirections() -- effective caller, is internal + internal.two_indirections( + skip_name_prefixes=f"{internal.__name__}.two_indirections", + skip_frames=1, + ) + + def test_nested_qualname(self): + with self.assertWarnsMessage(RemovedInNextVersionWarning, "Message") as warning: + internal.nested(skip_name_prefixes=f"{internal.__name__}.nested") + self.assertWarningPointsHere(warning) + + def test_does_not_mistake_third_party_packages_for_django(self): + # Simulate a "django_goodies" package (which is not part of Django) + # using a deprecated Django feature. + sys.modules["django.something"] = internal + self.addCleanup(sys.modules.pop, "django.something", None) + code = compile( + ( + "from django.something import deprecated_function\n" + "\n" + "def use_deprecated_function(*args, **kwargs):\n" + " deprecated_function(*args, **kwargs)\n" + ), + filename="/venv/site-packages/django_goodies/__init__.py", + mode="exec", + ) + namespace = {"__name__": "django_goodies"} + exec(code, namespace) + with self.assertWarnsMessage(RemovedInNextVersionWarning, "Message") as warning: + # internal_modules=None forces the default modules. + namespace["use_deprecated_function"](internal_modules=None) + self.assertEqual( + warning.filename, "/venv/site-packages/django_goodies/__init__.py" + ) + + def test_internal_modules_must_be_tuple(self): + with self.assertRaisesMessage( + TypeError, "internal_modules must be a tuple of module names" + ): + internal.deprecated_function(internal_modules="django") + + def test_warns_if_effective_caller_has_no_filename(self): + # Simulate a frame whose source location can't be identified by + # compiling with an empty filename. + code = compile( + ( + "def use_deprecated_function(*args, **kwargs):\n" + " deprecated_function(*args, **kwargs)\n" + ), + filename="", + mode="exec", + ) + namespace = {"deprecated_function": internal.deprecated_function} + exec(code, namespace) + with self.assertWarnsMessage(RemovedInNextVersionWarning, "Message") as warning: + namespace["use_deprecated_function"]() + self.assertEqual(warning.filename, "") + self.assertEqual(warning.lineno, 2) + + def test_handles_skip_frames_overflow(self): + too_many_frames = len(inspect.stack()) + 20 + with self.assertWarnsMessage(RemovedInNextVersionWarning, "Message") as warning: + internal.deprecated_function(skip_frames=too_many_frames) + # In CPython, warning.filename seems to be "<sys>" and warning.lineno + # is 0. But the exact values are likely implementation-dependent. + self.assertNotEqual(warning.filename, __file__) + + def test_handles_skip_name_prefixes_overflow(self): + with self.assertWarnsMessage(RemovedInNextVersionWarning, "Message") as warning: + # Every string startswith(""). This will ignore the entire stack. + internal.deprecated_function(skip_name_prefixes="") + self.assertNotEqual(warning.filename, __file__) |
