diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml new file mode 100644 index 0000000..3e3e1df --- /dev/null +++ b/.github/dependabot.yaml @@ -0,0 +1,7 @@ +version: 2 +updates: + + - package-ecosystem: github-actions + directory: / + schedule: + interval: monthly diff --git a/.github/release.yaml b/.github/release.yaml new file mode 100644 index 0000000..ee081a1 --- /dev/null +++ b/.github/release.yaml @@ -0,0 +1,18 @@ +changelog: + exclude: + labels: + - ignore-for-release + authors: + - dependabot + categories: + - title: Breaking Changes πŸ›  + labels: + - semver-major + - breaking-change + - title: New Features πŸŽ‰ + labels: + - semver-minor + - enhancement + - title: Other Changes + labels: + - '*' \ No newline at end of file diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..b10a2b2 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,81 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + tags: [v*.*.*] + +jobs: + build: + name: Test & Build + strategy: + matrix: + python-version: ['2.7', '3.7', '3.8', '3.10'] + runs-on: [ubuntu-latest] + env: + PYTHON: ${{ matrix.python-version }} + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 # fetch all history for setuptools_scm to be able to read tags + + - uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Install python dependencies + run: | + pip install wheel build tox + pip install .[dev] + + - name: Determine pyenv + id: pyenv + run: echo "::set-output name=value::py$(echo $PYTHON | tr -d '.')" + + - name: Run tests + env: + TOXENV: ${{ steps.pyenv.outputs.value }} + run: tox + + - name: Build python package + run: python -m build + + - name: Upload coverage + uses: codecov/codecov-action@v3 + with: + env_vars: PYTHON + fail_ci_if_error: true + files: .coverage.${{ steps.pyenv.outputs.value }}.xml + + - uses: actions/upload-artifact@v3 + if: matrix.python-version == '2.7' || matrix.python-version == '3.8' + with: + name: dist-${{ matrix.python-version }} + path: dist + + publish: + name: Publish to PyPI + needs: build + runs-on: [ubuntu-latest] + if: github.event_name != 'pull_request' + steps: + - uses: actions/download-artifact@v3 + + - name: Organize files for upload + run: | + mkdir dist + mv dist-3.8/* dist/ + mv dist-2.7/*.whl dist/ + + - name: Test Publish package + uses: pypa/gh-action-pypi-publish@release/v1 + with: + password: ${{ secrets.SHARED_PYPI_TEST_TOKEN }} + repository_url: https://test.pypi.org/legacy/ + + - name: Publish package + uses: pypa/gh-action-pypi-publish@release/v1 + if: startsWith(github.event.ref, 'refs/tags/v') + with: + password: ${{ secrets.SHARED_PYPI_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d0f4c78 --- /dev/null +++ b/.gitignore @@ -0,0 +1,285 @@ +# Created by https://www.toptal.com/developers/gitignore/api/python,linux,windows,macos,vim,visualstudiocode +# Edit at https://www.toptal.com/developers/gitignore?templates=python,linux,windows,macos,vim,visualstudiocode + +### Linux ### +*~ + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### macOS Patch ### +# iCloud generated files +*.icloud + +### Python ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +### Vim ### +# Swap +[._]*.s[a-v][a-z] +!*.svg # comment out if you don't need vector files +[._]*.sw[a-p] +[._]s[a-rt-v][a-z] +[._]ss[a-gi-z] +[._]sw[a-p] + +# Session +Session.vim +Sessionx.vim + +# Temporary +.netrwhist +# Auto-generated tag files +tags +# Persistent undo +[._]*.un~ + +### VisualStudioCode ### +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix + +### VisualStudioCode Patch ### +# Ignore all local history of files +.history +.ionide + +# Support for Project snippet scope +.vscode/*.code-snippets + +# Ignore code-workspaces +*.code-workspace + +### Windows ### +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# End of https://www.toptal.com/developers/gitignore/api/python,linux,windows,macos,vim,visualstudiocode diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..e5f01bc --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,21 @@ +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.3.0 + hooks: + - id: check-ast + - id: check-yaml + - id: mixed-line-ending + - id: trailing-whitespace +- repo: https://github.com/pycqa/isort + rev: 5.10.1 + hooks: + - id: isort + args: [--line-length=120] +- repo: local + hooks: + - id: test + name: run pytest + language: system + entry: pytest + pass_filenames: false + types: [python] \ No newline at end of file diff --git a/CREDITS.md b/CREDITS.md new file mode 100644 index 0000000..8ff6129 --- /dev/null +++ b/CREDITS.md @@ -0,0 +1,4 @@ +# Credits + +Thanks to [Tao Takashi](https://github.com/mrtopf) for +llsd PyPI package name. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9d14f49 --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2006 Linden Research, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c9c694d --- /dev/null +++ b/README.md @@ -0,0 +1,40 @@ +# llsd + +[![codecov](https://codecov.io/gh/secondlife/python-llsd/branch/main/graph/badge.svg?token=Y0CD45CTNI)](https://codecov.io/gh/secondlife/python-llsd) + +Official python serialization library for [Linden Lab Structured Data (LLSD)][llsd]. + +# Use + +Install **llsd** with pip: +``` +pip install llsd +``` + +Use **llsd** to parse/format your data: +```py +import llsd + +data = {"foo": "bar"} + +# Format + +data_xml = llsd.format_xml(data) +# >>> 'foobar' +data_notation = llsd.format_notation(data) +# >>> "{'foo':'bar'}" +data_binary = llsd.format_binary(data) +# >>> '\n{\x00\x00\x00\x01k\x00\x00\x00\x03foos\x00\x00\x00\x03bar}' + +# Parse + +data = llsd.parse(data_xml) +# >>> {'foo: 'bar'} +data = llsd.parse(data_notation) +# >>> {'foo: 'bar'} +data = llsd.parse(data_binary) +# >>> {'foo: 'bar'} +``` + +[llsd]: https://wiki.secondlife.com/wiki/LLSD +[llbase]: https://pypi.org/project/llbase/ diff --git a/llsd/__init__.py b/llsd/__init__.py new file mode 100644 index 0000000..9727479 --- /dev/null +++ b/llsd/__init__.py @@ -0,0 +1,84 @@ +""" +Types as well as parsing and formatting functions for handling LLSD. + +This is the llsd module -- parsers and formatters between the +supported subset of mime types and python objects. Documentation +available on the Second Life wiki: + +http://wiki.secondlife.com/wiki/LLSD +""" +from llsd.base import (_LLSD, BINARY_MIME_TYPE, NOTATION_MIME_TYPE, XML_MIME_TYPE, LLSDParseError, + LLSDSerializationError, LongType, UnicodeType, binary, starts_with, undef, uri) +from llsd.serde_binary import format_binary, parse_binary +from llsd.serde_notation import format_notation, parse_notation +from llsd.serde_xml import format_pretty_xml, format_xml, parse_xml + +__all__ = [ + "BINARY_MIME_TYPE", + "LLSD", + "LLSDParseError", + "LLSDSerializationError", + "LongType", + "NOTATION_MIME_TYPE", + "UnicodeType", + "XML_MIME_TYPE", + "binary", + "format_binary", + "format_notation", + "format_pretty_xml", + "format_xml", + "parse", + "parse_binary", + "parse_notation", + "parse_xml", + "undef", + "uri", +] + + +def parse(something, mime_type = None): + """ + This is the basic public interface for parsing llsd. + + :param something: The data to parse. This is expected to be bytes, not strings + :param mime_type: The mime_type of the data if it is known. + :returns: Returns a python object. + + Python 3 Note: when reading LLSD from a file, use open()'s 'rb' mode explicitly + """ + if mime_type in (XML_MIME_TYPE, 'application/llsd'): + return parse_xml(something) + elif mime_type == BINARY_MIME_TYPE: + return parse_binary(something) + elif mime_type == NOTATION_MIME_TYPE: + return parse_notation(something) + #elif content_type == 'application/json': + # return parse_notation(something) + try: + something = something.lstrip() #remove any pre-trailing whitespace + if starts_with(b'', something): + return parse_binary(something) + # This should be better. + elif starts_with(b'<', something): + return parse_xml(something) + else: + return parse_notation(something) + except KeyError as e: + raise LLSDParseError('LLSD could not be parsed: %s' % (e,)) + except TypeError as e: + raise LLSDParseError('Input stream not of type bytes. %s' % (e,)) + + +class LLSD(_LLSD): + def __bytes__(self): + return self.as_xml(self.thing) + + + def __str__(self): + return self.__bytes__().decode() + + parse = staticmethod(parse) + as_xml = staticmethod(format_xml) + as_pretty_xml = staticmethod(format_pretty_xml) + as_binary = staticmethod(format_binary) + as_notation = staticmethod(format_notation) \ No newline at end of file diff --git a/llsd/base.py b/llsd/base.py new file mode 100644 index 0000000..6ac695f --- /dev/null +++ b/llsd/base.py @@ -0,0 +1,460 @@ +import abc +import base64 +import binascii +import datetime +import os +import re +import sys +import types +import uuid + +try: + # If the future package is installed, then we support it. Any clients in + # python 2 using its str builtin replacement will actually be using instances + # of newstr, so we need to properly detect that as a string type + # for details see the docs: http://python-future.org/str_object.html + from future.types.newstr import newstr +except ImportError: + # otherwise we pass over it in silence + newstr = str + +PY2 = sys.version_info[0] == 2 + +XML_MIME_TYPE = 'application/llsd+xml' +BINARY_MIME_TYPE = 'application/llsd+binary' +NOTATION_MIME_TYPE = 'application/llsd+notation' + +ALL_CHARS = str(bytearray(range(256))) if PY2 else bytes(range(256)) + + +class _LLSD: + __metaclass__ = abc.ABCMeta + + def __init__(self, thing=None): + self.thing = thing + + +undef = _LLSD(None) + + +if PY2: + class binary(str): + "Simple wrapper for llsd.binary data." + pass +else: + binary = bytes + + +class uri(str): + "Simple wrapper for llsd.uri data." + pass + + +class LLSDParseError(Exception): + "Exception raised when the parser fails." + pass + + +class LLSDSerializationError(TypeError): + "Exception raised when serialization fails." + pass + + +# In Python 2, this expression produces (str, unicode); in Python 3 it's +# simply (str,). Either way, it's valid to test isinstance(somevar, +# StringTypes). (Some consumers test (type(somevar) in StringTypes), so we do +# want (str,) rather than plain str.) +StringTypes = tuple(set((type(''), type(u''), newstr))) + +try: + LongType = long + IntTypes = (int, long) +except NameError: + LongType = int + IntTypes = int + +try: + UnicodeType = unicode +except NameError: + UnicodeType = str + +# can't just check for NameError: 'bytes' is defined in both Python 2 and 3 +if PY2: + BytesType = str +else: + BytesType = bytes + +try: + b'%s' % (b'yes',) +except TypeError: + # There's a range of Python 3 versions, up through Python 3.4, for which + # bytes interpolation (bytes value with % operator) does not work. This + # hack can be removed once we no longer care about Python 3.4 -- in other + # words, once we're beyond jessie everywhere. + class B(object): + """ + Instead of writing: + b'format string' % stuff + write: + B('format string') % stuff + This class performs the conversions necessary to support bytes + interpolation when the language doesn't natively support it. + (We considered naming this class b, but that would be too confusing.) + """ + def __init__(self, fmt): + # Instead of storing the format string as bytes and converting it + # to string every time, convert initially and store the string. + try: + self.strfmt = fmt.decode('utf-8') + except AttributeError: + # caller passed a string literal rather than a bytes literal + self.strfmt = fmt + + def __mod__(self, args): + # __mod__() is engaged for (self % args) + if not isinstance(args, tuple): + # Unify the tuple and non-tuple cases. + args = (args,) + # In principle, this is simple: convert everything to string, + # interpolate, convert back. It's complicated by the fact that we + # must handle non-bytes args. + strargs = [] + for arg in args: + try: + decoder = arg.decode + except AttributeError: + # use arg exactly as is + strargs.append(arg) + else: + # convert from bytes to string + strargs.append(decoder('utf-8')) + return (self.strfmt % tuple(strargs)).encode('utf-8') +else: + # bytes interpolation Just Works + def B(fmt): + try: + # In the usual case, caller wrote B('fmt') rather than b'fmt'. But + # s/he really wants a bytes literal here. Encode the passed string. + return fmt.encode('utf-8') + except AttributeError: + # Caller wrote B(b'fmt')? + return fmt + + +def is_integer(o): + """ portable test if an object is like an int """ + return isinstance(o, IntTypes) + + +def is_unicode(o): + """ portable check if an object is unicode and not bytes """ + return isinstance(o, UnicodeType) + + +def is_string(o): + """ portable check if an object is string-like """ + return isinstance(o, StringTypes) + + +def is_bytes(o): + """ portable check if an object is an immutable byte array """ + return isinstance(o, BytesType) + + +#date: d"YYYY-MM-DDTHH:MM:SS.FFFFFFZ" +_date_regex = re.compile(r"(?P\d{4})-(?P\d{2})-(?P\d{2})T" + r"(?P\d{2}):(?P\d{2}):(?P\d{2})" + r"(?P(\.\d+)?)Z") + + +def _str_to_bytes(s): + if is_unicode(s): + return s.encode('utf-8') + else: + return s + + +def _format_datestr(v): + """ + Formats a datetime or date object into the string format shared by + xml and notation serializations. + """ + if not isinstance(v, datetime.date) and not isinstance(v, datetime.datetime): + raise LLSDParseError("invalid date string %s passed to date formatter" % v) + + if not isinstance(v, datetime.datetime): + v = datetime.datetime.combine(v, datetime.time(0)) + + return _str_to_bytes(v.isoformat() + 'Z') + + +def _parse_datestr(datestr): + """ + Parses a datetime object from the string format shared by + xml and notation serializations. + """ + if datestr == "": + return datetime.datetime(1970, 1, 1) + + match = re.match(_date_regex, datestr) + if not match: + raise LLSDParseError("invalid date string '%s'." % datestr) + + year = int(match.group('year')) + month = int(match.group('month')) + day = int(match.group('day')) + hour = int(match.group('hour')) + minute = int(match.group('minute')) + second = int(match.group('second')) + seconds_float = match.group('second_float') + usec = 0 + if seconds_float: + usec = int(float('0' + seconds_float) * 1e6) + return datetime.datetime(year, month, day, hour, minute, second, usec) + + +def _bool_to_python(node): + "Convert boolean node to a python object." + val = node.text or '' + try: + # string value, accept 'true' or 'True' or whatever + return (val.lower() in ('true', '1', '1.0')) + except AttributeError: + # not a string (no lower() method), use normal Python rules + return bool(val) + + +def _int_to_python(node): + "Convert integer node to a python object." + val = node.text or '' + if not val.strip(): + return 0 + return int(val) + + +def _real_to_python(node): + "Convert floating point node to a python object." + val = node.text or '' + if not val.strip(): + return 0.0 + return float(val) + + +def _uuid_to_python(node): + "Convert uuid node to a python object." + if node.text: + return uuid.UUID(hex=node.text) + return uuid.UUID(int=0) + + +def _str_to_python(node): + "Convert string node to a python object." + return node.text or '' + + +def _bin_to_python(node): + base = node.get('encoding') or 'base64' + try: + if base == 'base16': + # parse base16 encoded data + return binary(base64.b16decode(node.text or '')) + elif base == 'base64': + # parse base64 encoded data + return binary(base64.b64decode(node.text or '')) + elif base == 'base85': + return LLSDParseError("Parser doesn't support base85 encoding") + except binascii.Error as exc: + # convert exception class so it's more catchable + return LLSDParseError("Encoded binary data: " + str(exc)) + except TypeError as exc: + # convert exception class so it's more catchable + return LLSDParseError("Bad binary data: " + str(exc)) + + +def _date_to_python(node): + "Convert date node to a python object." + val = node.text or '' + if not val: + val = "1970-01-01T00:00:00Z" + return _parse_datestr(val) + + +def _uri_to_python(node): + "Convert uri node to a python object." + val = node.text or '' + return uri(val) + + +def _map_to_python(node): + "Convert map node to a python object." + result = {} + for index in range(len(node))[::2]: + if node[index].text is None: + result[''] = _to_python(node[index+1]) + else: + result[node[index].text] = _to_python(node[index+1]) + return result + + +def _array_to_python(node): + "Convert array node to a python object." + return [_to_python(child) for child in node] + + +NODE_HANDLERS = dict( + undef=lambda x: None, + boolean=_bool_to_python, + integer=_int_to_python, + real=_real_to_python, + uuid=_uuid_to_python, + string=_str_to_python, + binary=_bin_to_python, + date=_date_to_python, + uri=_uri_to_python, + map=_map_to_python, + array=_array_to_python, +) + + +def _to_python(node): + "Convert node to a python object." + return NODE_HANDLERS[node.tag](node) + + +def _hex_as_nybble(hex): + "Accepts a single hex character and returns a nybble." + if (hex >= b'0') and (hex <= b'9'): + return ord(hex) - ord(b'0') + elif (hex >= b'a') and (hex <=b'f'): + return 10 + ord(hex) - ord(b'a') + elif (hex >= b'A') and (hex <=b'F'): + return 10 + ord(hex) - ord(b'A') + else: + raise LLSDParseError('Invalid hex character: %s' % hex) + + + +class LLSDBaseFormatter(object): + """ + This base class cannot be instantiated on its own: it assumes a subclass + containing methods with canonical names specified in self.__init__(). The + role of this base class is to provide self.type_map based on the methods + defined in its subclass. + """ + def __init__(self): + "Construct a new formatter dispatch table." + self.type_map = { + type(None): self.UNDEF, + undef: self.UNDEF, + bool: self.BOOLEAN, + int: self.INTEGER, + LongType: self.INTEGER, + float: self.REAL, + uuid.UUID: self.UUID, + binary: self.BINARY, + str: self.STRING, + UnicodeType: self.STRING, + newstr: self.STRING, + uri: self.URI, + datetime.datetime: self.DATE, + datetime.date: self.DATE, + list: self.ARRAY, + tuple: self.ARRAY, + types.GeneratorType: self.ARRAY, + dict: self.MAP, + _LLSD: self.LLSD, + } + + +class LLSDBaseParser(object): + """ + Utility methods useful for parser subclasses. + """ + def __init__(self): + self._buffer = b'' + self._index = 0 + + def _error(self, message, offset=0): + try: + byte = self._buffer[self._index+offset] + except IndexError: + byte = None + raise LLSDParseError("%s at byte %d: %s" % (message, self._index+offset, byte)) + + def _peek(self, num=1): + if num < 0: + # There aren't many ways this can happen. The likeliest is that + # we've just read garbage length bytes from a binary input string. + # We happen to know that lengths are encoded as 4 bytes, so back + # off by 4 bytes to try to point the user at the right spot. + self._error("Invalid length field %d" % num, -4) + if self._index + num > len(self._buffer): + self._error("Trying to read past end of buffer") + return self._buffer[self._index:self._index + num] + + def _getc(self, num=1): + chars = self._peek(num) + self._index += num + return chars + + # map char following escape char to corresponding character + _escaped = { + b'a': b'\a', + b'b': b'\b', + b'f': b'\f', + b'n': b'\n', + b'r': b'\r', + b't': b'\t', + b'v': b'\v', + } + + def _parse_string_delim(self, delim): + "Parse a delimited string." + parts = bytearray() + found_escape = False + found_hex = False + found_digit = False + byte = 0 + while True: + cc = self._getc() + if found_escape: + if found_hex: + if found_digit: + found_escape = False + found_hex = False + found_digit = False + byte <<= 4 + byte |= _hex_as_nybble(cc) + parts.append(byte) + byte = 0 + else: + found_digit = True + byte = _hex_as_nybble(cc) + elif cc == b'x': + found_hex = True + else: + found_escape = False + # escape char preceding anything other than the chars in + # _escaped just results in that same char without the + # escape char + parts.extend(self._escaped.get(cc, cc)) + elif cc == b'\\': + found_escape = True + elif cc == delim: + break + else: + parts.extend(cc) + try: + return parts.decode('utf-8') + except UnicodeDecodeError as exc: + self._error(exc) + + +def starts_with(startstr, something): + if hasattr(something, 'startswith'): + return something.startswith(startstr) + else: + pos = something.tell() + s = something.read(len(startstr)) + something.seek(pos, os.SEEK_SET) + return (s == startstr) \ No newline at end of file diff --git a/llsd/fastest_elementtree.py b/llsd/fastest_elementtree.py new file mode 100644 index 0000000..8b7d6bb --- /dev/null +++ b/llsd/fastest_elementtree.py @@ -0,0 +1,48 @@ +""" +Concealing some gnarly import logic in here. This should export +the interface of elementtree. + +The parsing exception raised by the underlying library depends on the +ElementTree implementation we're using, so we provide an alias here. + +Generally, you can use this module as a drop in replacement for how +you would use ElementTree or cElementTree. + +
+from fastest_elementtree import fromstring
+fromstring(...)
+
+ +Use ElementTreeError as the exception type for catching parsing +errors. +""" + +## +# Using cElementTree might cause some unforeseen problems, so here's a +# convenient off switch during development and testing. +_use_celementree = True + +try: + if not _use_celementree: + raise ImportError() + # Python 2.3 and 2.4. + from cElementTree import * + ElementTreeError = SyntaxError +except ImportError: + try: + if not _use_celementree: + raise ImportError() + # Python 2.5 and above. + from xml.etree.cElementTree import * + ElementTreeError = SyntaxError + except ImportError: + # Pure Python code. + try: + # Python 2.3 and 2.4. + from elementtree.ElementTree import * + except ImportError: + # Python 2.5 and above. + from xml.etree.ElementTree import * + + # The pure Python ElementTree module uses Expat for parsing. + from xml.parsers.expat import ExpatError as ElementTreeError diff --git a/llsd/serde_binary.py b/llsd/serde_binary.py new file mode 100644 index 0000000..41d24f7 --- /dev/null +++ b/llsd/serde_binary.py @@ -0,0 +1,234 @@ +import calendar +import datetime +import struct +import uuid + +from llsd.base import (_LLSD, LLSDBaseParser, LLSDSerializationError, _str_to_bytes, binary, is_integer, is_string, + starts_with, uri) + + +class LLSDBinaryParser(LLSDBaseParser): + """ + Parse application/llsd+binary to a python object. + + See http://wiki.secondlife.com/wiki/LLSD#Binary_Serialization + """ + def __init__(self): + super(LLSDBinaryParser, self).__init__() + # One way of dispatching based on the next character we see would be a + # dict lookup, and indeed that's the best way to express it in source. + _dispatch_dict = { + b'{': self._parse_map, + b'[': self._parse_array, + b'!': lambda: None, + b'0': lambda: False, + b'1': lambda: True, + # 'i' = integer + b'i': lambda: struct.unpack("!i", self._getc(4))[0], + # 'r' = real number + b'r': lambda: struct.unpack("!d", self._getc(8))[0], + # 'u' = uuid + b'u': lambda: uuid.UUID(bytes=self._getc(16)), + # 's' = string + b's': self._parse_string, + # delimited/escaped string + b"'": lambda: self._parse_string_delim(b"'"), + b'"': lambda: self._parse_string_delim(b'"'), + # 'l' = uri + b'l': lambda: uri(self._parse_string()), + # 'd' = date in seconds since epoch + b'd': self._parse_date, + # 'b' = binary + # *NOTE: if not self._keep_binary, maybe have a binary placeholder + # which has the length. + b'b': lambda: bytes(self._parse_string_raw()) if self._keep_binary else None, + } + # But in fact it should be even faster to construct a list indexed by + # ord(char). Start by filling it with the 'else' case. Use offset=-1 + # because by the time we perform this lookup, we've scanned past the + # lookup char. + self._dispatch = 256*[lambda: self._error("invalid binary token", -1)] + # Now use the entries in _dispatch_dict to set the corresponding + # entries in _dispatch. + for c, func in _dispatch_dict.items(): + self._dispatch[ord(c)] = func + + def parse(self, buffer, ignore_binary = False): + """ + This is the basic public interface for parsing. + + :param buffer: the binary data to parse in an indexable sequence. + :param ignore_binary: parser throws away data in llsd binary nodes. + :returns: returns a python object. + """ + self._buffer = buffer + self._index = 0 + self._keep_binary = not ignore_binary + try: + return self._parse() + except struct.error as exc: + self._error(exc) + + def _parse(self): + "The actual parser which is called recursively when necessary." + cc = self._getc() + try: + func = self._dispatch[ord(cc)] + except IndexError: + self._error("invalid binary token", -1) + else: + return func() + + def _parse_map(self): + "Parse a single llsd map" + rv = {} + size = struct.unpack("!i", self._getc(4))[0] + count = 0 + cc = self._getc() + key = b'' + while (cc != b'}') and (count < size): + if cc == b'k': + key = self._parse_string() + elif cc in (b"'", b'"'): + key = self._parse_string_delim(cc) + else: + self._error("invalid map key", -1) + value = self._parse() + rv[key] = value + count += 1 + cc = self._getc() + if cc != b'}': + self._error("invalid map close token") + return rv + + def _parse_array(self): + "Parse a single llsd array" + rv = [] + size = struct.unpack("!i", self._getc(4))[0] + count = 0 + cc = self._peek() + while (cc != b']') and (count < size): + rv.append(self._parse()) + count += 1 + cc = self._peek() + if cc != b']': + self._error("invalid array close token") + self._index += 1 + return rv + + def _parse_string(self): + try: + return self._parse_string_raw().decode('utf-8') + except UnicodeDecodeError as exc: + self._error(exc) + + def _parse_string_raw(self): + "Parse a string which has the leadings size indicator" + try: + size = struct.unpack("!i", self._getc(4))[0] + except struct.error as exc: + # convert exception class for client convenience + self._error("struct " + str(exc)) + rv = self._getc(size) + return rv + + def _parse_date(self): + seconds = struct.unpack("\n' + _format_binary_recurse(something) + + +def _format_binary_recurse(something): + "Binary formatter workhorse." + def _format_list(something): + array_builder = [] + array_builder.append(b'[' + struct.pack('!i', len(something))) + for item in something: + array_builder.append(_format_binary_recurse(item)) + array_builder.append(b']') + return b''.join(array_builder) + + if something is None: + return b'!' + elif isinstance(something, _LLSD): + return _format_binary_recurse(something.thing) + elif isinstance(something, bool): + if something: + return b'1' + else: + return b'0' + elif is_integer(something): + try: + return b'i' + struct.pack('!i', something) + except (OverflowError, struct.error) as exc: + raise LLSDSerializationError(str(exc), something) + elif isinstance(something, float): + try: + return b'r' + struct.pack('!d', something) + except SystemError as exc: + raise LLSDSerializationError(str(exc), something) + elif isinstance(something, uuid.UUID): + return b'u' + something.bytes + elif isinstance(something, binary): + return b'b' + struct.pack('!i', len(something)) + something + elif is_string(something): + something = _str_to_bytes(something) + return b's' + struct.pack('!i', len(something)) + something + elif isinstance(something, uri): + return b'l' + struct.pack('!i', len(something)) + something + elif isinstance(something, datetime.datetime): + seconds_since_epoch = calendar.timegm(something.utctimetuple()) \ + + something.microsecond // 1e6 + return b'd' + struct.pack('', something): + just_binary = something.split(b'\n', 1)[1] + else: + just_binary = something + return LLSDBinaryParser().parse(just_binary) + diff --git a/llsd/serde_notation.py b/llsd/serde_notation.py new file mode 100644 index 0000000..73cae03 --- /dev/null +++ b/llsd/serde_notation.py @@ -0,0 +1,413 @@ +import base64 +import binascii +import re +import uuid + +from llsd.base import (_LLSD, B, LLSDBaseFormatter, LLSDBaseParser, LLSDParseError, LLSDSerializationError, UnicodeType, + _format_datestr, _parse_datestr, _str_to_bytes, binary, uri) + +_int_regex = re.compile(br"[-+]?\d+") +_real_regex = re.compile(br"[-+]?(?:(\d+(\.\d*)?|\d*\.\d+)([eE][-+]?\d+)?)|[-+]?inf|[-+]?nan") +_true_regex = re.compile(br"TRUE|true|\b[Tt]\b") +_false_regex = re.compile(br"FALSE|false|\b[Ff]\b") + + +class LLSDNotationParser(LLSDBaseParser): + """ + Parse LLSD notation. + + See http://wiki.secondlife.com/wiki/LLSD#Notation_Serialization + + * map: { string:object, string:object } + * array: [ object, object, object ] + * undef: ! + * boolean: true | false | 1 | 0 | T | F | t | f | TRUE | FALSE + * integer: i#### + * real: r#### + * uuid: u#### + * string: "g\'day" | 'have a "nice" day' | s(size)"raw data" + * uri: l"escaped" + * date: d"YYYY-MM-DDTHH:MM:SS.FFZ" + * binary: b##"ff3120ab1" | b(size)"raw data" + """ + def __init__(self): + super(LLSDNotationParser, self).__init__() + # Like LLSDBinaryParser, we want to dispatch based on the current + # character. + _dispatch_dict = { + # map + b'{': self._parse_map, + # array + b'[': self._parse_array, + # undefined -- have to eat the '!' + b'!': lambda: self._skip_then(None), + # false -- have to eat the '0' + b'0': lambda: self._skip_then(False), + # true -- have to eat the '1' + b'1': lambda: self._skip_then(True), + # false, must check for F|f|false|FALSE + b'F': lambda: self._get_re("'false'", _false_regex, False), + b'f': lambda: self._get_re("'false'", _false_regex, False), + # true, must check for T|t|true|TRUE + b'T': lambda: self._get_re("'true'", _true_regex, True), + b't': lambda: self._get_re("'true'", _true_regex, True), + # 'i' = integer + b'i': self._parse_integer, + # 'r' = real number + b'r': self._parse_real, + # 'u' = uuid + b'u': self._parse_uuid, + # string + b"'": self._parse_string, + b'"': self._parse_string, + b's': self._parse_string, + # 'l' = uri + b'l': self._parse_uri, + # 'd' = date in seconds since epoch + b'd': self._parse_date, + # 'b' = binary + b'b': self._parse_binary, + } + # Like LLSDBinaryParser, construct a lookup list from this dict. Start + # by filling with the 'else' case. + self._dispatch = 256*[lambda: self._error("Invalid notation token")] + # Then fill in specific entries based on the dict above. + for c, func in _dispatch_dict.items(): + self._dispatch[ord(c)] = func + + def parse(self, buffer, ignore_binary = False): + """ + This is the basic public interface for parsing. + + :param buffer: the notation string to parse. + :param ignore_binary: parser throws away data in llsd binary nodes. + :returns: returns a python object. + """ + if buffer == b"": + return False + + self._buffer = buffer + self._index = 0 + return self._parse() + + def _get_until(self, delim): + start = self._index + end = self._buffer.find(delim, start) + if end == -1: + return None + else: + self._index = end + 1 + return self._buffer[start:end] + + def _skip_then(self, value): + # We've already _peek()ed at the current character, which is how we + # decided to call this method. Skip past it and return constant value. + self._getc() + return value + + def _get_re(self, desc, regex, override=None): + match = re.match(regex, self._buffer[self._index:]) + if not match: + self._error("Invalid %s token" % desc) + else: + self._index += match.end() + return override if override is not None else match.group(0) + + def _parse(self): + "The notation parser workhorse." + cc = self._peek() + try: + func = self._dispatch[ord(cc)] + except IndexError: + # output error if the token was out of range + self._error("Invalid notation token") + else: + return func() + + def _parse_binary(self): + "parse a single binary object." + + self._getc() # eat the beginning 'b' + cc = self._peek() + if cc == b'(': + # parse raw binary + paren = self._getc() + + # grab the 'expected' size of the binary data + size = self._get_until(b')') + if size == None: + self._error("Invalid binary size") + size = int(size) + + # grab the opening quote + q = self._getc() + if q != b'"': + self._error('Expected " to start binary value') + + # grab the data + data = self._getc(size) + + # grab the closing quote + q = self._getc() + if q != b'"': + self._error('Expected " to end binary value') + + return binary(data) + + else: + # get the encoding base + base = self._getc(2) + try: + decoder = { + b'16': base64.b16decode, + b'64': base64.b64decode, + }[base] + except KeyError: + self._error("Parser doesn't support base %s encoding" % + base.decode('latin-1')) + + # grab the double quote + q = self._getc() + if q != b'"': + self._error('Expected " to start binary value') + + # grab the encoded data + encoded = self._get_until(q) + + try: + return binary(decoder(encoded or b'')) + except binascii.Error as exc: + # convert exception class so it's more catchable + self._error("Encoded binary data: " + str(exc)) + except TypeError as exc: + # convert exception class so it's more catchable + self._error("Bad binary data: " + str(exc)) + + def _parse_map(self): + """ + parse a single map + + map: { string:object, string:object } + """ + rv = {} + key = b'' + found_key = False + self._getc() # eat the beginning '{' + cc = self._peek() + while (cc != b'}'): + if cc is None: + self._error("Unclosed map") + if not found_key: + if cc in (b"'", b'"', b's'): + key = self._parse_string() + found_key = True + elif cc.isspace() or cc == b',': + self._getc() # eat the character + pass + else: + self._error("Invalid map key") + elif cc.isspace(): + self._getc() # eat the space + pass + elif cc == b':': + self._getc() # eat the ':' + value = self._parse() + rv[key] = value + found_key = False + else: + self._error("missing separator") + cc = self._peek() + + if self._getc() != b'}': + self._error("Invalid map close token") + + return rv + + def _parse_array(self): + """ + parse a single array. + + array: [ object, object, object ] + """ + rv = [] + self._getc() # eat the beginning '[' + cc = self._peek() + while (cc != b']'): + if cc is None: + self._error('Unclosed array') + if cc.isspace() or cc == b',': + self._getc() + cc = self._peek() + continue + rv.append(self._parse()) + cc = self._peek() + + if self._getc() != b']': + self._error("Invalid array close token") + return rv + + def _parse_uuid(self): + "Parse a uuid." + self._getc() # eat the beginning 'u' + # see comment on LLSDNotationFormatter.UUID() re use of latin-1 + return uuid.UUID(hex=self._getc(36).decode('latin-1')) + + def _parse_uri(self): + "Parse a URI." + self._getc() # eat the beginning 'l' + return uri(self._parse_string()) + + def _parse_date(self): + "Parse a date." + self._getc() # eat the beginning 'd' + datestr = self._parse_string() + return _parse_datestr(datestr) + + def _parse_real(self): + "Parse a floating point number." + self._getc() # eat the beginning 'r' + return float(self._get_re("real", _real_regex)) + + def _parse_integer(self): + "Parse an integer." + self._getc() # eat the beginning 'i' + return int(self._get_re("integer", _int_regex)) + + def _parse_string(self): + """ + Parse a string + + string: "g\'day" | 'have a "nice" day' | s(size)"raw data" + """ + rv = "" + delim = self._peek() + if delim in (b"'", b'"'): + delim = self._getc() # eat the beginning delim + rv = self._parse_string_delim(delim) + elif delim == b's': + rv = self._parse_string_raw() + else: + self._error("invalid string token") + + return rv + + def _parse_string_raw(self): + """ + Parse a sized specified string. + + string: s(size)"raw data" + """ + self._getc() # eat the beginning 's' + # Read the (size) portion. + cc = self._getc() + if cc != b'(': + self._error("Invalid string token") + + size = self._get_until(b')') + if size == None: + self._error("Invalid string size") + size = int(size) + + delim = self._getc() + if delim not in (b"'", b'"'): + self._error("Invalid string token") + + rv = self._getc(size) + cc = self._getc() + if cc != delim: + self._error("Invalid string closure token") + try: + return rv.decode('utf-8') + except UnicodeDecodeError as exc: + raise LLSDParseError(exc) + + +class LLSDNotationFormatter(LLSDBaseFormatter): + """ + Serialize a python object as application/llsd+notation + + See http://wiki.secondlife.com/wiki/LLSD#Notation_Serialization + """ + def LLSD(self, v): + return self._generate(v.thing) + def UNDEF(self, v): + return b'!' + def BOOLEAN(self, v): + if v: + return b'true' + else: + return b'false' + def INTEGER(self, v): + return B("i%d") % v + def REAL(self, v): + return B("r%r") % v + def UUID(self, v): + # latin-1 is the byte-to-byte encoding, mapping \x00-\xFF -> + # \u0000-\u00FF. It's also the fastest encoding, I believe, from + # https://docs.python.org/3/library/codecs.html#encodings-and-unicode + # UUID doesn't like the hex to be a bytes object, so I have to + # convert it to a string. I chose latin-1 to exactly match the old + # error behavior in case someone passes an invalid hex string, with + # things other than 0-9a-fA-F, so that they will fail in the UUID + # decode, rather than with a UnicodeError. + return B("u%s") % str(v).encode('latin-1') + def BINARY(self, v): + return b'b64"' + base64.b64encode(v).strip() + b'"' + + def STRING(self, v): + return B("'%s'") % _str_to_bytes(v).replace(b"\\", b"\\\\").replace(b"'", b"\\'") + def URI(self, v): + return B('l"%s"') % _str_to_bytes(v).replace(b"\\", b"\\\\").replace(b'"', b'\\"') + def DATE(self, v): + return B('d"%s"') % _format_datestr(v) + def ARRAY(self, v): + return B("[%s]") % b','.join([self._generate(item) for item in v]) + def MAP(self, v): + return B("{%s}") % b','.join([B("'%s':%s") % (_str_to_bytes(UnicodeType(key)).replace(b"\\", b"\\\\").replace(b"'", b"\\'"), self._generate(value)) + for key, value in v.items()]) + + def _generate(self, something): + "Generate notation from a single python object." + t = type(something) + handler = self.type_map.get(t) + if handler: + return handler(something) + elif isinstance(something, _LLSD): + return self.type_map[_LLSD](something) + else: + try: + return self.ARRAY(iter(something)) + except TypeError: + raise LLSDSerializationError( + "Cannot serialize unknown type: %s (%s)" % (t, something)) + + def format(self, something): + """ + Format a python object as application/llsd+notation + + :param something: a python object (typically a dict) to be serialized. + :returns: Returns a LLSD notation formatted string. + """ + return self._generate(something) + + +def format_notation(something): + """ + Format a python object as application/llsd+notation + + :param something: a python object (typically a dict) to be serialized. + :returns: Returns a LLSD notation formatted string. + + See http://wiki.secondlife.com/wiki/LLSD#Notation_Serialization + """ + return LLSDNotationFormatter().format(something) + + +def parse_notation(something): + """ + This is the basic public interface for parsing llsd+notation. + + :param something: The data to parse. + :returns: Returns a python object. + """ + return LLSDNotationParser().parse(something) \ No newline at end of file diff --git a/llsd/serde_xml.py b/llsd/serde_xml.py new file mode 100644 index 0000000..fcec338 --- /dev/null +++ b/llsd/serde_xml.py @@ -0,0 +1,265 @@ +import base64 +import re +import types + +from llsd.base import (_LLSD, ALL_CHARS, B, LLSDBaseFormatter, LLSDParseError, LLSDSerializationError, UnicodeType, + _format_datestr, _str_to_bytes, _to_python, is_unicode) +from llsd.fastest_elementtree import ElementTreeError, fromstring + +INVALID_XML_BYTES = b'\x00\x01\x02\x03\x04\x05\x06\x07\x08\x0b\x0c'\ + b'\x0e\x0f\x10\x11\x12\x13\x14\x15\x16\x17\x18'\ + b'\x19\x1a\x1b\x1c\x1d\x1e\x1f' +INVALID_XML_RE = re.compile(r'[\x00-\x08\x0b\x0c\x0e-\x1f]') + + +def remove_invalid_xml_bytes(b): + try: + # Dropping chars that cannot be parsed later on. The + # translate() function was benchmarked to be the fastest way + # to do this. + return b.translate(ALL_CHARS, INVALID_XML_BYTES) + except TypeError: + # we get here if s is a unicode object (should be limited to + # unit tests) + return INVALID_XML_RE.sub('', b) + + +class LLSDXMLFormatter(LLSDBaseFormatter): + """ + Class which implements LLSD XML serialization.. + + http://wiki.secondlife.com/wiki/LLSD#XML_Serialization + + This class wraps both a pure python and c-extension for formatting + a limited subset of python objects as application/llsd+xml. You do + not generally need to make an instance of this object since the + module level format_xml is the most convenient interface to this + functionality. + """ + + def _elt(self, name, contents=None): + "Serialize a single element." + if not contents: + return B("<%s />") % (name,) + else: + return B("<%s>%s") % (name, _str_to_bytes(contents), name) + + def xml_esc(self, v): + "Escape string or unicode object v for xml output" + + # Use is_unicode() instead of is_string() because in python 2, str is + # bytes, not unicode, and should not be "encode()"'d. attempts to + # encode("utf-8") a bytes type will result in an implicit + # decode("ascii") that will throw a UnicodeDecodeError if the string + # contains non-ascii characters + if is_unicode(v): + # we need to drop these invalid characters because they + # cannot be parsed (and encode() doesn't drop them for us) + v = v.replace(u'\uffff', u'') + v = v.replace(u'\ufffe', u'') + v = v.encode('utf-8') + v = remove_invalid_xml_bytes(v) + return v.replace(b'&',b'&').replace(b'<',b'<').replace(b'>',b'>') + + def LLSD(self, v): + return self._generate(v.thing) + def UNDEF(self, _v): + return self._elt(b'undef') + def BOOLEAN(self, v): + if v: + return self._elt(b'boolean', b'true') + else: + return self._elt(b'boolean', b'false') + def INTEGER(self, v): + return self._elt(b'integer', str(v)) + def REAL(self, v): + return self._elt(b'real', repr(v)) + def UUID(self, v): + if v.int == 0: + return self._elt(b'uuid') + else: + return self._elt(b'uuid', str(v)) + def BINARY(self, v): + return self._elt(b'binary', base64.b64encode(v).strip()) + def STRING(self, v): + return self._elt(b'string', self.xml_esc(v)) + def URI(self, v): + return self._elt(b'uri', self.xml_esc(str(v))) + def DATE(self, v): + return self._elt(b'date', _format_datestr(v)) + def ARRAY(self, v): + return self._elt( + b'array', + b''.join([self._generate(item) for item in v])) + def MAP(self, v): + return self._elt( + b'map', + b''.join([B("%s%s") % (self._elt(b'key', self.xml_esc(UnicodeType(key))), + self._generate(value)) + for key, value in v.items()])) + + typeof = type + def _generate(self, something): + "Generate xml from a single python object." + t = self.typeof(something) + if t in self.type_map: + return self.type_map[t](something) + elif isinstance(something, _LLSD): + return self.type_map[_LLSD](something) + else: + raise LLSDSerializationError( + "Cannot serialize unknown type: %s (%s)" % (t, something)) + + def _format(self, something): + "Pure Python implementation of the formatter." + return b'' + self._elt(b"llsd", self._generate(something)) + + def format(self, something): + """ + Format a python object as application/llsd+xml + + :param something: A python object (typically a dict) to be serialized. + :returns: Returns an XML formatted string. + """ + return self._format(something) + +class LLSDXMLPrettyFormatter(LLSDXMLFormatter): + """ + Class which implements 'pretty' LLSD XML serialization.. + + See http://wiki.secondlife.com/wiki/LLSD#XML_Serialization + + The output conforms to the LLSD DTD, unlike the output from the + standard python xml.dom DOM::toprettyxml() method which does not + preserve significant whitespace. + + This class is not necessarily suited for serializing very large objects. + It sorts on dict (llsd map) keys alphabetically to ease human reading. + """ + def __init__(self, indent_atom = None): + "Construct a pretty serializer." + # Call the super class constructor so that we have the type map + super(LLSDXMLPrettyFormatter, self).__init__() + + # Override the type map to use our specialized formatters to + # emit the pretty output. + self.type_map[list] = self.PRETTY_ARRAY + self.type_map[tuple] = self.PRETTY_ARRAY + self.type_map[types.GeneratorType] = self.PRETTY_ARRAY, + self.type_map[dict] = self.PRETTY_MAP + + # Private data used for indentation. + self._indent_level = 1 + if indent_atom is None: + self._indent_atom = b' ' + else: + self._indent_atom = indent_atom + + def _indent(self): + "Return an indentation based on the atom and indentation level." + return self._indent_atom * self._indent_level + + def PRETTY_ARRAY(self, v): + "Recursively format an array with pretty turned on." + rv = [] + rv.append(b'\n') + self._indent_level = self._indent_level + 1 + rv.extend([B("%s%s\n") % + (self._indent(), + self._generate(item)) + for item in v]) + self._indent_level = self._indent_level - 1 + rv.append(self._indent()) + rv.append(b'') + return b''.join(rv) + + def PRETTY_MAP(self, v): + "Recursively format a map with pretty turned on." + rv = [] + rv.append(b'\n') + self._indent_level = self._indent_level + 1 + # list of keys + keys = list(v) + keys.sort() + rv.extend([B("%s%s\n%s%s\n") % + (self._indent(), + self._elt(b'key', UnicodeType(key)), + self._indent(), + self._generate(v[key])) + for key in keys]) + self._indent_level = self._indent_level - 1 + rv.append(self._indent()) + rv.append(b'') + return b''.join(rv) + + def format(self, something): + """ + Format a python object as application/llsd+xml + + :param something: a python object (typically a dict) to be serialized. + :returns: Returns an XML formatted string. + """ + data = [] + data.append(b'\n') + data.append(self._generate(something)) + data.append(b'\n') + return b'\n'.join(data) + + +def format_pretty_xml(something): + """ + Serialize a python object as 'pretty' application/llsd+xml. + + :param something: a python object (typically a dict) to be serialized. + :returns: Returns an XML formatted string. + + See http://wiki.secondlife.com/wiki/LLSD#XML_Serialization + + The output conforms to the LLSD DTD, unlike the output from the + standard python xml.dom DOM::toprettyxml() method which does not + preserve significant whitespace. + This function is not necessarily suited for serializing very large + objects. It sorts on dict (llsd map) keys alphabetically to ease human + reading. + """ + return LLSDXMLPrettyFormatter().format(something) + + +declaration_regex = re.compile(br'^\s*(?:<\?[\x09\x0A\x0D\x20-\x7e]+\?>)|(?:)') +def validate_xml_declaration(something): + if not declaration_regex.match(something): + raise LLSDParseError("Invalid XML Declaration") + + +def parse_xml(something): + """ + This is the basic public interface for parsing llsd+xml. + + :param something: The data to parse. + :returns: Returns a python object. + """ + try: + # validate xml declaration manually until http://bugs.python.org/issue7138 is fixed + validate_xml_declaration(something) + return _to_python(fromstring(something)[0]) + except ElementTreeError as err: + raise LLSDParseError(*err.args) + + +_g_xml_formatter = None +def format_xml(something): + """ + Format a python object as application/llsd+xml + + :param something: a python object (typically a dict) to be serialized. + :returns: Returns an XML formatted string. + + Ssee http://wiki.secondlife.com/wiki/LLSD#XML_Serialization + + This function wraps both a pure python and c-extension for formatting + a limited subset of python objects as application/llsd+xml. + """ + global _g_xml_formatter + if _g_xml_formatter is None: + _g_xml_formatter = LLSDXMLFormatter() + return _g_xml_formatter.format(something) \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..a0f56f8 --- /dev/null +++ b/setup.py @@ -0,0 +1,35 @@ +import os + +from setuptools import find_packages, setup + +root_dir = os.path.dirname(__file__) +with open(os.path.join(root_dir, "README.md")) as f: + long_description = f.read() + + +setup( + name="llsd", + url="https://github.com/secondlife/python-llsd", + license="MIT", + author="Linden Research, Inc.", + author_email="opensource-dev@lists.secondlife.com", + description="Linden Lab Structured Data (LLSD) serialization library", + long_description=long_description, + long_description_content_type="text/markdown", + packages=find_packages(exclude=("tests",)), + setup_requires=["setuptools_scm<6"], + use_scm_version={ + 'local_scheme': 'no-local-version', # disable local-version to allow uploads to test.pypi.org + }, + extras_require={ + "dev": ["pytest", "pytest-cov<3"], + }, + classifiers=[ + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 2", + "Programming Language :: Python :: 3", + "Topic :: Software Development :: Libraries :: Python Modules", + "Topic :: Software Development", + ], +) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/fuzz.py b/tests/fuzz.py new file mode 100644 index 0000000..fb48ff0 --- /dev/null +++ b/tests/fuzz.py @@ -0,0 +1,465 @@ +from __future__ import division + +import random +import string +import struct +import time +import uuid +from datetime import date, datetime, timedelta + +# Ridiculous optimization -- compute once ahead of time so choice() doesn't +# call len(string.printable) a bajillion times. Amazing it makes a difference. +printable_len = len(string.printable) + + +import llsd +from llsd.base import is_string + + +class LLSDFuzzer(object): + """Generator of fuzzed LLSD objects. + + The :class:`LLSDFuzzer` constructor accepts a *seed* argument, which becomes the + seed of the PRNG driving the fuzzer. If *seed* is None, the current time is + used. The seed is also stored in the seed attribute on the object, which + is useful for producing deterministic trials. + """ + def __init__(self, seed=None): + self.r = random.Random() + if seed is None: + seed = time.time() + self.seed = seed + self.r.seed(seed) + + def random_boolean(self): + """Returns a random boolean.""" + return bool(self.r.getrandbits(1)) + + def random_integer(self): + """Returns a random integral value.""" + return self.r.getrandbits(32) - 2**31 + + def random_real(self): + """Returns a random floating-point value.""" + return self.r.uniform(-1000000.0, 1000000.0) + + def random_uuid(self): + """Returns a random UUID object.""" + # use bytes from our local Random, instead of the various uuid + # constructors, so as to be completely deterministic + return uuid.UUID(int= self.r.getrandbits(128)) + + def _string_length(self): + """Returns a 'reasonable' random length for a string. The current + distribution is such that it usually returns a number less than 25, but + occasionally can return numbers as high as a few thousand.""" + return int(self.r.lognormvariate(2.7, 1.3)) + + def random_printable(self, length = None): + """Returns a string of random printable characters with length *length*. + Uses a random length if none is specified.""" + if length is None: + length = self._string_length() + return ''.join([string.printable[int(self.r.random() * printable_len)] + for x in range(length)]) + + def random_unicode(self, length = None): + """Returns a string of random unicode characters with length *length*. + Uses a random length if none is specified.""" + # length of the unicode string will be only vaguely + # close to the specified length because we're not bothering + # to generate valid utf-16 and therefore many of our + # bytes may be discarded as invalid + if length is None: + length = self._string_length() + bytes = self.random_bytes(length * 2) + # utf-16 instead of utf-8 or 32 because it's way faster + return bytes.decode('utf-16', 'ignore') + + def random_bytes(self, length = None): + """Returns a string of random bytes with length *length*. + Uses a random length if none is specified.""" + if length is None: + length = self._string_length() + + if length % 8 == 0: + num_chunks = length // 8 + else: + num_chunks = length // 8 + 1 + + # this appears to be the fastest way to generate random byte strings + packstr = 'Q'*num_chunks + randchunks = [self.r.getrandbits(64) for i in range(num_chunks)] + return struct.pack(packstr, *randchunks)[:length] + + def random_binary(self, length=None): + """Returns a random llsd.binary object containing *length* random + bytes. Uses a random length if none is specified.""" + return llsd.binary(self.random_bytes(length)) + + def random_date(self): + """Returns a random date within the range of allowed LLSD dates (1970 + to 2038)""" + return datetime.utcfromtimestamp(0) + \ + timedelta(seconds=self.r.getrandbits(31)) + + def random_uri(self, length=None): + """Returns a random llsd.uri object containing *length* random + printable characters (so, not necessarily a legal uri). Uses a random + length if none is specified.""" + return llsd.uri(self.random_printable(length)) + + def _container_size(self): + "Returns a random 'reasonable' container size." + return int(round(self.r.expovariate(0.3)+1)) + + def random_map(self): + """Returns a Python dictionary which has string keys and + randomly-chosen values. It is single-level; none of the + values are themselves maps or arrays.""" + retval = {} + for x in range(self._container_size()): + if self.random_boolean(): + key = self.random_unicode() + else: + key = self.random_printable() + value = self.random_atom(include_containers=False) + retval[key] = value + return retval + + def random_array(self): + """Returns a random Python array which is populated + with random values. It is single-level; none of the + values are themselves maps or arrays.""" + return [self.random_atom(include_containers=False) + for x in range(self._container_size())] + + random_generators = [ + lambda self: None, + random_boolean, + random_integer, + random_real, + random_uuid, + random_printable, + random_unicode, + random_binary, + random_date, + random_uri, + random_map, + random_array] + + def random_atom(self, include_containers=True): + """Returns a random LLSD atomic value.""" + if include_containers: + return self.r.choice(self.random_generators)(self) + else: + return self.r.choice(self.random_generators[:-2])(self) + + def permute_undef(self, val): + """Permutes undef, the return value is always None.""" + return None + + def permute_boolean(self, val): + """Permutes booleans, the return value is always a boolean.""" + return self.random_boolean() + + integer_options = [ + lambda s, v: -v, + lambda s, v: 0, + lambda s, v: v + s.r.randint(-(v**4), (v**4)), + lambda s, v: 4294967296, # 2^32 + lambda s, v: -2147483649, # -2^31 - 1 + lambda s, v: 18446744073709551616, # 2^64 + lambda s, v: s.random_integer(), + lambda s, v: v * ((s.r.getrandbits(16) - 2**15) or 1), + lambda s, v: v // ((s.r.getrandbits(16) - 2**15) or 1), + lambda s, v: v + s.random_integer(), + lambda s, v: v - s.random_integer(), + lambda s, v: v * ((s.r.getrandbits(8) - 2**7) or 1), + lambda s, v: v // ((s.r.getrandbits(8) - 2**7) or 1) + ] + + def permute_integer(self, val): + """Generates variations on a given int or long, + returned value is an int or a long.""" + return self.r.choice(self.integer_options)(self, val) + + real_options = [ + lambda s, v: -v, + lambda s, v: 0.0, + lambda s, v: v/float(2**64), + lambda s, v: v*float(2**64), + lambda s, v: 1E400, + lambda s, v: -1E400, + lambda s, v: float('nan'), + lambda s, v: s.random_real(), + lambda s, v: v * (s.r.random() - 0.5), + lambda s, v: v / (s.r.random() - 0.5), + lambda s, v: v + s.random_real(), + lambda s, v: v - s.random_real(), + lambda s, v: v * s.random_real(), + lambda s, v: v / s.random_real() + ] + + def permute_real(self, val): + """Generates variations on a float, the returned + value is a float.""" + return self.r.choice(self.real_options)(self, val) + + def permute_uuid(self, val): + """ Generates variations on a uuid, the returned value is a uuid.""" + return uuid.UUID(int=self.r.getrandbits(128) ^ val.int) + + def rand_idx(self, val): + """Return a random index into the value.""" + if len(val) == 0: + return 0 + return self.r.randrange(0, len(val)) + + stringlike_options = [ + lambda s,v,strgen: strgen() + v, + lambda s,v,strgen: v + strgen(), + lambda s,v,strgen: v[s.rand_idx(v):], + lambda s,v,strgen: v[:s.rand_idx(v)], + lambda s,v,strgen: v[:s.rand_idx(v)] + strgen() + v[s.rand_idx(v):] + ] + + def _permute_stringlike(self, val, strgen): + if len(val) == 0: + return strgen() + else: + return self.r.choice(self.stringlike_options)(self, val, strgen) + + def permute_string(self, val): + """Generates variations on a given string or unicode. All + generated values are strings/unicodes.""" + assert is_string(val) + def string_strgen(length = None): + if self.random_boolean(): + return self.random_printable(length) + else: + return self.random_unicode(length) + return self._permute_stringlike(val, string_strgen) + + def permute_binary(self, val): + """Generates variations on a given binary value. All + generated values are llsd.binary.""" + assert isinstance(val, llsd.binary) + return llsd.binary(self._permute_stringlike(val, self.random_bytes)) + + def _date_clamp(self, val): + if val.year >= 2038: + raise OverflowError() + elif val.year < 1970: + return date(1970, val.month, val.day) + else: + return val + + date_options = [ + lambda s, v: s._date_clamp(v + timedelta( + seconds=s.r.getrandbits(21) - 2**20, + microseconds=s.r.getrandbits(20))), + lambda s, v: datetime.utcfromtimestamp(0), + lambda s, v: date(v.year, v.month, v.day), + lambda s, v: datetime.utcfromtimestamp(2**31-86400), + ] + + def permute_date(self, val): + """Generates variations on a given datetime. All generated + values are datetimes within the valid llsd daterange.""" + assert isinstance(val, (datetime, date)) + # have to retry a few times because the random-delta option + # sometimes gets out of range + while True: + try: + return self.r.choice(self.date_options)(self, val) + except (OverflowError, OSError): + continue + + def permute_uri(self, val): + """Generates variations on a given uri. All + generated values are llsd.uri.""" + assert isinstance(val, llsd.uri) + return llsd.uri(self._permute_stringlike(val, self.random_printable)) + + def _permute_map_permute_value(self, val): + if len(val) == 0: + return {} + # choose one of the keys from val + k = self.r.choice(list(val)) + permuted = val.copy() + permuted[k] = next(self.structure_fuzz(val[k])) + return permuted + + def _permute_map_key_delete(self, val): + if len(val) == 0: + return {} + # choose one of the keys from val + k = self.r.choice(list(val)) + permuted = val.copy() + permuted.pop(k, None) + return permuted + + def _permute_map_new_key(self, val): + permuted = val.copy() + if len(val) > 0 and self.random_boolean(): + # choose one of the keys from val + new_key = self.permute_string(self.r.choice(list(val))) + else: + new_key = self.random_unicode() + + permuted[new_key] = self.random_atom() + return permuted + + def _permute_map_permute_key_names(self, val): + if len(val) == 0: + return {} + # choose one of the keys from val + k = self.r.choice(list(val)) + k = self.permute_string(k) + permuted = val.copy() + v = permuted.pop(k, None) + permuted[k] = v + return permuted + + + map_sub_permuters = (_permute_map_permute_value, + _permute_map_permute_value, + _permute_map_key_delete, + _permute_map_new_key, + _permute_map_permute_key_names) + + def permute_map(self, val): + """ Generates variations on an input dict via a variety of steps. + The return value is a dict.""" + assert isinstance(val, dict) + permuted = self.r.choice(self.map_sub_permuters)(self, val) + for i in range(int(self.r.expovariate(0.5)) + 1): + permuted = self.r.choice(self.map_sub_permuters)(self, permuted) + return permuted + + def _permute_array_permute_value(self, val): + idx = self.r.randrange(0, len(val)) + permuted = list(val) + permuted[idx] = next(self.structure_fuzz(val[idx])) + return permuted + + def _permute_array_subsets(self, val): + return self.r.sample(val, self.r.randint(1, len(val))) + + def _permute_array_inserting(self, val): + new_idx = self.r.randint(0, len(val)) + inserted = self.random_atom() + permuted = list(val[:new_idx]) + [inserted] + list(val[new_idx:]) + return permuted + + def _permute_array_reorder(self, val): + permuted = list(val) + swaps = self.r.randrange(0, len(val)) + for s in range(swaps): + i = self.r.randrange(0, len(val)) + j = self.r.randrange(0, len(val)) + permuted[i], permuted[j] = permuted[j], permuted[i] + return permuted + + array_sub_permuters = (_permute_array_permute_value, + _permute_array_permute_value, + _permute_array_subsets, + _permute_array_inserting, + _permute_array_reorder) + + def permute_array(self, val): + """ Generates variations on an input array via a variety of steps. + The return value is a dict.""" + assert isinstance(val, (list, tuple)) + permuted = self.r.choice(self.array_sub_permuters)(self, val) + for i in range(int(self.r.expovariate(0.5)) + 1): + permuted = self.r.choice(self.array_sub_permuters)(self, permuted) + if self.random_boolean(): + permuted = tuple(permuted) + return permuted + + permuters = { + type(None): permute_undef, + bool: permute_boolean, + int: permute_integer, + llsd.LongType: permute_integer, + float: permute_real, + uuid.UUID: permute_uuid, + str: permute_string, + llsd.UnicodeType: permute_string, + llsd.binary: permute_binary, + datetime: permute_date, + date: permute_date, + llsd.uri: permute_uri, + dict: permute_map, + list: permute_array, + tuple: permute_array} + + def structure_fuzz(self, starting_structure): + """ Generates a series of Python structures + based on the input structure.""" + permuter = self.permuters[type(starting_structure)] + while True: + if self.r.getrandbits(2) == 0: + yield self.random_atom() + else: + yield permuter(self, starting_structure) + + def _random_numeric(self, length): + return ''.join([self.r.choice(string.digits) + for x in range(length)]) + + def _dirty(self, val): + idx1 = self.rand_idx(val) + idx2 = idx1 + int(round(self.r.expovariate(0.1))) + if self.random_boolean(): + # replace with same-length string + subst_len = idx2 - idx1 + else: + subst_len = int(round(self.r.expovariate(0.1))) + + if self.random_boolean(): + # use printable + if self.random_boolean(): + replacement = self._random_numeric(subst_len).encode('latin-1') + else: + replacement = self.random_printable(subst_len).encode('latin-1') + else: + replacement = self.random_bytes(subst_len) + + return val[:idx1] + replacement + val[idx2:] + + def _serialized_fuzz(self, it, formatter): + while True: + struct = next(it) + try: + text = formatter(struct) + except llsd.LLSDSerializationError: + continue + yield text + dirtied = self._dirty(text) + for i in range(int(round(self.r.expovariate(0.3)))): + dirtied = self._dirty(dirtied) + yield dirtied + + def binary_fuzz(self, starting_structure): + """ Generates a series of strings which are meant to be tested against + a service that parses binary LLSD.""" + return self._serialized_fuzz( + self.structure_fuzz(starting_structure), + llsd.format_binary) + + def xml_fuzz(self, starting_structure): + """ Generates a series of strings which are meant to be tested against + a service that parses XML LLSD.""" + return self._serialized_fuzz( + self.structure_fuzz(starting_structure), + llsd.format_xml) + + def notation_fuzz(self, starting_structure): + """ Generates a series of strings which are meant to be tested against + a service that parses the LLSD notation serialization.""" + return self._serialized_fuzz( + self.structure_fuzz(starting_structure), + llsd.format_notation) \ No newline at end of file diff --git a/tests/llsd_test.py b/tests/llsd_test.py new file mode 100644 index 0000000..e8d5fe4 --- /dev/null +++ b/tests/llsd_test.py @@ -0,0 +1,1861 @@ + +# -*- coding: utf-8 -*- +from __future__ import print_function + +import base64 +import pprint +import re +import struct +import time +import unittest +import uuid +from datetime import date, datetime +from itertools import islice + +import pytest + +import llsd +from llsd.base import PY2, is_integer, is_string, is_unicode +from llsd.serde_xml import remove_invalid_xml_bytes +from tests.fuzz import LLSDFuzzer + + +class Foo(object): + """ + Simple Mock Class used for testing. + """ + pass + +try: + from math import isnan as _isnan + def isnan(x): + if isinstance(x, float): + return _isnan(x) + else: + return False +except ImportError: + def isnan(x): + return x != x + + +class LLSDNotationUnitTest(unittest.TestCase): + """ + This class aggregates all the tests for parse_notation(something), + LLSD.as_notation(something) and format_notation (i.e. same as + LLSD.as_notation(something). Note that test scenarios for the + same input type are all put into single test method. And utility + method assert_notation_roundtrip is used to test parse_notation + and as_notation at the same time. + """ + def setUp(self): + """ + Set up the test class + """ + self.llsd = llsd.LLSD() + + def strip(self, the_string): + """ + Remove any whitespace characters from the input string. + + :Parameters: + - 'the_string': string to remove the whitespaces. + """ + return re.sub(b'\s', b'', the_string) + + def assertNotationRoundtrip(self, py_in, str_in, is_alternate_notation=False): + """ + Utility method to check the result of parse_notation and + LLSD.as_notation. + """ + # use parse to check here + py_out = self.llsd.parse(str_in) + str_out = self.llsd.as_notation(py_in) + py_roundtrip = self.llsd.parse(str_out) + str_roundtrip = self.llsd.as_notation(py_out) + # compare user-passed Python data with parsed user-passed string + self.assertEqual(py_in, py_out) + # compare user-passed Python data with parsed (serialized data) + self.assertEqual(py_in, py_roundtrip) + +## # Comparing serialized data invites exasperating spurious test +## # failures. Most interesting LLSD data is contained in dicts, and +## # Python has never guaranteed the serialization order of dict keys. +## # If str_in is an alternate notation, we can't compare it directly. +## if not is_alternate_notation: +## self.assertEqual(self.strip(str_out), self.strip(str_in)) +## self.assertEqual(self.strip(str_out), self.strip(str_roundtrip)) + + # use parse_notation to check again + py_out = llsd.parse_notation(str_in) + str_out = self.llsd.as_notation(py_in) + py_roundtrip = llsd.parse_notation(str_out) + str_roundtrip = self.llsd.as_notation(py_out) + self.assertEqual(py_in, py_out) + self.assertEqual(py_in, py_roundtrip) + +## # Disabled for the same reason as above. +## # If str_in is an alternate notation, we can't compare it directly. +## if not is_alternate_notation: +## self.assertEqual(self.strip(str_out), self.strip(str_in)) +## self.assertEqual(self.strip(str_out), self.strip(str_roundtrip)) + + def testInteger(self): + """ + Test the input type integer. + Maps to test scenarios module:llsd:test#4-6 + """ + pos_int_notation = b"i123456" + neg_int_notation = b"i-123457890" + blank_int_notation = b"i0" + + python_pos_int = 123456 + python_neg_int = -123457890 + + self.assertNotationRoundtrip(python_pos_int, + pos_int_notation) + self.assertNotationRoundtrip(python_neg_int, + neg_int_notation) + self.assertEqual(0, self.llsd.parse(blank_int_notation)) + + def testUndefined(self): + """ + Test the input type : undef + Maps to test scenarios module:llsd:test#7 + """ + undef_notation = b"!" + self.assertNotationRoundtrip(None, undef_notation) + + def testBoolean(self): + """ + Test the input type : Boolean + Maps to test scenarios module:llsd:test#8-17 + """ + sample_data = [(True, b"TRUE"), + (True, b"true"), + (True, b"T"), + (True, b"t"), + (True, b"1"), + (False, b"FALSE"), + (False, b"false"), + (False, b"F"), + (False, b"f"), + (False, b"0") + ] + for py, notation in sample_data: + is_alternate_notation = False + if notation not in (b"true", b"false"): + is_alternate_notation = True + self.assertNotationRoundtrip(py, notation, is_alternate_notation) + + blank_notation = b"" + self.assertEqual(False, self.llsd.parse(blank_notation)) + + def testReal(self): + """ + Test the input type: real. + Maps to test scenarios module:llsd:test#18-20 + """ + pos_real_notation = b"r2983287453.3000002" + neg_real_notation = b"r-2983287453.3000002" + blank_real_notation = b"r0" + + python_pos_real = 2983287453.3 + python_neg_real = -2983287453.3 + + self.assertNotationRoundtrip(python_pos_real, + pos_real_notation, True) + self.assertNotationRoundtrip(python_neg_real, + neg_real_notation, True) + self.assertEqual(0, self.llsd.parse(blank_real_notation)) + + + def testUUID(self): + """ + Test the input type : UUID. + Maps to test scenarios module:llsd:test#21 + """ + uuid_tests = { + uuid.UUID(hex='d7f4aeca-88f1-42a1-b385-b9db18abb255'):b"ud7f4aeca-88f1-42a1-b385-b9db18abb255", + uuid.UUID(hex='00000000-0000-0000-0000-000000000000'):b"u00000000-0000-0000-0000-000000000000"} + + for py, notation in uuid_tests.items(): + self.assertNotationRoundtrip(py, notation) + + def testString(self): + """ + Test the input type: String. + Maps to test scenarios module:llsd:test#22-24 + """ + sample_data = [('foo bar magic" go!', b"'foo bar magic\" go!'"), + ("foo bar magic's go!", b'"foo bar magic\'s go!"'), + ('have a nice day', b"'have a nice day'"), + ('have a nice day', b'"have a nice day"'), + ('have a nice day', b's(15)"have a nice day"'), + ('have a "nice" day', b'\'have a "nice" day\''), + ('have a "nice" day', b'"have a \\"nice\\" day"'), + ('have a "nice" day', b's(17)"have a "nice" day"'), + ("have a 'nice' day", b"'have a \\'nice\\' day'"), + ("have a 'nice' day", b'"have a \'nice\' day"'), + ("have a 'nice' day", b's(17)"have a \'nice\' day"'), + (u"Kanji: '\u5c0f\u5fc3\u8005'", + b"'Kanji: \\'\xe5\xb0\x8f\xe5\xbf\x83\xe8\x80\x85\\''"), + (u"Kanji: '\u5c0f\u5fc3\u8005'", + b"\"Kanji: '\\xe5\\xb0\\x8f\\xE5\\xbf\\x83\\xe8\\x80\\x85'\""), + ('\a\b\f\n\r\t\v', b'"\\a\\b\\f\\n\\r\\t\\v"') + ] + for py, notation in sample_data: + is_alternate_notation = False + if notation[0:1] != "'": + is_alternate_notation = True + self.assertNotationRoundtrip(py, notation, is_alternate_notation) + + def testURI(self): + """ + Test the input type: URI. + Maps to test scenarios module:llsd:test#25 - 26 + """ + uri_tests = { + llsd.uri('http://www.topcoder.com/tc/projects?id=1230'):b'l"http://www.topcoder.com/tc/projects?id=1230"', + llsd.uri('http://www.topcoder.com/tc/projects?id=1231'):b"l'http://www.topcoder.com/tc/projects?id=1231'"} + + blank_uri_notation = b'l""' + + for py, notation in uri_tests.items(): + is_alternate_notation = False + if notation[1:2] != b'"': + is_alternate_notation = True + self.assertNotationRoundtrip(py, notation, is_alternate_notation) + self.assertEqual('', self.llsd.parse(blank_uri_notation)) + + def testDate(self): + """ + Test the input type : Date. + Maps to test scenarios module:llsd:test#27 - 30 + """ + valid_date_notation = b'd"2006-02-01T14:29:53.460000Z"' + valid_date_notation_no_float = b'd"2006-02-01T14:29:53Z"' + valid_date_notation_zero_seconds = b'd"2006-02-01T14:29:00Z"' + valid_date_notation_filled = b'd"2006-02-01T14:29:05Z"' + valid_date_19th_century = b'd"1833-02-01T00:00:00Z"' + + blank_date_notation = b'd""' + + python_valid_date = datetime(2006, 2, 1, 14, 29, 53, 460000) + python_valid_date_no_float = datetime(2006, 2, 1, 14, 29, 53) + python_valid_date_zero_seconds = datetime(2006, 2, 1, 14, 29, 0) + python_valid_date_filled = datetime(2006, 2, 1, 14, 29, 5) + python_valid_date_19th_century = datetime(1833,2,1) + + python_blank_date = datetime(1970, 1, 1) + + self.assertNotationRoundtrip(python_valid_date, + valid_date_notation) + self.assertNotationRoundtrip(python_valid_date_no_float, + valid_date_notation_no_float) + self.assertNotationRoundtrip(python_valid_date_zero_seconds, + valid_date_notation_zero_seconds) + self.assertNotationRoundtrip(python_valid_date_filled, + valid_date_notation_filled) + + self.assertNotationRoundtrip(python_valid_date_filled, + valid_date_notation_filled) + self.assertNotationRoundtrip(python_valid_date_19th_century, + valid_date_19th_century) + + self.assertEqual(python_blank_date, self.llsd.parse(blank_date_notation)) + + def testArray(self): + """ + Test the input type : Array. + Maps to test scenarios module:llsd:test#31-33 + """ + # simple array + array_notation = b"['foo', 'bar']" + # composite array + array_within_array_notation = b"['foo', 'bar',['foo', 'bar']]" + # blank array + blank_array_notation = b"[]" + + python_array = [str("foo"), "bar"] + python_array_within_array = ["foo", "bar", ["foo", "bar"]] + python_blank_array = [] + + self.assertNotationRoundtrip(python_array, array_notation) + self.assertNotationRoundtrip(python_array_within_array, + array_within_array_notation) + self.assertNotationRoundtrip(python_blank_array, blank_array_notation) + + def testMap(self): + """ + Test the input type : Map. + Maps to test scenarios module:llsd:test#34-36 + """ + # simple map + map_notation = b"{'foo':'bar'}" + + # composite map + map_within_map_notation = b"{'foo':'bar','doo':{'goo':'poo'}}" + + # blank map + blank_map_notation = b"{}" + + python_map = {"foo":"bar"} + python_map_within_map = {"foo":"bar", "doo":{"goo":"poo"}} + python_blank_map = {} + + self.assertNotationRoundtrip(python_map, map_notation) + self.assertNotationRoundtrip(python_map_within_map, + map_within_map_notation) + self.assertNotationRoundtrip(python_blank_map, blank_map_notation) + + def testBinary(self): + """ + Test the input type: binary. + Maps to test scenarios module:llsd:test#37 + """ + string_data1 = b"quick brown fox!!" + string_data2 = b""" +

