diff --git a/minimum-constraints.txt b/minimum-constraints.txt index d077c395..d8961a2b 100644 --- a/minimum-constraints.txt +++ b/minimum-constraints.txt @@ -56,6 +56,7 @@ prompt-toolkit==2.0.1 # PyYAML is pulled in by zhmccli, zhmcclient, yamlloader PyYAML==5.3.1 +pyparsing==3.1.0 jsonschema==3.0.1 yamlloader==0.5.5 @@ -208,7 +209,6 @@ pathlib2==2.3.3; python_version < '3.4' and sys_platform != 'win32' # twine 3.0.0 depends on pkginfo>=1.4.2 pkginfo==1.4.2 py==1.11.0 -pyparsing==3.0.7 # tox 4.0.0 started using pyproject-api and requires pyproject-api>=1.2.1 pyproject-api==1.2.1; python_version >= '3.7' # build is using pyproject-hooks diff --git a/requirements.txt b/requirements.txt index c1faceb8..e154d58c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -39,6 +39,8 @@ prompt-toolkit>=2.0.1 PyYAML>=5.3.1,!=5.4.0,!=5.4.1; python_version <= '3.11' PyYAML>=5.3.1,!=5.4.0,!=5.4.1,!=6.0.0; python_version >= '3.12' +pyparsing>=3.1.0 + jsonschema>=3.0.1 yamlloader>=0.5.5 diff --git a/tests/function/test_parse_name_value.py b/tests/function/test_parse_name_value.py new file mode 100644 index 00000000..6f8a8f9c --- /dev/null +++ b/tests/function/test_parse_name_value.py @@ -0,0 +1,864 @@ +""" +Function test for '_parse_name_value' module. +""" + +from __future__ import absolute_import, print_function + +import re +import pytest + +from zhmccli import NameValueParseError, NameValueParser + + +TESTCASES_PARSE_NAME_VALUE = [ + # Testcases for test_parse_name_value() + # + # Each list item is a testcase with the following tuple items: + # * desc (str) - Testcase description. + # * init_kwargs (dict) - Keyword arguments for NameValueParser(). + # * parse_args (list) - Positional arguments for NameValueParser.parse(). + # * parse_kwargs (dict) - Keyword arguments for NameValueParser.parse(). + # * exp_result (object) - Expected return value from NameValueParser.parse() + # if expected to succeed, or None if expected to fail. + # * exp_exc_type (exception) - Expected exception type if expected to fail, + # or None if expected to succeed. + # * exp_exc_message (str) - Regexp pattern to match expected exception + # message if expected to fail, or None if expected to succeed. + + ( + r"""Simple null value 'null'.""", + dict(), + [], + dict(value=r'null'), + None, + None, None + ), + + ( + r"""Simple null value 'null' as positional arg.""", + dict(), + [r'null'], + dict(), + None, + None, None + ), + + ( + r"""Simple bool 'true'.""", + dict(), + [], + dict(value=r'true'), + True, + None, None + ), + ( + r"""Simple bool 'false'.""", + dict(), + [], + dict(value=r'false'), + False, + None, None + ), + + ( + r"""Simple int '42'.""", + dict(), + [], + dict(value=r'42'), + 42, + None, None + ), + + ( + r"""Simple float '42.0'.""", + dict(), + [], + dict(value=r'42.0'), + 42.0, + None, None + ), + ( + r"""Simple float '.1'.""", + dict(), + [], + dict(value=r'.1'), + 0.1, + None, None + ), + ( + r"""Simple float '1.'.""", + dict(), + [], + dict(value=r'1.'), + 1.0, + None, None + ), + + ( + r"""Simple quoted string '"abc"'.""", + dict(), + [], + dict(value=r'"abc"'), + 'abc', + None, None + ), + ( + r"""Simple quoted string '"42"'.""", + dict(), + [], + dict(value=r'"42"'), + '42', + None, None + ), + ( + r"""Simple quoted string '"true"'.""", + dict(), + [], + dict(value=r'"true"'), + 'true', + None, None + ), + ( + r"""Simple quoted string '"false"'.""", + dict(), + [], + dict(value=r'"false"'), + 'false', + None, None + ), + ( + r"""Simple quoted string '"null"'.""", + dict(), + [], + dict(value=r'"null"'), + 'null', + None, None + ), + ( + r"""Simple unquoted string 'abc'.""", + dict(), + [], + dict(value=r'abc'), + 'abc', + None, None + ), + ( + r"""Simple unquoted string 'a'.""", + dict(), + [], + dict(value=r'a'), + 'a', + None, None + ), + ( + r"""Simple quoted string with blank '"a b"'.""", + dict(), + [], + dict(value=r'"a b"'), + 'a b', + None, None + ), + ( + r"""Simple quoted string with escaped newline '"a\nb"'.""", + dict(), + [], + dict(value=r'"a\nb"'), + 'a\nb', + None, None + ), + ( + r"""Simple quoted string with escaped carriage return '"a\rb"'.""", + dict(), + [], + dict(value=r'"a\rb"'), + 'a\rb', + None, None + ), + ( + r"""Simple quoted string with escaped tab '"a\tb"'.""", + dict(), + [], + dict(value=r'"a\tb"'), + 'a\tb', + None, None + ), + ( + r"""Simple quoted string with escaped backslash '"a\\b"'.""", + dict(), + [], + dict(value=r'"a\\b"'), + 'a\\b', + None, None + ), + ( + r"""Invalid simple unquoted string with blank 'a b'.""", + dict(), + [], + dict(value=r'a b'), + None, + NameValueParseError, r".*: Expected end of text, found 'b'.*" + ), + ( + r"""Invalid simple unquoted string with leading blank ' b'.""", + dict(), + [], + dict(value=r' b'), + None, + NameValueParseError, r".*: , found 'b'.*" + ), + ( + r"""Simple unquoted string with trailing blank 'a '.""", + # TODO: Find out why this is not invalid. + dict(), + [], + dict(value=r'a '), + 'a', + None, None + ), + ( + r"""Invalid simple unquoted string with escaped newline 'a\nb'.""", + dict(), + [], + dict(value=r'a\nb'), + None, + NameValueParseError, r".*: Expected end of text, found '\\'.*" + ), + + ( + r"""Empty array.""", + dict(), + [], + dict(value=r'[]'), + [], + None, None + ), + ( + r"""Array with one null value 'null'.""", + dict(), + [], + dict(value=r'[null]'), + [None], + None, None + ), + ( + r"""Array with one boolean 'true'.""", + dict(), + [], + dict(value=r'[true]'), + [True], + None, None + ), + ( + r"""Array with one boolean 'false'.""", + dict(), + [], + dict(value=r'[false]'), + [False], + None, None + ), + ( + r"""Array with one int '42'.""", + dict(), + [], + dict(value=r'[42]'), + [42], + None, None + ), + ( + r"""Array with one float '42.0'.""", + dict(), + [], + dict(value=r'[42.0]'), + [42.0], + None, None + ), + ( + r"""Array with one quoted string '"null"'.""", + dict(), + [], + dict(value=r'["null"]'), + ['null'], + None, None + ), + ( + r"""Array with one quoted string '"true"'.""", + dict(), + [], + dict(value=r'["true"]'), + ['true'], + None, None + ), + ( + r"""Array with one quoted string '"false"'.""", + dict(), + [], + dict(value=r'["false"]'), + ['false'], + None, None + ), + ( + r"""Array with one quoted string '"a"'.""", + dict(), + [], + dict(value=r'["a"]'), + ['a'], + None, None + ), + ( + r"""Array with one unquoted string 'a'.""", + dict(), + [], + dict(value=r'[a]'), + ['a'], + None, None + ), + ( + r"""Array with one quoted string '"a]"'.""", + dict(), + [], + dict(value=r'["a]"]'), + ['a]'], + None, None + ), + ( + r"""Array with one quoted string '"[a"'.""", + dict(), + [], + dict(value=r'["[a"]'), + ['[a'], + None, None + ), + ( + r"""Array with two int items.""", + dict(), + [], + dict(value=r'[42,43]'), + [42, 43], + None, None + ), + ( + r"""Invalid array with unquoted string with blank 'a b'.""", + dict(), + [], + dict(value=r'[a b]'), + None, + NameValueParseError, r".*: , found 'b'.*" + ), + ( + r"""Array with unquoted string with leading blank ' b'.""", + # TODO: Find out why this is not invalid. + dict(), + [], + dict(value=r'[ b]'), + ['b'], + None, None + ), + ( + r"""Array with unquoted string with trailing blank 'a '.""", + # TODO: Find out why this is not invalid. + dict(), + [], + dict(value=r'[a ]'), + ['a'], + None, None + ), + ( + r"""Invalid array with trailing comma after one item.""", + dict(), + [], + dict(value=r'[42,]'), + None, + NameValueParseError, r".*: , found ','.*" + ), + ( + r"""Invalid array with leading comma before one item.""", + dict(), + [], + dict(value=r'[,42]'), + None, + NameValueParseError, r".*: , found ','.*" + ), + ( + r"""Invalid array with two commas between two items.""", + dict(), + [], + dict(value=r'[42,,43]'), + None, + NameValueParseError, r".*: , found ','.*" + ), + ( + r"""Invalid array with missing closing ']'.""", + dict(), + [], + dict(value=r'[42'), + None, + NameValueParseError, r".*: , found end of text.*" + ), + ( + r"""Invalid array with missing opening '['.""", + dict(), + [], + dict(value=r'42]'), + None, + NameValueParseError, r".*: Expected end of text, found '\]'.*" + ), + ( + r"""Invalid array with additional '[' after begin.""", + dict(), + [], + dict(value=r'[[42]'), + None, + NameValueParseError, r".*: , found end of text.*" + ), + ( + r"""Invalid array with additional ']'after begin.""", + dict(), + [], + dict(value=r'[]42]'), + None, + NameValueParseError, r".*: Expected end of text, found '42'.*" + ), + ( + r"""Invalid array with additional '[' before end.""", + dict(), + [], + dict(value=r'[42[]'), + None, + NameValueParseError, r".*: , found '\['.*" + ), + ( + r"""Invalid array with additional ']' before end.""", + dict(), + [], + dict(value=r'[42]]'), + None, + NameValueParseError, r".*: Expected end of text, found '\]'.*" + ), + + ( + r"""Array with one nested empty array.""", + dict(), + [], + dict(value=r'[[]]'), + [[]], + None, None + ), + ( + r"""Array with one nested empty object.""", + dict(), + [], + dict(value=r'[{}]'), + [dict()], + None, None + ), + ( + r"""Array with one nested array that has one item.""", + dict(), + [], + dict(value=r'[[42]]'), + [[42]], + None, None + ), + ( + r"""Array with one nested object that has one item.""", + dict(), + [], + dict(value=r'[{x=42}]'), + [dict(x=42)], + None, None + ), + ( + r"""Array with two nested array items.""", + dict(), + [], + dict(value=r'[[42,43],[]]'), + [[42, 43], []], + None, None + ), + ( + r"""Array with deeply nested array items.""", + dict(), + [], + dict(value=r'[a,[b,[c,[d,e]]]]'), + ['a', ['b', ['c', ['d', 'e']]]], + None, None + ), + + ( + r"""Empty object.""", + dict(), + [], + dict(value=r'{}'), + dict(), + None, None + ), + ( + r"""Object with one item of value 'null'.""", + dict(), + [], + dict(value=r'{x=null}'), + dict(x=None), + None, None + ), + ( + r"""Object with one item of boolean value 'true'.""", + dict(), + [], + dict(value=r'{x=true}'), + dict(x=True), + None, None + ), + ( + r"""Object with one item of boolean value 'false'.""", + dict(), + [], + dict(value=r'{x=false}'), + dict(x=False), + None, None + ), + ( + r"""Object with one item of int value '42'.""", + dict(), + [], + dict(value=r'{x=42}'), + dict(x=42), + None, None + ), + ( + r"""Object with one item of float value '42.0'.""", + dict(), + [], + dict(value=r'{x=42.0}'), + dict(x=42.0), + None, None + ), + ( + r"""Object with one item of quoted string value '"null"'.""", + dict(), + [], + dict(value=r'{x="null"}'), + dict(x='null'), + None, None + ), + ( + r"""Object with one item of quoted string value '"true"'.""", + dict(), + [], + dict(value=r'{x="true"}'), + dict(x='true'), + None, None + ), + ( + r"""Object with one item of quoted string value '"false"'.""", + dict(), + [], + dict(value=r'{x="false"}'), + dict(x='false'), + None, None + ), + ( + r"""Object with one item of quoted string value '"a"'.""", + dict(), + [], + dict(value=r'{x="a"}'), + dict(x='a'), + None, None + ), + ( + r"""Object with one item of unquoted string value 'a'.""", + dict(), + [], + dict(value=r'{x=a}'), + dict(x='a'), + None, None + ), + ( + r"""Object with one item of quoted string value '"a}"'.""", + dict(), + [], + dict(value=r'{x="a}"}'), + dict(x='a}'), + None, None + ), + ( + r"""Object with one item of quoted string value '"{a"'.""", + dict(), + [], + dict(value=r'{x="{a"}'), + dict(x='{a'), + None, None + ), + ( + r"""Object with two items of int values.""", + dict(), + [], + dict(value=r'{x=42,y=43}'), + dict(x=42, y=43), + None, None + ), + ( + r"""Object with one item of unquoted string value with blank 'a b'.""", + dict(), + [], + dict(value=r'{x=a b}'), + None, + NameValueParseError, r".*: , found 'b'.*" + ), + ( + r"""Object with one item of unquoted string value with leading """ + r"""blank ' b'.""", + dict(), + [], + dict(value=r'{x= b}'), + None, + NameValueParseError, r".*: , found 'x'.*" + ), + ( + r"""Object with one item of unquoted string value with trailing """ + r"""blank 'a '.""", + # TODO: Find out why this is not invalid. + dict(), + [], + dict(value=r'{x=a }'), + dict(x='a'), + None, None + ), + + ( + r"""Object with one item of name with hyphen.""", + dict(), + [], + dict(value=r'{abc-def=42}'), + {'abc-def': 42}, + None, None + ), + ( + r"""Object with one item of name with underscore.""", + dict(), + [], + dict(value=r'{abc_def=42}'), + {'abc_def': 42}, + None, None + ), + ( + r"""Object with one item of name with numeric.""", + dict(), + [], + dict(value=r'{a0=42}'), + {'a0': 42}, + None, None + ), + ( + r"""Object with one item of name with leading hyphen.""", + dict(), + [], + dict(value=r'{-a=42}'), + None, + NameValueParseError, r".*: , found '-'.*" + ), + ( + r"""Object with one item of name with leading underscore.""", + dict(), + [], + dict(value=r'{_a=42}'), + None, + NameValueParseError, r".*: , found '_'.*" + ), + ( + r"""Object with one item of name with leading numeric.""", + dict(), + [], + dict(value=r'{0x=42}'), + None, + NameValueParseError, r".*: , found '0x'.*" + ), + ( + r"""Invalid object with one item of name/value with '='""", + dict(), + [], + dict(value=r'{a=b=42}'), + None, + NameValueParseError, r".*: , found '='.*" + ), + ( + r"""Invalid object with one item with trailing comma.""", + dict(), + [], + dict(value=r'{x=42,}'), + None, + NameValueParseError, r".*: , found ','.*" + ), + ( + r"""Invalid object with one item with leading comma.""", + dict(), + [], + dict(value=r'{x=,42}'), + None, + NameValueParseError, r".*: , found 'x'.*" + ), + ( + r"""Invalid object with two commas between two items.""", + dict(), + [], + dict(value=r'{x=42,,y=43}'), + None, + NameValueParseError, r".*: , found ','.*" + ), + ( + r"""Invalid object with missing closing '}'.""", + dict(), + [], + dict(value=r'{x=42'), + None, + NameValueParseError, r".*: , found end of text.*" + ), + ( + r"""Invalid object with missing opening '{'.""", + dict(), + [], + dict(value=r'x=42}'), + None, + NameValueParseError, r".*: Expected end of text, found '='.*" + ), + ( + r"""Invalid object with additional '{' after begin.""", + dict(), + [], + dict(value=r'{{x=42}'), + None, + NameValueParseError, r".*: , found '\{'.*" + ), + ( + r"""Invalid object with additional '}' after begin.""", + dict(), + [], + dict(value=r'{}x=42}'), + None, + NameValueParseError, r".*: Expected end of text, found 'x'.*" + ), + ( + r"""Invalid object with additional '{' before end.""", + dict(), + [], + dict(value=r'{x=42{}'), + None, + NameValueParseError, r".*: , found '\{'.*" + ), + ( + r"""Invalid object with additional '}' before end.""", + dict(), + [], + dict(value=r'{x=42}}'), + None, + NameValueParseError, r".*: Expected end of text, found '\}'.*" + ), + + ( + r"""Object with nested empty object.""", + dict(), + [], + dict(value=r'{x={}}'), + dict(x=dict()), + None, None + ), + ( + r"""Object with nested empty array.""", + dict(), + [], + dict(value=r'{x=[]}'), + dict(x=[]), + None, None + ), + ( + r"""Object with nested object that has one item.""", + dict(), + [], + dict(value=r'{x={y=42}}'), + dict(x=dict(y=42)), + None, None + ), + ( + r"""Object with nested array that has one item.""", + dict(), + [], + dict(value=r'{x=[42]}'), + dict(x=[42]), + None, None + ), + ( + r"""Object with deeply nested object items.""", + dict(), + [], + dict(value=r'{a={b={c={d=x,e=y}}}}'), + dict(a=dict(b=dict(c=dict(d='x', e='y')))), + None, None + ), + + ( + r"""Non-default quoting char "'".""", + dict(quoting_char="'"), + [], + dict(value=r"'a'"), + 'a', + None, None + ), + ( + r"""Non-default separator char ';'.""", + dict(separator_char=';'), + [], + dict(value=r'[42;43]'), + [42, 43], + None, None + ), + ( + r"""Invalid non-default separator char ' '.""", + dict(separator_char=' '), + [], + dict(value=r'[42 43]'), + None, + NameValueParseError, r".*: , found '43'.*" + ), + ( + r"""Non-default assignment char ':'.""", + dict(assignment_char=':'), + [], + dict(value=r'{x:42}'), + dict(x=42), + None, None + ), + # TODO: Add more testcases with non-default special characters +] + + +@pytest.mark.parametrize( + "desc, init_kwargs, parse_args, parse_kwargs, exp_result, exp_exc_type, " + "exp_exc_message", + TESTCASES_PARSE_NAME_VALUE) +def test_parse_name_value( + desc, init_kwargs, parse_args, parse_kwargs, exp_result, exp_exc_type, + exp_exc_message): + # pylint: disable=unused-argument + """ + Test NameValueParser.parse() method. + """ + + parser = NameValueParser(**init_kwargs) + + if exp_exc_type is None: + + # The code to be tested + result = parser.parse(*parse_args, **parse_kwargs) + + assert result == exp_result + + else: + + with pytest.raises(exp_exc_type) as exc_info: + + # The code to be tested + parser.parse(*parse_args, **parse_kwargs) + + exc_value = exc_info.value + exc_message = str(exc_value) + match_str = exp_exc_message + '$' + assert re.match(match_str, exc_message) diff --git a/zhmccli/__init__.py b/zhmccli/__init__.py index f4186b7a..e39f9995 100644 --- a/zhmccli/__init__.py +++ b/zhmccli/__init__.py @@ -19,6 +19,7 @@ from __future__ import absolute_import from ._version import * # noqa: F401 +from ._parse_name_value import * # noqa: F401 from ._cmd_info import * # noqa: F401 from ._cmd_session import * # noqa: F401 from ._cmd_console import * # noqa: F401 diff --git a/zhmccli/_parse_name_value.py b/zhmccli/_parse_name_value.py new file mode 100644 index 00000000..35952e38 --- /dev/null +++ b/zhmccli/_parse_name_value.py @@ -0,0 +1,237 @@ +""" +Parser for complex name-value strings. +""" + +from __future__ import absolute_import + +import pyparsing +from pyparsing import Forward, Opt, Group, nums, alphas, Literal, Char, Word, \ + CharsNotIn, QuotedString, DelimitedList, Suppress, ParseResults + +__all__ = ['NameValueParseError', 'NameValueParser'] + + +# pyparsing parse actions + +# For debugging parse actions, there are two possibilities: +# * Add @pyparsing.trace_parse_action decorator to parse action +# * Invoke set_debug(True) on grammar element + +def _as_python_list(toks): + """Return parsed array as Python list""" + return ParseResults.List(toks.as_list()) + + +def _as_python_dict(toks): + """Return parsed object as Python dict""" + result = {} + for name, value in toks: + result[name] = value + return result + + +def _as_python_int(toks): + """Return parsed int string as Python int""" + assert len(toks) == 1 + return int(toks[0]) + + +def _as_python_float(toks): + """Return parsed float string as Python float""" + float_str = ''.join(toks) + return float(float_str) + + +def _as_python_bool(toks): + """Return parsed bool string as Python bool""" + assert len(toks) == 1 + mapping = { + 'true': True, + 'false': False, + } + return mapping[toks[0]] + + +def _as_python_null(toks): + """Return parsed null string as Python None""" + assert len(toks) == 1 + mapping = { + 'null': None, + } + # `None` can only be returned by using the ability of parse actions to + # modify their ParseResults argument in place. + toks[0] = mapping[toks[0]] + + +class NameValueParseError(Exception): + # pylint: disable=too-few-public-methods + """ + Indicates an error when parsing a complex name-value string. + """ + pass + + +class NameValueParser(object): + # pylint: disable=too-few-public-methods + r""" + Parser for complex name-value strings. + + The syntax for a complex name-value string is as follows: + + * ``complex-value := simple-value | array-value | object-value`` + + * ``simple-value`` is a value of a simple type, as follows: + + - none/null: ``null``. + Will be returned as Python ``None``. + + - bool: ``true`` or ``false``. + Will be returned as Python ``True`` or ``False``. + + - int: decimal string representation of an integer number that is + parseable by ``int()``. + Will be returned as a Python ``int`` value. + + - float: string representation of a floating point number that is + parseable by ``float()``. + Will be returned as a Python ``float`` value. + + - string: Unicode string value, with character range and representation + as for JSON string values, surrounded by double quotes. The quoting + character and the escape character can be configured. The quoting + characters can be omitted if the string value is not escaped, does not + contain any of ``[]{}``, blank, the escape character, the quoting + character, the assignment character, and if it cannot be interpreted + as a value of one of the other simple types. + Will be returned as an unescaped Python Unicode string value. + + The following escape sequences are recognized in quoted strings + (shown using the default escape character): + + - ``\b``: U+0008 (backspace) + - ``\t``: U+0009 (tab) + - ``\n``: U+000A (newline) + - ``\r``: U+000D (carriage return) + - ``\uNNNN``: 4..6 digit Unicode code point + + * ``array-value`` is a value of array/list type, specified as a + comma-separated list of ``complex-value`` items, surrounded by + ``[`` and ``]``. + The item separator character can be configured. + Will be returned as a Python ``list`` value. + + * ``object-value`` is a value of object/dict type, specified as a + comma-separated list of ``name=complex-value`` items, surrounded by + ``{`` and ``}``. + The item separator and assignment characters can be configured. + ``name`` must begin with an alphabetic character, + and may be followed by zero or more alphanumeric characters, + ``-`` (hyphen) and ``_`` (underscore). + Will be returned as a Python ``dict`` value. + + Examples (using the default quoting, escape, separator and assignment + characters): + + ``` + parser = NameValueParser() + + print(parser.parse('null')) # -> None + print(parser.parse('false')) # -> False + print(parser.parse('true')) # -> True + print(parser.parse('"true"')) # -> 'true' + print(parser.parse('42')) # -> 42 + print(parser.parse('42.')) # -> 42.0 + print(parser.parse('.1')) # -> 0.1 + print(parser.parse('abc')) # -> 'abc' + print(parser.parse('"ab c"')) # -> 'ab c' + print(parser.parse('"ab\nc"')) # -> 'ab\nc' + print(parser.parse('[abc,42]')) # -> ['abc', 42] + + print(parser.parse('{interface-name=abc,port=42}')) + # -> {'interface-name': 'abc', 'port': 42} + + print(parser.parse('{title="ab\tc",host-addresses=[10.11.12.13,null]}')) + # -> {'title': 'ab\tc', 'host-addresses': ['10.11.12.13', None]} + """ + + def __init__(self, quoting_char='"', separator_char=',', + assignment_char='='): + """ + Parameters: + + quoting_char(str): Quoting character, used around strings to make + them quoted strings. + + separator_char(str): Separator character, used to separate items + in arrays and objects. + + assignment_char(str): Assignment character, used between name and + value within object items. + """ + assert len(quoting_char) == 1 + assert len(separator_char) == 1 + assert len(assignment_char) == 1 + + self.quoting_char = quoting_char + self.separator_char = separator_char + self.assignment_char = assignment_char + + # Note: Setting escape_char to something else does not work + escape_char = '\\' + + special_chars = quoting_char + separator_char + assignment_char + \ + escape_char + + # pyparsing grammar for complex name-value strings + null_value = Literal('null') + bool_value = Literal('true') | Literal('false') + int_value = Word(nums) + float_value = (Word(nums) + Char('.') + Opt(Word(nums))) | \ + (Char('.') + Word(nums)) + quoted_string_value = \ + QuotedString(quote_char=quoting_char, esc_char=escape_char) + unquoted_string_value = CharsNotIn(' []{}' + special_chars) + simple_value = null_value | bool_value | float_value | int_value | \ + quoted_string_value | unquoted_string_value + complex_value = Forward() + name = Word(alphas, alphas + nums + '-_') + named_value = Group(name + Suppress(assignment_char) + complex_value) + array_value = Suppress('[') + \ + Opt(DelimitedList(complex_value, delim=separator_char)) + \ + Suppress(']') + object_value = Suppress('{') + \ + Opt(DelimitedList(named_value, delim=separator_char)) + \ + Suppress('}') + # pylint: disable=pointless-statement + complex_value << (array_value | object_value | simple_value) + + array_value.add_parse_action(_as_python_list) + object_value.add_parse_action(_as_python_dict) + int_value.add_parse_action(_as_python_int) + float_value.add_parse_action(_as_python_float) + bool_value.add_parse_action(_as_python_bool) + null_value.add_parse_action(_as_python_null) + + self._grammar = complex_value + + def parse(self, value): + """ + Parse a complex name-value string and return the Python data object + representing the value. + + Parameters: + + value(str): The complex name-value string to be parsed. + + Returns: + object: Python object representing the complex name-value string. + + Raises: + NameValueParseError: Parsing error. + """ + try: + parse_result = self._grammar.parse_string(value, parse_all=True) + except pyparsing.ParseException as exc: + raise NameValueParseError( + "Cannot parse {!r}: {}".format(value, exc)) + return parse_result[0]