changeset 542:f71d34dda19f

Add an optional C-implementation for configmix.config.unquote and configmix.config.pathstr2path. This is currently for Python 3.5+. It is tested with Python 3.7 and Python3.8 (FreeBSD 12.2 amd64, LLVM 10.0.1). A build for the stable API ("abi3") fails because PyUnicode_New() is currently not in the stable API. Also includes are extended tests for unquote() and pathstr2path().
author Franz Glasner <fzglas.hg@dom66.de>
date Fri, 31 Dec 2021 21:24:16 +0100
parents 25b61f0a1958
children 491413368c7c
files .hgkwarchive MANIFEST.in configmix/_speedups.c configmix/config.py setup.py tests/_perf_config.py tests/test.py
diffstat 7 files changed, 620 insertions(+), 29 deletions(-) [+]
line wrap: on
line diff
--- a/.hgkwarchive	Wed Dec 29 13:33:11 2021 +0100
+++ b/.hgkwarchive	Fri Dec 31 21:24:16 2021 +0100
@@ -1,4 +1,5 @@
 [patterns]
 configmix/**.py = RCS, reST
+configmix/**.c = RCS, reST
 path:README.txt = reST
 path:docs/conf.py =
--- a/MANIFEST.in	Wed Dec 29 13:33:11 2021 +0100
+++ b/MANIFEST.in	Fri Dec 31 21:24:16 2021 +0100
@@ -1,4 +1,5 @@
 include .hg* *.txt requirement*
+include configmix/*.c
 graft docs
 graft tests
 prune docs/_build
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/configmix/_speedups.c	Fri Dec 31 21:24:16 2021 +0100
@@ -0,0 +1,399 @@
+/* -*- coding: utf-8 -*- */
+/*
+ * Speedups for configmix.
+ *
+ * :Copyright: (c) 2021, Franz Glasner. All rights reserved.
+ * :License:   BSD-3-Clause. See LICENSE.txt for details.
+ */
+
+#define PY_SSIZE_T_CLEAN
+#include "Python.h"
+
+
+const char _id[] = "@(#)configmix._speedups $Header$";
+static const char release[] = "|VCSRevision|";
+static const char date[] = "|VCSJustDate|";
+
+
+/*
+ * Module state holds pre-created some objects
+ */
+struct speedups_state {
+    PyObject *DOT;
+    PyObject *QUOTE;
+    PyObject *EMPTY_STR;
+};
+
+
+static
+int
+_hex2ucs4(PyObject *s, Py_ssize_t end, Py_UCS4 *result)
+{
+    Py_ssize_t i;
+    Py_UCS4 c;
+    Py_UCS4 r = 0;
+
+    for (i=1; i < end; i++) {
+        r *= 16;
+        c = PyUnicode_ReadChar(s, i);
+        if ((c >= 48) && (c <= 57)) {    /* 0 - 9 */
+            r += (c - 48);
+        }
+        else {
+            if ((c >= 97) && (c <= 102)) {    /* a - f */
+                r += (c - 87);
+            }
+            else {
+                if ((c >= 65) && (c <= 70)) {   /* A - F */
+                    r += (c - 55);
+                }
+                else {
+                    PyErr_SetString(PyExc_ValueError, "invalid base-16 literal");
+                    return -1;
+                }
+            }
+        }
+    }
+    *result = r;
+    return 0;  /* success */
+}
+
+
+static
+PyObject *
+_hex2string(PyObject *s, Py_ssize_t end)
+{
+    Py_UCS4 c;
+    PyObject *u = NULL;
+
+    if (_hex2ucs4(s, end, &c) != 0)
+        return NULL;
+    u = PyUnicode_New(1, c);    /* ARGH: not  in the stable API */
+    if (u == NULL)
+        return NULL;
+    if (PyUnicode_WriteChar(u, 0, c) != 0) {
+        Py_DECREF(u);
+        return NULL;
+    }
+    return u;
+}
+
+
+static
+PyObject *
+_fast_unquote(PyObject *self, PyObject *s, struct speedups_state *sstate)
+{
+    Py_ssize_t find;
+    Py_ssize_t s_len;
+    Py_ssize_t parts_len;
+    PyObject *res;
+    PyObject *res_parts = NULL;
+    PyObject *parts = NULL;
+    PyObject *o;
+    PyObject *pb;
+    Py_ssize_t pb_len;
+    Py_ssize_t i;
+    Py_UCS4 c;
+
+    if (!PyUnicode_Check(s)) {
+        PyErr_SetString(PyExc_TypeError, "a (unicode) string type is expected");
+        return NULL;
+    }
+    s_len = PyUnicode_GetLength(s);
+    if (s_len < 0) {
+        return NULL;
+    }
+    if (s_len == 0) {
+        Py_INCREF(s);
+        return s;
+    }
+    find = PyUnicode_FindChar(s, '%', 0, s_len, 1);
+    if (find == -2) {
+        return NULL;
+    }
+    if (find == -1) {
+        Py_INCREF(s);
+        return s;
+    }
+
+    if (sstate == NULL) {
+        sstate = PyModule_GetState(self);
+        if (sstate == NULL) {
+            PyErr_SetString(PyExc_RuntimeError, "no module state available");
+            return NULL;
+        }
+    }
+    parts = PyUnicode_Split(s, sstate->QUOTE, -1);
+    if (parts == NULL) {
+        goto error;
+    }
+    parts_len = PyList_Size(parts);
+    if (parts_len < 0) {
+        goto error;
+    }
+    res_parts = PyTuple_New((parts_len-1)*2 + 1);
+    if (res_parts == NULL) {
+        goto error;
+    }
+
+    o = PyList_GetItem(parts, 0);   /* borrowed */
+    if (o == NULL) {
+        goto error;
+    }
+    /*
+     * The first item may be also the empty string if `s' starts with
+     * a quoted character.
+     */
+    Py_INCREF(o);   /* because PyTuple_SetItem steals -- and o is borrowed */
+    PyTuple_SetItem(res_parts, 0, o);
+
+    for (i=1; i<parts_len; i++) {
+        pb = PyList_GetItem(parts, i);   /* borrowed */
+        pb_len = PyUnicode_GetLength(pb);
+        if (pb_len < 1) {
+            PyErr_SetString(PyExc_ValueError, "unknown quote syntax string");
+            goto error;
+        }
+        c = PyUnicode_ReadChar(pb, 0);
+        switch (c) {
+        case 0x55:   /* U */
+            if (pb_len < 9) {
+                PyErr_SetString(PyExc_ValueError, "quote syntax: length too small");
+                goto error;
+            }
+            o = _hex2string(pb, 9);
+            if (o == NULL) {
+                goto error;
+            }
+            PyTuple_SetItem(res_parts, (i-1)*2 + 1, o);   /* steals */
+            o = PyUnicode_Substring(pb, 9, pb_len);
+            if (o == NULL) {
+                goto error;
+            }
+            PyTuple_SetItem(res_parts, i*2, o);    /* steals */
+            break;
+        case 0x75:   /* u */
+            if (pb_len < 5) {
+                PyErr_SetString(PyExc_ValueError, "quote syntax: length too small");
+                goto error;
+            }
+            o = _hex2string(pb, 5);
+            if (o == NULL) {
+                goto error;
+            }
+            PyTuple_SetItem(res_parts, (i-1)*2 + 1, o);  /* steals */
+            o = PyUnicode_Substring(pb, 5, pb_len);
+            if (o == NULL) {
+                goto error;
+            }
+            PyTuple_SetItem(res_parts, i*2, o);    /* steals */
+            break;
+        case 0x78:   /* x */
+            if (pb_len < 3) {
+                PyErr_SetString(PyExc_ValueError, "quote syntax: length too small");
+                goto error;
+            }
+            o = _hex2string(pb, 3);
+            if (o == NULL) {
+                goto error;
+            }
+            PyTuple_SetItem(res_parts, (i-1)*2 + 1, o);  /* steals */
+            o = PyUnicode_Substring(pb, 3, pb_len);
+            if (o == NULL) {
+                goto error;
+            }
+            PyTuple_SetItem(res_parts, i*2, o);    /* steals */
+            break;
+
+        default:
+            PyErr_SetString(PyExc_ValueError, "unknown quote syntax string");
+            goto error;
+        }
+    }
+
+    res = PyUnicode_Join(sstate->EMPTY_STR, res_parts);
+    if (res == NULL) {
+        goto error;
+    }
+    Py_DECREF(parts);
+    Py_DECREF(res_parts);
+    return res;
+
+error:
+    Py_XDECREF(res_parts);
+    Py_XDECREF(parts);
+    return NULL;
+}
+
+
+static
+PyObject *
+fast_unquote(PyObject *self, PyObject *s)
+{
+    return _fast_unquote(self, s, NULL);
+}
+
+
+static
+PyObject *
+fast_pathstr2path(PyObject *self, PyObject *varname)
+{
+    Py_ssize_t varname_len;
+    PyObject *parts = NULL;
+    Py_ssize_t parts_len;
+    PyObject *res = NULL;
+    Py_ssize_t i;
+    PyObject *o;
+    PyObject *u;
+    struct speedups_state *sstate;
+
+    if (!PyUnicode_Check(varname)) {
+        PyErr_SetString(PyExc_TypeError, "a (unicode) string type is expected");
+        return NULL;
+    }
+    varname_len = PyUnicode_GetLength(varname);
+    if (varname_len < 0) {
+        return NULL;
+    }
+    if (varname_len == 0) {
+        return PyTuple_New(0);
+    }
+
+    sstate = PyModule_GetState(self);
+    if (sstate == NULL) {
+        PyErr_SetString(PyExc_RuntimeError, "no module state available");
+        return NULL;
+    }
+    parts = PyUnicode_Split(varname, sstate->DOT, -1);
+    if (parts == NULL) {
+        goto error;
+    }
+    parts_len = PyList_Size(parts);
+    if (parts_len < 0) {
+        goto error;
+    }
+    res = PyTuple_New(parts_len);
+    if (res == NULL) {
+        goto error;
+    }
+    for (i=0; i < parts_len; i++) {
+        o = PyList_GetItem(parts, i);   /* borrowed */
+        u = _fast_unquote(self, o, sstate);
+        if (u == NULL) {
+            goto error;
+        }
+        PyTuple_SetItem(res, i, u);     /* steals */
+    }
+
+    Py_DECREF(parts);
+    return res;
+
+error:
+    Py_XDECREF(parts);
+    Py_XDECREF(res);
+    return NULL;
+}
+
+
+static struct PyMethodDef speedups_methods[] = {
+    {"fast_unquote", fast_unquote, METH_O, PyDoc_STR("C-implementation of configmix.unquote")},
+    {"fast_pathstr2path", fast_pathstr2path, METH_O, PyDoc_STR("C-implementation of configmix.pathstr2path")},
+    {NULL, NULL, 0, NULL}
+};
+
+
+static
+int
+speedups_exec(PyObject *module)
+{
+    struct speedups_state *sstate = PyModule_GetState(module);
+
+    if (sstate == NULL) {
+        PyErr_SetString(PyExc_ImportError, "no module state available yet");
+        return -1;
+    }
+
+    PyModule_AddStringConstant(module, "__release__", release);
+    PyModule_AddStringConstant(module, "__date__", date);
+    PyModule_AddStringConstant(module, "__author__", "Franz Glasner");
+
+    sstate->DOT = PyUnicode_FromStringAndSize(".", 1);
+    if (sstate->DOT == NULL) {
+        return -1;
+    }
+    PyUnicode_InternInPlace(&(sstate->DOT));
+
+    sstate->QUOTE = PyUnicode_FromStringAndSize("%", 1);
+    if (sstate->QUOTE == NULL) {
+        return -1;
+    }
+    PyUnicode_InternInPlace(&(sstate->QUOTE));
+
+    sstate->EMPTY_STR = PyUnicode_FromStringAndSize("", 0);
+    if (sstate->EMPTY_STR == NULL) {
+        return -1;
+    }
+    PyUnicode_InternInPlace(&(sstate->EMPTY_STR));
+
+    return 0;
+}
+
+
+static
+int
+speeeupds_traverse(PyObject *module, visitproc visit, void *arg)
+{
+    struct speedups_state *sstate = PyModule_GetState(module);
+
+    if (sstate != NULL) {
+        Py_VISIT(sstate->DOT);
+        Py_VISIT(sstate->QUOTE);
+        Py_VISIT(sstate->EMPTY_STR);
+    }
+    return 0;
+}
+
+
+static
+int
+speedups_clear(PyObject *module)
+{
+    struct speedups_state *sstate = PyModule_GetState(module);
+
+    if (sstate != NULL) {
+        Py_CLEAR(sstate->DOT);
+        Py_CLEAR(sstate->QUOTE);
+        Py_CLEAR(sstate->EMPTY_STR);
+    }
+    return 0;
+}
+
+
+static struct PyModuleDef_Slot speedups_slots[] = {
+    {Py_mod_exec, speedups_exec},
+    {0, NULL}
+};
+
+
+static struct PyModuleDef speedups_def = {
+    PyModuleDef_HEAD_INIT,                      /* m_base */
+    "_speedups",                                /* m_name  (relative) */
+    PyDoc_STR("Speedups for configmix"),        /* m_doc */
+    sizeof(struct speedups_state),              /* m_size */
+    speedups_methods,                           /* m_methods */
+    speedups_slots,                             /* m_slots */
+    speeeupds_traverse,                         /* m_traverse */
+    speedups_clear,                             /* m_clear */
+    NULL                                        /* m_free */
+};
+
+
+PyMODINIT_FUNC
+PyInit__speedups(void)
+{
+    /*
+     * Use multi-phase extension module initialization (PEP 489).
+     * This is Python 3.5+.
+     */
+    return PyModuleDef_Init(&speedups_def);
+}
--- a/configmix/config.py	Wed Dec 29 13:33:11 2021 +0100
+++ b/configmix/config.py	Fri Dec 31 21:24:16 2021 +0100
@@ -30,6 +30,11 @@
 from .variables import lookup_varns, lookup_filter
 from .compat import u, uchr, n, str_and_u, PY2
 from .constants import REF_NAMESPACE, NONE_FILTER, EMPTY_FILTER
