From 998da0e2f6a41f8a90cd9ecaca04ecb13443741b Mon Sep 17 00:00:00 2001 From: Sergey Lavrinenko Date: Tue, 31 Mar 2026 02:37:54 +0300 Subject: [PATCH 1/4] Remove Python 2 compatibility code - Remove from __future__ imports from all files - Simplify emails/compat: remove Python 2 branch, keep only Python 3 code - Delete unused ordereddict.py and orderedset.py - Remove is_py2/is_py3/is_py26/is_py34_plus version checks - Remove Travis CI pypy workaround from test_loaders - All 74 tests pass on Python 3.14 --- emails/__init__.py | 1 - emails/backend/inmemory/__init__.py | 1 - emails/backend/smtp/backend.py | 1 - emails/backend/smtp/exceptions.py | 1 - emails/compat/__init__.py | 212 +++----------- emails/compat/ordereddict.py | 264 ------------------ emails/compat/orderedset.py | 70 ----- emails/django/__init__.py | 1 - emails/loader/helpers.py | 1 - emails/loader/local_store.py | 1 - emails/message.py | 1 - emails/signers.py | 1 - emails/store/file.py | 1 - emails/store/store.py | 1 - emails/template/base.py | 1 - emails/template/jinja_template.py | 1 - emails/template/mako_template.py | 1 - emails/testsuite/conftest.py | 1 - .../django_/test_django_integrations.py | 1 - emails/testsuite/loader/test_helpers.py | 1 - emails/testsuite/loader/test_loaders.py | 13 +- emails/testsuite/loader/test_rfc822_loader.py | 1 - emails/testsuite/message/helpers.py | 1 - emails/testsuite/message/test_dkim.py | 6 +- emails/testsuite/message/test_lazy_gettext.py | 1 - emails/testsuite/message/test_message.py | 11 +- emails/testsuite/message/test_send.py | 1 - emails/testsuite/message/test_template.py | 1 - emails/testsuite/smtp/test_factory.py | 1 - emails/testsuite/smtp/test_smtp_backend.py | 1 - emails/testsuite/store/test_store.py | 1 - emails/testsuite/test_templates.py | 1 - emails/testsuite/test_utils.py | 1 - emails/testsuite/transformer/test_parser.py | 1 - .../testsuite/transformer/test_transformer.py | 1 - emails/transformer.py | 1 - emails/utils.py | 13 +- 37 files changed, 53 insertions(+), 566 deletions(-) delete mode 100644 emails/compat/ordereddict.py delete mode 100644 emails/compat/orderedset.py diff --git a/emails/__init__.py b/emails/__init__.py index 6a94adc..b4f29e6 100644 --- a/emails/__init__.py +++ b/emails/__init__.py @@ -35,7 +35,6 @@ """ -from __future__ import unicode_literals __title__ = 'emails' __version__ = '0.6' diff --git a/emails/backend/inmemory/__init__.py b/emails/backend/inmemory/__init__.py index 6a39406..5f24697 100644 --- a/emails/backend/inmemory/__init__.py +++ b/emails/backend/inmemory/__init__.py @@ -1,5 +1,4 @@ # encoding: utf-8 -from __future__ import unicode_literals __all__ = ['InMemoryBackend', ] diff --git a/emails/backend/smtp/backend.py b/emails/backend/smtp/backend.py index 9fde8e8..e1cf989 100644 --- a/emails/backend/smtp/backend.py +++ b/emails/backend/smtp/backend.py @@ -1,5 +1,4 @@ # encoding: utf-8 -from __future__ import unicode_literals import smtplib import logging from functools import wraps diff --git a/emails/backend/smtp/exceptions.py b/emails/backend/smtp/exceptions.py index df19e93..a747aec 100644 --- a/emails/backend/smtp/exceptions.py +++ b/emails/backend/smtp/exceptions.py @@ -1,5 +1,4 @@ # encoding: utf-8 -from __future__ import unicode_literals import socket diff --git a/emails/compat/__init__.py b/emails/compat/__init__.py index d4a10f8..b73fd6a 100644 --- a/emails/compat/__init__.py +++ b/emails/compat/__init__.py @@ -1,177 +1,22 @@ # -*- coding: utf-8 -*- -from __future__ import unicode_literals -""" -pythoncompat -""" - -from .orderedset import OrderedSet - -#from . import _urlparse as urlparse - - import sys +import urllib.parse as urlparse +from collections import OrderedDict +from collections.abc import Callable +from io import StringIO, BytesIO -# ------- -# Pythons -# ------- - -# Syntax sugar. -_ver = sys.version_info - -#: Python 2.x? -is_py2 = (_ver[0] == 2) - -#: Python 3.x? -is_py3 = (_ver[0] == 3) - -#: Python 3.0.x -is_py30 = (is_py3 and _ver[1] == 0) - -#: Python 3.1.x -is_py31 = (is_py3 and _ver[1] == 1) - -#: Python 3.2.x -is_py32 = (is_py3 and _ver[1] == 2) - -#: Python 3.3.x -is_py33 = (is_py3 and _ver[1] == 3) - -#: Python 3.4.x -is_py34 = (is_py3 and _ver[1] == 4) -is_py34_plus = (is_py3 and _ver[1] >= 4) - -#: Python 2.7.x -is_py27 = (is_py2 and _ver[1] == 7) - -#: Python 2.6.x -is_py26 = (is_py2 and _ver[1] == 6) - -#: Python 2.5.x -is_py25 = (is_py2 and _ver[1] == 5) - -#: Python 2.4.x -is_py24 = (is_py2 and _ver[1] == 4) - - -# --------- -# Platforms -# --------- - - -# Syntax sugar. -_ver = sys.version.lower() - -is_pypy = ('pypy' in _ver) -is_jython = ('jython' in _ver) -is_ironpython = ('iron' in _ver) - -# Assume CPython, if nothing else. -is_cpython = not any((is_pypy, is_jython, is_ironpython)) - -# Windows-based system. -is_windows = 'win32' in str(sys.platform).lower() - -# Standard Linux 2+ system. -is_linux = ('linux' in str(sys.platform).lower()) -is_osx = ('darwin' in str(sys.platform).lower()) -is_hpux = ('hpux' in str(sys.platform).lower()) # Complete guess. -is_solaris = ('solar==' in str(sys.platform).lower()) # Complete guess. - -# --------- -# Specifics -# --------- +from email.utils import escapesre, specialsre -if is_py2: +NativeStringIO = StringIO - unichr = unichr - text_type = unicode - string_types = (str, unicode) - integer_types = (int, long) - int_to_byte = chr +string_types = (str, ) +text_type = str - import urlparse - from .ordereddict import OrderedDict - from StringIO import StringIO - from cStringIO import StringIO as BytesIO - NativeStringIO = BytesIO - - def to_native(x, charset=sys.getdefaultencoding(), errors='strict'): - if x is None or isinstance(x, str): - return x - return x.encode(charset, errors) - - def is_callable(x): - return callable(x) - - def to_bytes(x, charset=sys.getdefaultencoding(), errors='strict'): - if x is None: - return None - if isinstance(x, (bytes, bytearray, buffer)): - return bytes(x) - if isinstance(x, unicode): - return x.encode(charset, errors) - raise TypeError('Expected bytes') - - from email.utils import formataddr - - -elif is_py3: - import urllib.parse as urlparse - - try: - from collections.abc import Callable - except ImportError: - from collections import Callable - - from collections import OrderedDict - - from io import StringIO, BytesIO - NativeStringIO = StringIO - - unichr = chr - text_type = str - string_types = (str, ) - integer_types = (int, ) - - def to_native(x, charset=sys.getdefaultencoding(), errors='strict'): - if x is None or isinstance(x, str): - return x - return x.decode(charset, errors) - - def is_callable(x): - return isinstance(x, Callable) - - def to_bytes(x, charset=sys.getdefaultencoding(), errors='strict'): - if x is None: - return None - if isinstance(x, (bytes, bytearray, memoryview)): - return bytes(x) - if isinstance(x, str): - return x.encode(charset, errors) - raise TypeError('Expected bytes') - - from email.utils import escapesre, specialsre - - def formataddr(pair): - """ - This code is copy of python2 email.utils.formataddr. - Takes a 2-tuple of the form (realname, email_address) and returns RFC2822-like string. - Does not encode non-ascii realname. - - Python3 email.utils.formataddr do encode realname. - - TODO: switch to email.headerregistry.AddressHeader ? - """ - - name, address = pair - if name: - quotes = '' - if specialsre.search(name): - quotes = '"' - name = escapesre.sub(r'\\\g<0>', name) - return '%s%s%s <%s>' % (quotes, name, quotes, address) - return address +def to_native(x, charset=sys.getdefaultencoding(), errors='strict'): + if x is None or isinstance(x, str): + return x + return x.decode(charset, errors) def to_unicode(x, charset=sys.getdefaultencoding(), errors='strict', @@ -179,7 +24,36 @@ def to_unicode(x, charset=sys.getdefaultencoding(), errors='strict', if x is None: return None if not isinstance(x, bytes): - return text_type(x) + return str(x) if charset is None and allow_none_charset: return x - return x.decode(charset, errors) \ No newline at end of file + return x.decode(charset, errors) + + +def to_bytes(x, charset=sys.getdefaultencoding(), errors='strict'): + if x is None: + return None + if isinstance(x, (bytes, bytearray, memoryview)): + return bytes(x) + if isinstance(x, str): + return x.encode(charset, errors) + raise TypeError('Expected bytes') + + +def is_callable(x): + return isinstance(x, Callable) + + +def formataddr(pair): + """ + Takes a 2-tuple of the form (realname, email_address) and returns RFC2822-like string. + Does not encode non-ascii realname (unlike stdlib email.utils.formataddr). + """ + name, address = pair + if name: + quotes = '' + if specialsre.search(name): + quotes = '"' + name = escapesre.sub(r'\\\g<0>', name) + return '%s%s%s <%s>' % (quotes, name, quotes, address) + return address diff --git a/emails/compat/ordereddict.py b/emails/compat/ordereddict.py deleted file mode 100644 index eefdac6..0000000 --- a/emails/compat/ordereddict.py +++ /dev/null @@ -1,264 +0,0 @@ -# encoding: utf-8 -from __future__ import unicode_literals -__all__ = ['OrderedDict', ] - -# Backport of OrderedDict() class that runs on Python 2.4, 2.5, 2.6, 2.7 and pypy. -# Passes Python2.7's test suite and incorporates all the latest updates. -# Copyright 2009 Raymond Hettinger, released under the MIT License. -# http://code.activestate.com/recipes/576693/ - -try: - from thread import get_ident as _get_ident -except ImportError: - from dummy_thread import get_ident as _get_ident - -try: - from _abcoll import KeysView, ValuesView, ItemsView -except ImportError: - pass - - -class OrderedDict(dict): - 'Dictionary that remembers insertion order' - # An inherited dict maps keys to values. - # The inherited dict provides __getitem__, __len__, __contains__, and get. - # The remaining methods are order-aware. - # Big-O running times for all methods are the same as for regular dictionaries. - - # The internal self.__map dictionary maps keys to links in a doubly linked list. - # The circular doubly linked list starts and ends with a sentinel element. - # The sentinel element never gets deleted (this simplifies the algorithm). - # Each link is stored as a list of length three: [PREV, NEXT, KEY]. - - def __init__(self, *args, **kwds): - '''Initialize an ordered dictionary. Signature is the same as for - regular dictionaries, but keyword arguments are not recommended - because their insertion order is arbitrary. - - ''' - if len(args) > 1: - raise TypeError('expected at most 1 arguments, got %d' % len(args)) - try: - self.__root - except AttributeError: - self.__root = root = [] # sentinel node - root[:] = [root, root, None] - self.__map = {} - self.__update(*args, **kwds) - - def __setitem__(self, key, value, dict_setitem=dict.__setitem__): - 'od.__setitem__(i, y) <==> od[i]=y' - # Setting a new item creates a new link which goes at the end of the linked - # list, and the inherited dictionary is updated with the new key/value pair. - if key not in self: - root = self.__root - last = root[0] - last[1] = root[0] = self.__map[key] = [last, root, key] - dict_setitem(self, key, value) - - def __delitem__(self, key, dict_delitem=dict.__delitem__): - 'od.__delitem__(y) <==> del od[y]' - # Deleting an existing item uses self.__map to find the link which is - # then removed by updating the links in the predecessor and successor nodes. - dict_delitem(self, key) - link_prev, link_next, key = self.__map.pop(key) - link_prev[1] = link_next - link_next[0] = link_prev - - def __iter__(self): - 'od.__iter__() <==> iter(od)' - root = self.__root - curr = root[1] - while curr is not root: - yield curr[2] - curr = curr[1] - - def __reversed__(self): - 'od.__reversed__() <==> reversed(od)' - root = self.__root - curr = root[0] - while curr is not root: - yield curr[2] - curr = curr[0] - - def clear(self): - 'od.clear() -> None. Remove all items from od.' - try: - for node in self.__map.itervalues(): - del node[:] - root = self.__root - root[:] = [root, root, None] - self.__map.clear() - except AttributeError: - pass - dict.clear(self) - - def popitem(self, last=True): - '''od.popitem() -> (k, v), return and remove a (key, value) pair. - Pairs are returned in LIFO order if last is true or FIFO order if false. - - ''' - if not self: - raise KeyError('dictionary is empty') - root = self.__root - if last: - link = root[0] - link_prev = link[0] - link_prev[1] = root - root[0] = link_prev - else: - link = root[1] - link_next = link[1] - root[1] = link_next - link_next[0] = root - key = link[2] - del self.__map[key] - value = dict.pop(self, key) - return key, value - - # -- the following methods do not depend on the internal structure -- - - def keys(self): - 'od.keys() -> list of keys in od' - return list(self) - - def values(self): - 'od.values() -> list of values in od' - return [self[key] for key in self] - - def items(self): - 'od.items() -> list of (key, value) pairs in od' - return [(key, self[key]) for key in self] - - def iterkeys(self): - 'od.iterkeys() -> an iterator over the keys in od' - return iter(self) - - def itervalues(self): - 'od.itervalues -> an iterator over the values in od' - for k in self: - yield self[k] - - def iteritems(self): - 'od.iteritems -> an iterator over the (key, value) items in od' - for k in self: - yield (k, self[k]) - - def update(*args, **kwds): - '''od.update(E, **F) -> None. Update od from dict/iterable E and F. - - If E is a dict instance, does: for k in E: od[k] = E[k] - If E has a .keys() method, does: for k in E.keys(): od[k] = E[k] - Or if E is an iterable of items, does: for k, v in E: od[k] = v - In either case, this is followed by: for k, v in F.items(): od[k] = v - - ''' - if len(args) > 2: - raise TypeError('update() takes at most 2 positional ' - 'arguments (%d given)' % (len(args),)) - elif not args: - raise TypeError('update() takes at least 1 argument (0 given)') - self = args[0] - # Make progressively weaker assumptions about "other" - other = () - if len(args) == 2: - other = args[1] - if isinstance(other, dict): - for key in other: - self[key] = other[key] - elif hasattr(other, 'keys'): - for key in other.keys(): - self[key] = other[key] - else: - for key, value in other: - self[key] = value - for key, value in kwds.items(): - self[key] = value - - __update = update # let subclasses override update without breaking __init__ - - __marker = object() - - def pop(self, key, default=__marker): - '''od.pop(k[,d]) -> v, remove specified key and return the corresponding value. - If key is not found, d is returned if given, otherwise KeyError is raised. - - ''' - if key in self: - result = self[key] - del self[key] - return result - if default is self.__marker: - raise KeyError(key) - return default - - def setdefault(self, key, default=None): - 'od.setdefault(k[,d]) -> od.get(k,d), also set od[k]=d if k not in od' - if key in self: - return self[key] - self[key] = default - return default - - def __repr__(self, _repr_running={}): - 'od.__repr__() <==> repr(od)' - call_key = id(self), _get_ident() - if call_key in _repr_running: - return '...' - _repr_running[call_key] = 1 - try: - if not self: - return '%s()' % (self.__class__.__name__,) - return '%s(%r)' % (self.__class__.__name__, self.items()) - finally: - del _repr_running[call_key] - - def __reduce__(self): - 'Return state information for pickling' - items = [[k, self[k]] for k in self] - inst_dict = vars(self).copy() - for k in vars(OrderedDict()): - inst_dict.pop(k, None) - if inst_dict: - return (self.__class__, (items,), inst_dict) - return self.__class__, (items,) - - def copy(self): - 'od.copy() -> a shallow copy of od' - return self.__class__(self) - - @classmethod - def fromkeys(cls, iterable, value=None): - '''OD.fromkeys(S[, v]) -> New ordered dictionary with keys from S - and values equal to v (which defaults to None). - - ''' - d = cls() - for key in iterable: - d[key] = value - return d - - def __eq__(self, other): - '''od.__eq__(y) <==> od==y. Comparison to another OD is order-sensitive - while comparison to a regular mapping is order-insensitive. - - ''' - if isinstance(other, OrderedDict): - return len(self)==len(other) and self.items() == other.items() - return dict.__eq__(self, other) - - def __ne__(self, other): - return not self == other - - # -- the following methods are only used in Python 2.7 -- - - def viewkeys(self): - "od.viewkeys() -> a set-like object providing a view on od's keys" - return KeysView(self) - - def viewvalues(self): - "od.viewvalues() -> an object providing a view on od's values" - return ValuesView(self) - - def viewitems(self): - "od.viewitems() -> a set-like object providing a view on od's items" - return ItemsView(self) \ No newline at end of file diff --git a/emails/compat/orderedset.py b/emails/compat/orderedset.py deleted file mode 100644 index e75dc23..0000000 --- a/emails/compat/orderedset.py +++ /dev/null @@ -1,70 +0,0 @@ -# encoding: utf-8 -from __future__ import unicode_literals -__all__ = ['OrderedSet', ] - -try: - from collections.abc import MutableSet -except ImportError: - from collections import MutableSet - - -# http://code.activestate.com/recipes/576694/ - - -class OrderedSet(MutableSet): - - def __init__(self, iterable=None): - self.end = end = [] - end += [None, end, end] # sentinel node for doubly linked list - self.map = {} # key --> [key, prev, next] - if iterable is not None: - self |= iterable - - def __len__(self): - return len(self.map) - - def __contains__(self, key): - return key in self.map - - def add(self, key): - if key not in self.map: - end = self.end - curr = end[1] - curr[2] = end[1] = self.map[key] = [key, curr, end] - - def discard(self, key): - if key in self.map: - key, prev, next = self.map.pop(key) - prev[2] = next - next[1] = prev - - def __iter__(self): - end = self.end - curr = end[2] - while curr is not end: - yield curr[0] - curr = curr[2] - - def __reversed__(self): - end = self.end - curr = end[1] - while curr is not end: - yield curr[0] - curr = curr[1] - - def pop(self, last=True): - if not self: - raise KeyError('set is empty') - key = self.end[1][0] if last else self.end[2][0] - self.discard(key) - return key - - def __repr__(self): - if not self: - return '%s()' % (self.__class__.__name__,) - return '%s(%r)' % (self.__class__.__name__, list(self)) - - def __eq__(self, other): - if isinstance(other, OrderedSet): - return len(self) == len(other) and list(self) == list(other) - return set(self) == set(other) diff --git a/emails/django/__init__.py b/emails/django/__init__.py index 78832df..1594643 100644 --- a/emails/django/__init__.py +++ b/emails/django/__init__.py @@ -1,5 +1,4 @@ # encoding: utf-8 -from __future__ import absolute_import from django.core.mail import get_connection from .. message import MessageTransformerMixin, MessageSignMixin, MessageBuildMixin, BaseMessage from .. utils import sanitize_email diff --git a/emails/loader/helpers.py b/emails/loader/helpers.py index 73578ec..4712617 100644 --- a/emails/loader/helpers.py +++ b/emails/loader/helpers.py @@ -1,5 +1,4 @@ # encoding: utf-8 -from __future__ import unicode_literals __all__ = ['guess_charset', 'fix_content_type'] from email.message import Message diff --git a/emails/loader/local_store.py b/emails/loader/local_store.py index 1d1cbeb..da9552d 100644 --- a/emails/loader/local_store.py +++ b/emails/loader/local_store.py @@ -1,5 +1,4 @@ # encoding: utf-8 -from __future__ import unicode_literals from email.utils import parseaddr import logging import mimetypes diff --git a/emails/message.py b/emails/message.py index 341a70f..4de31ec 100644 --- a/emails/message.py +++ b/emails/message.py @@ -1,5 +1,4 @@ # coding: utf-8 -from __future__ import unicode_literals from email.utils import getaddresses diff --git a/emails/signers.py b/emails/signers.py index e261bed..5ebf689 100644 --- a/emails/signers.py +++ b/emails/signers.py @@ -5,7 +5,6 @@ # - use dkimpy v0.3 from http://hewgill.com/pydkim/ # - install hashlib (https://pypi.python.org/pypi/hashlib/20081119) and dnspython -from __future__ import unicode_literals import logging from .packages import dkim diff --git a/emails/store/file.py b/emails/store/file.py index 4a9d26a..9b0e469 100644 --- a/emails/store/file.py +++ b/emails/store/file.py @@ -1,5 +1,4 @@ # encoding: utf-8 -from __future__ import unicode_literals import uuid from mimetypes import guess_type diff --git a/emails/store/store.py b/emails/store/store.py index d43b04c..1102f61 100644 --- a/emails/store/store.py +++ b/emails/store/store.py @@ -1,5 +1,4 @@ # encoding: utf-8 -from __future__ import unicode_literals from os.path import splitext from ..compat import OrderedDict, string_types diff --git a/emails/template/base.py b/emails/template/base.py index b80d57d..7b9976c 100644 --- a/emails/template/base.py +++ b/emails/template/base.py @@ -1,5 +1,4 @@ # encoding: utf-8 -from __future__ import unicode_literals import string diff --git a/emails/template/jinja_template.py b/emails/template/jinja_template.py index 8f92f37..8a31d88 100644 --- a/emails/template/jinja_template.py +++ b/emails/template/jinja_template.py @@ -1,5 +1,4 @@ # encoding: utf-8 -from __future__ import unicode_literals from .base import BaseTemplate diff --git a/emails/template/mako_template.py b/emails/template/mako_template.py index f2f497d..c8b7208 100644 --- a/emails/template/mako_template.py +++ b/emails/template/mako_template.py @@ -1,5 +1,4 @@ # encoding: utf-8 -from __future__ import unicode_literals from .base import BaseTemplate diff --git a/emails/testsuite/conftest.py b/emails/testsuite/conftest.py index 60c93c8..3fb2949 100644 --- a/emails/testsuite/conftest.py +++ b/emails/testsuite/conftest.py @@ -8,7 +8,6 @@ import sys import platform -from emails.compat import to_native, is_py3, to_unicode logging.basicConfig(level=logging.DEBUG) logger = logging.getLogger() diff --git a/emails/testsuite/django_/test_django_integrations.py b/emails/testsuite/django_/test_django_integrations.py index 8ec8031..f8e43bb 100644 --- a/emails/testsuite/django_/test_django_integrations.py +++ b/emails/testsuite/django_/test_django_integrations.py @@ -1,5 +1,4 @@ # encoding: utf-8 -from __future__ import unicode_literals import warnings import emails import emails.message diff --git a/emails/testsuite/loader/test_helpers.py b/emails/testsuite/loader/test_helpers.py index 9c7eea7..ab24b2a 100644 --- a/emails/testsuite/loader/test_helpers.py +++ b/emails/testsuite/loader/test_helpers.py @@ -1,5 +1,4 @@ # encoding: utf-8 -from __future__ import unicode_literals, print_function import logging; import cssutils; cssutils.log.setLevel(logging.FATAL) diff --git a/emails/testsuite/loader/test_loaders.py b/emails/testsuite/loader/test_loaders.py index 1f2dbdf..958574a 100644 --- a/emails/testsuite/loader/test_loaders.py +++ b/emails/testsuite/loader/test_loaders.py @@ -1,5 +1,4 @@ # encoding: utf-8 -from __future__ import unicode_literals, print_function import os from lxml.etree import XMLSyntaxError import pytest @@ -10,7 +9,6 @@ import emails.transformer from emails.loader.local_store import (MsgLoader, FileSystemLoader, FileNotFound, ZipLoader, split_template_path, BaseLoader) -from emails.compat import text_type, is_pypy from emails.loader.helpers import guess_charset from emails.exc import HTTPLoaderError @@ -178,14 +176,7 @@ def test_external_urls(): # Skip if external site does responds 500 pass except SystemError: - if is_pypy and os.environ.get('TRAVIS'): - # pypy on travis-ci raises SystemError/StackOverflow - # in lxml xpath expression for [very complex] smashingmagazine.com html - # Think this is not critical. - # And I can't reproduce this locally, so just ignore it. - pass - else: - raise + raise assert success # one of urls should work I hope @@ -199,7 +190,7 @@ def _get_loaders(): def test_local_store1(): for loader in _get_loaders(): print(loader) - assert isinstance(loader.content('index.html'), text_type) + assert isinstance(loader.content('index.html'), str) assert isinstance(loader['index.html'], bytes) assert ' Date: Tue, 31 Mar 2026 02:45:28 +0300 Subject: [PATCH 2/4] Remove emails.compat module entirely Move to_unicode, to_native, to_bytes, formataddr to emails.utils. Replace all compat aliases with direct stdlib imports: - string_types -> str - text_type -> str - is_callable -> callable() - urlparse -> urllib.parse - NativeStringIO/StringIO/BytesIO -> io - OrderedDict -> collections Delete emails/compat package and remove from setup.py. All 74 tests pass. --- emails/compat/__init__.py | 59 ------------------ emails/compat/_urlparse.py | 6 -- emails/loader/__init__.py | 5 +- emails/loader/helpers.py | 2 +- emails/loader/local_store.py | 10 ++-- emails/message.py | 14 ++--- emails/signers.py | 2 +- emails/store/file.py | 7 ++- emails/store/store.py | 7 ++- emails/testsuite/loader/test_rfc822_loader.py | 2 +- emails/testsuite/message/test_dkim.py | 8 ++- emails/testsuite/message/test_message.py | 4 +- emails/testsuite/smtp_servers.py | 2 +- emails/transformer.py | 6 +- emails/utils.py | 60 ++++++++++++++++--- setup.py | 1 - 16 files changed, 89 insertions(+), 106 deletions(-) delete mode 100644 emails/compat/__init__.py delete mode 100644 emails/compat/_urlparse.py diff --git a/emails/compat/__init__.py b/emails/compat/__init__.py deleted file mode 100644 index b73fd6a..0000000 --- a/emails/compat/__init__.py +++ /dev/null @@ -1,59 +0,0 @@ -# -*- coding: utf-8 -*- -import sys -import urllib.parse as urlparse -from collections import OrderedDict -from collections.abc import Callable -from io import StringIO, BytesIO - -from email.utils import escapesre, specialsre - -NativeStringIO = StringIO - -string_types = (str, ) -text_type = str - - -def to_native(x, charset=sys.getdefaultencoding(), errors='strict'): - if x is None or isinstance(x, str): - return x - return x.decode(charset, errors) - - -def to_unicode(x, charset=sys.getdefaultencoding(), errors='strict', - allow_none_charset=False): - if x is None: - return None - if not isinstance(x, bytes): - return str(x) - if charset is None and allow_none_charset: - return x - return x.decode(charset, errors) - - -def to_bytes(x, charset=sys.getdefaultencoding(), errors='strict'): - if x is None: - return None - if isinstance(x, (bytes, bytearray, memoryview)): - return bytes(x) - if isinstance(x, str): - return x.encode(charset, errors) - raise TypeError('Expected bytes') - - -def is_callable(x): - return isinstance(x, Callable) - - -def formataddr(pair): - """ - Takes a 2-tuple of the form (realname, email_address) and returns RFC2822-like string. - Does not encode non-ascii realname (unlike stdlib email.utils.formataddr). - """ - name, address = pair - if name: - quotes = '' - if specialsre.search(name): - quotes = '"' - name = escapesre.sub(r'\\\g<0>', name) - return '%s%s%s <%s>' % (quotes, name, quotes, address) - return address diff --git a/emails/compat/_urlparse.py b/emails/compat/_urlparse.py deleted file mode 100644 index 378fab6..0000000 --- a/emails/compat/_urlparse.py +++ /dev/null @@ -1,6 +0,0 @@ -# encoding: utf-8 - -try: - from urlparse import * -except ImportError: - from urllib.parse import * diff --git a/emails/loader/__init__.py b/emails/loader/__init__.py index 3e2767d..eb8ca3a 100644 --- a/emails/loader/__init__.py +++ b/emails/loader/__init__.py @@ -2,8 +2,9 @@ import os.path from email.utils import formataddr -from ..compat import to_unicode, to_native -from ..compat import urlparse +import urllib.parse as urlparse + +from ..utils import to_unicode, to_native from ..message import Message from ..utils import fetch_url from .local_store import (FileSystemLoader, ZipLoader, MsgLoader, FileNotFound) diff --git a/emails/loader/helpers.py b/emails/loader/helpers.py index 4712617..7147417 100644 --- a/emails/loader/helpers.py +++ b/emails/loader/helpers.py @@ -13,7 +13,7 @@ except ImportError: import chardet -from ..compat import to_native, to_unicode +from ..utils import to_native, to_unicode # HTML page charset stuff diff --git a/emails/loader/local_store.py b/emails/loader/local_store.py index da9552d..646f783 100644 --- a/emails/loader/local_store.py +++ b/emails/loader/local_store.py @@ -9,11 +9,9 @@ from zipfile import ZipFile import email -from ..compat import to_unicode, string_types, to_native, formataddr as compat_formataddr - +from ..utils import to_unicode, to_native, formataddr, decode_header from ..loader.helpers import decode_text from ..message import Message -from ..utils import decode_header class FileNotFound(Exception): pass @@ -127,7 +125,7 @@ class FileSystemLoader(BaseLoader): """ def __init__(self, searchpath, encoding='utf-8', base_path=None): - if isinstance(searchpath, string_types): + if isinstance(searchpath, str): searchpath = [searchpath] self.searchpath = list(searchpath) self.encoding = encoding @@ -229,7 +227,7 @@ class MsgLoader(BaseLoader): common_charsets = ['ascii', 'utf-8', 'utf-16', 'windows-1252', 'cp850', 'windows-1251'] def __init__(self, msg, base_path=None): - if isinstance(msg, string_types): + if isinstance(msg, str): self.msg = email.message_from_string(msg) elif isinstance(msg, bytes): self.msg = email.message_from_string(to_native(msg)) @@ -366,7 +364,7 @@ def decode_address_header_value(self, value, skip_invalid=False): if not skip_invalid: r.append(decode_header(email)) else: - r.append(compat_formataddr([decode_header(name), email])) + r.append(formataddr([decode_header(name), email])) return r diff --git a/emails/message.py b/emails/message.py index 4de31ec..ab0ae1c 100644 --- a/emails/message.py +++ b/emails/message.py @@ -2,8 +2,8 @@ from email.utils import getaddresses -from .compat import (string_types, is_callable, formataddr as compat_formataddr, to_unicode, to_native) -from .utils import (SafeMIMEText, SafeMIMEMultipart, sanitize_address, +from .utils import (formataddr, to_unicode, to_native, + SafeMIMEText, SafeMIMEMultipart, sanitize_address, parse_name_and_email, load_email_charsets, encode_header as encode_header_, renderable, format_date_header, parse_name_and_email_list, @@ -156,9 +156,9 @@ def get_date(self): v = self._date if v is False: return None - if is_callable(v): + if callable(v): v = v() - if not isinstance(v, string_types): + if not isinstance(v, str): v = format_date_header(v) return v @@ -170,7 +170,7 @@ def message_id(self): mid = self._message_id if mid is False: return None - return is_callable(mid) and mid() or mid + return callable(mid) and mid() or mid @message_id.setter def message_id(self, value): @@ -210,7 +210,7 @@ def encode_address_header(self, pair): if not pair: return None name, email = pair - return compat_formataddr((name or '', email)) + return formataddr((name or '', email)) encode_name_header = encode_address_header # legacy name @@ -220,7 +220,7 @@ def set_header(self, msg, key, value, encode=True): # TODO: may be remove header here ? return - if not isinstance(value, string_types): + if not isinstance(value, str): value = to_unicode(value) # Prevent header injection diff --git a/emails/signers.py b/emails/signers.py index 5ebf689..4ab52d2 100644 --- a/emails/signers.py +++ b/emails/signers.py @@ -10,7 +10,7 @@ from .packages import dkim from .packages.dkim import DKIMException, UnparsableKeyError from .packages.dkim.crypto import parse_pem_private_key -from .compat import to_bytes, to_native +from .utils import to_bytes, to_native class DKIMSigner: diff --git a/emails/store/file.py b/emails/store/file.py index 9b0e469..080ffbb 100644 --- a/emails/store/file.py +++ b/emails/store/file.py @@ -6,8 +6,9 @@ from email.encoders import encode_base64 from os.path import basename -from ..compat import urlparse, string_types, to_bytes -from ..utils import fetch_url, encode_header +import urllib.parse as urlparse + +from ..utils import fetch_url, encode_header, to_bytes MIMETYPE_UNKNOWN = 'application/unknown' @@ -50,7 +51,7 @@ def as_dict(self, fields=None): def get_data(self): _data = getattr(self, '_data', None) - if isinstance(_data, string_types): + if isinstance(_data, str): return _data elif hasattr(_data, 'read'): return _data.read() diff --git a/emails/store/store.py b/emails/store/store.py index 1102f61..60fd63a 100644 --- a/emails/store/store.py +++ b/emails/store/store.py @@ -1,7 +1,8 @@ # encoding: utf-8 from os.path import splitext -from ..compat import OrderedDict, string_types +from collections import OrderedDict + from .file import BaseFile @@ -22,7 +23,7 @@ def __init__(self, file_cls=None): def __contains__(self, k): if isinstance(k, self.file_cls): return k.uri in self._files - elif isinstance(k, string_types): + elif isinstance(k, str): return k in self._files else: return False @@ -41,7 +42,7 @@ def remove(self, uri): if isinstance(uri, self.file_cls): uri = uri.uri - assert isinstance(uri, string_types) + assert isinstance(uri, str) v = self[uri] if v: diff --git a/emails/testsuite/loader/test_rfc822_loader.py b/emails/testsuite/loader/test_rfc822_loader.py index 75b2814..5f5cb8b 100644 --- a/emails/testsuite/loader/test_rfc822_loader.py +++ b/emails/testsuite/loader/test_rfc822_loader.py @@ -3,7 +3,7 @@ import email import datetime import os.path -from emails.compat import to_native +from emails.utils import to_native import emails.loader from emails.loader.local_store import MsgLoader diff --git a/emails/testsuite/message/test_dkim.py b/emails/testsuite/message/test_dkim.py index 26b2f49..4c9eafd 100644 --- a/emails/testsuite/message/test_dkim.py +++ b/emails/testsuite/message/test_dkim.py @@ -4,7 +4,9 @@ import pytest import emails from emails import Message -from emails.compat import NativeStringIO, to_bytes, to_native +from io import StringIO + +from emails.utils import to_bytes, to_native from emails.exc import DKIMException from emails.utils import load_email_charsets import emails.packages.dkim @@ -56,7 +58,7 @@ def test_dkim(): priv_key, pub_key = _generate_key(length=1024) - DKIM_PARAMS = [dict(key=NativeStringIO(to_native(priv_key)), + DKIM_PARAMS = [dict(key=StringIO(to_native(priv_key)), selector='_dkim', domain='somewhere1.net'), @@ -139,7 +141,7 @@ def test_dkim_sign_twice(): priv_key, pub_key = _generate_key(length=1024) message = Message(**common_email_data()) - message.dkim(key=NativeStringIO(to_native(priv_key)), selector='_dkim', domain='somewhere.net') + message.dkim(key=StringIO(to_native(priv_key)), selector='_dkim', domain='somewhere.net') for n in range(2): message.subject = 'Test %s' % n assert _check_dkim(message, pub_key) diff --git a/emails/testsuite/message/test_message.py b/emails/testsuite/message/test_message.py index e72573d..c02626d 100644 --- a/emails/testsuite/message/test_message.py +++ b/emails/testsuite/message/test_message.py @@ -7,7 +7,9 @@ import emails from emails import Message import emails.exc -from emails.compat import to_unicode, StringIO +from io import StringIO + +from emails.utils import to_unicode from emails.utils import decode_header, MessageID from emails.backend.inmemory import InMemoryBackend diff --git a/emails/testsuite/smtp_servers.py b/emails/testsuite/smtp_servers.py index 4b0afd2..56c09d4 100644 --- a/emails/testsuite/smtp_servers.py +++ b/emails/testsuite/smtp_servers.py @@ -4,7 +4,7 @@ import datetime import random import time -from emails.compat import to_unicode +from emails.utils import to_unicode DEFAULT_FROM = os.environ.get('SMTP_TEST_FROM_EMAIL') or 'python-emails@lavr.me' SUBJECT_SUFFIX = os.environ.get('SMTP_TEST_SUBJECT_SUFFIX') diff --git a/emails/transformer.py b/emails/transformer.py index f3adb69..f586be6 100644 --- a/emails/transformer.py +++ b/emails/transformer.py @@ -11,7 +11,9 @@ from premailer import Premailer from premailer.premailer import ExternalNotFoundError -from .compat import urlparse, to_unicode, is_callable +import urllib.parse as urlparse + +from .utils import to_unicode from .loader.local_store import FileNotFound from .store import MemoryFileStore, LazyHTTPFile from .template.base import BaseTemplate @@ -310,7 +312,7 @@ def load_and_transform(self, # 2. Load linked images and transform links # If load_images is a function, use if as callback if load_images: - if is_callable(load_images): + if callable(load_images): func = functools.partial(self._load_attachment_func, callback=load_images) else: func = self._load_attachment_func diff --git a/emails/utils.py b/emails/utils.py index 2e38050..61d895c 100644 --- a/emails/utils.py +++ b/emails/utils.py @@ -1,26 +1,68 @@ # encoding: utf-8 +import sys import os import socket from time import mktime from datetime import datetime from random import randrange from functools import wraps +from io import StringIO, BytesIO import email.charset from email import generator from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart from email.header import Header, decode_header as decode_header_ -from email.utils import parseaddr, formatdate -from emails.compat import formataddr +from email.utils import parseaddr, formatdate, escapesre, specialsre import requests from . import USER_AGENT -from .compat import string_types, to_unicode, NativeStringIO, BytesIO, to_native from .exc import HTTPLoaderError +def to_native(x, charset=sys.getdefaultencoding(), errors='strict'): + if x is None or isinstance(x, str): + return x + return x.decode(charset, errors) + + +def to_unicode(x, charset=sys.getdefaultencoding(), errors='strict', + allow_none_charset=False): + if x is None: + return None + if not isinstance(x, bytes): + return str(x) + if charset is None and allow_none_charset: + return x + return x.decode(charset, errors) + + +def to_bytes(x, charset=sys.getdefaultencoding(), errors='strict'): + if x is None: + return None + if isinstance(x, (bytes, bytearray, memoryview)): + return bytes(x) + if isinstance(x, str): + return x.encode(charset, errors) + raise TypeError('Expected bytes') + + +def formataddr(pair): + """ + Takes a 2-tuple of the form (realname, email_address) and returns RFC2822-like string. + Does not encode non-ascii realname (unlike stdlib email.utils.formataddr). + """ + name, address = pair + if name: + quotes = '' + if specialsre.search(name): + quotes = '"' + name = escapesre.sub(r'\\\g<0>', name) + return '%s%s%s <%s>' % (quotes, name, quotes, address) + return address + + _charsets_loaded = False CHARSETS_FIX = [ @@ -119,7 +161,7 @@ def parse_name_and_email_list(elements, encoding='utf-8'): if not elements: return [] - if isinstance(elements, string_types): + if isinstance(elements, str): return [parse_name_and_email(elements, encoding), ] if not isinstance(elements, (list, tuple)): @@ -130,7 +172,7 @@ def parse_name_and_email_list(elements, encoding='utf-8'): # Let's do some guesses if isinstance(elements, tuple): n, e = elements - if isinstance(e, string_types) and (not n or isinstance(n, string_types)): + if isinstance(e, str) and (not n or isinstance(n, str)): # It is probably a pair (name, email) return [parse_name_and_email(elements, encoding), ] @@ -146,7 +188,7 @@ def parse_name_and_email(obj, encoding='utf-8'): name, email = obj else: raise ValueError("Can not parse_name_and_email from %s" % obj) - elif isinstance(obj, string_types): + elif isinstance(obj, str): name, email = parseaddr(obj) else: raise ValueError("Can not parse_name_and_email from %s" % obj) @@ -171,7 +213,7 @@ def sanitize_email(addr, encoding='ascii', parse=False): def sanitize_address(addr, encoding='ascii'): - if isinstance(addr, string_types): + if isinstance(addr, str): addr = parseaddr(to_unicode(addr)) nm, addr = addr # This try-except clause is needed on Python 3 < 3.2.4 @@ -191,7 +233,7 @@ def as_string(self, unixfrom=False, linesep='\n'): This overrides the default as_string() implementation to not mangle lines that begin with 'From '. See bug #13433 for details. """ - fp = NativeStringIO() + fp = StringIO() g = generator.Generator(fp, mangle_from_=False) g.flatten(self, unixfrom=unixfrom, linesep=linesep) @@ -238,7 +280,7 @@ def fetch_url(url, valid_http_codes=(200, ), requests_args=None): def encode_header(value, charset='utf-8'): - if isinstance(value, string_types): + if isinstance(value, str): value = to_unicode(value, charset=charset).rstrip() _r = Header(value, charset) return str(_r) diff --git a/setup.py b/setup.py index 442de22..216c982 100644 --- a/setup.py +++ b/setup.py @@ -116,7 +116,6 @@ def find_version(*file_paths): author_email='s@lavr.me', url='https://github.com/lavr/python-emails', packages=['emails', - 'emails.compat', 'emails.django', 'emails.loader', 'emails.store', From b792313185ed9e8e01e542cf25c6ee287ce4e20b Mon Sep 17 00:00:00 2001 From: Sergey Lavrinenko Date: Tue, 31 Mar 2026 03:04:14 +0300 Subject: [PATCH 3/4] Fix test_message_policy: move test body out of gen_policy() The policy test code was accidentally indented inside gen_policy() after the return statement, making it unreachable. --- emails/testsuite/message/test_message.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/emails/testsuite/message/test_message.py b/emails/testsuite/message/test_message.py index c02626d..7f7f830 100644 --- a/emails/testsuite/message/test_message.py +++ b/emails/testsuite/message/test_message.py @@ -161,18 +161,18 @@ def gen_policy(**kw): import email.policy return email.policy.SMTP.clone(**kw) - # Generate without policy - m1 = emails.Message(**common_email_data()) - m1.policy = None - # Just generate without policy - m1.as_string() - - # Generate with policy - m1 = emails.Message(**common_email_data()) - m1.policy = gen_policy(max_line_length=60) - # WTF: This check fails. - # assert max([len(l) for l in m1.as_string().split(b'\n')]) <= 60 - # TODO: another policy checks + # Generate without policy + m1 = emails.Message(**common_email_data()) + m1.policy = None + # Just generate without policy + m1.as_string() + + # Generate with policy + m1 = emails.Message(**common_email_data()) + m1.policy = gen_policy(max_line_length=60) + # WTF: This check fails. + # assert max([len(l) for l in m1.as_string().split(b'\n')]) <= 60 + # TODO: another policy checks def test_message_id(): From 13908c98fd17a3e2ca885bca1c4a38634448438b Mon Sep 17 00:00:00 2001 From: Sergey Lavrinenko Date: Tue, 31 Mar 2026 03:09:34 +0300 Subject: [PATCH 4/4] Exclude testsuite from coverage reporting --- setup.cfg | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/setup.cfg b/setup.cfg index 73c0a4a..741e83c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -5,3 +5,9 @@ universal=1 norecursedirs = .* {arch} *.egg *.egg-info dist build requirements markers = e2e: tests that require a running SMTP server + +[coverage:run] +omit = emails/testsuite/* + +[coverage:report] +omit = emails/testsuite/*