Opened 7 months ago

Last modified 6 days ago

#36664 assigned New feature

Python 3.15 compatibility.

Reported by: Mariusz Felisiak Owned by: Mariusz Felisiak
Component: Core (Other) Version: dev
Severity: Normal Keywords:
Cc: Mike Edmunds Triage Stage: Someday/Maybe
Has patch: no Needs documentation: no
Needs tests: no Patch needs improvement: no
Easy pickings: no UI/UX: no

Description

Python 3.15 final is scheduled for October 2026 (see ​​PEP 790). This is a tracking ticket for compatibility fixes for Django submitted in the meantime.

Django 6.1 will be the first version to support Python 3.15, because Django 6.0 will end the mainstream support in August 2026.

Change History (11)

comment:1 by Jacob Walls, 7 months ago

Triage Stage: UnreviewedAccepted

comment:2 by Mariusz Felisiak, 7 months ago

Triage Stage: AcceptedSomeday/Maybe

comment:3 by GitHub <noreply@…>, 7 months ago

In 5e2bbebe:

Refs #36664 -- Added Python 3.15 to daily builds.

comment:4 by Jacob Walls <jacobtylerwalls@…>, 12 days ago

In e7f539f:

Refs #36712, #36664 -- Used annotation_format parameter of getfullargspec() on Python 3.15.

https://github.com/python/cpython/pull/149457

comment:5 by Jacob Walls, 8 days ago

Cc: Mike Edmunds added

Hi Mike,

Thanks to you, the future referenced in EmailBackend.prep_address() has arrived:

        if not force_ascii:
            # Non-ASCII local-part is valid with SMTPUTF8. Remove once
            # https://github.com/python/cpython/issues/81074 is fixed.
            defects.discard("local-part contains non-ASCII characters)")

We have on the 3.15 builds:

======================================================================
ERROR: test_rejects_non_ascii_local_part (mail.test_backends.SMTPBackendTests.test_rejects_non_ascii_local_part)
The SMTP EmailBackend does not currently support non-ASCII local-parts.
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/Users/jwalls/django/tests/mail/test_backends.py", line 832, in test_rejects_non_ascii_local_part
    backend.send_messages([email])
    ~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^
  File "/Users/jwalls/django/django/core/mail/backends/smtp.py", line 138, in send_messages
    sent = self._send(message)
  File "/Users/jwalls/django/django/core/mail/backends/smtp.py", line 155, in _send
    self.connection.sendmail(from_email, recipients, message.as_bytes())
    ^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Library/Frameworks/Python.framework/Versions/3.15/lib/python3.15/unittest/mock.py", line 697, in __getattr__
    raise AttributeError("Mock object has no attribute %r" % name)
AttributeError: Mock object has no attribute 'sendmail'

It fails more informatively when I remove spec=object():

======================================================================
ERROR: test_rejects_non_ascii_local_part (mail.test_backends.SMTPBackendTests.test_rejects_non_ascii_local_part)
The SMTP EmailBackend does not currently support non-ASCII local-parts.
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/Library/Frameworks/Python.framework/Versions/3.15/lib/python3.15/email/_header_value_parser.py", line 2843, in _refold_without_ew
    tstr.encode(encoding)
    ~~~~~~~~~~~^^^^^^^^^^