+try:
+    from ._speedups import fast_unquote, fast_pathstr2path
+except ImportError:
+    fast_unquote = None
+    fast_pathstr2path = None
 
 
 _MARKER = object()
@@ -280,7 +285,7 @@
         return s
 
 
-def unquote(s):
+def py_unquote(s):
     """Unquote the content of `s`: handle all patterns ``%xNN``,
     ``%uNNNN`` or ``%UNNNNNNNN``.
 
@@ -317,7 +322,13 @@
     return _EMPTY_STR.join(res)
 
 
-def pathstr2path(varname):
+if fast_unquote:
+    unquote = fast_unquote
+else:
+    unquote = py_unquote
+
+
+def py_pathstr2path(varname):
     """Parse a dot-separated path string `varname` into a tuple of
     unquoted path items
 
@@ -345,6 +356,12 @@
         return tuple()
 
 
+if fast_pathstr2path:
+    pathstr2path = fast_pathstr2path
+else:
+    pathstr2path = py_pathstr2path
+
+
 def _split_ns(varname):
     """Split the variable name string `varname` into the namespace and
     the namespace-specific name
@@ -355,7 +372,6 @@
     :rtype: tuple(str or None, str)
 
     """
-    
     ns, sep, rest = varname.partition(_NS_SEPARATOR)
     if sep:
         return (unquote(ns), rest)
