Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 18 additions & 10 deletions django/conf/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,16 @@
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"
Expand Down Expand Up @@ -146,13 +148,19 @@ def configured(self):
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:
Expand Down
79 changes: 79 additions & 0 deletions django/utils/deprecation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
66 changes: 66 additions & 0 deletions tests/deprecation/internal.py
Original file line number Diff line number Diff line change
@@ -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)
178 changes: 178 additions & 0 deletions tests/deprecation/test_warn_about_external_use.py
Original file line number Diff line number Diff line change
@@ -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__)
Loading