Skip to content

Commit 3ce7a12

Browse files
gh-152997: Support system locale encodings via an iconv-based codec (GH-153001)
Where the C library provides iconv(), the codecs module now exposes every encoding iconv() knows but Python has no built-in codec for -- the POSIX counterpart of the Windows code-page support. Use it by name (e.g. "cp1133"), or with an "iconv:" prefix to force it over a built-in codec. The codec is a last-resort search function and never shadows a built-in. Both directions pivot through native-endian UTF-32, keeping one input unit per code point so error handlers get the exact string position. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 33678dc commit 3ce7a12

18 files changed

Lines changed: 1027 additions & 1 deletion

Doc/library/codecs.rst

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1087,6 +1087,18 @@ On Windows, ``cpXXX`` codecs are available for all code pages.
10871087
But only codecs listed in the following table are guaranteed to exist on
10881088
other platforms.
10891089

1090+
On platforms that provide the C library's :manpage:`iconv(3)` function
1091+
(such as those using the GNU C Library),
1092+
every encoding known to ``iconv`` for which Python has no built-in codec
1093+
is available as well.
1094+
Such an encoding is looked up by its ``iconv`` name (for example ``cp1133``).
1095+
Prefixing the name with ``iconv:`` forces the use of the ``iconv``-based codec
1096+
even when a built-in codec of the same name exists (for example ``iconv:latin1``),
1097+
which is mostly useful for testing.
1098+
1099+
.. versionchanged:: next
1100+
Added support for encodings provided by the C library's ``iconv``.
1101+
10901102
.. impl-detail::
10911103

10921104
Some common encodings can bypass the codecs lookup machinery to

Doc/whatsnew/3.16.rst

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,16 @@ New modules
8686
Improved modules
8787
================
8888

89+
codecs
90+
------
91+
92+
* On platforms that provide the C library's :manpage:`iconv(3)` function,
93+
every encoding known to ``iconv`` for which Python has no built-in codec
94+
is now available (for example ``cp1133``).
95+
Prefixing an encoding name with ``iconv:`` forces the ``iconv``-based codec
96+
even when a built-in codec of the same name exists.
97+
(Contributed by Serhiy Storchaka in :gh:`152997`.)
98+
8999
curses
90100
------
91101

Include/internal/pycore_unicodeobject.h

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,22 @@ extern int _PyUnicodeWriter_FormatV(
182182
const char *format,
183183
va_list vargs);
184184

185+
/* --- iconv Codec -------------------------------------------------------- */
186+
187+
#ifdef HAVE_ICONV
188+
extern PyObject* _PyUnicode_DecodeIconv(
189+
const char *encoding, /* iconv encoding name */
190+
const char *string, /* encoded string */
191+
Py_ssize_t length, /* size of string */
192+
const char *errors, /* error handling */
193+
Py_ssize_t *consumed); /* bytes consumed, or NULL for non-stateful */
194+
195+
extern PyObject* _PyUnicode_EncodeIconv(
196+
const char *encoding, /* iconv encoding name */
197+
PyObject *unicode, /* Unicode object */
198+
const char *errors); /* error handling */
199+
#endif
200+
185201
/* --- UTF-7 Codecs ------------------------------------------------------- */
186202