@@ -366,7 +382,7 @@
 def _split_filters(varname):
     """Split off the filter part from the `varname` string
 
-    :type varname: str    
+    :type varname: str
     :return: The tuple of the variable name without the filters and a list
              of filters
     :rtype: tuple(str, list)
--- a/setup.py	Wed Dec 29 13:33:11 2021 +0100
+++ b/setup.py	Fri Dec 31 21:24:16 2021 +0100
@@ -3,6 +3,7 @@
 
 import re
 import os
+import platform
 import sys
 try:
     from setuptools import setup
@@ -43,6 +44,58 @@
 all_requirements.extend(yaml_requirements)
 all_requirements.extend(toml_requirements)
 
+
+cmdclass = {}
+ext_modules = []
+
+#
+# Handle the optinal C-extension for Python3.7+ and CPython only.
+# PyPy does not need this.
+#
+
+if (platform.python_implementation() == "CPython"
+    and (sys.version_info[0] > 3
+         or (sys.version_info[0] == 3 and sys.version_info[1] >= 7))):
+
+    py_limited_api = False
+
+    if py_limited_api:
+        define_macros = [("Py_LIMITED_API", "0x03070000")]
+    else:
+        define_macros = []
+
+    try:
+        from setuptools import Extension
+    except ImportError:
+        from distutils.core import Extension
+
+    ext_modules = [
+        Extension(
+            name="configmix._speedups",
+            sources=["configmix/_speedups.c"],
+            define_macros=define_macros,
+            py_limited_api=py_limited_api,
+            optional=True
+        ),
+    ]
+
+    if py_limited_api:
+        #
+        # Build a wheel that is properly named using the stable API
+        #
+        try:
+            import wheel.bdist_wheel
+        except ImportError:
+            cmdclass = {}
+        else:
+            class BDistWheel(wheel.bdist_wheel.bdist_wheel):
+                def finalize_options(self):
+                    # Synchronize this with Py_LIMITED_API
+                    self.py_limited_api = 'cp37'
+                    super().finalize_options()
+
+            cmdclass = {'bdist_wheel': BDistWheel}
+
 setup(
     name="ConfigMix",
     version=version,
@@ -69,8 +122,10 @@
         "Topic :: Software Development :: Libraries :: Python Modules"
     ],
     python_requires=">=2.6",
+    ext_modules=ext_modules,
+    cmdclass=cmdclass,
     extras_require={
-        "aws" : aws_requirements,        
+        "aws" : aws_requirements,
         "toml": toml_requirements,
         "yaml": yaml_requirements,
         "all" : all_requirements,
--- a/tests/_perf_config.py	Wed Dec 29 13:33:11 2021 +0100
+++ b/tests/_perf_config.py	Fri Dec 31 21:24:16 2021 +0100
@@ -15,11 +15,20 @@
 opts = sys.argv[1:]
 all = not opts or "all" in opts
 
+try:
+    from configmix.config import fast_unquote, fast_pathstr2path
+except ImportError:
+    fast_unquote = fast_pathstr2path = None
+
 setup = """
 import os
 
 import configmix
