├── .gitignore ├── MANIFEST.in ├── README.md ├── rison ├── __init__.py ├── constants.py ├── decoder.py ├── encoder.py └── utils.py ├── setup.cfg ├── setup.py └── tests ├── __init__.py ├── decoder.py └── encoder.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | .idea 3 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pifantastic/python-rison/2d2c153cd12078af0ee00e9c3a66f9533524a081/MANIFEST.in -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # python-rison 2 | 3 | Python version of the rison encoder/decoder originally taken from http://mjtemplate.org/examples/rison.html 4 | 5 | ## Usage 6 | 7 | ```python 8 | import rison 9 | 10 | print rison.dumps({'foo': 'bar'}) # '(foo:bar)' 11 | 12 | print rison.loads('(foo:bar)') # {'foo': 'bar'} 13 | ``` 14 | 15 | ## Tests 16 | 17 | ``` 18 | pip install nose 19 | nosetests tests/*.py 20 | ``` 21 | -------------------------------------------------------------------------------- /rison/__init__.py: -------------------------------------------------------------------------------- 1 | from decoder import loads 2 | from encoder import dumps 3 | -------------------------------------------------------------------------------- /rison/constants.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | 4 | WHITESPACE = '' 5 | 6 | IDCHAR_PUNCTUATION = '_-./~' 7 | 8 | NOT_IDCHAR = ''.join([c for c in (chr(i) for i in range(127)) 9 | if not (c.isalnum() 10 | or c in IDCHAR_PUNCTUATION)]) 11 | 12 | # Additionally, we need to distinguish ids and numbers by first char. 13 | NOT_IDSTART = '-0123456789' 14 | 15 | # Regexp string matching a valid id. 16 | IDRX = ('[^' + NOT_IDSTART + NOT_IDCHAR + '][^' + NOT_IDCHAR + ']*') 17 | 18 | # Regexp to check for valid rison ids. 19 | ID_OK_RE = re.compile('^' + IDRX + '$', re.M) 20 | 21 | # Regexp to find the end of an id when parsing. 22 | NEXT_ID_RE = re.compile(IDRX, re.M) 23 | -------------------------------------------------------------------------------- /rison/decoder.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | import re 4 | 5 | from constants import NEXT_ID_RE, WHITESPACE 6 | 7 | 8 | class ParserException(Exception): 9 | pass 10 | 11 | 12 | class Parser(object): 13 | 14 | def __init__(self): 15 | self.string = None 16 | self.index = 0 17 | 18 | """ 19 | This parser supports RISON, RISON-A and RISON-O. 20 | """ 21 | def parse(self, string, format=str): 22 | if format in [list, 'A']: 23 | self.string = "!({0})".format(string) 24 | elif format in [dict, 'O']: 25 | self.string = "({0})".format(string) 26 | elif format is str: 27 | self.string = string 28 | else: 29 | raise ValueError("""Parse format should be one of str, list, dict, 30 | 'A' (alias for list), '0' (alias for dict).""") 31 | 32 | self.index = 0 33 | 34 | value = self.read_value() 35 | if self.next(): 36 | raise ParserException("unable to parse rison string %r" % (string,)) 37 | return value 38 | 39 | def read_value(self): 40 | c = self.next() 41 | 42 | if c == '!': 43 | return self.parse_bang() 44 | if c == '(': 45 | return self.parse_open_paren() 46 | if c == "'": 47 | return self.parse_single_quote() 48 | if c in '-0123456789': 49 | return self.parse_number() 50 | 51 | # fell through table, parse as an id 52 | s = self.string 53 | i = self.index-1 54 | 55 | m = NEXT_ID_RE.match(s, i) 56 | if m: 57 | _id = m.group(0) 58 | self.index = i + len(_id) 59 | return _id 60 | 61 | if c: 62 | raise ParserException("invalid character: '" + c + "'") 63 | raise ParserException("empty expression") 64 | 65 | def parse_array(self): 66 | ar = [] 67 | while 1: 68 | c = self.next() 69 | if c == ')': 70 | return ar 71 | 72 | if c is None: 73 | raise ParserException("unmatched '!('") 74 | 75 | if len(ar): 76 | if c != ',': 77 | raise ParserException("missing ','") 78 | elif c == ',': 79 | raise ParserException("extra ','") 80 | else: 81 | self.index -= 1 82 | n = self.read_value() 83 | ar.append(n) 84 | 85 | return ar 86 | 87 | def parse_bang(self): 88 | s = self.string 89 | c = s[self.index] 90 | self.index += 1 91 | if c is None: 92 | raise ParserException('"!" at end of input') 93 | if c not in self.bangs: 94 | raise ParserException('unknown literal: "!' + c + '"') 95 | x = self.bangs[c] 96 | if callable(x): 97 | return x(self) 98 | 99 | return x 100 | 101 | def parse_open_paren(self): 102 | count = 0 103 | o = {} 104 | 105 | while 1: 106 | c = self.next() 107 | if c == ')': 108 | return o 109 | if count: 110 | if c != ',': 111 | raise ParserException("missing ','") 112 | elif c == ',': 113 | raise ParserException("extra ','") 114 | else: 115 | self.index -= 1 116 | k = self.read_value() 117 | 118 | if self.next() != ':': 119 | raise ParserException("missing ':'") 120 | v = self.read_value() 121 | 122 | o[k] = v 123 | count += 1 124 | 125 | def parse_single_quote(self): 126 | s = self.string 127 | i = self.index 128 | start = i 129 | segments = [] 130 | 131 | while 1: 132 | if i >= len(s): 133 | raise ParserException('unmatched "\'"') 134 | 135 | c = s[i] 136 | i += 1 137 | if c == "'": 138 | break 139 | 140 | if c == '!': 141 | if start < i-1: 142 | segments.append(s[start:i-1]) 143 | c = s[i] 144 | i += 1 145 | if c in "!'": 146 | segments.append(c) 147 | else: 148 | raise ParserException('invalid string escape: "!'+c+'"') 149 | 150 | start = i 151 | 152 | if start < i-1: 153 | segments.append(s[start:i-1]) 154 | self.index = i 155 | return ''.join(segments) 156 | 157 | # Also any number start (digit or '-') 158 | def parse_number(self): 159 | s = self.string 160 | i = self.index 161 | start = i-1 162 | state = 'int' 163 | permitted_signs = '-' 164 | transitions = { 165 | 'int+.': 'frac', 166 | 'int+e': 'exp', 167 | 'frac+e': 'exp' 168 | } 169 | 170 | while 1: 171 | if i >= len(s): 172 | i += 1 173 | break 174 | 175 | c = s[i] 176 | i += 1 177 | 178 | if '0' <= c <= '9': 179 | continue 180 | 181 | if permitted_signs.find(c) >= 0: 182 | permitted_signs = '' 183 | continue 184 | 185 | state = transitions.get(state + '+' + c.lower(), None) 186 | if state is None: 187 | break 188 | if state == 'exp': 189 | permitted_signs = '-' 190 | 191 | self.index = i - 1 192 | s = s[start:self.index] 193 | if s == '-': 194 | raise ParserException("invalid number") 195 | if re.search('[.e]', s): 196 | return float(s) 197 | return int(s) 198 | 199 | # return the next non-whitespace character, or undefined 200 | def next(self): 201 | s = self.string 202 | i = self.index 203 | c = None 204 | 205 | while 1: 206 | if i == len(s): 207 | return None 208 | c = s[i] 209 | i += 1 210 | if c not in WHITESPACE: 211 | break 212 | 213 | self.index = i 214 | return c 215 | 216 | bangs = { 217 | 't': True, 218 | 'f': False, 219 | 'n': None, 220 | '(': parse_array 221 | } 222 | 223 | 224 | def loads(s, format=str): 225 | return Parser().parse(s, format=format) 226 | -------------------------------------------------------------------------------- /rison/encoder.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from utils import quote 4 | from constants import ID_OK_RE 5 | 6 | 7 | class Encoder(object): 8 | 9 | def __init__(self): 10 | pass 11 | 12 | @staticmethod 13 | def encoder(v): 14 | if isinstance(v, list): 15 | return Encoder.list 16 | elif isinstance(v, (str, basestring)): 17 | return Encoder.string 18 | elif isinstance(v, bool): 19 | return Encoder.bool 20 | elif isinstance(v, (float, int)): 21 | return Encoder.number 22 | elif isinstance(v, type(None)): 23 | return Encoder.none 24 | elif isinstance(v, dict): 25 | return Encoder.dict 26 | else: 27 | raise AssertionError('Unable to encode type: {0}'.format(type(v))) 28 | 29 | @staticmethod 30 | def encode(v): 31 | encoder = Encoder.encoder(v) 32 | return encoder(v) 33 | 34 | @staticmethod 35 | def list(x): 36 | a = ['!('] 37 | b = None 38 | for i in range(len(x)): 39 | v = x[i] 40 | f = Encoder.encoder(v) 41 | if f: 42 | v = f(v) 43 | if isinstance(v, (str, basestring)): 44 | if b: 45 | a.append(',') 46 | a.append(v) 47 | b = True 48 | a.append(')') 49 | return ''.join(a) 50 | 51 | @staticmethod 52 | def number(v): 53 | return str(v).replace('+', '') 54 | 55 | @staticmethod 56 | def none(_): 57 | return '!n' 58 | 59 | @staticmethod 60 | def bool(v): 61 | return '!t' if v else '!f' 62 | 63 | @staticmethod 64 | def string(v): 65 | if v == '': 66 | return "''" 67 | 68 | if ID_OK_RE.match(v): 69 | return v 70 | 71 | def replace(match): 72 | if match.group(0) in ["'", '!']: 73 | return '!' + match.group(0) 74 | return match.group(0) 75 | 76 | v = re.sub(r'([\'!])', replace, v) 77 | 78 | return "'" + v + "'" 79 | 80 | @staticmethod 81 | def dict(x): 82 | a = ['('] 83 | b = None 84 | ks = sorted(x.keys()) 85 | for i in ks: 86 | v = x[i] 87 | f = Encoder.encoder(v) 88 | if f: 89 | v = f(v) 90 | if isinstance(v, (str, basestring)): 91 | if b: 92 | a.append(',') 93 | a.append(Encoder.string(i)) 94 | a.append(':') 95 | a.append(v) 96 | b = True 97 | 98 | a.append(')') 99 | return ''.join(a) 100 | 101 | 102 | def encode_array(v): 103 | if not isinstance(v, list): 104 | raise AssertionError('encode_array expects a list argument') 105 | r = dumps(v) 106 | return r[2, len(r)-1] 107 | 108 | 109 | def encode_object(v): 110 | if not isinstance(v, dict) or v is None or isinstance(v, list): 111 | raise AssertionError('encode_object expects an dict argument') 112 | r = dumps(v) 113 | return r[1, len(r)-1] 114 | 115 | 116 | def encode_uri(v): 117 | return quote(dumps(v)) 118 | 119 | 120 | def dumps(string): 121 | return Encoder.encode(string) 122 | -------------------------------------------------------------------------------- /rison/utils.py: -------------------------------------------------------------------------------- 1 | import re 2 | import urllib 3 | 4 | 5 | RE_QUOTE = re.compile('^[-A-Za-z0-9~!*()_.\',:@$/]*$') 6 | 7 | 8 | def quote(x): 9 | if RE_QUOTE.match(x): 10 | return x 11 | 12 | return urllib.quote(x.encode('utf-8'))\ 13 | .replace('%2C', ',', 'g')\ 14 | .replace('%3A', ':', 'g')\ 15 | .replace('%40', '@', 'g')\ 16 | .replace('%24', '$', 'g')\ 17 | .replace('%2F', '/', 'g')\ 18 | .replace('%20', '+', 'g') 19 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pifantastic/python-rison/2d2c153cd12078af0ee00e9c3a66f9533524a081/setup.cfg -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | 4 | setup( 5 | name='rison', 6 | version='0.0.1', 7 | description='A Python rison encoder/decoder', 8 | long_description='A Python rison encoder/decoder', 9 | url='https://github.com/pifantastic/python-rison', 10 | author='Aaron Forsander', 11 | author_email='aaron.forsander@gmail.com', 12 | license='MIT', 13 | classifiers=[ 14 | 'Development Status :: 5 - Production/Stable', 15 | 'Intended Audience :: Developers', 16 | 'Topic :: Software Development :: Encoding', 17 | 'License :: OSI Approved :: MIT License', 18 | 'Programming Language :: Python :: 2.7' 19 | ], 20 | keywords='rison', 21 | packages=find_packages(exclude=['contrib', 'docs', 'tests*']), 22 | extras_require={ 23 | 'test': ['nose'], 24 | } 25 | ) 26 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pifantastic/python-rison/2d2c153cd12078af0ee00e9c3a66f9533524a081/tests/__init__.py -------------------------------------------------------------------------------- /tests/decoder.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from rison import loads 3 | 4 | 5 | class TestDecoder(unittest.TestCase): 6 | 7 | def test_dict(self): 8 | self.assertEqual(loads('()'), {}) 9 | self.assertEqual(loads('(a:0,b:1)'), { 10 | 'a': 0, 11 | 'b': 1 12 | }) 13 | self.assertEqual(loads("(a:0,b:foo,c:'23skidoo')"), { 14 | 'a': 0, 15 | 'c': '23skidoo', 16 | 'b': 'foo' 17 | }) 18 | self.assertEqual(loads('(id:!n,type:/common/document)'), { 19 | 'type': '/common/document', 20 | 'id': None 21 | }) 22 | self.assertEqual(loads("(a:0)"), { 23 | 'a': 0 24 | }) 25 | 26 | def test_bool(self): 27 | self.assertEqual(loads('!t'), True) 28 | self.assertEqual(loads('!f'), False) 29 | 30 | def test_none(self): 31 | self.assertEqual(loads('!n'), None) 32 | 33 | def test_list(self): 34 | self.assertEqual(loads('!(1,2,3)'), [1, 2, 3]) 35 | self.assertEqual(loads('!()'), []) 36 | self.assertEqual(loads("!(!t,!f,!n,'')"), [True, False, None, '']) 37 | 38 | def test_number(self): 39 | self.assertEqual(loads('0'), 0) 40 | self.assertEqual(loads('1.5'), 1.5) 41 | self.assertEqual(loads('-3'), -3) 42 | self.assertEqual(loads('1e30'), 1e+30) 43 | self.assertEqual(loads('1e-30'), 1.0000000000000001e-30) 44 | 45 | def test_string(self): 46 | self.assertEqual(loads("''"), '') 47 | self.assertEqual(loads('G.'), 'G.') 48 | self.assertEqual(loads('a'), 'a') 49 | self.assertEqual(loads("'0a'"), '0a') 50 | self.assertEqual(loads("'abc def'"), 'abc def') 51 | self.assertEqual(loads("'-h'"), '-h') 52 | self.assertEqual(loads('a-z'), 'a-z') 53 | self.assertEqual(loads("'wow!!'"), 'wow!') 54 | self.assertEqual(loads('domain.com'), 'domain.com') 55 | self.assertEqual(loads("'user@domain.com'"), 'user@domain.com') 56 | self.assertEqual(loads("'US $10'"), 'US $10') 57 | self.assertEqual(loads("'can!'t'"), "can't") 58 | -------------------------------------------------------------------------------- /tests/encoder.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from rison import dumps 3 | 4 | 5 | class TestEncoder(unittest.TestCase): 6 | 7 | def test_dict(self): 8 | self.assertEqual('()', dumps({})) 9 | self.assertEqual('(a:0,b:1)', dumps({ 10 | 'a': 0, 11 | 'b': 1 12 | })) 13 | self.assertEqual("(a:0,b:foo,c:'23skidoo')", dumps({ 14 | 'a': 0, 15 | 'c': '23skidoo', 16 | 'b': 'foo' 17 | })) 18 | self.assertEqual('(id:!n,type:/common/document)', dumps({ 19 | 'type': '/common/document', 20 | 'id': None 21 | })) 22 | self.assertEqual("(a:0)", dumps({ 23 | 'a': 0 24 | })) 25 | 26 | def test_bool(self): 27 | self.assertEqual('!t', dumps(True)) 28 | self.assertEqual('!f', dumps(False)) 29 | 30 | def test_none(self): 31 | self.assertEqual('!n', dumps(None)) 32 | 33 | def test_list(self): 34 | self.assertEqual('!(1,2,3)', dumps([1, 2, 3])) 35 | self.assertEqual('!()', dumps([])) 36 | self.assertEqual("!(!t,!f,!n,'')", dumps([True, False, None, ''])) 37 | 38 | def test_number(self): 39 | self.assertEqual('0', dumps(0)) 40 | self.assertEqual('1.5', dumps(1.5)) 41 | self.assertEqual('-3', dumps(-3)) 42 | self.assertEqual('1e30', dumps(1e+30)) 43 | self.assertEqual('1e-30', dumps(1.0000000000000001e-30)) 44 | 45 | def test_string(self): 46 | self.assertEqual("''", dumps('')) 47 | self.assertEqual('G.', dumps('G.')) 48 | self.assertEqual('a', dumps('a')) 49 | self.assertEqual("'0a'", dumps('0a')) 50 | self.assertEqual("'abc def'", dumps('abc def')) 51 | self.assertEqual("'-h'", dumps('-h')) 52 | self.assertEqual('a-z', dumps('a-z')) 53 | self.assertEqual("'wow!!'", dumps('wow!')) 54 | self.assertEqual('domain.com', dumps('domain.com')) 55 | self.assertEqual("'user@domain.com'", dumps('user@domain.com')) 56 | self.assertEqual("'US $10'", dumps('US $10')) 57 | self.assertEqual("'can!'t'", dumps("can't")) 58 | 59 | --------------------------------------------------------------------------------