UnicodeEncodeError: 'ascii' codec can't encode character '\xf8' in position 1: ordinal not in range(128)

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/Users/jwalls/django/tests/mail/test_backends.py", line 832, in test_rejects_non_ascii_local_part
    backend.send_messages([email])
    ~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^
  File "/Users/jwalls/django/django/core/mail/backends/smtp.py", line 138, in send_messages
    sent = self._send(message)
  File "/Users/jwalls/django/django/core/mail/backends/smtp.py", line 155, in _send
    self.connection.sendmail(from_email, recipients, message.as_bytes())
                                                     ~~~~~~~~~~~~~~~~^^
  File "/Library/Frameworks/Python.framework/Versions/3.15/lib/python3.15/email/message.py", line 214, in as_bytes
    g.flatten(self, unixfrom=unixfrom)
    ~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Library/Frameworks/Python.framework/Versions/3.15/lib/python3.15/email/generator.py", line 118, in flatten
    self._write(msg)
    ~~~~~~~~~~~^^^^^
  File "/Library/Frameworks/Python.framework/Versions/3.15/lib/python3.15/email/generator.py", line 201, in _write
    self._write_headers(msg)
    ~~~~~~~~~~~~~~~~~~~^^^^^
  File "/Library/Frameworks/Python.framework/Versions/3.15/lib/python3.15/email/generator.py", line 433, in _write_headers
    folded = self.policy.fold_binary(h, v)
  File "/Library/Frameworks/Python.framework/Versions/3.15/lib/python3.15/email/policy.py", line 207, in fold_binary
    folded = self._fold(name, value, refold_binary=self.cte_type=='7bit')
  File "/Library/Frameworks/Python.framework/Versions/3.15/lib/python3.15/email/policy.py", line 213, in _fold
    return value.fold(policy=self)
           ~~~~~~~~~~^^^^^^^^^^^^^
  File "/Library/Frameworks/Python.framework/Versions/3.15/lib/python3.15/email/headerregistry.py", line 251, in fold
    return header.fold(policy=policy)
           ~~~~~~~~~~~^^^^^^^^^^^^^^^
  File "/Library/Frameworks/Python.framework/Versions/3.15/lib/python3.15/email/_header_value_parser.py", line 170, in fold
    return _refold_parse_tree(self, policy=policy)
  File "/Library/Frameworks/Python.framework/Versions/3.15/lib/python3.15/email/_header_value_parser.py", line 2832, in _refold_parse_tree
    _refold_with_ew(parse_tree, lines, maxlen, encoding, policy=policy)
    ~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Library/Frameworks/Python.framework/Versions/3.15/lib/python3.15/email/_header_value_parser.py", line 2936, in _refold_with_ew
    encoded_part = part.fold(policy=policy)[:-len(policy.linesep)]
                   ~~~~~~~~~^^^^^^^^^^^^^^^
  File "/Library/Frameworks/Python.framework/Versions/3.15/lib/python3.15/email/_header_value_parser.py", line 170, in fold
    return _refold_parse_tree(self, policy=policy)
  File "/Library/Frameworks/Python.framework/Versions/3.15/lib/python3.15/email/_header_value_parser.py", line 2832, in _refold_parse_tree
    _refold_with_ew(parse_tree, lines, maxlen, encoding, policy=policy)
    ~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Library/Frameworks/Python.framework/Versions/3.15/lib/python3.15/email/_header_value_parser.py", line 2936, in _refold_with_ew
    encoded_part = part.fold(policy=policy)[:-len(policy.linesep)]
                   ~~~~~~~~~^^^^^^^^^^^^^^^
  File "/Library/Frameworks/Python.framework/Versions/3.15/lib/python3.15/email/_header_value_parser.py", line 170, in fold
    return _refold_parse_tree(self, policy=policy)
  File "/Library/Frameworks/Python.framework/Versions/3.15/lib/python3.15/email/_header_value_parser.py", line 2834, in _refold_parse_tree
    _refold_without_ew(parse_tree, lines, maxlen, encoding, policy=policy)
    ~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Library/Frameworks/Python.framework/Versions/3.15/lib/python3.15/email/_header_value_parser.py", line 2854, in _refold_without_ew
    raise errors.HeaderWriteError(
    ...<2 lines>...
    )
email.errors.HeaderWriteError: Non-ASCII local-part 'nø' is invalid under current policy setting (utf8=False)

----------------------------------------------------------------------

Should we be catching email.errors.HeaderWriteError and re-raising in the code, or should we be adjusting the test for 3.15? And should we do anything about the if not force_ascii: branch for now? Appreciate your advice.

comment:6 by Jacob Walls, 8 days ago

