Skip to content

Commit 39a817e

Browse files
gh-152905: Decode LC_TIME items in nl_langinfo() from glibc wide data (GH-152911)
On glibc, locale.nl_langinfo() now decodes the LC_TIME text items from the wide (_NL_W*) locale data, independently of the LC_CTYPE encoding. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 3ce7a12 commit 39a817e

4 files changed

Lines changed: 152 additions & 28 deletions

File tree

Doc/library/locale.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,10 @@ The :mod:`!locale` module defines the following exception and functions:
342342
.. versionchanged:: 3.14
343343
The function now temporarily sets the ``LC_CTYPE`` locale in some cases.
344344

345+
.. versionchanged:: next
346+
On glibc, the ``LC_TIME`` items (except ``ERA``) are now decoded
347+
independently of the ``LC_CTYPE`` encoding.
348+
345349

346350
.. function:: getdefaultlocale([envvars])
347351

Lib/test/test__locale.py

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import _locale
12
from _locale import (setlocale, LC_ALL, LC_CTYPE, LC_NUMERIC, LC_TIME, localeconv, Error)
23
try:
34
from _locale import (RADIXCHAR, THOUSEP, nl_langinfo)
@@ -6,8 +7,9 @@
67

78
import locale
89
import sys
10+
import unicodedata
911
import unittest
10-
from platform import uname
12+
from platform import uname, libc_ver
1113

1214
from test import support
1315

@@ -271,6 +273,79 @@ def test_era_nl_langinfo(self):
271273
if not tested:
272274
self.skipTest('no suitable locales')
273275

276+
@unittest.skipUnless(nl_langinfo, "nl_langinfo is not available")
277+
@unittest.skipUnless(libc_ver()[0] == 'glibc',
278+
"wide nl_langinfo variants are glibc-specific")
279+
def test_nl_langinfo_encoding_independent(self):
280+
# gh-152905: The LC_TIME text items are decoded independently of the
281+
# LC_CTYPE encoding (on glibc via the wide nl_langinfo variants), so
282+
# the same locale in different encodings yields identical strings.
283+
# ERA has no wide variant and is not tested.
284+
self.addCleanup(setlocale, LC_TIME, setlocale(LC_TIME))
285+
286+
names = [f'MON_{i}' for i in range(1, 13)]
287+
names += [f'ABMON_{i}' for i in range(1, 13)]
288+
names += [f'DAY_{i}' for i in range(1, 8)]
289+
names += [f'ABDAY_{i}' for i in range(1, 8)]
290+
names += ['AM_STR', 'PM_STR', 'D_T_FMT', 'D_FMT', 'T_FMT']
291+
names += [name for name in ('T_FMT_AMPM', 'ERA_D_FMT', 'ERA_D_T_FMT',
292+
'ERA_T_FMT', 'ALT_DIGITS', '_DATE_FMT')
293+
if hasattr(_locale, name)]
294+
items = [(name, getattr(_locale, name)) for name in names]
295+
296+
# Legacy (non-UTF-8) locales, compared against their UTF-8 variant.
297+
legacy_locales = [
298+
'en_US.ISO8859-1',
299+
'es_ES.ISO8859-1',
300+
'fr_FR.ISO8859-1',
301+
'de_DE.ISO8859-1',
302+
'pl_PL.ISO8859-2',
303+
'mt_MT.ISO8859-3',
304+
'ar_SA.ISO8859-6',
305+
'el_GR.ISO8859-7',
306+
'he_IL.ISO8859-8',
307+
'tr_TR.ISO8859-9',
308+
'lt_LT.ISO8859-13',
309+
'cy_GB.ISO8859-14',
310+
'et_EE.ISO8859-15',
311+
'uk_UA.KOI8-U',
312+
'bg_BG.CP1251',
313+
'ja_JP.EUC-JP',
314+
'ko_KR.EUC-KR',
315+
'zh_CN.GB2312',
316+
'zh_TW.BIG5',
317+
'th_TH.TIS-620',
318+
]
319+
320+
# An 8-bit locale substitutes an equivalent for a space it cannot
321+
# encode (e.g. es_ES has U+202F in UTF-8 but U+00A0 in ISO-8859-1),
322+
# so fold spaces before comparing.
323+
def fold_spaces(s):
324+
return ''.join(' ' if unicodedata.category(c) == 'Zs' else c
325+
for c in s)
326+
327+
tested = False
328+
for legacy_locale in legacy_locales:
329+
locs = (legacy_locale.partition('.')[0] + '.UTF-8', legacy_locale)
330+
values = []
331+
for loc in locs:
332+
try:
333+
setlocale(LC_TIME, loc)
334+
except Error:
335+
break
336+
values.append({name: nl_langinfo(item) for name, item in items})
337+
if len(values) < 2:
338+
continue
339+
tested = True
340+
341+
# The result must not depend on the locale encoding.
342+
for name, item in items:
343+
with self.subTest(locales=locs, name=name):
344+
self.assertEqual(fold_spaces(values[0][name]),
345+
fold_spaces(values[1][name]))
346+
if not tested:
347+
self.skipTest('no suitable locale pairs')
348+
274349
def test_float_parsing(self):
275350
# Bug #1391872: Test whether float parsing is okay on European
276351
# locales.
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
On glibc, :func:`locale.nl_langinfo` now decodes the ``LC_TIME`` items (such
2+
as the month and day names) using the wide locale data, so the result no
3+
longer depends on the ``LC_CTYPE`` encoding.

