Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ Changes
1.26 (unreleased)
~~~~~~~~~~~~~~~~~

* Preserve the numeric locale when serializing Unix timestamps
(`issue 252`__)

__ https://github.com/python-rapidjson/python-rapidjson/issues/252

* Add Windows ARM64 build (`PR #242`__), thanks to Nikhil Dabas

__ https://github.com/python-rapidjson/python-rapidjson/pull/242
Expand Down
55 changes: 16 additions & 39 deletions rapidjson.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@
// :Copyright: © 2015-2026 Lele Gaifax
//

#include <locale.h>

#include <Python.h>
#include <datetime.h>
#include <structmember.h>
Expand Down Expand Up @@ -2805,30 +2803,18 @@ dumps_internal(
if (datetimeMode & DM_ONLY_SECONDS) {
writer->Int64((int64_t) timestamp);
} else {
// Writer.SetMaxDecimalPlaces(6) truncates the value,
// so for example 1514893636.276703 would come out as
// 1514893636.276702, because its exact double value is
// 1514893636.2767028808593750000000000...
char tsStr[12 + 1 + 6 + 1];

// Temporarily switch to a POSIX locale, in case the outer world is
// configured differently: by chance I got one doctest failure, and I
// can only imagine that recent Sphinx (that is, 4.2+) initializes the
// locale to something that implies a decimal separator different from
// a dot ".", say a comma "," when LANG is "it_IT"... not the best
// thing to do when emitting JSON!

const char* locale = setlocale(LC_NUMERIC, NULL);
setlocale(LC_NUMERIC, "C");

int size = snprintf(tsStr, 12 + 1 + 6, "%.6f", timestamp);

setlocale(LC_NUMERIC, locale);

// Remove trailing 0s
// Keep microsecond precision without changing the process locale.
char* tsStr = PyOS_double_to_string(timestamp, 'f', 6, 0, NULL);
if (tsStr == NULL) {
Py_XDECREF(asUTC);
return false;
}
size_t size = strlen(tsStr);
// Keep one fractional digit, as before.
while (tsStr[size-2] != '.' && tsStr[size-1] == '0')
size--;
writer->RawValue(tsStr, size, kNumberType);
PyMem_Free(tsStr);
}
} else {
hour = PyDateTime_TIME_GET_HOUR(dtObject);
Expand Down Expand Up @@ -2892,25 +2878,16 @@ dumps_internal(
if (datetimeMode & DM_ONLY_SECONDS) {
writer->Int64((int64_t) timestamp);
} else {
// Writer.SetMaxDecimalPlaces(6) truncates the value,
// so for example 1514893636.276703 would come out as
// 1514893636.276702, because its exact double value is
// 1514893636.2767028808593750000000000...
char tsStr[12 + 1 + 6 + 1];

// Temporarily switch to a POSIX locale, in case the outer
// world is configured differently, see above

const char* locale = setlocale(LC_NUMERIC, NULL);
setlocale(LC_NUMERIC, "C");

setlocale(LC_NUMERIC, locale);

int size = snprintf(tsStr, 12 + 1 + 6, "%.6f", timestamp);
// Remove trailing 0s
// Keep microsecond precision without changing the process locale.
char* tsStr = PyOS_double_to_string(timestamp, 'f', 6, 0, NULL);
if (tsStr == NULL)
return false;
size_t size = strlen(tsStr);
// Keep one fractional digit, as before.
while (tsStr[size-2] != '.' && tsStr[size-1] == '0')
size--;
writer->RawValue(tsStr, size, kNumberType);
PyMem_Free(tsStr);
}
}
} else if (uuidMode != UM_NONE
Expand Down
34 changes: 34 additions & 0 deletions tests/test_params.py
Original file line number Diff line number Diff line change
Expand Up @@ -878,3 +878,37 @@ def test_encoder_attrs():
e = rj.Encoder(mapping_mode=rj.MM_SKIP_NON_STRING_KEYS|rj.MM_SORT_KEYS)
assert e.skip_invalid_keys
assert e.sort_keys


@pytest.mark.parametrize('value', [
date(2026, 10, 26),
datetime(2026, 10, 26, 1, 2, 3, 123456, tzinfo=timezone.utc),
])
@pytest.mark.parametrize('comma_decimal', [False, True])
def test_unix_time_preserves_numeric_locale(value, comma_decimal, dumps):
import locale

original = locale.setlocale(locale.LC_NUMERIC)
try:
if comma_decimal:
for candidate in ('de_DE.UTF-8', 'de_DE.utf8', 'German_Germany.1252'):
try:
active = locale.setlocale(locale.LC_NUMERIC, candidate)
except locale.Error:
continue
if locale.localeconv()['decimal_point'] == ',':
break
else:
pytest.skip('a comma-decimal locale is not available')
else:
active = locale.setlocale(locale.LC_NUMERIC, 'C')

encoded = dumps(value, datetime_mode=rj.DM_UNIX_TIME | rj.DM_NAIVE_IS_UTC)
assert locale.setlocale(locale.LC_NUMERIC) == active
assert locale.format_string('%.1f', 1.5) == ('1,5' if comma_decimal else '1.5')
assert ',' not in encoded
expected = (value.timestamp() if isinstance(value, datetime)
else datetime(2026, 10, 26, tzinfo=timezone.utc).timestamp())
assert rj.loads(encoded) == expected
finally:
locale.setlocale(locale.LC_NUMERIC, original)
3 changes: 3 additions & 0 deletions tests/test_refs_count.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ def default(obj):

NO_OPTION = {}
DATETIMES = {'datetime_mode': rj.DM_ISO8601}
UNIX_TIMES = {'datetime_mode': rj.DM_UNIX_TIME}
UUIDS = {'uuid_mode': rj.UM_CANONICAL}
FOOS_DUMP = {'default': default}
FOOS_LOAD = {'object_hook': hook}
Expand All @@ -79,6 +80,8 @@ def default(obj):
( rj.RawJSON(' "foo" '), NO_OPTION, NO_OPTION),
( right_now, DATETIMES, DATETIMES ),
( date, DATETIMES, DATETIMES ),
( right_now, UNIX_TIMES, NO_OPTION ),
( date, UNIX_TIMES, NO_OPTION ),
( time, DATETIMES, DATETIMES ),
( nil, UUIDS, UUIDS ),
( foo, FOOS_DUMP, FOOS_LOAD ),
Expand Down
Loading