On another topic, we have a failure in test_max_diff also due to the new pprint defaults. It's only a matter of doing this, because the output of assertDictEqual() is now more compact...

  • tests/test_utils/tests.py

    diff --git a/tests/test_utils/tests.py b/tests/test_utils/tests.py
    index e54b9ef624..18a35c018a 100644
    a b class AssertQuerySetEqualTests(TestCase):  
    353353        )
    354354
    355355    def test_maxdiff(self):
    356         names = ["Joe Smith %s" % i for i in range(20)]
     356        names = ["Joe Smith %s" % i for i in range(25)]
    357357        Person.objects.bulk_create([Person(name=name) for name in names])
    358358        names.append("Extra Person")

but they change might be reverted, so let's wait.

Version 0, edited 8 days ago by Jacob Walls (next)

in reply to:  5 comment:7 by Mike Edmunds, 7 days ago

Replying to Jacob Walls:

We have on the 3.15 builds:

ERROR: test_rejects_non_ascii_local_part (mail.test_backends.SMTPBackendTests.test_rejects_non_ascii_local_part)
The SMTP EmailBackend does not currently support non-ASCII local-parts.

[...]

Should we be catching email.errors.HeaderWriteError and re-raising in the code, or should we be adjusting the test for 3.15? And should we do anything about the if not force_ascii: branch for now? Appreciate your advice.

I'll open a PR to update the test. I think the right approach to catching unsupported non-ASCII email usernames is:

  • Let stdlib email report the problem when it's capable (i.e., 3.15+), as a HeaderWriteError: Non-ASCII local-part … is invalid under current policy. I know we try to avoid testing stdlib behavior, but given the upstream flux in this area we might want to ensure something useful gets reported to the user (without pinning our tests to the precise stdlib error).
  • Continue to raise (and test) our own ValueError in prep_address() if stdlib email would incorrectly encode the email as something undeliverable (i.e., before 3.15; there are currently no plans to backport the bugfix from 3.15 due to compatibility concerns).

It fails more informatively when I remove spec=object():

There's actually no need for that test to mock the smtplib connection in the first place; I'll remove it.

comment:8 by Mike Edmunds, 7 days ago

Just a heads up that the markers in tests/requirements/py3.txt should probably be sys_platform rather than sys.platform. Also, there's no numpy wheel available for 3.150b1 on macOS, and the build requirements seem complex, so I just disabled it like was already done for win32.

  • tests/requirements/py3.txt

    diff --git a/tests/requirements/py3.txt b/tests/requirements/py3.txt
    a b  
    66docutils >= 0.22
    77geoip2 >= 4.8.0
    88jinja2 >= 2.11.0
    9 numpy >= 1.26.0; sys.platform != 'win32' or python_version < '3.15'
    10 Pillow >= 10.1.0; sys.platform != 'win32' or python_version < '3.15'
     9numpy >= 1.26.0; sys_platform != 'win32' and sys_platform != 'darwin' or python_version < '3.15'
     10Pillow >= 10.1.0; sys_platform != 'win32' or python_version < '3.15'
    1111# pylibmc/libmemcached can't be built on Windows.
    1212pylibmc; sys_platform != 'win32'
    1313pymemcache >= 3.4.0

comment:9 by Mike Edmunds, 7 days ago

Has patch: set

Email test updates: https://github.com/django/django/pull/21284.

(Don't know whether I'm supposed to mark "has patch" for refs PRs, but figured it wouldn't hurt to put it in the review queue.)

comment:10 by Jacob Walls <jacobtylerwalls@…>, 6 days ago

In 25cf1cb:

Refs #36664 -- Updated SMTP EmailBackend tests for Python 3.15.

Versions of Python prior to 3.15 would incorrectly encode non-ASCII
email addresses using rfc2047, resulting in undeliverable email. The
SMTP EmailBackend detects and prevents that (#35713). Python 3.15 fixes
that behavior (CPython issue gh-122476).

Updated test_rejects_non_ascii_local_part() to feature-detect the fix
(in case it is backported) and check for a representative section of
the Python error message if so; otherwise test for the SMTP EmailBackend
workaround.

Updated comments to clarify need and requirement.

comment:11 by Jacob Walls, 6 days ago

Has patch: unset
Note: See TracTickets for help on using tickets.
Back to Top