diff --git a/docs/ref/tasks.txt b/docs/ref/tasks.txt index c427c4c4d33f..99d8eb6a0aac 100644 --- a/docs/ref/tasks.txt +++ b/docs/ref/tasks.txt @@ -7,6 +7,10 @@ Tasks .. module:: django.tasks :synopsis: Django's built-in background Task system. +The Task framework provides the contract and plumbing for background work, not +the engine that runs it. The Tasks API defines how work is described, queued, +and tracked, but leaves actual execution to external infrastructure. + Task definition =============== @@ -274,6 +278,10 @@ Task errors Backends ======== +Backends handle how Tasks are stored and executed. All backends share a common +interface defined by ``BaseTaskBackend``, which specifies the core methods for +enqueueing Tasks and retrieving results. + Base backend ------------ @@ -365,6 +373,10 @@ Feature :class:`.DummyBackend` :class:`.ImmediateBackend` Available backends ------------------ +Django includes only development and testing backends. These support local +execution and inspection, for production ready backends refer to +:ref:`configuring-a-task-backend`. + Immediate backend ~~~~~~~~~~~~~~~~~ diff --git a/docs/releases/6.0.1.txt b/docs/releases/6.0.1.txt new file mode 100644 index 000000000000..946102030cb6 --- /dev/null +++ b/docs/releases/6.0.1.txt @@ -0,0 +1,12 @@ +========================== +Django 6.0.1 release notes +========================== + +*Expected January 6, 2026* + +Django 6.0.1 fixes several bugs in 6.0. + +Bugfixes +======== + +* ... diff --git a/docs/releases/6.0.txt b/docs/releases/6.0.txt index 88ea04a9fa2d..89786c7ccb62 100644 --- a/docs/releases/6.0.txt +++ b/docs/releases/6.0.txt @@ -1,14 +1,14 @@ -============================================ -Django 6.0 release notes - UNDER DEVELOPMENT -============================================ +======================== +Django 6.0 release notes +======================== -*Expected December 2025* +*December 3, 2025* Welcome to Django 6.0! These release notes cover the :ref:`new features `, as well as -some :ref:`backwards incompatible changes ` you'll -want to be aware of when upgrading from Django 5.2 or earlier. We've +some :ref:`backwards incompatible changes ` you +should be aware of when upgrading from Django 5.2 or earlier. We've :ref:`begun the deprecation process for some features `. @@ -102,6 +102,11 @@ Django now includes a built-in Tasks framework for running code outside the HTTP request–response cycle. This enables offloading work, such as sending emails or processing data, to background workers. +The framework provides task definition, validation, queuing, and result +handling. Django guarantees consistent behavior for creating and managing +tasks, while the responsibility for running them continues to belong to +external worker processes. + Tasks are defined using the :func:`~django.tasks.task` decorator:: from django.core.mail import send_mail @@ -160,6 +165,12 @@ Minor features * The new :attr:`.AdminSite.password_change_form` attribute allows customizing the form used in the admin site password change view. +* Message levels ``messages.DEBUG`` and ``messages.INFO`` now have distinct + icons and CSS styling. Previously, both levels shared the same appearance as + ``messages.SUCCESS``. Given that :meth:`.ModelAdmin.message_user` uses + ``messages.INFO`` by default, set the level to ``messages.SUCCESS`` to keep + the previous icon and styling. + :mod:`django.contrib.auth` ~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -189,6 +200,11 @@ Minor features :class:`GeometryType() ` database function allow filtering geometries by their types. +* Widgets from :mod:`django.contrib.gis.forms.widgets` now render without + inline JavaScript in templates. If you have customized any geometry widgets + or their templates, you may need to :ref:`update them + ` to match the new layout. + :mod:`django.contrib.postgres` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -217,6 +233,10 @@ Minor features ensures consistent path ordering in manifest files, making them more reproducible and reducing unnecessary diffs. +* The :djadmin:`collectstatic` command now reports only a summary for skipped + files (and for deleted files when using ``--clear``) at ``--verbosity`` 1. To + see per-file details for either case, set ``--verbosity`` to 2 or higher. + Email ~~~~~ @@ -462,29 +482,9 @@ Miscellaneous refactored to use Python's :class:`email.message.Message` for parsing. Input headers exceeding 10000 characters will now raise :exc:`ValueError`. -* Widgets from :mod:`django.contrib.gis.forms.widgets` now render without - inline JavaScript in templates. If you have customized any geometry widgets - or their templates, you may need to :ref:`update them - ` to match the new layout. - -* Message levels ``messages.DEBUG`` and ``messages.INFO`` now have distinct - icons and CSS styling in the admin. Previously, these used the same icon and - styling as the ``messages.SUCCESS`` level. Since - :meth:`.ModelAdmin.message_user` uses the ``messages.INFO`` level by default, - set the level to ``messages.SUCCESS`` to retain the previous icon and - styling. - * The minimum supported version of ``asgiref`` is increased from 3.8.1 to 3.9.1. -* The :djadmin:`collectstatic` command now reports only a summary of skipped - files due to conflicts when ``--verbosity`` is 1. To see warnings for each - conflicting destination path, set the ``--verbosity`` flag to 2 or higher. - -* The :option:`collectstatic --clear` command now reports only a summary of - deleted files when ``--verbosity`` is 1. To see the details for each file - deleted, set the ``--verbosity`` flag to 2 or higher. - .. _deprecated-features-6.0: Features deprecated in 6.0 @@ -516,14 +516,15 @@ Miscellaneous available :class:`~django.db.models.StringAgg` class. * The PostgreSQL ``OrderableAggMixin`` is deprecated in favor of the - ``order_by`` attribute now available on the ``Aggregate`` class. + ``order_by`` attribute now available on the + :class:`~django.db.models.Aggregate` class. * The default protocol in :tfilter:`urlize` and :tfilter:`urlizetrunc` will change from HTTP to HTTPS in Django 7.0. Set the transitional setting - ``URLIZE_ASSUME_HTTPS`` to ``True`` to opt into assuming HTTPS during the - Django 6.x release cycle. + :setting:`URLIZE_ASSUME_HTTPS` to ``True`` to opt into assuming HTTPS during + the Django 6.x release cycle. -* ``URLIZE_ASSUME_HTTPS`` transitional setting is deprecated. +* The :setting:`URLIZE_ASSUME_HTTPS` transitional setting is deprecated. * Setting :setting:`ADMINS` or :setting:`MANAGERS` to a list of (name, address) tuples is deprecated. Set to a list of email address strings instead. Django diff --git a/docs/releases/index.txt b/docs/releases/index.txt index 9eb42725c77a..4dff9514c7b2 100644 --- a/docs/releases/index.txt +++ b/docs/releases/index.txt @@ -32,6 +32,7 @@ versions of the documentation contain the release notes for any later releases. .. toctree:: :maxdepth: 1 + 6.0.1 6.0 5.2 release diff --git a/docs/topics/tasks.txt b/docs/topics/tasks.txt index 13afd8b09f6c..96deac6dc3de 100644 --- a/docs/topics/tasks.txt +++ b/docs/topics/tasks.txt @@ -15,10 +15,11 @@ to be run elsewhere, potentially at a later date. This keeps requests fast, reduces latency, and improves the user experience. For example, a user shouldn't have to wait for an email to send before their page finishes loading. -Django's new Tasks framework makes it easy to define and enqueue such work. It +Django's Tasks framework makes it easy to define and enqueue such work. It does not provide a worker mechanism to run Tasks. The actual execution must be handled by infrastructure outside Django, such as a separate process or -service. +service. Given that, a :ref:`task backend ` capable +of executing tasks on that service should be evaluated and configured. Background Task fundamentals ============================ @@ -41,9 +42,13 @@ Configuring a Task backend The Task backend determines how and where Tasks are stored for execution and how they are executed. Different Task backends have different characteristics and configuration options, which may impact the performance and reliability of -your application. Django comes with a number of :ref:`built-in backends -`. Django does not provide a generic way to execute -Tasks, only enqueue them. +your application. Django comes with :ref:`built-in backends +`, but these are for development and testing only. + +Django handles task definition, validation, queuing, and result handling, not +execution, so production setups need a backend or worker process that actually +runs queued work. Relevant options are listed in the `Community Ecosystem +`__ page. Task backends are configured using the :setting:`TASKS` setting in your settings file. Whilst most applications will only need a single backend, @@ -103,13 +108,14 @@ Stored results can be cleared using the >>> len(default_task_backend.results) 0 -Using a custom backend ----------------------- +Third-party backends +-------------------- -While Django includes support for a number of Task backends out-of-the-box, -sometimes you might want to customize the Task backend. To use an external Task -backend with Django, use the Python import path as the :setting:`BACKEND -` of the :setting:`TASKS` setting, like so:: +As mentioned at the beginning of this section, Django includes backends +suitable for development and testing only. Production systems should rely on +backends that supply a worker process and durable queue implementation. To use +an external Task backend with Django, use the Python import path as the +:setting:`BACKEND ` of the :setting:`TASKS` setting, like so:: TASKS = { "default": { diff --git a/tests/auth_tests/test_validators.py b/tests/auth_tests/test_validators.py index 2a3a93efe887..3ce261163370 100644 --- a/tests/auth_tests/test_validators.py +++ b/tests/auth_tests/test_validators.py @@ -202,24 +202,24 @@ def test_validate(self): self.assertEqual(cm.exception.messages, [expected_error % "username"]) self.assertEqual(cm.exception.error_list[0].code, "password_too_similar") - with self.assertRaises(ValidationError) as cm: + msg = expected_error % "email address" + with self.assertRaisesMessage(ValidationError, msg): UserAttributeSimilarityValidator().validate("example.com", user=user) - self.assertEqual(cm.exception.messages, [expected_error % "email address"]) - with self.assertRaises(ValidationError) as cm: + msg = expected_error % "first name" + with self.assertRaisesMessage(ValidationError, msg): UserAttributeSimilarityValidator( user_attributes=["first_name"], max_similarity=0.3, ).validate("testclient", user=user) - self.assertEqual(cm.exception.messages, [expected_error % "first name"]) # max_similarity=1 doesn't allow passwords that are identical to the # attribute's value. - with self.assertRaises(ValidationError) as cm: + msg = expected_error % "first name" + with self.assertRaisesMessage(ValidationError, msg): UserAttributeSimilarityValidator( user_attributes=["first_name"], max_similarity=1, ).validate(user.first_name, user=user) - self.assertEqual(cm.exception.messages, [expected_error % "first name"]) # Very low max_similarity is rejected. msg = "max_similarity must be at least 0.1" with self.assertRaisesMessage(ValueError, msg): @@ -240,11 +240,9 @@ class TestUser(models.Model): def username(self): return "foobar" - with self.assertRaises(ValidationError) as cm: + msg = "The password is too similar to the username." + with self.assertRaisesMessage(ValidationError, msg): UserAttributeSimilarityValidator().validate("foobar", user=TestUser()) - self.assertEqual( - cm.exception.messages, ["The password is too similar to the username."] - ) def test_help_text(self): self.assertEqual( @@ -294,18 +292,16 @@ def test_validate(self): expected_error = "This password is too common." self.assertIsNone(CommonPasswordValidator().validate("a-safe-password")) - with self.assertRaises(ValidationError) as cm: + with self.assertRaisesMessage(ValidationError, expected_error): CommonPasswordValidator().validate("godzilla") - self.assertEqual(cm.exception.messages, [expected_error]) def test_common_hexed_codes(self): expected_error = "This password is too common." common_hexed_passwords = ["asdfjkl:", "ठ:"] for password in common_hexed_passwords: with self.subTest(password=password): - with self.assertRaises(ValidationError) as cm: + with self.assertRaisesMessage(ValidationError, expected_error): CommonPasswordValidator().validate(password) - self.assertEqual(cm.exception.messages, [expected_error]) def test_validate_custom_list(self): path = os.path.join( diff --git a/tests/postgres_tests/test_array.py b/tests/postgres_tests/test_array.py index d4289d79dfb3..eea99358047d 100644 --- a/tests/postgres_tests/test_array.py +++ b/tests/postgres_tests/test_array.py @@ -1087,12 +1087,9 @@ def test_blank_true(self): def test_with_size(self): field = ArrayField(models.IntegerField(), size=3) field.clean([1, 2, 3], None) - with self.assertRaises(exceptions.ValidationError) as cm: + msg = "List contains 4 items, it should contain no more than 3." + with self.assertRaisesMessage(exceptions.ValidationError, msg): field.clean([1, 2, 3, 4], None) - self.assertEqual( - cm.exception.messages[0], - "List contains 4 items, it should contain no more than 3.", - ) def test_with_size_singular(self): field = ArrayField(models.IntegerField(), size=1) @@ -1156,21 +1153,15 @@ def test_valid(self): def test_to_python_fail(self): field = SimpleArrayField(forms.IntegerField()) - with self.assertRaises(exceptions.ValidationError) as cm: + msg = "Item 1 in the array did not validate: Enter a whole number." + with self.assertRaisesMessage(exceptions.ValidationError, msg): field.clean("a,b,9") - self.assertEqual( - cm.exception.messages[0], - "Item 1 in the array did not validate: Enter a whole number.", - ) def test_validate_fail(self): field = SimpleArrayField(forms.CharField(required=True)) - with self.assertRaises(exceptions.ValidationError) as cm: + msg = "Item 3 in the array did not validate: This field is required." + with self.assertRaisesMessage(exceptions.ValidationError, msg): field.clean("a,b,") - self.assertEqual( - cm.exception.messages[0], - "Item 3 in the array did not validate: This field is required.", - ) def test_validate_fail_base_field_error_params(self): field = SimpleArrayField(forms.CharField(max_length=2)) @@ -1203,12 +1194,9 @@ def test_validate_fail_base_field_error_params(self): def test_validators_fail(self): field = SimpleArrayField(forms.RegexField("[a-e]{2}")) - with self.assertRaises(exceptions.ValidationError) as cm: + msg = "Item 1 in the array did not validate: Enter a valid value." + with self.assertRaisesMessage(exceptions.ValidationError, msg): field.clean("a,bc,de") - self.assertEqual( - cm.exception.messages[0], - "Item 1 in the array did not validate: Enter a valid value.", - ) def test_delimiter(self): field = SimpleArrayField(forms.CharField(), delimiter="|") @@ -1227,21 +1215,15 @@ def test_prepare_value(self): def test_max_length(self): field = SimpleArrayField(forms.CharField(), max_length=2) - with self.assertRaises(exceptions.ValidationError) as cm: + msg = "List contains 3 items, it should contain no more than 2." + with self.assertRaisesMessage(exceptions.ValidationError, msg): field.clean("a,b,c") - self.assertEqual( - cm.exception.messages[0], - "List contains 3 items, it should contain no more than 2.", - ) def test_min_length(self): field = SimpleArrayField(forms.CharField(), min_length=4) - with self.assertRaises(exceptions.ValidationError) as cm: + msg = "List contains 3 items, it should contain no fewer than 4." + with self.assertRaisesMessage(exceptions.ValidationError, msg): field.clean("a,b,c") - self.assertEqual( - cm.exception.messages[0], - "List contains 3 items, it should contain no fewer than 4.", - ) def test_min_length_singular(self): field = SimpleArrayField(forms.IntegerField(), min_length=2) @@ -1252,9 +1234,9 @@ def test_min_length_singular(self): def test_required(self): field = SimpleArrayField(forms.CharField(), required=True) - with self.assertRaises(exceptions.ValidationError) as cm: + msg = "This field is required." + with self.assertRaisesMessage(exceptions.ValidationError, msg): field.clean("") - self.assertEqual(cm.exception.messages[0], "This field is required.") def test_model_field_formfield(self): model_field = ArrayField(models.CharField(max_length=27)) diff --git a/tests/postgres_tests/test_ranges.py b/tests/postgres_tests/test_ranges.py index f1c104e83bc9..7e21d2704ff9 100644 --- a/tests/postgres_tests/test_ranges.py +++ b/tests/postgres_tests/test_ranges.py @@ -842,21 +842,21 @@ def test_integer_incorrect_data_type(self): def test_integer_invalid_lower(self): field = pg_forms.IntegerRangeField() - with self.assertRaises(exceptions.ValidationError) as cm: + msg = "Enter a whole number." + with self.assertRaisesMessage(exceptions.ValidationError, msg): field.clean(["a", "2"]) - self.assertEqual(cm.exception.messages[0], "Enter a whole number.") def test_integer_invalid_upper(self): field = pg_forms.IntegerRangeField() - with self.assertRaises(exceptions.ValidationError) as cm: + msg = "Enter a whole number." + with self.assertRaisesMessage(exceptions.ValidationError, msg): field.clean(["1", "b"]) - self.assertEqual(cm.exception.messages[0], "Enter a whole number.") def test_integer_required(self): field = pg_forms.IntegerRangeField(required=True) - with self.assertRaises(exceptions.ValidationError) as cm: + msg = "This field is required." + with self.assertRaisesMessage(exceptions.ValidationError, msg): field.clean(["", ""]) - self.assertEqual(cm.exception.messages[0], "This field is required.") value = field.clean([1, ""]) self.assertEqual(value, NumericRange(1, None)) @@ -884,21 +884,21 @@ def test_decimal_incorrect_data_type(self): def test_decimal_invalid_lower(self): field = pg_forms.DecimalRangeField() - with self.assertRaises(exceptions.ValidationError) as cm: + msg = "Enter a number." + with self.assertRaisesMessage(exceptions.ValidationError, msg): field.clean(["a", "3.1415926"]) - self.assertEqual(cm.exception.messages[0], "Enter a number.") def test_decimal_invalid_upper(self): field = pg_forms.DecimalRangeField() - with self.assertRaises(exceptions.ValidationError) as cm: + msg = "Enter a number." + with self.assertRaisesMessage(exceptions.ValidationError, msg): field.clean(["1.61803399", "b"]) - self.assertEqual(cm.exception.messages[0], "Enter a number.") def test_decimal_required(self): field = pg_forms.DecimalRangeField(required=True) - with self.assertRaises(exceptions.ValidationError) as cm: + msg = "This field is required." + with self.assertRaisesMessage(exceptions.ValidationError, msg): field.clean(["", ""]) - self.assertEqual(cm.exception.messages[0], "This field is required.") value = field.clean(["1.61803399", ""]) self.assertEqual(value, NumericRange(Decimal("1.61803399"), None)) @@ -926,21 +926,21 @@ def test_date_incorrect_data_type(self): def test_date_invalid_lower(self): field = pg_forms.DateRangeField() - with self.assertRaises(exceptions.ValidationError) as cm: + msg = "Enter a valid date." + with self.assertRaisesMessage(exceptions.ValidationError, msg): field.clean(["a", "2013-04-09"]) - self.assertEqual(cm.exception.messages[0], "Enter a valid date.") def test_date_invalid_upper(self): field = pg_forms.DateRangeField() - with self.assertRaises(exceptions.ValidationError) as cm: + msg = "Enter a valid date." + with self.assertRaisesMessage(exceptions.ValidationError, msg): field.clean(["2013-04-09", "b"]) - self.assertEqual(cm.exception.messages[0], "Enter a valid date.") def test_date_required(self): field = pg_forms.DateRangeField(required=True) - with self.assertRaises(exceptions.ValidationError) as cm: + msg = "This field is required." + with self.assertRaisesMessage(exceptions.ValidationError, msg): field.clean(["", ""]) - self.assertEqual(cm.exception.messages[0], "This field is required.") value = field.clean(["1976-04-16", ""]) self.assertEqual(value, DateRange(datetime.date(1976, 4, 16), None)) @@ -986,21 +986,21 @@ def test_datetime_incorrect_data_type(self): def test_datetime_invalid_lower(self): field = pg_forms.DateTimeRangeField() - with self.assertRaises(exceptions.ValidationError) as cm: + msg = "Enter a valid date/time." + with self.assertRaisesMessage(exceptions.ValidationError, msg): field.clean(["45", "2013-04-09 11:45"]) - self.assertEqual(cm.exception.messages[0], "Enter a valid date/time.") def test_datetime_invalid_upper(self): field = pg_forms.DateTimeRangeField() - with self.assertRaises(exceptions.ValidationError) as cm: + msg = "Enter a valid date/time." + with self.assertRaisesMessage(exceptions.ValidationError, msg): field.clean(["2013-04-09 11:45", "sweet pickles"]) - self.assertEqual(cm.exception.messages[0], "Enter a valid date/time.") def test_datetime_required(self): field = pg_forms.DateTimeRangeField(required=True) - with self.assertRaises(exceptions.ValidationError) as cm: + msg = "This field is required." + with self.assertRaisesMessage(exceptions.ValidationError, msg): field.clean(["", ""]) - self.assertEqual(cm.exception.messages[0], "This field is required.") value = field.clean(["2013-04-09 11:45", ""]) self.assertEqual( value, DateTimeTZRange(datetime.datetime(2013, 4, 9, 11, 45), None) diff --git a/tests/test_client_regress/tests.py b/tests/test_client_regress/tests.py index 7168c8b07813..e3754ef904e5 100644 --- a/tests/test_client_regress/tests.py +++ b/tests/test_client_regress/tests.py @@ -309,15 +309,13 @@ def test_no_context(self): # The no template case doesn't mess with the template assertions self.assertTemplateNotUsed(response, "GET Template") - try: + msg = "No templates used to render the response" + with self.assertRaisesMessage(AssertionError, msg): self.assertTemplateUsed(response, "GET Template") - except AssertionError as e: - self.assertIn("No templates used to render the response", str(e)) - try: + msg = "No templates used to render the response" + with self.assertRaisesMessage(AssertionError, msg): self.assertTemplateUsed(response, "GET Template", msg_prefix="abc") - except AssertionError as e: - self.assertIn("abc: No templates used to render the response", str(e)) msg = "No templates used to render the response" with self.assertRaisesMessage(AssertionError, msg): @@ -400,23 +398,19 @@ def test_redirect_page(self): """ # This page will redirect with code 301, not 302 response = self.client.get("/permanent_redirect_view/") - try: + msg = ( + "Response didn't redirect as expected: Response code was 301 " + "(expected 302)" + ) + with self.assertRaisesMessage(AssertionError, msg): self.assertRedirects(response, "/get_view/") - except AssertionError as e: - self.assertIn( - "Response didn't redirect as expected: Response code was 301 " - "(expected 302)", - str(e), - ) - try: + msg = ( + "abc: Response didn't redirect as expected: Response code was 301 " + "(expected 302)" + ) + with self.assertRaisesMessage(AssertionError, msg): self.assertRedirects(response, "/get_view/", msg_prefix="abc") - except AssertionError as e: - self.assertIn( - "abc: Response didn't redirect as expected: Response code was 301 " - "(expected 302)", - str(e), - ) def test_followed_redirect_unexpected_initial_status_code(self): response = self.client.get("/permanent_redirect_view/", follow=True) @@ -452,35 +446,25 @@ def test_lost_query(self): parameters. """ response = self.client.get("/redirect_view/", {"var": "value"}) - try: + msg = "Response redirected to '/get_view/?var=value', expected '/get_view/'" + with self.assertRaisesMessage(AssertionError, msg): self.assertRedirects(response, "/get_view/") - except AssertionError as e: - self.assertIn( - "Response redirected to '/get_view/?var=value', expected '/get_view/'", - str(e), - ) - try: + msg = ( + "abc: Response redirected to '/get_view/?var=value', expected '/get_view/'" + ) + with self.assertRaisesMessage(AssertionError, msg): self.assertRedirects(response, "/get_view/", msg_prefix="abc") - except AssertionError as e: - self.assertIn( - "abc: Response redirected to '/get_view/?var=value', expected " - "'/get_view/'", - str(e), - ) def test_incorrect_target(self): "An assertion is raised if the response redirects to another target" response = self.client.get("/permanent_redirect_view/") - try: + msg = ( + "Response didn't redirect as expected: Response code was 301 (expected 302)" + ) + with self.assertRaisesMessage(AssertionError, msg): # Should redirect to get_view self.assertRedirects(response, "/some_view/") - except AssertionError as e: - self.assertIn( - "Response didn't redirect as expected: Response code was 301 " - "(expected 302)", - str(e), - ) def test_target_page(self): """ @@ -488,27 +472,23 @@ def test_target_page(self): retrieved as expected. """ response = self.client.get("/double_redirect_view/") - try: + msg = ( + "Couldn't retrieve redirection page '/permanent_redirect_view/': " + "response code was 301 (expected 200)" + ) + with self.assertRaisesMessage(AssertionError, msg): # The redirect target responds with a 301 code, not 200 self.assertRedirects(response, "http://testserver/permanent_redirect_view/") - except AssertionError as e: - self.assertIn( - "Couldn't retrieve redirection page '/permanent_redirect_view/': " - "response code was 301 (expected 200)", - str(e), - ) - try: + msg = ( + "abc: Couldn't retrieve redirection page '/permanent_redirect_view/': " + "response code was 301 (expected 200)" + ) + with self.assertRaisesMessage(AssertionError, msg): # The redirect target responds with a 301 code, not 200 self.assertRedirects( response, "http://testserver/permanent_redirect_view/", msg_prefix="abc" ) - except AssertionError as e: - self.assertIn( - "abc: Couldn't retrieve redirection page '/permanent_redirect_view/': " - "response code was 301 (expected 200)", - str(e), - ) def test_redirect_chain(self): "You can follow a redirect chain of multiple redirects" @@ -632,23 +612,18 @@ def test_redirect_chain_on_non_redirect_page(self): """ # This page will redirect with code 301, not 302 response = self.client.get("/get_view/", follow=True) - try: + msg = ( + "Response didn't redirect as expected: Response code was 200 (expected 302)" + ) + with self.assertRaisesMessage(AssertionError, msg): self.assertRedirects(response, "/get_view/") - except AssertionError as e: - self.assertIn( - "Response didn't redirect as expected: Response code was 200 " - "(expected 302)", - str(e), - ) - try: + msg = ( + "abc: Response didn't redirect as expected: Response code was 200 " + "(expected 302)" + ) + with self.assertRaisesMessage(AssertionError, msg): self.assertRedirects(response, "/get_view/", msg_prefix="abc") - except AssertionError as e: - self.assertIn( - "abc: Response didn't redirect as expected: Response code was 200 " - "(expected 302)", - str(e), - ) def test_redirect_on_non_redirect_page(self): """ @@ -657,23 +632,18 @@ def test_redirect_on_non_redirect_page(self): """ # This page will redirect with code 301, not 302 response = self.client.get("/get_view/") - try: + msg = ( + "Response didn't redirect as expected: Response code was 200 (expected 302)" + ) + with self.assertRaisesMessage(AssertionError, msg): self.assertRedirects(response, "/get_view/") - except AssertionError as e: - self.assertIn( - "Response didn't redirect as expected: Response code was 200 " - "(expected 302)", - str(e), - ) - try: + msg = ( + "abc: Response didn't redirect as expected: Response code was 200 " + "(expected 302)" + ) + with self.assertRaisesMessage(AssertionError, msg): self.assertRedirects(response, "/get_view/", msg_prefix="abc") - except AssertionError as e: - self.assertIn( - "abc: Response didn't redirect as expected: Response code was 200 " - "(expected 302)", - str(e), - ) def test_redirect_scheme(self): """