-import configmix.config
+from configmix.config import _HIER_SEPARATOR, quote, py_pathstr2path, py_unquote
+try:
+    from configmix.config import fast_unquote, fast_pathstr2path
+except ImportError:
+    fast_unquote = fast_pathstr2path = None
 
 TESTDATADIR = os.path.join(
     os.path.abspath(os.path.dirname(configmix.__file__)),
@@ -27,10 +36,6 @@
     "tests",
     "data")
 
-unquote = configmix.unquote
-quote = configmix.quote
-pathstr2path = configmix.pathstr2path
-
 cfg = configmix.load(os.path.join(TESTDATADIR, "conf_perf.py"))
 
 se = u""
@@ -43,12 +48,20 @@
 num_quote = 1 * num
 
 if all or "quote" in opts or "unquote" in opts or "path" in opts:
-    print("unquote/nothing/split: %.4f" % timeit.timeit('a = tuple([unquote(vp) for vp in u"abc.def.hij".split(configmix.config._HIER_SEPARATOR)])', setup=setup, number=num_quote))
-    print("unquote/yes/split: %.4f" % timeit.timeit('a = [unquote(vp) for vp in u"ab%x20.def.h%x2ej".split(configmix.config._HIER_SEPARATOR)]', setup=setup, number=num_quote))
-    print("unquote/nothing/no-split: %.4f" % timeit.timeit('a = [unquote(vp) for vp in (u"abc," u"def", u"hij")]', setup=setup, number=num_quote))
-    print("unquote/yes/no-split: %.4f" % timeit.timeit('a = [unquote(vp) for vp in (u"ab%x20", u"def", u"h%x2ej")]', setup=setup, number=num_quote))
-    print("pathstr2path/non-empty: %.4f" % timeit.timeit('a = pathstr2path(s1)', setup=setup, number=num_quote))
-    print("pathstr2path/empty: %.4f" % timeit.timeit('a = pathstr2path(se)', setup=setup, number=num_quote))    
+    print("unquote/nothing/split: %.4f" % timeit.timeit('a = [py_unquote(vp) for vp in u"abc.def.hij".split(_HIER_SEPARATOR)]', setup=setup, number=num_quote))
+    print("unquote/yes/split: %.4f" % timeit.timeit('a = [py_unquote(vp) for vp in u"ab%x20.def.h%x2ej".split(_HIER_SEPARATOR)]', setup=setup, number=num_quote))
+    print("unquote/nothing/no-split: %.4f" % timeit.timeit('a = [py_unquote(vp) for vp in (u"abc," u"def", u"hij")]', setup=setup, number=num_quote))
+    print("unquote/yes/no-split: %.4f" % timeit.timeit('a = [py_unquote(vp) for vp in (u"ab%x20", u"def", u"h%x2ej")]', setup=setup, number=num_quote))
+    if fast_unquote:
+        print("fast-unquote/nothing/split: %.4f" % timeit.timeit('a = [fast_unquote(vp) for vp in u"abc.def.hij".split(_HIER_SEPARATOR)]', setup=setup, number=num_quote))
+        print("fast-unquote/yes/split: %.4f" % timeit.timeit('a = [fast_unquote(vp) for vp in u"ab%x20.def.h%x2ej".split(_HIER_SEPARATOR)]', setup=setup, number=num_quote))
+        print("fast-unquote/nothing/no-split: %.4f" % timeit.timeit('a = [fast_unquote(vp) for vp in (u"abc," u"def", u"hij")]', setup=setup, number=num_quote))
+        print("fast-unquote/yes/no-split: %.4f" % timeit.timeit('a = [fast_unquote(vp) for vp in (u"ab%x20", u"def", u"h%x2ej")]', setup=setup, number=num_quote))    
+    print("pathstr2path/non-empty: %.4f" % timeit.timeit('a = py_pathstr2path(s1)', setup=setup, number=num_quote))
+    print("pathstr2path/empty: %.4f" % timeit.timeit('a = py_pathstr2path(se)', setup=setup, number=num_quote))
+    if fast_pathstr2path:
+        print("fast-pathstr2path/non-empty: %.4f" % timeit.timeit('a = fast_pathstr2path(s1)', setup=setup, number=num_quote))
+        print("fast-pathstr2path/empty: %.4f" % timeit.timeit('a = fast_pathstr2path(se)', setup=setup, number=num_quote))    
     print("quote/nothing: %.4f" % timeit.timeit('a = [quote(vp) for vp in (u"abc", u"def", u"hij")]', setup=setup, number=num_quote))
     print("quote/yes: %.4f" % timeit.timeit('a = [quote(vp) for vp in (u"ab:c", u"def", u"h.ij")]', setup=setup, number=num_quote))
     print("="*50)
