diff --git a/.travis.yml b/.travis.yml index 72e9f8d..b3e486b 100644 --- a/.travis.yml +++ b/.travis.yml @@ -33,11 +33,11 @@ env: # Note: TWINE_PASSWORD is set in Travis settings install: - - if [ $TRAVIS_OS_NAME = osx ]; then brew install python3; fi + - if [ $TRAVIS_OS_NAME = osx ]; then brew upgrade python; fi - pip3 install -r requirements-test.txt script: - - pip3 install . && pytest tests + - pip3 install . && pytest tests && cd docs; make doctest; cd - - | pip3 install cibuildwheel==0.6.0 cibuildwheel --output-dir wheelhouse diff --git a/docs/api.rst b/docs/api.rst index 8cf1a77..fbe72af 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -21,6 +21,7 @@ encoder decoder validator + rawjson .. data:: __author__ diff --git a/docs/rawjson.rst b/docs/rawjson.rst new file mode 100644 index 0000000..638f2ff --- /dev/null +++ b/docs/rawjson.rst @@ -0,0 +1,44 @@ +.. -*- coding: utf-8 -*- +.. :Project: python-rapidjson -- RawJSON class documentation +.. :Author: Silvio Tomatis +.. :License: MIT License +.. :Copyright: © 2018 Silvio Tomatis +.. :Copyright: © 2018 Lele Gaifax +.. + +=============== + RawJSON class +=============== + +.. module:: rapidjson + +.. testsetup:: + + from rapidjson import RawJSON, dumps + +.. class:: RawJSON(value) + + Preserialized JSON string. + + :param str value: The string that rapidjson should use verbatim when serializing this object + +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. + + .. doctest:: + + >>> raw_list = RawJSON('[1, 2,3]') + >>> dumps({'foo': raw_list}) + '{"foo":[1, 2,3]}' + +Rapidjson runs no checks on the preserialized data. This means that it can +potentially output invalid JSON, if you provide it: + + .. doctest:: + + >>> raw_list = RawJSON('[1, ') + >>> dumps({'foo': raw_list}) + '{"foo":[1, }' diff --git a/rapidjson.cpp b/rapidjson.cpp index 3741543..74182c3 100644 --- a/rapidjson.cpp +++ b/rapidjson.cpp @@ -415,6 +415,107 @@ inline void PutUnsafe(PyWriteStreamWrapper& stream, char c) { } +///////////// +// RawJSON // +///////////// + + +typedef struct { + PyObject_HEAD + PyObject *value; +} RawJSON; + +static void +RawJSON_dealloc(RawJSON* self) +{ + Py_XDECREF(self->value); + Py_TYPE(self)->tp_free((PyObject*)self); +} + + +static int +RawJSON_init(RawJSON *self, PyObject *args, PyObject *kwds) +{ + PyObject *value=NULL, *tmp; + + static char *kwlist[] = {"value", NULL}; + + if (! PyArg_ParseTupleAndKeywords(args, kwds, "U", kwlist, + &value)) + return -1; + + if (!PyUnicode_Check(value)) { + PyErr_SetString(PyExc_TypeError, "Only unicode strings allowed"); + return -1; + } + + if (value) { + tmp = self->value; + Py_INCREF(value); + self->value = value; + Py_XDECREF(tmp); + } + + return 0; +} + +static PyMemberDef RawJSON_members[] = { + {"value", T_OBJECT_EX, offsetof(RawJSON, value), 0, + "Byte string representing a serialized JSON object"}, + {NULL} /* Sentinel */ +}; + + +PyDoc_STRVAR(rawjson_doc, + "Raw (preserialized) JSON objects\n" + "\n" + "When rapidjson tries to serialize objects of this class, it will" + " use their literal `value`. For instance:\n" + ">>> rapidjson.dumps(RawJSON('{\"already\": \"serialized\"}'))\n" + "'{\"already\": \"serialized\"}'"); + +static PyTypeObject RawJSON_Type = { + PyVarObject_HEAD_INIT(NULL, 0) + "rapidjson.RawJSON", /* tp_name */ + sizeof(RawJSON), /* tp_basicsize */ + 0, /* tp_itemsize */ + (destructor)RawJSON_dealloc, /* tp_dealloc */ + 0, /* tp_print */ + 0, /* tp_getattr */ + 0, /* tp_setattr */ + 0, /* tp_compare */ + 0, /* tp_repr */ + 0, /* tp_as_number */ + 0, /* tp_as_sequence */ + 0, /* tp_as_mapping */ + 0, /* tp_hash */ + 0, /* tp_call */ + 0, /* tp_str */ + 0, /* tp_getattro */ + 0, /* tp_setattro */ + 0, /* tp_as_buffer */ + Py_TPFLAGS_DEFAULT, /* tp_flags */ + rawjson_doc, /* tp_doc */ + 0, /* tp_traverse */ + 0, /* tp_clear */ + 0, /* tp_richcompare */ + 0, /* tp_weaklistoffset */ + 0, /* tp_iter */ + 0, /* tp_iternext */ + 0, /* tp_methods */ + RawJSON_members, /* tp_members */ + 0, /* tp_getset */ + 0, /* tp_base */ + 0, /* tp_dict */ + 0, /* tp_descr_get */ + 0, /* tp_descr_set */ + 0, /* tp_dictoffset */ + (initproc)RawJSON_init, /* tp_init */ + 0, /* tp_alloc */ + PyType_GenericNew, /* tp_new */ +}; + + ///////////// // Decoder // ///////////// @@ -2623,6 +2724,15 @@ dumps_internal( writer->EndArray(); } + else if PyObject_TypeCheck(object, &RawJSON_Type) { + const char* jsonStr; + Py_ssize_t l; + jsonStr = PyUnicode_AsUTF8AndSize(((RawJSON*)object)->value, &l); + if (jsonStr == NULL) + return false; + ASSERT_VALID_SIZE(l); + writer->RawValue(jsonStr, (SizeType) l, kStringType); + } else if (defaultFn) { PyObject* retval = PyObject_CallFunctionObjArgs(defaultFn, object, NULL); if (retval == NULL) @@ -3686,6 +3796,12 @@ PyInit_rapidjson() Py_INCREF(&Validator_Type); PyModule_AddObject(m, "Validator", (PyObject*) &Validator_Type); + RawJSON_Type.tp_new = PyType_GenericNew; + if (PyType_Ready(&RawJSON_Type) < 0) + return NULL; + Py_INCREF(&RawJSON_Type); + PyModule_AddObject(m, "RawJSON", (PyObject *)&RawJSON_Type); + return m; error: diff --git a/tests/test_rawjson.py b/tests/test_rawjson.py new file mode 100644 index 0000000..d495bb4 --- /dev/null +++ b/tests/test_rawjson.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +# :Project: python-rapidjson -- Tests on RawJSON +# :Author: Silvio Tomatis +# :License: MIT License +# :Copyright: © 2018 Silvio Tomatis +# :Copyright: © 2018 Lele Gaifax +# + +import pytest + +import rapidjson as rj + + +@pytest.mark.unit +def test_instantiation_positional(): + value = 'a string' + raw = rj.RawJSON(value) + assert raw.value == value + + +@pytest.mark.unit +def test_instantiation_named(): + value = 'a string' + raw = rj.RawJSON(value=value) + assert raw.value == value + + +@pytest.mark.unit +def test_only_bytes_allowed(): + with pytest.raises(TypeError): + rj.RawJSON(b'A bytes string') + with pytest.raises(TypeError): + rj.RawJSON({}) + with pytest.raises(TypeError): + rj.RawJSON(None) + + +def test_mix_preserialized(): + assert rj.dumps({'a': rj.RawJSON('{1 : 2}')}) == '{"a":{1 : 2}}' diff --git a/tests/test_refs_count.py b/tests/test_refs_count.py index 7e737c2..6f99ef9 100644 --- a/tests/test_refs_count.py +++ b/tests/test_refs_count.py @@ -76,6 +76,7 @@ def default(obj): ( plain_string, NO_OPTION, NO_OPTION ), ( bigint, NO_OPTION, NO_OPTION ), ( pi, NO_OPTION, NO_OPTION ), + ( rj.RawJSON(' "foo" '), NO_OPTION, NO_OPTION), ( right_now, DATETIMES, DATETIMES ), ( date, DATETIMES, DATETIMES ), ( time, DATETIMES, DATETIMES ), @@ -138,16 +139,20 @@ def string(self, string): assert (rc1 - rc0) < THRESHOLD +@pytest.mark.parametrize('value', ['Foo', rj.RawJSON('Foo')]) @pytest.mark.skipif(not hasattr(sys, 'gettotalrefcount'), reason='Non-debug Python') -def test_encoder_call_leaks(): +def test_encoder_call_leaks(value): class MyEncoder(rj.Encoder): + def __init__(self, value): + self.value = value + def default(self, obj): - return 'Foo' + return self.value class Foo: pass - encoder = MyEncoder() + encoder = MyEncoder(value) foo = Foo() rc0 = sys.gettotalrefcount() for i in range(1000):