187203
extern PyObject* _PyUnicode_EncodeUTF7(

Lib/encodings/__init__.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,3 +176,30 @@ def win32_code_page_search_function(encoding):
176176
return create_win32_code_page_codec(cp)
177177

178178
codecs.register(win32_code_page_search_function)
179+
180+
try:
181+
from _codecs import iconv_encode as _iconv_encode
182+
except ImportError:
183+
pass
184+
else:
185+
from ._iconv_codecs import create_iconv_codec
186+
187+
# Last-resort search function backed by the C library's iconv(): provides
188+
# any encoding iconv knows that Python has no built-in codec for. Registered
189+
# last, so it never shadows a built-in; an "iconv:" prefix forces it.
190+
def iconv_search_function(encoding):
191+
if encoding.startswith('iconv:'):
192+
name = encoding[len('iconv:'):]
193+
else:
194+
name = encoding
195+
if not name:
196+
return None
197+
# Test if the encoding is supported by iconv.
198+
try:
199+
_iconv_encode(name, '')
200+
except (LookupError, OSError):
201+
return None
202+
203+
return create_iconv_codec(encoding, name)
204+
205+
codecs.register(iconv_search_function)

Lib/encodings/_iconv_codecs.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import codecs
2+
3+
def create_iconv_codec(name, encoding):
4+
from _codecs import iconv_encode, iconv_decode
5+
6+
def encode(input, errors='strict'):
7+
return iconv_encode(encoding, input, errors)
8+
9+
def decode(input, errors='strict'):
10+
return iconv_decode(encoding, input, errors, True)
11+
12+
class IncrementalEncoder(codecs.IncrementalEncoder):
13+
def encode(self, input, final=False):
14+
return iconv_encode(encoding, input, self.errors)[0]
15+
16+
class IncrementalDecoder(codecs.BufferedIncrementalDecoder):
17+
def _buffer_decode(self, input, errors, final):
18+
return iconv_decode(encoding, input, errors, final)
19+
20+
class StreamWriter(codecs.StreamWriter):
21+
def encode(self, input, errors='strict'):
22+
return iconv_encode(encoding, input, errors)
23+
24+
class StreamReader(codecs.StreamReader):
25+
def decode(self, input, errors, final=False):
26+
return iconv_decode(encoding, input, errors, final)
27+
28+
return codecs.CodecInfo(
29+
name=name,
30+
encode=encode,
31+
decode=decode,
32+
incrementalencoder=IncrementalEncoder,
33+
incrementaldecoder=IncrementalDecoder,
34+
streamreader=StreamReader,
35+
streamwriter=StreamWriter,
36+
)

Lib/test/test_codecs.py

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3653,6 +3653,178 @@ def test_large_utf8_input(self, size):
36533653
self.assertEqual(decoded[0][-11:], '56\ud1000123456\ud100')
36543654

36553655

3656+
def iconv_encoding_available(name):
3657+
# The encodings iconv provides are platform-dependent, so tests must probe
3658+
# availability rather than assume it.
3659+
try:
3660+
codecs.iconv_encode(name, '')
3661+
except (LookupError, OSError):
3662+
return False
3663+
return True
3664+
3665+
3666+
# Candidate encodings with sample text; tests probe several and skip only when a
3667+
# whole category is unavailable.
3668+
_ICONV_SINGLE_BYTE = [
3669+
('KOI8-U', 'Привіт, світ!'),
3670+
('ISO-8859-7', 'Καλημέρα'),
3671+
('ISO-8859-2', 'Zażółć gęślą jaźń'),
3672+
('ISO-8859-1', 'Grüße'),
3673+
]
3674+
_ICONV_MULTIBYTE = ['EUC-JP', 'SHIFT_JIS', 'GBK', 'GB18030', 'BIG5']
3675+
# Encodings iconv may provide but for which CPython has no built-in codec
3676+
# (cp1047 is EBCDIC, i.e. not ASCII-compatible).
3677+
_ICONV_ONLY = ['cp1047', 'cp1133', 'GEORGIAN-PS', 'ARMSCII-8']
3678+
3679+
3680+
@unittest.skipUnless(hasattr(codecs, 'iconv_encode'),
3681+
'the iconv codec is not available')
3682+
class IconvTest(unittest.TestCase):
3683+
3684+
def require(self, *names):
3685+
for name in names:
3686+
if iconv_encoding_available(name):
3687+
return name
3688+
self.skipTest('no suitable iconv encoding is available')
3689+
3690+
def require_single_byte(self):
3691+
for enc, text in _ICONV_SINGLE_BYTE:
3692+
if iconv_encoding_available(enc):
3693+
return enc, text
3694+
self.skipTest('no single-byte iconv encoding is available')
3695+
3696+
def test_unknown_encoding(self):
3697+
self.assertRaises(LookupError, codecs.iconv_encode, 'no-such-enc-42', 'a')
3698+
self.assertRaises(LookupError, codecs.iconv_decode, 'no-such-enc-42', b'a')
3699+
self.assertRaises(LookupError, codecs.lookup, 'iconv:no-such-enc-42')
3700+
3701+
def test_roundtrip(self):
3702+
cases = _ICONV_SINGLE_BYTE + [(enc, '日本語') for enc in _ICONV_MULTIBYTE]
3703+
tested = False
3704+
for enc, text in cases:
3705+
if not iconv_encoding_available(enc):
3706+
continue
3707+
tested = True
3708+
with self.subTest(encoding=enc):
3709+
data = codecs.iconv_encode(enc, text)[0]
3710+
decoded, consumed = codecs.iconv_decode(enc, data, 'strict', True)
3711+
self.assertEqual(decoded, text)
3712+
self.assertEqual(consumed, len(data))
3713+
if not tested:
3714+
self.skipTest('none of the test encodings are available')
3715+
3716+
def test_encode_errors(self):
3717+
# A non-ASCII character is not representable in ASCII.
3718+
enc = self.require('ASCII')
3719+
with self.assertRaises(UnicodeEncodeError) as cm:
3720+
codecs.iconv_encode(enc, 'a€b')
3721+
self.assertEqual((cm.exception.encoding, cm.exception.start,
3722+
cm.exception.end), (enc, 1, 2))
3723+
self.assertEqual(codecs.iconv_encode(enc, 'a€b', 'replace')[0], b'a?b')
3724+
self.assertEqual(codecs.iconv_encode(enc, 'a€b', 'ignore')[0], b'ab')
3725+
self.assertEqual(codecs.iconv_encode(enc, 'a€b', 'backslashreplace')[0],
3726+
b'a\\u20acb')
3727+
self.assertEqual(codecs.iconv_encode(enc, 'a€b', 'xmlcharrefreplace')[0],
3728+
b'a&#8364;b')
3729+
3730+
def test_decode_errors(self):
3731+
enc = self.require('ASCII')
3732+
bad = b'a\xffb'
3733+
with self.assertRaises(UnicodeDecodeError) as cm:
3734+
codecs.iconv_decode(enc, bad, 'strict', True)
3735+
self.assertEqual((cm.exception.encoding, cm.exception.start), (enc, 1))
3736+
self.assertEqual(codecs.iconv_decode(enc, bad, 'replace', True)[0],
3737+
'a\ufffdb')
3738+
self.assertEqual(codecs.iconv_decode(enc, bad, 'ignore', True)[0], 'ab')
3739+
self.assertEqual(codecs.iconv_decode(enc, bad, 'backslashreplace', True)[0],
3740+
'a\\xffb')
3741+
3742+
def test_stateful_decode(self):
3743+
enc = self.require(*_ICONV_MULTIBYTE)
3744+
full = codecs.iconv_encode(enc, '日本')[0]
3745+
# A trailing incomplete multibyte sequence is deferred when final is
3746+
# false, and reported through *consumed*.
3747+
text, consumed = codecs.iconv_decode(enc, full[:-1], 'strict', False)
3748+
self.assertEqual(text, '日')
3749+
self.assertEqual(consumed, len(full) - 2)
3750+
# With final=True the same input is an error.
3751+
self.assertRaises(UnicodeDecodeError,
3752+
codecs.iconv_decode, enc, full[:-1], 'strict', True)
3753+
3754+
def test_empty(self):
3755+
enc = self.require('ASCII')
3756+
self.assertEqual(codecs.iconv_encode(enc, ''), (b'', 0))
3757+
self.assertEqual(codecs.iconv_decode(enc, b'', 'strict', True), ('', 0))
3758+
3759+
def test_lookup_bare_name(self):
3760+
# An encoding that iconv knows but Python has no built-in codec for.
3761+
for name in _ICONV_ONLY:
3762+
if (iconv_encoding_available(name)
3763+
and encodings.search_function(name) is None):
3764+
break
3765+
else:
3766+
self.skipTest('no iconv-only encoding is available')
3767+
info = codecs.lookup(name)
3768+
self.assertEqual(info.name, name.lower())
3769+
# The encoding need not be ASCII-compatible (e.g. EBCDIC), so just
3770+
# check that it round-trips.
3771+
self.assertEqual('abc'.encode(name).decode(name), 'abc')
3772+
3773+
def test_lookup_does_not_shadow_builtin(self):
3774+
# Built-in codecs must win over the iconv fallback.
3775+
self.assertEqual(codecs.lookup('utf-8').name, 'utf-8')
3776+
self.assertEqual(codecs.lookup('ascii').name, 'ascii')
3777+
3778+
def test_iconv_prefix_forces_engine(self):
3779+
# These candidates all have a built-in codec to compare against.
3780+
enc, text = self.require_single_byte()
3781+
info = codecs.lookup('iconv:' + enc)
3782+
# The registry lower-cases the requested name.
3783+
self.assertEqual(info.name, ('iconv:' + enc).lower())
3784+
self.assertEqual(text.encode('iconv:' + enc), text.encode(enc))
3785+
self.assertEqual(text.encode('iconv:' + enc).decode('iconv:' + enc), text)
3786+
3787+
def test_incremental_decode(self):
3788+
enc = self.require(*_ICONV_MULTIBYTE)
3789+
text = '日本語'
3790+
data = codecs.encode(text, 'iconv:' + enc)
3791+
dec = codecs.getincrementaldecoder('iconv:' + enc)()
3792+
out = ''.join(dec.decode(data[i:i+1]) for i in range(len(data)))
3793+
out += dec.decode(b'', True)
3794+
self.assertEqual(out, text)
3795+
3796+
def test_stream(self):
3797+
enc = self.require(*_ICONV_MULTIBYTE)
3798+
text = '日本語'
3799+
raw = codecs.encode(text, 'iconv:' + enc)
3800+
reader = codecs.getreader('iconv:' + enc)(io.BytesIO(raw))
3801+
self.assertEqual(reader.read(), text)
3802+
3803+
def test_encode_kinds(self):
3804+
# The string's own buffer is fed to iconv per storage kind; check each
3805+
# of the 1-, 2- and 4-byte kinds against the built-in codec.
3806+
enc = self.require('UTF-8')
3807+
for text in ('Gr\xfc\xdfe', 'ĀāĂ', 'A\U0001f389B'):
3808+
with self.subTest(text=text):
3809+
self.assertEqual(text.encode('iconv:' + enc), text.encode(enc))
3810+
3811+
def test_encode_surrogateescape(self):
3812+
# A lone surrogate lives in the 2-byte kind and round-trips.
3813+
enc = self.require('ASCII')
3814+
data = b'ab\xff'
3815+
s = data.decode(enc, 'surrogateescape')
3816+
self.assertEqual(s.encode('iconv:' + enc, 'surrogateescape'), data)
3817+
3818+
def test_encode_surrogate_pair(self):
3819+
# A surrogate pair must stay two code points, never combined into an
3820+
# astral character (as UTF-16 would): backslashreplace escapes each
3821+
# surrogate separately, not the escape of a single combined character.
3822+
pair = '\ud83c\udf89'
3823+
latin1 = self.require('ISO-8859-1', 'ASCII')
3824+
self.assertEqual(pair.encode('iconv:' + latin1, 'backslashreplace'),
3825+
rb'\ud83c\udf89')
3826+
3827+
36563828
class ASCIITest(unittest.TestCase):
36573829
def test_encode(self):
36583830
self.assertEqual('abc123'.encode('ascii'), b'abc123')

Makefile.pre.in

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1656,6 +1656,7 @@ FROZEN_FILES_IN = \
16561656
Lib/encodings/aliases.py \
16571657
Lib/encodings/utf_8.py \
16581658
Lib/encodings/_win_cp_codecs.py \
1659+
Lib/encodings/_iconv_codecs.py \
16591660
Lib/io.py \
16601661
Lib/_collections_abc.py \
16611662
Lib/_sitebuiltins.py \
@@ -1686,6 +1687,7 @@ FROZEN_FILES_OUT = \
16861687
Python/frozen_modules/encodings.aliases.h \
16871688
Python/frozen_modules/encodings.utf_8.h \
16881689
Python/frozen_modules/encodings._win_cp_codecs.h \
1690+
Python/frozen_modules/encodings._iconv_codecs.h \
16891691
Python/frozen_modules/io.h \
16901692
Python/frozen_modules/_collections_abc.h \
16911693
Python/frozen_modules/_sitebuiltins.h \
@@ -1747,6 +1749,9 @@ Python/frozen_modules/encodings.utf_8.h: Lib/encodings/utf_8.py $(FREEZE_MODULE_
17471749
Python/frozen_modules/encodings._win_cp_codecs.h: Lib/encodings/_win_cp_codecs.py $(FREEZE_MODULE_DEPS)
17481750
$(FREEZE_MODULE) encodings._win_cp_codecs $(srcdir)/Lib/encodings/_win_cp_codecs.py Python/frozen_modules/encodings._win_cp_codecs.h
17491751

1752+
Python/frozen_modules/encodings._iconv_codecs.h: Lib/encodings/_iconv_codecs.py $(FREEZE_MODULE_DEPS)
1753+
$(FREEZE_MODULE) encodings._iconv_codecs $(srcdir)/Lib/encodings/_iconv_codecs.py Python/frozen_modules/encodings._iconv_codecs.h
1754+
17501755
Python/frozen_modules/io.h: Lib/io.py $(FREEZE_MODULE_DEPS)
17511756
$(FREEZE_MODULE) io $(srcdir)/Lib/io.py Python/frozen_modules/io.h
17521757

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
On platforms providing the C library's :manpage:`iconv(3)` function,
2+
the :mod:`codecs` module now exposes every encoding known to ``iconv``
3+
for which Python has no built-in codec.
4+
Such an encoding can be used by its name (for example ``"cp1133"``)
5+
or, to force the ``iconv``-based engine even when a built-in codec exists,
6+
with an ``"iconv:"`` prefix (for example ``"iconv:latin1"``).

0 commit comments

Comments
 (0)