diff --git a/docs/fixers.rst b/docs/fixers.rst index c84a379f..01336422 100644 --- a/docs/fixers.rst +++ b/docs/fixers.rst @@ -260,6 +260,31 @@ Django 5.1 `Release Notes `__ +``BadHeaderError`` +~~~~~~~~~~~~~~~~~~ + +**Name:** ``bad_header_error`` + +Replaces ``django.core.mail.BadHeaderError`` with Python's built-in ``ValueError``. +Django's ``BadHeaderError`` has been deprecated in favor of the standard ``ValueError``. + +.. code-block:: diff + + -from django.core.mail import BadHeaderError, send_mail + +from django.core.mail import send_mail + + -raise BadHeaderError("Invalid header") + +raise ValueError("Invalid header") + +.. code-block:: diff + + + try: + send_mail(...) + -except BadHeaderError: + +except ValueError: + pass + .. _check_constraint_condition: ``CheckConstraint`` ``condition`` argument diff --git a/src/django_upgrade/fixers/bad_header_error.py b/src/django_upgrade/fixers/bad_header_error.py new file mode 100644 index 00000000..999e62c2 --- /dev/null +++ b/src/django_upgrade/fixers/bad_header_error.py @@ -0,0 +1,84 @@ +""" +Swap django.core.mail.BadHeaderError with ValueError: +https://docs.djangoproject.com/en/5.1/releases/5.1/#features-deprecated-in-5-1 +""" + +from __future__ import annotations + +import ast +from collections.abc import Iterable, MutableMapping +from functools import partial +from weakref import WeakKeyDictionary + +from tokenize_rt import Offset + +from django_upgrade.ast import ast_start_offset, is_rewritable_import_from +from django_upgrade.data import Fixer, State, TokenFunc +from django_upgrade.tokens import find_and_replace_name, update_import_names + +fixer = Fixer( + __name__, + min_version=(5, 1), +) + +MODULE = "django.core.mail" +OLD_NAME = "BadHeaderError" +NEW_NAME = "ValueError" + +# Track aliased imports like "from django.core.mail import BadHeaderError as BHE" +# Maps state to the set of alias names that should be replaced with ValueError +aliased_names: MutableMapping[State, set[str]] = WeakKeyDictionary() + + +@fixer.register(ast.ImportFrom) +def visit_ImportFrom( + state: State, + node: ast.ImportFrom, + parents: tuple[ast.AST, ...], +) -> Iterable[tuple[Offset, TokenFunc]]: + if node.module == MODULE and is_rewritable_import_from(node): + # Check if BadHeaderError is in the imports + aliases_to_track = set() + for alias in node.names: + if alias.name == OLD_NAME: + # Track the alias name if it exists + if alias.asname is not None: + aliases_to_track.add(alias.asname) + # Remove BadHeaderError from the import + name_map = {OLD_NAME: ""} + if aliases_to_track: + # Merge with any previously recorded aliases for this state + existing = aliased_names.get(state) + if existing is None: + aliased_names[state] = aliases_to_track + else: + existing.update(aliases_to_track) + yield ( + ast_start_offset(node), + partial( + update_import_names, + node=node, + name_map=name_map, + ), + ) + break + + +@fixer.register(ast.Name) +def visit_Name( + state: State, + node: ast.Name, + parents: tuple[ast.AST, ...], +) -> Iterable[tuple[Offset, TokenFunc]]: + # Check if this is a direct usage of BadHeaderError (unaliased import) + if node.id == OLD_NAME and OLD_NAME in state.from_imports[MODULE]: + yield ( + ast_start_offset(node), + partial(find_and_replace_name, name=OLD_NAME, new=NEW_NAME), + ) + # Check if this is an aliased usage (e.g., BHE when imported as BadHeaderError as BHE) + elif node.id in aliased_names.get(state, set()): + yield ( + ast_start_offset(node), + partial(find_and_replace_name, name=node.id, new=NEW_NAME), + ) diff --git a/tests/fixers/test_bad_header_error.py b/tests/fixers/test_bad_header_error.py new file mode 100644 index 00000000..7a4f0c17 --- /dev/null +++ b/tests/fixers/test_bad_header_error.py @@ -0,0 +1,162 @@ +from __future__ import annotations + +from functools import partial + +from django_upgrade.data import Settings +from tests.fixers import tools + +settings = Settings(target_version=(5, 1)) +check_noop = partial(tools.check_noop, settings=settings) +check_transformed = partial(tools.check_transformed, settings=settings) + + +def test_no_bad_header_error(): + check_noop( + """\ + from django.core.mail import send_mail + + send_mail('subject', 'message', 'from@example.com', ['to@example.com']) + """, + ) + + +def test_bad_header_error_simple(): + check_transformed( + """\ + from django.core.mail import BadHeaderError + + raise BadHeaderError("Invalid header") + """, + """\ + + raise ValueError("Invalid header") + """, + ) + + +def test_bad_header_error_with_other_imports(): + check_transformed( + """\ + from django.core.mail import BadHeaderError, send_mail + + try: + send_mail('subject', 'message', 'from@example.com', ['to@example.com']) + except BadHeaderError: + pass + """, + """\ + from django.core.mail import send_mail + + try: + send_mail('subject', 'message', 'from@example.com', ['to@example.com']) + except ValueError: + pass + """, + ) + + +def test_bad_header_error_in_except(): + check_transformed( + """\ + from django.core.mail import BadHeaderError + + try: + do_something() + except BadHeaderError as e: + handle_error(e) + """, + """\ + + try: + do_something() + except ValueError as e: + handle_error(e) + """, + ) + + +def test_bad_header_error_raise_with_message(): + check_transformed( + """\ + from django.core.mail import BadHeaderError + + if invalid_header: + raise BadHeaderError("Header contains newline") + """, + """\ + + if invalid_header: + raise ValueError("Header contains newline") + """, + ) + + +def test_bad_header_error_multiple_uses(): + check_transformed( + """\ + from django.core.mail import BadHeaderError, EmailMessage + + try: + msg = EmailMessage() + msg.send() + except BadHeaderError: + raise BadHeaderError("Invalid header found") + """, + """\ + from django.core.mail import EmailMessage + + try: + msg = EmailMessage() + msg.send() + except ValueError: + raise ValueError("Invalid header found") + """, + ) + + +def test_bad_header_error_with_alias(): + check_transformed( + """\ + from django.core.mail import BadHeaderError as BHE + + raise BHE("Invalid") + """, + """\ + + raise ValueError("Invalid") + """, + ) + + +def test_bad_header_error_not_from_django(): + check_noop( + """\ + from myapp.exceptions import BadHeaderError + + raise BadHeaderError("Custom error") + """, + ) + + +def test_bad_header_error_builtin_ValueError_already_used(): + check_transformed( + """\ + from django.core.mail import BadHeaderError + + try: + int("not a number") + except ValueError: + pass + + raise BadHeaderError("Invalid header") + """, + """\ + + try: + int("not a number") + except ValueError: + pass + + raise ValueError("Invalid header") + """, + )