summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--django/conf/__init__.py28
-rw-r--r--django/utils/deprecation.py79
-rw-r--r--tests/deprecation/internal.py66
-rw-r--r--tests/deprecation/test_warn_about_external_use.py178
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__)