Modules/_localemodule.c

Lines changed: 69 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -561,11 +561,23 @@ _locale__getdefaultlocale_impl(PyObject *module)
561561
#endif
562562

563563
#ifdef HAVE_LANGINFO_H
564-
#define LANGINFO(X, Y) {#X, X, Y}
564+
/* On glibc, LC_TIME text items also have a wide (_NL_W*) form, named
565+
_NL_W<narrow name>, that nl_langinfo() returns as wchar_t*. LANGINFO_NW
566+
marks the items that have no wide counterpart. */
567+
#ifdef __GLIBC__
568+
# define LANGINFO(X, Y) {#X, X, Y, _NL_W ## X}
569+
# define LANGINFO_NW(X, Y) {#X, X, Y, 0}
570+
#else
571+
# define LANGINFO(X, Y) {#X, X, Y}
572+
# define LANGINFO_NW(X, Y) {#X, X, Y}
573+
#endif
565574
static struct langinfo_constant{
566575
const char *name;
567576
int value;
568577
int category;
578+
#ifdef __GLIBC__
579+
nl_item wide; /* wide _NL_W* counterpart, or 0 if none */
580+
#endif
569581
} langinfo_constants[] =
570582
{
571583
/* These constants should exist on any langinfo implementation */
@@ -613,16 +625,16 @@ static struct langinfo_constant{
613625

614626
#ifdef RADIXCHAR
615627
/* The following are not available with glibc 2.0 */
616-
LANGINFO(RADIXCHAR, LC_NUMERIC),
617-
LANGINFO(THOUSEP, LC_NUMERIC),
628+
LANGINFO_NW(RADIXCHAR, LC_NUMERIC),
629+
LANGINFO_NW(THOUSEP, LC_NUMERIC),
618630
/* YESSTR and NOSTR are deprecated in glibc, since they are
619631
a special case of message translation, which should be rather
620632
done using gettext. So we don't expose it to Python in the
621633
first place.
622634
LANGINFO(YESSTR, LC_MESSAGES),
623635
LANGINFO(NOSTR, LC_MESSAGES),
624636
*/
625-
LANGINFO(CRNCYSTR, LC_MONETARY),
637+
LANGINFO_NW(CRNCYSTR, LC_MONETARY),
626638
#endif
627639

628640
LANGINFO(D_T_FMT, LC_TIME),
@@ -636,13 +648,13 @@ static struct langinfo_constant{
636648
a few of the others.
637649
Solution: ifdef-test them all. */
638650
#ifdef CODESET
639-
LANGINFO(CODESET, LC_CTYPE),
651+
LANGINFO_NW(CODESET, LC_CTYPE),
640652
#endif
641653
#ifdef T_FMT_AMPM
642654
LANGINFO(T_FMT_AMPM, LC_TIME),
643655
#endif
644656
#ifdef ERA
645-
LANGINFO(ERA, LC_TIME),
657+
LANGINFO_NW(ERA, LC_TIME),
646658
#endif
647659
#ifdef ERA_D_FMT
648660
LANGINFO(ERA_D_FMT, LC_TIME),
@@ -657,10 +669,10 @@ static struct langinfo_constant{
657669
LANGINFO(ALT_DIGITS, LC_TIME),
658670
#endif
659671
#ifdef YESEXPR
660-
LANGINFO(YESEXPR, LC_MESSAGES),
672+
LANGINFO_NW(YESEXPR, LC_MESSAGES),
661673
#endif
662674
#ifdef NOEXPR
663-
LANGINFO(NOEXPR, LC_MESSAGES),
675+
LANGINFO_NW(NOEXPR, LC_MESSAGES),
664676
#endif
665677
#ifdef _DATE_FMT
666678
/* This is not available in all glibc versions that have CODESET. */
@@ -709,15 +721,14 @@ restore_locale(char *oldloc)
709721
}
710722

711723
#ifdef __GLIBC__
712-
#if defined(ALT_DIGITS) || defined(ERA)
713724
static PyObject *
714-
decode_strings(const char *result, size_t max_count)
725+
decode_strings(const char *result)
715726
{
716727
/* Convert a sequence of NUL-separated C strings to a Python string
717728
* containing semicolon separated items. */
718729
size_t i = 0;
719730
size_t count = 0;
720-
for (; count < max_count && result[i]; count++) {
731+
for (; result[i]; count++) {
721732
i += strlen(result + i) + 1;
722733
}
723734
char *buf = PyMem_Malloc(i);
@@ -737,7 +748,35 @@ decode_strings(const char *result, size_t max_count)
737748
return pyresult;
738749
}
739750
#endif
740-
#endif
751+
752+
#ifdef __GLIBC__
753+
static PyObject *
754+
decode_wide_strings(const wchar_t *result, size_t max_count)
755+
{
756+
/* Wide-character counterpart of decode_strings(): convert a sequence of
757+
* NUL-separated wchar_t strings to a str with semicolon-separated items. */
758+
size_t i = 0;
759+
size_t count = 0;
760+
for (; count < max_count && result[i]; count++) {
761+
i += wcslen(result + i) + 1;
762+
}
763+
wchar_t *buf = PyMem_New(wchar_t, i);
764+
if (buf == NULL) {
765+
PyErr_NoMemory();
766+
return NULL;
767+
}
768+
memcpy(buf, result, i * sizeof(wchar_t));
769+
/* Replace all NULs with semicolons. */
770+
i = 0;
771+
while (--count) {
772+
i += wcslen(buf + i);
773+
buf[i++] = L';';
774+
}
775+
PyObject *pyresult = PyUnicode_FromWideChar(buf, -1);
776+
PyMem_Free(buf);
777+
return pyresult;
778+
}
779+
#endif /* __GLIBC__ */
741780

742781
/*[clinic input]
743782
_locale.nl_langinfo
@@ -758,6 +797,22 @@ _locale_nl_langinfo_impl(PyObject *module, int item)
758797
crash PyUnicode_FromString. */
759798
for (i = 0; langinfo_constants[i].name; i++) {
760799
if (langinfo_constants[i].value == item) {
800+
#ifdef __GLIBC__
801+
/* Prefer the wide variant: it is decoded independently of the
802+
LC_CTYPE encoding. */
803+
nl_item wide = langinfo_constants[i].wide;
804+
if (wide) {
805+
const wchar_t *wresult = (const wchar_t *)nl_langinfo(wide);
806+
if (wresult == NULL) {
807+
wresult = L"";
808+
}
809+
/* ALT_DIGITS is a sequence of NUL-separated strings. */
810+
if (item == ALT_DIGITS && *wresult) {
811+
return decode_wide_strings(wresult, 100);
812+
}
813+
return PyUnicode_FromWideChar(wresult, -1);
814+
}
815+
#endif
761816
/* Check NULL as a workaround for GNU libc's returning NULL
762817
instead of an empty string for nl_langinfo(ERA). */
763818
const char *result = nl_langinfo(item);
@@ -766,13 +821,8 @@ _locale_nl_langinfo_impl(PyObject *module, int item)
766821
if (langinfo_constants[i].category != LC_CTYPE
767822
&& *result && (
768823
#ifdef __GLIBC__
769-
// gh-133740: Always change the locale for ALT_DIGITS and ERA
770-
# ifdef ALT_DIGITS
771-
item == ALT_DIGITS ||
772-
# endif
773-
# ifdef ERA
824+
// gh-133740: Always change the locale for ERA
774825
item == ERA ||
775-
# endif
776826
#endif
777827
!is_all_ascii(result))
778828
&& change_locale(langinfo_constants[i].category, &oldloc) < 0)
@@ -784,18 +834,10 @@ _locale_nl_langinfo_impl(PyObject *module, int item)
784834
/* According to the POSIX specification the result must be
785835
* a sequence of semicolon-separated strings.
786836
* But in Glibc they are NUL-separated. */
787-
#ifdef ALT_DIGITS
788-
if (item == ALT_DIGITS && *result) {
789-
pyresult = decode_strings(result, 100);
790-
}
791-
else
792-
#endif
793-
#ifdef ERA
794837
if (item == ERA && *result) {
795-
pyresult = decode_strings(result, SIZE_MAX);
838+
pyresult = decode_strings(result);
796839
}
797840
else
798-
#endif
799841
#endif
800842
{
801843
pyresult = PyUnicode_DecodeLocale(result, NULL);

0 commit comments

Comments
 (0)