"Take some more tea ," the March Hare said to Alice, very earnestly.

+ """ + python_binary1 = llsd.binary(string_data1) + python_binary2 = llsd.binary(string_data2) + + notation1 = b'b64' + b'"' + base64.b64encode(string_data1).strip() + b'"' + notation2 = b'b64' + b'"' + base64.b64encode(string_data2).strip() + b'"' + notation3 = b'b16' + b'"' + base64.b16encode(string_data1).strip() + b'"' + notation4 = b'b16' + b'"' + base64.b16encode(string_data2).strip() + b'"' + notation5 = b'b85' + b'"<~EHPu*CER),Dg-(AAoDo;+T~>"' + notation6 = b'b85' + b'"<~4E*J.<+0QR+EMIu4+@0gX@q@26G%G]>+D"u%DImm2Cj@Wq05s)~>"' + + self.assertNotationRoundtrip(python_binary1, notation1, True) + self.assertNotationRoundtrip(python_binary2, notation2, True) + self.assertNotationRoundtrip(python_binary1, notation3, True) + self.assertNotationRoundtrip(python_binary2, notation4, True) + self.assertRaises(llsd.LLSDParseError, self.llsd.parse, notation5) + self.assertRaises(llsd.LLSDParseError, self.llsd.parse, notation6) + + ''' + def testProblemMap(self): + """ + This is some data that the fuzzer generated that caused a parse error + """ + string_data = b"{'$g7N':!,'3r=h':true,'\xe8\x88\xbc\xe9\xa7\xb9\xe1\xb9\xa6\xea\xb3\x95\xe0\xa8\xb3\xe1\x9b\x84\xef\xb2\xa7\xe8\x8f\x99\xe8\x94\xa0\xe9\x90\xb9\xe6\x88\x9b\xe0\xaf\x84\xe8\xb8\xa2\xe4\x94\x83\xea\xb5\x8b\xed\x8c\x8a\xe5\xb5\x97':'\xe6\xbb\xa6\xe3\xbf\x88\xea\x9b\x82\xea\x9f\x8d\xee\xbb\xba\xe4\xbf\x87\xe3\x8c\xb5\xe3\xb2\xb0\xe7\x90\x91\xee\x8f\xab\xee\x81\xa5\xea\x94\x98'}" + python_obj = {} + + import pdb; pdb.set_trace() + self.assertNotationRoundtrip(python_obj, string_data, True) + ''' + + def testNotationOfAllTypes(self): + """ + Test notation with mixed with all kinds of simple types. + Maps to test scenarios module:llsd:test#38 + """ + python_object = [{'destination': 'http://secondlife.com'}, {'version': + 1}, {'modification_date': datetime(2006, 2, 1, 14, 29, 53, + 460000)}, {'first_name': 'Phoenix', 'last_name': 'Linden', 'granters': + [uuid.UUID('a2e76fcd-9360-4f6d-a924-000000000003')], 'look_at': [-0.043753, + -0.999042, 0.0], 'attachment_data': [{'attachment_point': + 2, 'item_id': uuid.UUID('d6852c11-a74e-309a-0462-50533f1ef9b3'), + 'asset_id': uuid.UUID('c69b29b1-8944-58ae-a7c5-2ca7b23e22fb')}, + {'attachment_point': 10, 'item_id': + uuid.UUID('ff852c22-a74e-309a-0462-50533f1ef900'), 'asset_id': + uuid.UUID('5868dd20-c25a-47bd-8b4c-dedc99ef9479')}], 'session_id': + uuid.UUID('2c585cec-038c-40b0-b42e-a25ebab4d132'), 'agent_id': + uuid.UUID('3c115e51-04f4-523c-9fa6-98aff1034730'), 'circuit_code': 1075, + 'position': [70.9247, 254.378, + 38.7304]}] + + notation = b"""[ + {'destination':'http://secondlife.com'}, + {'version':i1}, + {'modification_date':d"2006-02-01T14:29:53.460000Z"} + { + 'agent_id':u3c115e51-04f4-523c-9fa6-98aff1034730, + 'session_id':u2c585cec-038c-40b0-b42e-a25ebab4d132, + 'circuit_code':i1075, + 'first_name':'Phoenix', + 'last_name':'Linden', + 'position':[r70.9247,r254.378,r38.7304], + 'look_at':[r-0.043753,r-0.999042,r0.0], + 'granters':[ua2e76fcd-9360-4f6d-a924-000000000003], + 'attachment_data':[ + { + 'attachment_point':i2, + 'item_id':ud6852c11-a74e-309a-0462-50533f1ef9b3, + 'asset_id':uc69b29b1-8944-58ae-a7c5-2ca7b23e22fb + }, + { + 'attachment_point':i10, + 'item_id':uff852c22-a74e-309a-0462-50533f1ef900, + 'asset_id':u5868dd20-c25a-47bd-8b4c-dedc99ef9479 + } + ] + }]""" + + result = self.llsd.parse(notation) + self.assertEqual(python_object, result) + + # roundtrip test + notation_result = self.llsd.as_notation(python_object) + python_object_roundtrip = self.llsd.parse(notation_result) + self.assertEqual(python_object_roundtrip, python_object) + + def testLLSDSerializationFailure(self): + """ + Test llsd searialization with non supportd object type. + TypeError should be raised. + + Maps test scenarios : module:llsd:test#91 + """ + # make an object not supported by llsd + python_native_obj = Foo() + + # assert than an exception is raised + self.assertRaises(TypeError, self.llsd.as_notation, python_native_obj) + self.assertRaises(llsd.LLSDParseError, self.llsd.parse, b'2') + + def testParseNotationInvalidNotation1(self): + """ + Test with an invalid array notation. + Maps to module:llsd:test#76, 86 + """ + self.assertRaises(llsd.LLSDParseError, self.llsd.parse, b"[ 'foo' : 'bar')") + + def testParseNotationInvalidNotation2(self): + """ + Test with an invalid map notation. + Maps to module:llsd:test#87 + """ + self.assertRaises(llsd.LLSDParseError, self.llsd.parse, b"{'foo':'bar','doo':{'goo' 'poo'}") # missing separator + self.assertRaises(llsd.LLSDParseError, self.llsd.parse, b"{'foo':'bar','doo':{'goo' : 'poo'}") # missing closing '}' + + def testParseNotationInvalidNotation3(self): + """ + Test with an invalid map notation. + Maps to module:llsd:test#88 + """ + self.assertRaises(llsd.LLSDParseError, self.llsd.parse, b"day day up, day day up") + + def testParseNotationInvalidNotation4(self): + """ + Test with an invalid date notation. + """ + self.assertRaises(llsd.LLSDParseError, self.llsd.parse, b'd"2006#02-01T1429:53.460000Z"') + + def testParseNotationInvalidNotation5(self): + """ + Test with an invalid int notation. + """ + self.assertRaises(llsd.LLSDParseError, self.llsd.parse, b'i*123xx') + + def testParseNotationInvalidNotation6(self): + """ + Test with an invalid real notation. + """ + self.assertRaises(llsd.LLSDParseError, self.llsd.parse, b'r**1.23.3434') + + def testParseNotationInvalidNotation7(self): + """ + Test with an invalid binary notation. + """ + self.assertRaises(llsd.LLSDParseError, self.llsd.parse, b"b634'bGFsYQ='") + + def testParseNotationInvalidNotation8(self): + """ + Test with an invalid map notation. + """ + self.assertRaises(llsd.LLSDParseError, self.llsd.parse, b"{'foo':'bar',doo':{'goo' 'poo'}}") + + def testParseNotationInvalidNotation9(self): + """ + Test with an invalid map notation. + """ + self.assertRaises(llsd.LLSDParseError, self.llsd.parse, b"[i123,i123)") + + def testParseNotationInvalidNotation10(self): + """ + Test with an invalid raw string notation. + """ + self.assertRaises(llsd.LLSDParseError, self.llsd.parse, b"s[2]'xx'") + + def testParseNotationInvalidNotation11(self): + """ + Test with an invalid raw string notation. + """ + self.assertRaises(llsd.LLSDParseError, self.llsd.parse, b"s(2]'xx'") + + def testParseNotationInvalidNotation12(self): + """ + Test with an invalid raw string notation. + """ + self.assertRaises(llsd.LLSDParseError, self.llsd.parse, b"s(2)'xxxxx'") + + def testParseNotationInvalidNotation13(self): + """ + Test with an invalid raw string notation. + """ + self.assertRaises(llsd.LLSDParseError, self.llsd.parse, b"s(2)*xx'") + + def testParseNotationIncorrectMIME(self): + """ + Test with correct notation format but incorrect MIME type. -> llsd:test79 + """ + try: + self.llsd.parse(b"[ {'foo':'bar'}, {'foo':'bar'} ]", llsd.XML_MIME_TYPE) + self.fail("LLSDParseError should be raised.") + except llsd.LLSDParseError: + pass + + +class LLSDBinaryUnitTest(unittest.TestCase): + """ + This class aggregates all the tests for parse_binary and LLSD.as_binary + which is the same as module function format_binary. The tests use roundtrip + test to check the serialization of llsd object and the parsing of binary + representation of llsd object. + + Note that llsd binary test scenarios maps to module:llsd:test#66 which reuses + all the test scenarios of llsd xml. + """ + def setUp(self): + """ + Set up the test class, create a LLSD object and assign to self.llsd. + """ + self.llsd = llsd.LLSD() + + def roundTrip(self, something): + """ + Utility method which serialize the passed in object using + binary format, parse the serialized binary format into object, and + return the object. + """ + binary = self.llsd.as_binary(something) + return self.llsd.parse(binary) + + def testMap(self): + """ + Test the binary serialization and parse of llsd type : Map. + """ + map_xml = b"""\ + + + +foo +bar + +""" + + map_within_map_xml = b"\ +\ +\ +\ +foo\ +bar\ +doo\ +\ +goo\ +poo\ +\ +\ +" + + blank_map_xml = b"\ +\ +\ +" + + python_map = {"foo" : "bar"} + python_map_within_map = {"foo":"bar", "doo":{"goo":"poo"}} + + self.assertEqual(python_map, self.roundTrip(self.llsd.parse(map_xml))) + self.assertEqual( + python_map_within_map, + self.roundTrip(self.llsd.parse(map_within_map_xml))) + self.assertEqual({}, self.roundTrip(self.llsd.parse(blank_map_xml))) + + def testArray(self): + """ + Test the binary serialization and parse of llsd type : Array. + """ + array_xml = b"\ +\ +\ +\ +foo\ +bar\ +\ +" + array_within_array_xml = b"\ +\ +\ +\ +foo\ +bar\ +\ +foo\ +bar\ +\ +\ +" + blank_array_xml = b"\ +\ +\ +" + + python_array = ["foo", "bar"] + python_array_within_array = ["foo", "bar", ["foo", "bar"]] + + self.assertEqual( + python_array, + self.roundTrip(self.llsd.parse(array_xml))) + self.assertEqual( + python_array_within_array, + self.roundTrip(self.llsd.parse(array_within_array_xml))) + self.assertEqual( + [], + self.roundTrip(self.llsd.parse(blank_array_xml))) + + def testString(self): + """ + Test the binary serialization and parse of llsd type : string. + """ + normal_xml = b""" + + +foo +""" + + blank_xml = b"\ +\ +\ +\ +" + + self.assertEqual('foo', self.roundTrip(self.llsd.parse(normal_xml))) + self.assertEqual("", self.roundTrip(self.llsd.parse(blank_xml))) + + def testInteger(self): + """ + Test the binary serialization and parse of llsd type : integer + """ + pos_int_xml = b"\ +\ +\ +289343\ +" + + neg_int_xml = b"\ +\ +\ +-289343\ +" + + blank_int_xml = b"\ +\ +\ +\ +" + + python_pos_int = 289343 + python_neg_int = -289343 + + self.assertEqual( + python_pos_int, + self.roundTrip(self.llsd.parse(pos_int_xml))) + self.assertEqual( + python_neg_int, + self.roundTrip(self.llsd.parse(neg_int_xml))) + self.assertEqual( + 0, + self.roundTrip(self.llsd.parse(blank_int_xml))) + + def testReal(self): + """ + Test the binary serialization and parse of llsd type : real. + """ + pos_real_xml = b"\ +\ +\ +2983287453.3\ +" + + neg_real_xml = b"\ +\ +\ +-2983287453.3\ +" + + blank_real_xml = b"\ +\ +\ +\ +" + + python_pos_real = 2983287453.3 + python_neg_real = -2983287453.3 + + self.assertEqual( + python_pos_real, + self.roundTrip(self.llsd.parse(pos_real_xml))) + self.assertEqual( + python_neg_real, + self.roundTrip(self.llsd.parse(neg_real_xml))) + self.assertEqual( + 0, + self.roundTrip(self.llsd.parse(blank_real_xml))) + + def testBoolean(self): + """ + Test the binary serialization and parse of llsd type : boolean. + """ + true_xml = b"\ +\ +\ +true\ +" + + false_xml = b"\ +\ +\ +false\ +" + + blank_xml = b"\ +\ +\ +\ +" + + self.assertEqual(True, self.roundTrip(self.llsd.parse(true_xml))) + self.assertEqual(False, self.roundTrip(self.llsd.parse(false_xml))) + self.assertEqual(False, self.roundTrip(self.llsd.parse(blank_xml))) + + def testDate(self): + """ + Test the binary serialization and parse of llsd type : date. + """ + valid_date_binary = b"d\x00\x00\x40\x78\x31\xf8\xd0\x41" + valid_date_xml = b"\ +\ +\ +2006-02-01T14:29:53Z\ +" + + blank_date_xml = b"\ +\ +\ +\ +" + python_valid_date = datetime(2006, 2, 1, 14, 29, 53) + python_blank_date = datetime(1970, 1, 1) + + self.assertEqual( + python_valid_date, + self.roundTrip(self.llsd.parse(valid_date_xml))) + self.assertEqual( + python_valid_date, + self.roundTrip(llsd.parse_binary(valid_date_binary))) + self.assertEqual( + python_blank_date, + self.roundTrip(self.llsd.parse(blank_date_xml))) + + def testBinary(self): + """ + Test the binary serialization and parse of llsd type : binary. + """ + base64_binary_xml = b"\ +\ +\ +dGhlIHF1aWNrIGJyb3duIGZveA==\ +" + + foo = self.llsd.parse(base64_binary_xml) + self.assertEqual( + llsd.binary(b"the quick brown fox"), + self.roundTrip(foo)) + + def testUUID(self): + """ + Test the binary serialization and parse of llsd type : UUID. + """ + valid_uuid_xml = b"\ +\ +\ +d7f4aeca-88f1-42a1-b385-b9db18abb255\ +" + blank_uuid_xml = b"\ +\ +\ +\ +" + self.assertEqual( + 'd7f4aeca-88f1-42a1-b385-b9db18abb255', + self.roundTrip(str(self.llsd.parse(valid_uuid_xml)))) + self.assertEqual( + '00000000-0000-0000-0000-000000000000', + self.roundTrip(str(self.llsd.parse(blank_uuid_xml)))) + + binary_uuid = b"""\nu\xe1g\xa9D\xd9\x06\x89\x04-\x04\x92\xab\x8e\xaf5\xbf""" + + self.assertEqual(uuid.UUID('e167a944-d906-8904-2d04-92ab8eaf35bf'), + llsd.parse(binary_uuid)) + + def testURI(self): + """ + Test the binary serialization and parse of llsd type : URI. + """ + valid_uri_xml = b"\ +\ +\ +http://sim956.agni.lindenlab.com:12035/runtime/agents\ +" + + blank_uri_xml = b"\ +\ +\ +\ +" + + self.assertEqual( + 'http://sim956.agni.lindenlab.com:12035/runtime/agents', + self.roundTrip(self.llsd.parse(valid_uri_xml))) + self.assertEqual( + '', + self.roundTrip(self.llsd.parse(blank_uri_xml))) + + def testUndefined(self): + """ + Test the binary serialization and parse of llsd type : undef. + """ + undef_xml = b"" + self.assertEqual( + None, + self.roundTrip(self.llsd.parse(undef_xml))) + + + def testBinaryOfAllTypes(self): + """ + Test the binary serialization and parse of a composited llsd object + which is composited of simple llsd types. + """ + multi_xml = b"\ +\ +\ +\ +\ +\ +content-typeapplication/binary\ +\ +MTIzNDU2Cg==\ +\ +\ +\ +content-typeapplication/exe\ +\ +d2hpbGUoMSkgeyBwcmludCAneWVzJ307Cg==\ +\ +\ +" + + multi_python = [ + [{'content-type':'application/binary'},b'123456\n'], + [{'content-type':'application/exe'},b"while(1) { print 'yes'};\n"]] + + self.assertEqual( + multi_python, + self.roundTrip(self.llsd.parse(multi_xml))) + + def testInvalidBinaryFormat(self): + """ + Test the parse with an invalid binary format. LLSDParseError should + be raised. + + Maps to test scenarios : module:llsd:test#78 + """ + invalid_binary = b"""\n[\\xx0{}]]""" + + self.assertRaises(llsd.LLSDParseError, llsd.parse, invalid_binary) + + def testParseBinaryIncorrectMIME(self): + """ + Test parse with binary format data but has an incorrect MIME type. + + LLSDParseError should be raised. + + Maps to test scenarios : module:llsd:test#81 + """ + binary_data = b"""\n[\x00\x00\x00\x02i\x00\x00\x00{i\x00\x00\x00{]""" + + try: + llsd.parse(binary_data, llsd.XML_MIME_TYPE) + self.fail("LLSDParseError should be raised.") + except llsd.LLSDParseError: + pass + + def testParseBinaryInvlaidBinaryFormat(self): + """ + Test the parse_binary with an invalid binary format. LLSDParseError + should be raised. + + Maps to test scenario : module:llsd:test#82 + """ + invalid_binary = b"""\n[\\xx0{}]]""" + + self.assertRaises(llsd.LLSDParseError, llsd.parse_binary, invalid_binary) + + def testAsBinaryWithNonSupportedType(self): + """ + Test the as_binary with a non-supported python type. + + Maps to test scenario module:llsd:test#89 + """ + # make an object not supported by llsd + python_native_obj = Foo() + + # assert than an exception is raised + self.assertRaises(TypeError, self.llsd.as_binary, python_native_obj) + self.assertRaises(llsd.LLSDParseError, self.llsd.parse, b'2') + + def testInvlaidBinaryParse1(self): + """ + Test with invalid binary format of map. + """ + invalid_binary = b"""\n{\x00\x00\x00\x01k\x00\x00\x00\x06'kaka'i\x00\x00\x00{{""" + + self.assertRaises(llsd.LLSDParseError, self.llsd.parse, invalid_binary) + + def testInvlaidBinaryParse2(self): + """ + Test with invalid binary format of array. + """ + invalid_binary = b"""\n[\x00\x00\x00\x02i\x00\x00\x00\x01i\x00\x00\x00\x02*""" + + self.assertRaises(llsd.LLSDParseError, self.llsd.parse, invalid_binary) + + def testParseDelimitedString(self): + """ + Test parse_binary with delimited string. + """ + delimited_string = b"""\n'\\t\\a\\b\\f\\n\\r\\t\\v\\x0f\\p'""" + + self.assertEqual('\t\x07\x08\x0c\n\r\t\x0b\x0fp', llsd.parse(delimited_string)) + + + +class LLSDPythonXMLUnitTest(unittest.TestCase): + """ + This class aggregates all the tests for parse_xml(something), LLSD.as_xml(something) + and format_xml (i.e. same as LLSD.as_xml(something). + Note that test scenarios for the same input type are all put into single test method. And utility + method assert_xml_roundtrip is used to test parse_xml and as_xml at the same time. + + NOTE: Tests in this class use the pure python implementation for + serialization of llsd object to llsd xml format. + """ + def setUp(self): + """ + Create a LLSD object + """ + self.llsd = llsd.LLSD() + + def assertXMLRoundtrip(self, py, xml, ignore_rounding=False): + """ + Utility method to test parse_xml and as_xml at the same time + """ + + # use parse to check + parsed_py = self.llsd.parse(xml) + formatted_xml = self.llsd.as_xml(py) + self.assertEqual(parsed_py, py) + self.assertEqual(py, self.llsd.parse(formatted_xml)) +## if not ignore_rounding: +## self.assertEqual(self.strip(formatted_xml), +## self.strip(xml)) +## self.assertEqual(self.strip(xml), +## self.strip(self.llsd.as_xml(parsed_py))) + + # use parse_xml to check again + parsed_py = llsd.parse_xml(xml) + formatted_xml = self.llsd.as_xml(py) + self.assertEqual(parsed_py, py) + self.assertEqual(py, llsd.parse_xml(formatted_xml)) +## if not ignore_rounding: +## self.assertEqual(self.strip(formatted_xml), +## self.strip(xml)) +## self.assertEqual(self.strip(xml), +## self.strip(self.llsd.as_xml(parsed_py))) + + def testBytesConversion(self): + """ + Test the __bytes__() conversion on the LLSD class + """ + if PY2: + return # not applicable on python 2 + some_xml =b"\ +\ +\ +1234\ +" + + c = llsd.LLSD(llsd.parse_xml(some_xml)) + out_xml = bytes(c) + + self.assertEqual(some_xml, out_xml) + + def testStrConversion(self): + """ + Test the __str__() conversion on the LLSD class + """ + some_xml =b"\ +\ +\ +1234\ +" + + c = llsd.LLSD(llsd.parse_xml(some_xml)) + out_xml = str(c).encode() + + self.assertEqual(some_xml, out_xml) + + def testInteger(self): + """ + Test the parse and serializatioin of input type : integer + Maps to the test scenarios : module:llsd:test#39 - 41 + """ + pos_int_xml = b"\ +\ +\ +289343\ +" + + neg_int_xml = b"\ +\ +\ +-289343\ +" + + blank_int_xml = b"\ +\ +\ +\ +" + + python_pos_int = 289343 + python_neg_int = -289343 + python_blank_int = 0 + + self.assertXMLRoundtrip(python_pos_int, + pos_int_xml) + self.assertXMLRoundtrip(python_neg_int, + neg_int_xml) + self.assertEqual(python_blank_int, self.llsd.parse(blank_int_xml)) + + def testUndefined(self): + """ + Test the parse and serialization of input type: undef + + Maps to test scenarios module:llsd:test#42 + """ + undef_xml = b"" + self.assertXMLRoundtrip(None, undef_xml) + + def testBoolean(self): + """ + Test the parse and serialization of input tye: boolean. -> llsd:test 43 - 45 + """ + true_xml = b"\ +\ +\ +true\ +" + + false_xml = b"\ +\ +\ +false\ +" + + blank_xml = b"\ +\ +\ +\ +" + + self.assertXMLRoundtrip(True, true_xml) + self.assertXMLRoundtrip(False, false_xml) + self.assertEqual(False, self.llsd.parse(blank_xml)) + + def testReal(self): + """ + Test the parse and serialization of input type : real. + Maps to test scenarios module:llsd:test# 46 - 48 + """ + pos_real_xml = b"\ +\ +\ +2983287453.3000002\ +" + + neg_real_xml = b"\ +\ +\ +-2983287453.3000002\ +" + + blank_real_xml = b"\ +\ +\ +\ +" + + python_pos_real = 2983287453.3 + python_neg_real = -2983287453.3 + python_blank_real = 0.0 + + self.assertXMLRoundtrip(python_pos_real, + pos_real_xml, True) + self.assertXMLRoundtrip(python_neg_real, + neg_real_xml, True) + self.assertEqual(python_blank_real, self.llsd.parse(blank_real_xml)) + + def testUUID(self): + """ + Test the parse and serialization of input type: UUID. + Maps to test scenarios module:llsd:test#49 + """ + uuid_tests = { + uuid.UUID(hex='d7f4aeca-88f1-42a1-b385-b9db18abb255'):b"\ +\ +\ +d7f4aeca-88f1-42a1-b385-b9db18abb255\ +", + uuid.UUID(int=0):b"\ +\ +\ +\ +"} + + for py, xml in uuid_tests.items(): + self.assertXMLRoundtrip(py, xml) + + + def testString(self): + """ + Test the parse and serialization of input type : String. + Maps to test scenarios module:llsd:test# 50 - 51 + """ + sample_data = {'foo':b"\ +\ +\ +foo\ +", + '':b"\ +\ +\ +\ +", + '&ent;':b"\ +\ +\ +<xml>&ent;</xml>\ +" + } + for py, xml in sample_data.items(): + self.assertXMLRoundtrip(py, xml) + + def testURI(self): + """ + Test the parse and serialization of input type: URI. + Maps to test scenarios module:llsd:test# 52 - 53 + """ + uri_tests = { + llsd.uri('http://sim956.agni.lindenlab.com:12035/runtime/agents'):b"\ +\ +\ +http://sim956.agni.lindenlab.com:12035/runtime/agents\ +"} + + blank_uri_xml = b"\ +\ +\ +\ +" + + for py, xml in uri_tests.items(): + self.assertXMLRoundtrip(py, xml) + self.assertEqual('', self.llsd.parse(blank_uri_xml)) + + def testDate(self): + """ + Test the parse and serialization of input type : Date. + Maps to test scenarios module:llsd:test#54 - 57 + """ + valid_date_xml = b"\ +\ +\ +2006-02-01T14:29:53.460000Z\ +" + + valid_date_xml_no_fractional = b"\ +\ +\ +2006-02-01T14:29:53Z\ +" + valid_date_xml_filled = b"\ +\ +\ +2006-02-01T14:29:05Z\ +" + + blank_date_xml = b"\ +\ +\ +\ +" + + before_19th_century_date = b"\ +\ +\ +1853-02-01T00:00:00Z\ +" + + python_valid_date = datetime(2006, 2, 1, 14, 29, 53, 460000) + python_valid_date_no_fractional = datetime(2006, 2, 1, 14, 29, 53) + python_valid_date_filled = datetime(2006, 2, 1, 14, 29, 5) + python_blank_date = datetime(1970, 1, 1) + python_19th_century_date = datetime(1853, 2, 1) + self.assertXMLRoundtrip(python_valid_date, + valid_date_xml) + self.assertXMLRoundtrip(python_valid_date_no_fractional, + valid_date_xml_no_fractional) + self.assertXMLRoundtrip(python_valid_date_filled, + valid_date_xml_filled) + self.assertXMLRoundtrip(python_19th_century_date, + before_19th_century_date) + self.assertEqual(python_blank_date, self.llsd.parse(blank_date_xml)) + + def testArray(self): + """ + Test the parse and serialization of input type : Array. + Maps to test scenarios module:llsd:test# 58 - 60 + """ + # simple array + array_xml = b"\ +\ +\ +\ +foo\ +bar\ +\ +" + # composite array + array_within_array_xml = b"\ +\ +\ +\ +foo\ +bar\ +\ +foo\ +bar\ +\ +\ +" + # blank array + blank_array_xml = b"\ +\ +\ +\ +" + + python_array = ["foo", "bar"] + python_array_within_array = ["foo", "bar", ["foo", "bar"]] + + self.assertXMLRoundtrip(python_array, array_xml) + self.assertXMLRoundtrip(python_array_within_array, + array_within_array_xml) + self.assertXMLRoundtrip([], blank_array_xml) + + def testMap(self): + """ + Test the parse and serialization of input type : map. + Maps to test scenarios module:llsd:test# 61 - 63 + """ + # simple map + map_xml = b"""\ + + + +foo +bar + +""" + # composite map + map_within_map_xml = b"\ +\ +\ +\ +foo\ +bar\ +doo\ +\ +goo\ +poo\ +\ +\ +" + # blank map + blank_map_xml = b"\ +\ +\ +\ +" + + python_map = {"foo":"bar"} + python_map_within_map = {"foo":"bar", "doo":{"goo":"poo"}} + + self.assertXMLRoundtrip(python_map, map_xml) + self.assertXMLRoundtrip(python_map_within_map, + map_within_map_xml) + self.assertXMLRoundtrip({}, blank_map_xml) + + def testBinary(self): + """ + Test the parse and serialization of input type : binary. + Maps to test scenarios module:llsd:test#64 + """ + base64_binary_xml = b"\ +\ +\ +dGhlIHF1aWNrIGJyb3duIGZveA==\ +" + + python_binary = llsd.binary(b"the quick brown fox") + self.assertXMLRoundtrip(python_binary, + base64_binary_xml) + + blank_binary_xml = b"""""" + + python_binary = llsd.binary(b''); + + self.assertXMLRoundtrip(python_binary, blank_binary_xml) + + def testXMLOfAllTypes(self): + """ + Test parse_xml with complex xml data which contains all types xml element. + Maps to test scenarios module:llsd:test#65 + """ + xml_of_all_types = b""" + + + string1 + 3.1415 + 18686 + + www.topcoder.com/tc + 2006-02-01T14:29:53.43Z + + region_id + 67153d5b-3659-afb4-8510-adda2c034649 + scale + one minute + simulator statistics + + time dilation + 0.9878624 + sim fps + 44.38898 + pysics fps + 44.38906 + agent updates per second + 1.34 + lsl instructions per second + 0 + total task count + 4 + active task count + 0 + active script count + 4 + main agent count + 0 + child agent count + 0 + inbound packets per second + 1.228283 + outbound packets per second + 1.277508 + pending downloads + 0 + pending uploads + 0.0001096525 + frame ms + 0.7757886 + net ms + 0.3152919 + sim other ms + 0.1826937 + sim physics ms + 0.04323055 + agent ms + 0.01599029 + image ms + 0.01865955 + script ms + 0.1338836 + + + + """ + + python_object = ['string1', 3.1415, 18686, None, + 'www.topcoder.com/tc', datetime(2006, 2, 1, 14, 29, 53, 430000), + {'scale': 'one minute', 'region_id': + uuid.UUID('67153d5b-3659-afb4-8510-adda2c034649'), + 'simulator statistics': {'total task count': 4.0, 'active task count': 0.0, + 'time dilation': 0.9878624, 'lsl instructions per second': 0.0, 'frame ms': + 0.7757886, 'agent ms': 0.01599029, 'sim other ms': 0.1826937, + 'pysics fps': 44.38906, 'outbound packets per second': 1.277508, + 'pending downloads': 0.0, 'pending uploads': 0.0001096525, 'net ms': 0.3152919, + 'agent updates per second': 1.34, 'inbound packets per second': + 1.228283, 'script ms': 0.1338836, 'main agent count': 0.0, + 'active script count': 4.0, 'image ms': 0.01865955, 'sim physics ms': + 0.04323055, 'child agent count': 0.0, 'sim fps': 44.38898}}] + + parsed_python = llsd.parse(xml_of_all_types) + + self.assertEqual(python_object, parsed_python) + + def testFormatPrettyXML(self): + """ + Test the format_pretty_xml function, characters like \n,\t should be generated within + the output to beautify the output xml. + + This maps to test scenarios module:llsd:test#75 + """ + python_object = {'id': ['string1', 123, {'name': 123}]} + + output_xml = llsd.format_pretty_xml(python_object) + + # check whether the output_xml contains whitespaces and new line character + whitespaces_count = output_xml.count(b' ') + newline_count = output_xml.count(b'\n') + + self.assertTrue(whitespaces_count > 50) + self.assertTrue(newline_count > 10) + + # remove all the whitespaces and new line chars from output_xml + result = self.strip(output_xml) + + # the result should equal to the reuslt of format_xml + # the xml version tag should be removed before comparing + format_xml_result = self.llsd.as_xml(python_object) + self.assertEqual(result[result.find(b"?>") + 2: len(result)], + format_xml_result[format_xml_result.find(b"?>") + 2: len(format_xml_result)]) + + def testLLSDSerializationFailure(self): + """ + Test serialization function as_xml with an object of non-supported type. + TypeError should be raised. + + This maps test scenarios module:llsd:test#90 + """ + # make an object not supported by llsd + python_native_obj = Foo() + + # assert than an exception is raised + self.assertRaises(TypeError, self.llsd.as_xml, python_native_obj) + + def testParseXMLIncorrectMIME(self): + """ + Test parse function with llsd in xml format but with incorrect mime type. + + Maps to test scenario module:llsd:test#80 + """ + llsd_xml = b"""12.3232""" + + try: + self.llsd.parse(llsd_xml, llsd.NOTATION_MIME_TYPE) + self.fail("LLSDParseError should be raised.") + except llsd.LLSDParseError: + pass + + def testParseXMLIncorrectMIME2(self): + """ + Test parse function with llsd in xml format but with incorrect mime type. + + Maps to test scenario module:llsd:test#80 + """ + llsd_xml = b"""12.3232""" + + try: + self.llsd.parse(llsd_xml, llsd.BINARY_MIME_TYPE) + self.fail("LLSDParseError should be raised.") + except llsd.LLSDParseError: + pass + + def testParseMalformedXML(self): + """ + Test parse with malformed llsd xml. LLSDParseError should be raised. + + Maps to test scenarios module:llsd:test#77 + """ + malformed_xml = b"""string>123/llsd>""" + self.assertRaises(llsd.LLSDParseError, llsd.parse, malformed_xml) + + def testParseXMLUnsupportedTag(self): + """ + Test parse with llsd xml which has non-supported tag. LLSDParseError + should be raised. + + Maps to test scenario module:llsd:test#83 + """ + unsupported_tag_xml = b"""123 + 1/llsd>""" + self.assertRaises(llsd.LLSDParseError, llsd.parse, unsupported_tag_xml) + + def testParseXMLWithoutRootTag(self): + """ + Test parse with xml which does not have root tag . + LLSDParseError should be raised. + + Maps to test scenario module:llsd:test#84 + """ + no_root_tag_xml = b"""test1.3434""" + + self.assertRaises(llsd.LLSDParseError, llsd.parse, no_root_tag_xml) + + def testParseXMLUnclosedTag(self): + """ + Test parse with xml which has unclosed tag. + LLSDParseError should be raised. + + Maps to test scenario module:llsd:test#85 + """ + unclosed_tag_xml = b"""123 + 12345/llsd>""" + self.assertRaises(llsd.LLSDParseError, llsd.parse, unclosed_tag_xml) + + def strip(self, the_string): + """ + Utility method to remove all the whitespace characters from + the given string. + """ + return re.sub(b'\s', b'', the_string) + + def test_segfault(self): + for i, badstring in enumerate([ + b'', + b'', + b'', + b'', + b'', + b'', + b'']): + self.assertRaises(llsd.LLSDParseError, llsd.parse, badstring) + +class LLSDStressTest(unittest.TestCase): + """ + This class aggregates all the stress tests for llsd. + """ + + # python object used for testing + python_object = [{'destination': 'http://secondlife.com'}, {'version': + 1}, {'modification_date': datetime(2006, 2, 1, 14, 29, 53, + 460000)}, {'first_name': 'Phoenix', 'last_name': 'Linden', 'granters': + [uuid.UUID('a2e76fcd-9360-4f6d-a924-000000000003')], 'look_at': [-0.043753, + -0.999042, 0.0], 'attachment_data': [{'attachment_point': + 2, 'item_id': uuid.UUID('d6852c11-a74e-309a-0462-50533f1ef9b3'), + 'asset_id': uuid.UUID('c69b29b1-8944-58ae-a7c5-2ca7b23e22fb')}, + {'attachment_point': 10, 'item_id': + uuid.UUID('ff852c22-a74e-309a-0462-50533f1ef900'), 'asset_id': + uuid.UUID('5868dd20-c25a-47bd-8b4c-dedc99ef9479')}], 'session_id': + uuid.UUID('2c585cec-038c-40b0-b42e-a25ebab4d132'), 'agent_id': + uuid.UUID('3c115e51-04f4-523c-9fa6-98aff1034730'), 'circuit_code': 1075, + 'position': [70.9247, 254.378, + 38.7304]}] + + # how many times to run + number = 5000 + + def testParseAndFormatXMLStress(self): + """ + Stress test for parse_xml and as_xml. + + Maps to test scenraio module:llsd:test#95 + """ + t = time.time() + for i in range(0, self.number): + x = llsd.format_xml(self.python_object) + delta = time.time() - t + print("format_xml", str(self.number), " times takes total :", delta, "secs") + print("average time:", delta / self.number, "secs") + + t = time.time() + for i in range(0, self.number): + r = llsd.parse(x) + delta = time.time() - t + print("parse_xml", str(self.number), " times takes total :", delta, "secs") + print("average time:", delta / self.number, "secs") + + + def testParseAndFormatNotationStress(self): + """ + Stress test for parse_notation and as_notation. + + Maps to test scenario module:llsd:test#96 + """ + t = time.time() + for i in range(0, self.number): + x = llsd.format_notation(self.python_object) + delta = time.time() - t + print("format_notation", str(self.number), " times takes total :", delta, "secs") + print("average time:", delta / self.number, "secs") + + t = time.time() + for i in range(0, self.number): + r = llsd.parse(x) + delta = time.time() - t + print("parse_notation", str(self.number), " times takes total :", delta, "secs") + print("average time:", delta / self.number, "secs") + + def testParseAndFormatBinaryStress(self): + """ + Stress test for parse_binary and as_binary. + + Maps to test scenarios module:llsd:test#97,98 + """ + t = time.time() + for i in range(0, self.number): + x = llsd.format_binary(self.python_object) + delta = time.time() - t + print("format_binary", str(self.number), " times takes total :", delta, "secs") + print("average time:", delta / self.number, "secs") + + t = time.time() + for i in range(0, self.number): + r = llsd.parse(x) + delta = time.time() - t + print("parse_binary", str(self.number), " times takes total :", delta, "secs") + print("average time:", delta / self.number, "secs") + + +FUZZ_ITERATIONS = 5000 +class LLSDFuzzTest(unittest.TestCase): + """ + This class aggregates all the fuzz tests for llsd. + """ + python_object = LLSDStressTest.python_object + def assertEqualsPretty(self, a, b): + try: + self.assertEqual(a,b) + except AssertionError: + self.fail("\n%s\n !=\n%s" % (pprint.pformat(a), pprint.pformat(b))) + + def fuzz_parsing_base(self, fuzz_method_name, legit_exceptions): + fuzzer = LLSDFuzzer(seed=1234) + fuzz_method = getattr(fuzzer, fuzz_method_name) + for f in islice(fuzz_method(self.python_object), FUZZ_ITERATIONS): + try: + parsed = llsd.parse(f) + except legit_exceptions: + pass # expected, since many of the inputs will be invalid + except Exception as e: + print("Raised exception", e.__class__) + print("Fuzzed value was", repr(f)) + raise + + def fuzz_roundtrip_base(self, formatter_method, normalize=None): + fuzzer = LLSDFuzzer(seed=1234) + for f in islice(fuzzer.structure_fuzz(self.python_object), FUZZ_ITERATIONS): + try: + try: + text = formatter_method(f) + except llsd.LLSDSerializationError: + # sometimes the fuzzer will generate invalid llsd + continue + parsed = llsd.parse(text) + try: + self.assertEqualsPretty(parsed, f) + except AssertionError: + if normalize: + self.assertEqualsPretty(normalize(parsed), normalize(f)) + else: + raise + except llsd.LLSDParseError: + print("Failed to parse", repr(text)) + raise + + + def test_notation_parsing(self): + self.fuzz_parsing_base('notation_fuzz', + (llsd.LLSDParseError, IndexError, ValueError)) + + def test_notation_roundtrip(self): + def normalize(s): + """ Certain transformations of input data are permitted by + the spec; this function normalizes a python data structure + so it receives these transformations as well. + * date objects -> datetime objects (parser only produces datetimes) + * nan converted to None (just because nan's are incomparable) + """ + if is_string(s): + return s + if isnan(s): + return None + if isinstance(s, date): + return datetime(s.year, s.month, s.day) + if isinstance(s, (list, tuple)): + s = [normalize(x) for x in s] + if isinstance(s, dict): + s = dict([(normalize(k), normalize(v)) + for k,v in s.items()]) + return s + + self.fuzz_roundtrip_base(llsd.format_notation, normalize) + + def test_binary_parsing(self): + self.fuzz_parsing_base('binary_fuzz', + (llsd.LLSDParseError, IndexError, ValueError)) + + def test_binary_roundtrip(self): + def normalize(s): + """ Certain transformations of input data are permitted by + the spec; this function normalizes a python data structure + so it receives these transformations as well. + * date objects -> datetime objects (parser only produces datetimes) + * fractional seconds dropped from datetime objects + * integral values larger than a signed 32-bit int become wrapped + * integral values larger than an unsigned 32-bit int become 0 + * nan converted to None (just because nan's are incomparable) + """ + if isnan(s): + return None + if is_integer(s): + if (s > (2<<30) - 1 or + s < -(2<<30)): + return struct.unpack('!i', struct.pack('!i', s))[0] + if isinstance(s, date): + return datetime(s.year, s.month, s.day) + if isinstance(s, datetime): + return datetime(s.year, s.month, s.day, s.hour, s.minute, s.second) + if isinstance(s, (list, tuple)): + s = [normalize(x) for x in s] + if isinstance(s, dict): + s = dict([(normalize(k), normalize(v)) + for k,v in s.items()]) + return s + self.fuzz_roundtrip_base(llsd.format_binary, normalize) + + def test_xml_parsing(self): + self.fuzz_parsing_base('xml_fuzz', + (llsd.LLSDParseError, IndexError, ValueError)) + + newline_re = re.compile(r'[\r\n]+') + + @pytest.mark.skipif(PY2, reason="Fails because fuzz generates invalid unicode sequences on Python 2") + def test_xml_roundtrip(self): + def normalize(s): + """ Certain transformations of input data are permitted by + the spec; this function normalizes a python data structure + so it receives these transformations as well. + * codepoints disallowed in xml dropped from strings and unicode objects + * any sequence of \n and \r compressed into a single \n + * date objects -> datetime objects (parser only produces datetimes) + * nan converted to None (just because nan's are incomparable) + """ + if is_string(s): + s = remove_invalid_xml_bytes(s) + s = self.newline_re.sub('\n', s) + if is_unicode(s): + s = s.replace(u'\uffff', u'') + s = s.replace(u'\ufffe', u'') + return s + if isnan(s): + return None + if isinstance(s, date): + return datetime(s.year, s.month, s.day) + if isinstance(s, (list, tuple)): + s = [normalize(x) for x in s] + if isinstance(s, dict): + s = dict([(normalize(k), normalize(v)) + for k,v in s.items()]) + return s + self.fuzz_roundtrip_base(llsd.format_xml, normalize) + +class Regression(unittest.TestCase): + ''' + Regression tests. + ''' + + def test_no_newline_in_base64_notation(self): + n = llsd.format_notation(llsd.binary(b'\0'*100)) + self.assertEqual(n.replace(b'\n', b''), n) + + def test_no_newline_in_base64_xml(self): + n = llsd.format_xml(llsd.binary(b'\0'*100)) + self.assertEqual(n.replace(b'\n', b''), n) + + def test_SL_13073(self): + # "new note" in Russian with Cyrillic characters. + good_xml = u'Новая Π·Π°ΠΌΠ΅Ρ‚ΠΊΠ°'.encode('utf8') + new_note_unicode = u"Новая Π·Π°ΠΌΠ΅Ρ‚ΠΊΠ°" + new_note_str = "Новая Π·Π°ΠΌΠ΅Ρ‚ΠΊΠ°" + + # Py2 unicode + # Py3 str (unicode) + self.assertEqual(llsd.format_xml(new_note_unicode), good_xml) + + # Py2 LLSD(unicode) + # Py3 LLSD(str (unicode)) + self.assertEqual(llsd.format_xml(llsd.LLSD(new_note_unicode)), good_xml) + + # Py2 str (b"") + # Py3 str (unicode) + self.assertEqual(llsd.format_xml(new_note_str), good_xml) + + # Py2 LLSD(str (b"")) + # Py3 LLSD(str (unicode)) + self.assertEqual(llsd.format_xml(llsd.LLSD(new_note_str)), good_xml) + + if PY2: + bytes_xml = good_xml + else: + bytes_xml = b'0J3QvtCy0LDRjyDQt9Cw0LzQtdGC0LrQsA==' + # Py2 str (b"") + # Py3 bytes (turned into binary type by llsd) + self.assertEqual(llsd.format_xml(new_note_unicode.encode("utf-8")), bytes_xml) + + # Py2 LLSD(str (b"")) + # Py3 LLSD(bytes) (turned into binary type by llsd) + self.assertEqual(llsd.format_xml(llsd.LLSD(new_note_unicode.encode("utf-8"))), bytes_xml) + +class MapConstraints(unittest.TestCase): + ''' + Implied type conversion tests + ''' + + def test_int_map_key(self): + ''' + LLSD Map keys are supposed to be strings; convert a map with an int key + ''' + llsdmap=llsd.LLSD({5 : 'int'}) + self.assertEqual(llsd.format_xml(llsdmap), b'5int') + self.assertEqual(llsd.format_notation(llsdmap), b"{'5':'int'}") + + def test_date_map_key(self): + ''' + LLSD Map keys are supposed to be strings; convert a map with a date key + ''' + llsdmap=llsd.LLSD({datetime(2006, 2, 1, 14, 29, 53, 460000) : 'date'}) + self.assertEqual(llsd.format_xml(llsdmap), b'2006-02-01 14:29:53.460000date') + self.assertEqual(llsd.format_notation(llsdmap), b"{'2006-02-01 14:29:53.460000':'date'}") + + def test_uuid_map_key(self): + ''' + LLSD Map keys are supposed to be strings; convert a map with a uuid key + ''' + llsdmap=llsd.LLSD({uuid.UUID(int=0) : 'uuid'}) + self.assertEqual(llsd.format_xml(llsdmap), b'00000000-0000-0000-0000-000000000000uuid') + self.assertEqual(llsd.format_notation(llsdmap), b"{'00000000-0000-0000-0000-000000000000':'uuid'}") \ No newline at end of file diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..7a27bb7 --- /dev/null +++ b/tox.ini @@ -0,0 +1,8 @@ +[tox] +envlist = py27, py37, py38, py310 + +[testenv] +setenv = + COVERAGE_FILE = .coverage.{envname} +deps = .[dev] +commands = pytest --cov=llsd --cov-report=xml:.coverage.{envname}.xml tests/ \ No newline at end of file