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
6 changes: 6 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
Changes
-------

Unreleased
~~~~~~~~~~

* Preserve the value of non-ASCII ``RawJSON`` input with ``ensure_ascii=True``
by emitting JSON Unicode escapes instead of truncating code points.

1.26 (2026-11-01)
~~~~~~~~~~~~~~~~~

Expand Down
5 changes: 3 additions & 2 deletions docs/rawjson.rst
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,14 @@
Preserialized JSON string.

:param str value: the string that rapidjson should use verbatim when serializing this
object
object (except non-ASCII escaping when ``ensure_ascii=True``)

Some applications might decide to store JSON-serialized objects in their database, but
might need to assemble them in a bigger JSON.

The ``RawJSON`` class serves this purpose. When serialized, the string value provided will
be used verbatim, whitespace included:
retain its spelling and whitespace. With ``ensure_ascii=True`` (the default), non-ASCII
characters are emitted as JSON Unicode escapes; ``ensure_ascii=False`` preserves them:

.. doctest::

Expand Down
41 changes: 36 additions & 5 deletions rapidjson.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2331,13 +2331,14 @@ dumps_internal(
unsigned uuidMode,
unsigned bytesMode,
unsigned iterableMode,
unsigned mappingMode)
unsigned mappingMode,
bool ensureAscii)
{
int is_decimal;

#define RECURSE(v) dumps_internal(writer, v, defaultFn, \
numberMode, datetimeMode, uuidMode, \
bytesMode, iterableMode, mappingMode)
bytesMode, iterableMode, mappingMode, ensureAscii)

#define ASSERT_VALID_SIZE(l) do { \
if (l < 0 || l > UINT_MAX) { \
Expand Down Expand Up @@ -2969,7 +2970,37 @@ dumps_internal(
if (jsonStr == NULL)
return false;
ASSERT_VALID_SIZE(l);
writer->RawValue(jsonStr, (SizeType) l, kStringType);
// RawValue transcodes to ASCII without JSON escaping, which truncates Unicode.
// Preserve the raw spelling except for the escaping requested by ensure_ascii.
if (ensureAscii && !PyUnicode_IS_ASCII(((RawJSON*) object)->value)) {
std::string escaped;
PyObject* value = ((RawJSON*) object)->value;
static const char hex[] = "0123456789abcdef";
int kind = PyUnicode_KIND(value);
void* data = PyUnicode_DATA(value);
for (Py_ssize_t i = 0; i < PyUnicode_GET_LENGTH(value); i++) {
Py_UCS4 ch = PyUnicode_READ(kind, data, i);
if (ch < 128) {
escaped += (char) ch;
} else {
Py_UCS4 units[2] = {ch, 0};
int count = 1;
if (ch > 0xffff) {
units[0] = 0xd800 + ((ch - 0x10000) >> 10);
units[1] = 0xdc00 + ((ch - 0x10000) & 0x3ff);
count = 2;
}
for (int j = 0; j < count; j++) {
escaped += "\\u";
for (int shift = 12; shift >= 0; shift -= 4)
escaped += hex[(units[j] >> shift) & 15];
}
}
}
writer->RawValue(escaped.data(), escaped.size(), kStringType);
} else {
writer->RawValue(jsonStr, (SizeType) l, kStringType);
}
} else if (defaultFn) {
PyObject* retval = PyObject_CallFunctionObjArgs(defaultFn, object, NULL);
if (retval == NULL)
Expand Down Expand Up @@ -3387,7 +3418,7 @@ static PyTypeObject Encoder_Type = {
uuidMode, \
bytesMode, \
iterableMode, \
mappingMode) \
mappingMode, ensureAscii) \
? PyUnicode_FromString(buf.GetString()) : NULL)


Expand Down Expand Up @@ -3436,7 +3467,7 @@ do_encode(PyObject* value, PyObject* defaultFn, bool ensureAscii, unsigned write
uuidMode, \
bytesMode, \
iterableMode, \
mappingMode) \
mappingMode, ensureAscii) \
? Py_INCREF(Py_None), Py_None : NULL)


Expand Down
37 changes: 37 additions & 0 deletions tests/test_rawjson.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,40 @@ def test_only_bytes_allowed():

def test_mix_preserialized():
assert rj.dumps({'a': rj.RawJSON('{1 : 2}')}) == '{"a":{1 : 2}}'


@pytest.mark.parametrize('raw', ['"☃"', '{"key": "é"}', '"😀"'])
@pytest.mark.parametrize('write_mode', [rj.WM_COMPACT, rj.WM_PRETTY])
@pytest.mark.parametrize('ensure_ascii', [True, False])
@pytest.mark.parametrize('api', ['dumps', 'encoder', 'text', 'bytes'])
def test_unicode_rawjson_preserves_value(raw, write_mode, ensure_ascii, api):
import io
import json
value = {'raw': rj.RawJSON(raw), 'normal': '☃'}
options = dict(write_mode=write_mode, ensure_ascii=ensure_ascii)
if api == 'dumps':
encoded = rj.dumps(value, **options)
elif api == 'encoder':
encoded = rj.Encoder(**options)(value)
else:
stream = io.StringIO() if api == 'text' else io.BytesIO()
rj.dump(value, stream, **options)
encoded = stream.getvalue()
if isinstance(encoded, bytes):
encoded = encoded.decode('utf-8')
if ensure_ascii:
assert encoded.isascii()
assert json.loads(encoded) == {'raw': json.loads(raw), 'normal': '☃'}


def test_ascii_rawjson_keeps_original_whitespace():
raw = ' { "x" : [1, 2] } '
assert rj.dumps(rj.RawJSON(raw)) == raw


def test_unicode_rawjson_stream_error_propagates():
class BrokenStream:
def write(self, data):
raise RuntimeError('controlled write failure')
with pytest.raises(RuntimeError, match='controlled write failure'):
rj.dump(rj.RawJSON('"☃"'), BrokenStream())
Loading