--- a/tests/test.py	Wed Dec 29 13:33:11 2021 +0100
+++ b/tests/test.py	Fri Dec 31 21:24:16 2021 +0100
@@ -17,6 +17,7 @@
 import configmix.json
 import configmix.py
 import configmix.toml
+import configmix.config
 from configmix.compat import u, PY2
 
 
@@ -1215,19 +1216,6 @@
             "value",
             self._cfg.getvar_s("events.qc-2021%x2e1-5G-summit.xref.%x23"))
 
-    def test_quoting_and_unquoting_are_inverse(self):
-        for c in """%.:#|"'{}[]""":
-            qc = configmix.quote(c)
-            self.assertTrue(qc.startswith("%x") and len(qc) == 4)
-            self.assertEqual(c, configmix.unquote(qc))
-
-    def test_quoting_and_unquoting_are_identical(self):
-        # other characters
-        for c in """abc09/""":
-            qc = configmix.quote(c)
-            self.assertEqual(c, qc)
-            self.assertEqual(c, configmix.unquote(qc))
-
     def test_namespace_quoting(self):
         v1 = self._cfg.getvar("PY:version")
         v2 = self._cfg.getvar("P%x59:version")
@@ -1784,5 +1772,123 @@
         self.assertEqual(u"in the root namespace", jcfg[2])
 
 
+class _TParserMixin:
+    def test_quote_and_unquote_empty(self):
+        e = configmix.quote(u"")
+        self.assertEqual(u"", e)
+        self.assertEqual(u"", self.unquote(e))
+        
+    def test_quoting_and_unquoting_are_inverse(self):
+        for c in u"""%.:#|"'{}[]""":
+            qc = configmix.quote(c)
+            self.assertTrue(qc.startswith(u"%x") and len(qc) == 4)
+            self.assertEqual(c, self.unquote(qc))
+
+    def test_quoting_and_unquoting_are_identical(self):
+        # other characters
+        for c in configmix.config._QUOTE_SAFE:
+            qc = configmix.quote(c)
+            self.assertEqual(c, qc)
+            self.assertEqual(c, self.unquote(qc))
+
+    def test_unquote_unimax(self):
+        self.assertEqual(u"\U00019001", self.unquote(u"%U00019001"))
+        self.assertEqual(u"X\U00019AF1Z", self.unquote(u"X%U00019aF1Z"))
+
+    def test_unquote_base_plane(self):
+        self.assertEqual(u"\uFFFF", self.unquote(u"%uffff"))
+        self.assertEqual(u"X\uFFFFZ", self.unquote(u"X%uffffZ"))
+
+    def test_unquote_latin(self):
+        self.assertEqual(u"\xFF", self.unquote(u"%xff"))
+        self.assertEqual(u"X\xFFZ", self.unquote(u"X%xffZ"))
+
+    def test_unquote_zero(self):
+        self.assertEqual(u"\x00", self.unquote(u"%x00"))
+        self.assertEqual(u"X\x00Z", self.unquote(u"X%x00Z"))
+
+    def test_unquote_adjacent_x(self):
+        self.assertEqual(u"\x00\x01\xA0\xB0\xFF",
+                         self.unquote(u"%x00%x01%xA0%xB0%xFF"))
+
+    def test_unquote_adjacent_u(self):
+        self.assertEqual(u"\u0000\u0001\u00A0\uABCD\uFEFE",
+                         self.unquote(u"%u0000%u0001%u00A0%uabCD%ufeFE"))
+
+    def test_unquote_adjacent_U(self):
+        self.assertEqual(
+            u"\U00000000\U00000001\U000000A0\U0001ABCD\U0001FEFE",
+            self.unquote(u"%U00000000%U00000001%U000000A0%U0001abCD%U0001feFE"))
+
+    def test_invalid_hex_digits(self):
+        self.assertRaises(
+            ValueError,
+            self.unquote,
+            u"%xgG")
+        self.assertRaises(
+            ValueError,
+            self.unquote,
+            u"%ugGGG")
+        self.assertRaises(
+            ValueError,
+            self.unquote,
+            u"%UgGGGgGGG")
+
+    def test_invalid_too_short(self):
+        self.assertRaises(
+            ValueError,
+            self.unquote,
+            u"%x0")
+        self.assertRaises(
+            ValueError,
+            self.unquote,
+            u"%u000")
+        self.assertRaises(
+            ValueError,
+            self.unquote,
+            u"%U0000000")
+
+    def test_invalid_too_short_no_sigil(self):
+        self.assertRaises(
+            ValueError,
+            self.unquote,
+            u"%")
+
+    def test_empty_pathstr(self):
+        # an empty path string returns an empty path tuple
+        self.assertEqual(tuple(), self.pathstr2path(u""))
+
+    def test_split(self):
+        p = self.pathstr2path(u"abc.def.hik.jkl")
+        self.assertEqual((u"abc", u"def", u"hik", u"jkl"), p)
+
+    def test_split_all_empty_parts(self):
+        p = self.pathstr2path(u"....")
+        self.assertEqual((u"", u"", u"", u"", u""), p)
+
+    def test_split_all_empty_tail(self):
+        p = self.pathstr2path(u"1.2.")
+        self.assertEqual((u"1", u"2", u""), p)
+
+    def test_split_unquote(self):
+        p = self.pathstr2path(u"a%x2Eb.c%u002Ed.e%U0000002Ef")
+        self.assertEqual((u"a.b", u"c.d", u"e.f"), p)
+
+
+class T09Parser(_TParserMixin, unittest.TestCase):
+
+    def setUp(self):
+        self.unquote = configmix.config.py_unquote
+        self.pathstr2path = configmix.config.py_pathstr2path
+
+
+if configmix.config.fast_unquote is not None:
+    class T10FastParser(_TParserMixin, unittest.TestCase):
+
+        def setUp(self):
+            self.unquote = configmix.config.fast_unquote
+            self.pathstr2path = configmix.config.fast_pathstr2path
+
+
 if __name__ == "__main__":
     unittest.main()