├── test ├── __init__.py ├── couchdbkit │ ├── __init__.py │ ├── couchdbkit.py │ ├── application.py │ └── data │ │ ├── basic.json │ │ └── multimedia_map.json ├── test_couchdbkit.py ├── test_stringconversions.py └── tests.py ├── .gitignore ├── MANIFEST.in ├── scripts ├── install_cython.sh └── test_cython_files.sh ├── jsonobject ├── exceptions.py ├── __init__.py ├── utils.pyx ├── api.pyx ├── properties.pyx ├── containers.pyx ├── base_properties.pyx └── base.pyx ├── .github └── workflows │ ├── rebuild_c_files.yml │ ├── tests.yml │ └── pypi.yml ├── setup.py ├── pyproject.toml ├── CHANGES.md ├── LICENSE ├── LIFECYCLE.md └── README.md /test/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/couchdbkit/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | /.idea 3 | jsonobject.egg-info/ 4 | unittest2-0.5.1-py2.7.egg/ 5 | dist/ 6 | .eggs 7 | build/ 8 | *.so 9 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include LICENSE 3 | include jsonobject/*.pyx 4 | include jsonobject/*.c 5 | recursive-include test *.py *.json 6 | -------------------------------------------------------------------------------- /scripts/install_cython.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | # grep pyproject.toml for the pinned version of cython 4 | PINNED_CYTHON=$(grep -oE 'Cython>?=[^"]+' pyproject.toml) 5 | 6 | echo "Installing $PINNED_CYTHON" 7 | pip install $PINNED_CYTHON setuptools 8 | -------------------------------------------------------------------------------- /jsonobject/exceptions.py: -------------------------------------------------------------------------------- 1 | class DeleteNotAllowed(Exception): 2 | pass 3 | 4 | 5 | class BadValueError(Exception): 6 | """raised when a value can't be validated or is required""" 7 | 8 | 9 | class WrappingAttributeError(AttributeError): 10 | pass 11 | -------------------------------------------------------------------------------- /jsonobject/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import JsonObjectMeta 2 | from .containers import JsonArray 3 | from .properties import * 4 | from .api import JsonObject 5 | 6 | __version__ = '2.3.1' 7 | __all__ = [ 8 | 'IntegerProperty', 'FloatProperty', 'DecimalProperty', 9 | 'StringProperty', 'BooleanProperty', 10 | 'DateProperty', 'DateTimeProperty', 'TimeProperty', 11 | 'ObjectProperty', 'ListProperty', 'DictProperty', 'SetProperty', 12 | 'JsonObject', 'JsonObjectMeta', 'JsonArray', 13 | ] 14 | -------------------------------------------------------------------------------- /test/couchdbkit/couchdbkit.py: -------------------------------------------------------------------------------- 1 | import functools 2 | from jsonobject import * 3 | 4 | SchemaProperty = ObjectProperty 5 | SchemaListProperty = ListProperty 6 | StringListProperty = functools.partial(ListProperty, str) 7 | SchemaDictProperty = DictProperty 8 | 9 | 10 | class DocumentSchema(JsonObject): 11 | 12 | @StringProperty 13 | def doc_type(self): 14 | return self.__class__.__name__ 15 | 16 | 17 | class Document(DocumentSchema): 18 | 19 | _id = StringProperty() 20 | _rev = StringProperty() 21 | _attachments = DictProperty() 22 | -------------------------------------------------------------------------------- /test/test_couchdbkit.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | from unittest import TestCase 4 | from .couchdbkit.application import Application 5 | from io import open 6 | 7 | 8 | class CouchdbkitTestCase(TestCase): 9 | def _test(self, name): 10 | with open(os.path.join('test', 'couchdbkit', 'data', '{0}.json'.format(name))) as f: 11 | Application.wrap(json.load(f)) 12 | 13 | def test_basic(self): 14 | self._test('basic') 15 | 16 | def test_medium(self): 17 | self._test('medium') 18 | 19 | def test_large(self): 20 | self._test('large') 21 | 22 | def test_multimedia_map(self): 23 | self._test('multimedia_map') 24 | -------------------------------------------------------------------------------- /scripts/test_cython_files.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | find jsonobject -iname '*.c' -delete 4 | find jsonobject -iname '*.so' -delete 5 | 6 | python setup.py build_ext --inplace 7 | 8 | git update-index -q --refresh 9 | if git diff --quiet HEAD --; then 10 | echo "The recompiled cython files are a match" 11 | exit 0 12 | else 13 | echo "=====================================" 14 | echo "ERROR: ./scripts/test_cython_files.sh" 15 | echo "-------------------------------------" 16 | git diff HEAD -- | head -n 20 17 | echo "-------------------------------------" 18 | echo "Compiling the C files from scratch shows a difference" 19 | echo "The first 20 lines of the diff is shown above" 20 | echo "Did you rebuild and commit the changes? Try running:" 21 | echo " find jsonobject -iname '*.c' -delete" 22 | echo " find jsonobject -iname '*.so' -delete" 23 | echo " python setup.py build_ext --inplace" 24 | exit 1 25 | fi 26 | -------------------------------------------------------------------------------- /.github/workflows/rebuild_c_files.yml: -------------------------------------------------------------------------------- 1 | on: 2 | schedule: 3 | - cron: 0 0 1 * * 4 | pull_request: 5 | types: [opened, synchronize, reopened, closed] 6 | 7 | name: Rebuild .c files with latest Cython 8 | jobs: 9 | rebuild_c_files: 10 | name: Rebuild .c files with latest Cython 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Install dependencies 14 | run: | 15 | pip install cython 16 | echo "cython_version=$(pip freeze | grep -i cython | cut -d'=' -f3)" >> $GITHUB_ENV 17 | - name: Rebuild .c files with latest Cython 18 | # From https://github.com/marketplace/actions/create-pr-action: 19 | # > executes an arbitrary command and commits the changes to the new pull request 20 | uses: technote-space/create-pr-action@v2 21 | with: 22 | EXECUTE_COMMANDS: | 23 | find jsonobject -iname '*.c' -delete 24 | find jsonobject -iname '*.so' -delete 25 | python setup.py build_ext --inplace 26 | COMMIT_MESSAGE: 'Rebuild C files using cython==${{ env.cython_version }}' 27 | PR_BRANCH_NAME: 'cython-${{ env.cython_version }}' 28 | PR_TITLE: 'Rebuild C files using cython==${{ env.cython_version }}' 29 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | from setuptools.extension import Extension 4 | 5 | try: 6 | # Only use Cython if it's installed in the environment, otherwise use the provided C 7 | import Cython # noqa: F401 8 | USE_CYTHON = True 9 | except ImportError: 10 | USE_CYTHON = False 11 | 12 | ext = '.pyx' if USE_CYTHON else '.c' 13 | extensions = [ 14 | Extension('jsonobject.api', ["jsonobject/api" + ext],), 15 | Extension('jsonobject.base', ["jsonobject/base" + ext],), 16 | Extension('jsonobject.base_properties', ["jsonobject/base_properties" + ext],), 17 | Extension('jsonobject.containers', ["jsonobject/containers" + ext],), 18 | Extension('jsonobject.properties', ["jsonobject/properties" + ext],), 19 | Extension('jsonobject.utils', ["jsonobject/utils" + ext],), 20 | ] 21 | 22 | if USE_CYTHON: 23 | from Cython.Build import cythonize 24 | extensions = cythonize(extensions, compiler_directives={"language_level" : "3str"}) 25 | else: 26 | print("You are running without Cython installed. It is highly recommended to run\n" 27 | " ./scripts/install_cython.sh\n" 28 | "before you continue") 29 | 30 | setup( 31 | name='jsonobject', 32 | ext_modules=extensions, 33 | ) 34 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "jsonobject" 3 | description = "A library for dealing with JSON as python objects" 4 | authors = [{name = "Danny Roberts", email = "droberts@dimagi.com"}] 5 | license = {file = "LICENSE"} 6 | readme = {file = "README.md", content-type = "text/markdown"} 7 | dynamic = ["version"] 8 | requires-python = ">= 3.9" 9 | classifiers = [ 10 | "Programming Language :: Python", 11 | "Programming Language :: Python :: 3", 12 | 13 | # The following classifiers are parsed by Github Actions workflows. 14 | # Precise formatting is important (no extra spaces, etc.) 15 | "Programming Language :: Python :: 3.9", 16 | "Programming Language :: Python :: 3.10", 17 | "Programming Language :: Python :: 3.11", 18 | "Programming Language :: Python :: 3.12", 19 | "Programming Language :: Python :: 3.13", 20 | 21 | "License :: OSI Approved :: BSD License", 22 | ] 23 | 24 | [project.urls] 25 | Home = "https://github.com/dimagi/jsonobject" 26 | 27 | [build-system] 28 | requires = [ 29 | "setuptools>=75", 30 | "Cython>=3.0.0,<4.0.0", 31 | ] 32 | build-backend = "setuptools.build_meta" 33 | 34 | [tool.setuptools] 35 | packages = ["jsonobject"] 36 | 37 | [tool.setuptools.dynamic] 38 | version = {attr = "jsonobject.__version__"} 39 | -------------------------------------------------------------------------------- /jsonobject/utils.pyx: -------------------------------------------------------------------------------- 1 | from jsonobject.exceptions import BadValueError 2 | 3 | 4 | def check_type(obj, item_type, message): 5 | if obj is None: 6 | return item_type() 7 | elif not isinstance(obj, item_type): 8 | raise BadValueError('{}. Found object of type: {}'.format(message, type(obj))) 9 | else: 10 | return obj 11 | 12 | 13 | class SimpleDict(dict): 14 | """ 15 | Re-implements destructive methods of dict 16 | to use only setitem and getitem and delitem 17 | """ 18 | def update(self, E=None, **F): 19 | for dct in (E, F): 20 | if dct: 21 | for key, value in dct.items(): 22 | self[key] = value 23 | 24 | def clear(self): 25 | for key in list(self.keys()): 26 | del self[key] 27 | 28 | def pop(self, key, *args): 29 | if len(args) > 1: 30 | raise TypeError('pop expected at most 2 arguments, got 3') 31 | try: 32 | val = self[key] 33 | del self[key] 34 | return val 35 | except KeyError: 36 | try: 37 | return args[0] 38 | except IndexError: 39 | raise KeyError(key) 40 | 41 | def popitem(self): 42 | try: 43 | arbitrary_key = list(self.keys())[0] 44 | except IndexError: 45 | raise KeyError('popitem(): dictionary is empty') 46 | val = self[arbitrary_key] 47 | del self[arbitrary_key] 48 | return (arbitrary_key, val) 49 | 50 | def setdefault(self, key, default=None): 51 | try: 52 | return self[key] 53 | except KeyError: 54 | self[key] = default 55 | return default 56 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: jsonobject tests 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | 11 | jobs: 12 | configure: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | - name: Read Python versions from pyproject.toml 17 | id: read-versions 18 | # produces output like: python_versions=[ "3.9", "3.10", "3.11", "3.12" ] 19 | run: >- 20 | echo "python_versions=$( 21 | grep -oP '(?<=Language :: Python :: )\d\.\d+' pyproject.toml 22 | | jq --raw-input . 23 | | jq --slurp . 24 | | tr '\n' ' ' 25 | )" >> $GITHUB_OUTPUT 26 | outputs: 27 | python_versions: ${{ steps.read-versions.outputs.python_versions }} 28 | 29 | build: 30 | needs: [configure] 31 | runs-on: ubuntu-latest 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | python-version: ${{ fromJSON(needs.configure.outputs.python_versions) }} 36 | 37 | steps: 38 | - uses: actions/checkout@v4 39 | - name: Set up Python ${{ matrix.python-version }} 40 | uses: actions/setup-python@v5 41 | with: 42 | python-version: ${{ matrix.python-version }} 43 | - name: Install dependencies 44 | run: | 45 | python -m pip install --upgrade pip 46 | python -m pip install -e . 47 | - name: Run tests 48 | run: | 49 | python -m unittest 50 | - name: Test cython files 51 | run: | 52 | scripts/install_cython.sh 53 | scripts/test_cython_files.sh 54 | -------------------------------------------------------------------------------- /jsonobject/api.pyx: -------------------------------------------------------------------------------- 1 | import datetime 2 | import decimal 3 | import re 4 | 5 | from jsonobject.base import JsonObjectBase, _LimitedDictInterfaceMixin 6 | from . import properties 7 | from .containers import JsonArray, JsonDict, JsonSet 8 | 9 | 10 | re_date = re.compile(r'^(\d{4})\D?(0[1-9]|1[0-2])\D?([12]\d|0[1-9]|3[01])$') 11 | re_time = re.compile( 12 | r'^([01]\d|2[0-3])\D?([0-5]\d)\D?([0-5]\d)?\D?(\d{3,6})?$') 13 | re_datetime = re.compile( 14 | r'^(\d{4})\D?(0[1-9]|1[0-2])\D?([12]\d|0[1-9]|3[01])' 15 | r'(\D?([01]\d|2[0-3])\D?([0-5]\d)\D?([0-5]\d)?\D?(\d{3,6})?' 16 | r'([zZ]|([\+-])([01]\d|2[0-3])\D?([0-5]\d)?)?)?$' 17 | ) 18 | re_decimal = re.compile(r'^(\d+)\.(\d+)$') 19 | 20 | 21 | class JsonObject(JsonObjectBase, _LimitedDictInterfaceMixin): 22 | def __getstate__(self): 23 | return self.to_json() 24 | 25 | def __setstate__(self, dct): 26 | self.__init__(dct) 27 | 28 | class Meta(object): 29 | properties = { 30 | decimal.Decimal: properties.DecimalProperty, 31 | datetime.datetime: properties.DateTimeProperty, 32 | datetime.date: properties.DateProperty, 33 | datetime.time: properties.TimeProperty, 34 | str: properties.StringProperty, 35 | str: properties.StringProperty, 36 | bool: properties.BooleanProperty, 37 | int: properties.IntegerProperty, 38 | int: properties.IntegerProperty, 39 | float: properties.FloatProperty, 40 | list: properties.ListProperty, 41 | dict: properties.DictProperty, 42 | set: properties.SetProperty, 43 | JsonArray: properties.ListProperty, 44 | JsonDict: properties.DictProperty, 45 | JsonSet: properties.SetProperty, 46 | } 47 | string_conversions = ( 48 | (re_date, datetime.date), 49 | (re_time, datetime.time), 50 | (re_datetime, datetime.datetime), 51 | (re_decimal, decimal.Decimal), 52 | ) 53 | -------------------------------------------------------------------------------- /CHANGES.md: -------------------------------------------------------------------------------- 1 | # Release History 2 | 3 | ## dev 4 | 5 | No significant changes since the last release 6 | 7 | 8 | ## 2.3.1 9 | 10 | | Released on | Released by | 11 | |-------------|---------------| 12 | | 2025-02-27 | @millerdev | 13 | 14 | - Restore `jsonobject.JsonObjectMeta` 15 | 16 | ## 2.3.0 17 | 18 | | Released on | Released by | 19 | |-------------|---------------| 20 | | 2025-02-24 | @millerdev | 21 | 22 | - Improve build and automate push to PyPI (https://github.com/dimagi/jsonobject/pull/236) 23 | - Add pyproject.toml to replace most of setup.py 24 | - Automate python version matrix on gitub actions 25 | - Update github action versions 26 | - Publish releases to pypi.org 27 | - Build C files with Cython 3.0.12 (https://github.com/dimagi/jsonobject/pull/235) 28 | - Add support for Python 3.13 29 | 30 | ## 2.2.0 31 | 32 | | Released on | Released by | 33 | |-------------|---------------| 34 | | 2024-09-09 | @gherceg | 35 | 36 | - Add support for Python 3.12 (https://github.com/dimagi/jsonobject/pull/227) 37 | - Build C files with Cython 0.29.37 (https://github.com/dimagi/jsonobject/pull/225) 38 | 39 | Contributors: @nickbaum 40 | 41 | ## 2.1.0 42 | 43 | | Released on | Released by | 44 | |-------------|---------------| 45 | | 2022-11-08 | @dannyroberts | 46 | 47 | - Add support for Python 3.11 (https://github.com/dimagi/jsonobject/pull/205, https://github.com/dimagi/jsonobject/pull/206) 48 | 49 | ## 2.0.0 50 | 51 | | Released on | Released by | 52 | |-------------|---------------| 53 | | 2022-04-08 | @dannyroberts | 54 | 55 | This is a major release because it changes behavior in a way that we regard as fixing an unintuitive behavior 56 | but could technically be breaking if the previous behavior was relied upon. 57 | 58 | - Passing an iterable to the value type of a ``ListProperty`` 59 | (``JsonArray(iterable)``) returns a plain Python ``list`` rather than raising 60 | ``BadValueError``. (https://github.com/dimagi/jsonobject/pull/200) 61 | 62 | 63 | ## 1.0.0 64 | 65 | | Released on | Released by | 66 | |-------------|---------------| 67 | | 2022-03-14 | @dannyroberts | 68 | 69 | This is a major release only because it officially drops support for Python 2.7, 3.5, and 3.6. 70 | There are no behavior changes, and no other breaking changes. 71 | 72 | - Add support for Python 3.10 and remove support for Python < 3.7 (past EOL) 73 | - Upgrade Cython for building .c files from 0.29.21 to 0.29.28 74 | 75 | ## 0.9.10 76 | 77 | | Released on | Released by | 78 | |--------------|-------------| 79 | | 2021-02-11 | @czue | 80 | 81 | - Add official support for python 3.7 through 3.9 82 | - Upgrade Cython for building .c files from 0.29.6 to 0.29.21 83 | - Do not produce "universal wheels" (https://github.com/dimagi/jsonobject/pull/169) 84 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013-2014, Dimagi Inc., and individual contributors. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | * Redistributions of source code must retain the above copyright 7 | notice, this list of conditions and the following disclaimer. 8 | * Redistributions in binary form must reproduce the above copyright 9 | notice, this list of conditions and the following disclaimer in the 10 | documentation and/or other materials provided with the distribution. 11 | * Neither the name of the nor the 12 | names of its contributors may be used to endorse or promote products 13 | derived from this software without specific prior written permission. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 16 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 17 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 18 | DISCLAIMED. IN NO EVENT SHALL BE LIABLE FOR ANY 19 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 20 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 21 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 22 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 23 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 24 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 25 | 26 | --- 27 | 28 | Jsonobject is a reimplementation of a subset of the couchdbkit project, 29 | and original versions of the code included some code from that project. 30 | In the spirit of attribution, that project's license 31 | is reproduced below in full: 32 | 33 | 2008-2012 (c) Benoît Chesneau 34 | 35 | Permission is hereby granted, free of charge, to any person 36 | obtaining a copy of this software and associated documentation 37 | files (the "Software"), to deal in the Software without 38 | restriction, including without limitation the rights to use, 39 | copy, modify, merge, publish, distribute, sublicense, and/or sell 40 | copies of the Software, and to permit persons to whom the 41 | Software is furnished to do so, subject to the following 42 | conditions: 43 | 44 | The above copyright notice and this permission notice shall be 45 | included in all copies or substantial portions of the Software. 46 | 47 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 48 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 49 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 50 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 51 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 52 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 53 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 54 | OTHER DEALINGS IN THE SOFTWARE. 55 | -------------------------------------------------------------------------------- /LIFECYCLE.md: -------------------------------------------------------------------------------- 1 | # Running tests 2 | 3 | You must rebuild C files for the tests to pick up your changes. Try this for iterating: 4 | 5 | ``` 6 | $ python setup.py build_ext --inplace && python -m unittest 7 | ``` 8 | 9 | # Maintaining built C files 10 | 11 | For speed, jsonobject uses Cython to build C files from the .py files. 12 | Never edit these C files directly. 13 | Instead, [a GitHub Actions workflow](https://github.com/dimagi/jsonobject/blob/master/.github/workflows/rebuild_c_files.yml) 14 | will create a PR into your PR that includes any necessary updates to these files, 15 | and the tests that run on your PR will only pass once the C files match the codebase. 16 | Additionally, this Github Action will run once a month, and if there are any exogenous changes, 17 | such as the release of a new Cython version, it will create a PR into the master branch 18 | that updates these C files as needed. 19 | 20 | ## Recreating C source files locally 21 | It's always an option to build the C files locally and commit the changes, 22 | rather than waiting for Github Actions to do that for you. 23 | 24 | For any changes in the pyx files, the corresponding C files can be recompiled with 25 | 26 | ``` 27 | $ find jsonobject -iname '*.c' -delete 28 | $ find jsonobject -iname '*.so' -delete 29 | $ python setup.py build_ext --inplace 30 | ``` 31 | 32 | These changes should be committed independently of the non-automated changes you made, 33 | in a separate commit containing only automated changes. 34 | 35 | # Release Process 36 | 37 | This section contains instructions for the Dimagi team member performing the release process. 38 | 39 | ## Bump version & update CHANGES.md 40 | 41 | In a single PR, bump the version number in `jsonobject/__init__.py` and update 42 | CHANGES.md to include release notes for this new version. 43 | 44 | ### Pick a version number 45 | 46 | jsonobject uses [semantic versioning](https://semver.org/). 47 | For a backwards-compatible bugfix, bump the **patch** version. 48 | For new backwards compatible functionality, bump the **minor** version. 49 | For backwards-incompatible functionality, bump the **major** version. 50 | 51 | ### Document the changes 52 | 53 | Follow the pattern in CHANGES.md to add a new version to the top. 54 | Move everything currently in the "dev" section to the new version. 55 | Then look through the diff between the last version and the new version 56 | and include one bullet point per pull request that includes any changes 57 | that a consumer of the library may want to be aware of, which will be nearly all PRs. 58 | 59 | ### Make the PR 60 | 61 | The PR you make should include both the version bump in setup.py and the associated CHANGES.md updates. 62 | Once this PR is reviewed and merged, move on to the steps to release the update to pypi. 63 | 64 | ## Release the new version 65 | 66 | To push the package to pypi, create a git tag named "vX.Y.Z" using the version 67 | in `jsonobject/__init__.py` and push it to Github. 68 | 69 | A dev release is pushed to pypi.com/p/jsonobject/#history on each push/merge to 70 | master. A dev release may also be published on-demand for any branch with 71 | [workflow dispatch](https://github.com/dimagi/jsonobject/actions/workflows/pypi.yml). 72 | -------------------------------------------------------------------------------- /test/test_stringconversions.py: -------------------------------------------------------------------------------- 1 | from decimal import Decimal 2 | import datetime 3 | from jsonobject.exceptions import BadValueError 4 | from jsonobject import JsonObject, ObjectProperty, DateTimeProperty 5 | import unittest 6 | from jsonobject.base import get_settings 7 | 8 | 9 | class StringConversionsTest(unittest.TestCase): 10 | 11 | EXAMPLES = { 12 | 'decimal': '1.2', 13 | 'date': '2014-02-04', 14 | 'datetime': '2014-01-03T01:02:03Z', 15 | 'dict': { 16 | 'decimal': '1.4', 17 | }, 18 | 'list': ['1.0', '2000-01-01'], 19 | } 20 | EXAMPLES_CONVERTED = { 21 | 'decimal': Decimal('1.2'), 22 | 'date': datetime.date(2014, 2, 4), 23 | 'dict': { 24 | 'decimal': Decimal('1.4'), 25 | }, 26 | 'list': [Decimal('1.0'), datetime.date(2000, 1, 1)], 27 | 'datetime': datetime.datetime(2014, 1, 3, 1, 2, 3) 28 | } 29 | 30 | def test_default_conversions(self): 31 | class Foo(JsonObject): 32 | pass 33 | foo = Foo.wrap(self.EXAMPLES) 34 | for key, value in self.EXAMPLES_CONVERTED.items(): 35 | self.assertEqual(getattr(foo, key), value) 36 | 37 | def test_no_conversions(self): 38 | class Foo(JsonObject): 39 | class Meta(object): 40 | string_conversions = () 41 | 42 | foo = Foo.wrap(self.EXAMPLES) 43 | for key, value in self.EXAMPLES.items(): 44 | self.assertEqual(getattr(foo, key), value) 45 | 46 | def test_nested_1(self): 47 | 48 | class Bar(JsonObject): 49 | # default string conversions 50 | pass 51 | 52 | class Foo(JsonObject): 53 | bar = ObjectProperty(Bar) 54 | 55 | class Meta(object): 56 | string_conversions = () 57 | 58 | foo = Foo.wrap({ 59 | # don't convert 60 | 'decimal': '1.0', 61 | # do convert 62 | 'bar': {'decimal': '2.4'} 63 | }) 64 | self.assertEqual(foo.decimal, '1.0') 65 | self.assertNotEqual(foo.decimal, Decimal('1.0')) 66 | self.assertEqual(foo.bar.decimal, Decimal('2.4')) 67 | 68 | def test_nested_2(self): 69 | class Bar(JsonObject): 70 | 71 | class Meta(object): 72 | string_conversions = () 73 | 74 | class Foo(JsonObject): 75 | # default string conversions 76 | bar = ObjectProperty(Bar) 77 | 78 | foo = Foo.wrap({ 79 | # do convert 80 | 'decimal': '1.0', 81 | # don't convert 82 | 'bar': {'decimal': '2.4'} 83 | }) 84 | self.assertNotEqual(foo.decimal, '1.0') 85 | self.assertEqual(foo.decimal, Decimal('1.0')) 86 | self.assertEqual(foo.bar.decimal, '2.4') 87 | 88 | def test_update_properties(self): 89 | class Foo(JsonObject): 90 | 91 | class Meta(object): 92 | update_properties = {datetime.datetime: ExactDateTimeProperty} 93 | 94 | self.assertEqual( 95 | get_settings(Foo).type_config.properties[datetime.datetime], 96 | ExactDateTimeProperty 97 | ) 98 | with self.assertRaisesRegex(BadValueError, 99 | 'is not a datetime-formatted string'): 100 | Foo.wrap(self.EXAMPLES) 101 | examples = self.EXAMPLES.copy() 102 | examples['datetime'] = '2014-01-03T01:02:03.012345Z' 103 | examples_converted = self.EXAMPLES_CONVERTED.copy() 104 | examples_converted['datetime'] = datetime.datetime( 105 | 2014, 1, 3, 1, 2, 3, 12345) 106 | foo = Foo.wrap(examples) 107 | for key, value in examples_converted.items(): 108 | self.assertEqual(getattr(foo, key), value) 109 | 110 | 111 | class ExactDateTimeProperty(DateTimeProperty): 112 | def __init__(self, **kwargs): 113 | if 'exact' in kwargs: 114 | assert kwargs['exact'] is True 115 | kwargs['exact'] = True 116 | super(ExactDateTimeProperty, self).__init__(**kwargs) 117 | -------------------------------------------------------------------------------- /.github/workflows/pypi.yml: -------------------------------------------------------------------------------- 1 | name: Build wheels and publish to PyPI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | tags: 8 | - "v*" 9 | workflow_dispatch: 10 | 11 | jobs: 12 | configure: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | - name: Read Python versions from pyproject.toml 17 | id: read-versions 18 | # produces output like: python_versions=39,310,311,312,313 19 | run: >- 20 | echo "python_versions=$( 21 | grep -oP '(?<=Language :: Python :: )\d.\d+' pyproject.toml 22 | | sed 's/\.//' 23 | | tr '\n' ',' 24 | | sed 's/,$//' 25 | )" >> $GITHUB_OUTPUT 26 | outputs: 27 | python_versions: ${{ steps.read-versions.outputs.python_versions }} 28 | 29 | build_sdist: 30 | name: Build source distribution 31 | runs-on: ubuntu-latest 32 | steps: 33 | - uses: actions/checkout@v4 34 | - name: Check version 35 | if: ${{ startsWith(github.ref, 'refs/tags/v') }} 36 | run: pipx run pyverno check jsonobject/__init__.py "${{ github.ref }}" 37 | - name: Add untagged version suffix 38 | if: ${{ ! startsWith(github.ref, 'refs/tags/v') }} 39 | run: pipx run pyverno update jsonobject/__init__.py 40 | - name: Build sdist 41 | run: pipx run build --sdist 42 | - uses: actions/upload-artifact@v4 43 | with: 44 | name: sdist 45 | path: dist 46 | 47 | choose_linux_wheel_types: 48 | name: Decide which wheel types to build 49 | runs-on: ubuntu-latest 50 | steps: 51 | - id: manylinux_x86_64 52 | run: echo "wheel_types=manylinux_x86_64" >> $GITHUB_OUTPUT 53 | - id: musllinux_x86_64 54 | run: echo "wheel_types=musllinux_x86_64" >> $GITHUB_OUTPUT 55 | outputs: 56 | wheel_types: ${{ toJSON(steps.*.outputs.wheel_types) }} 57 | 58 | build_linux_wheels: 59 | needs: [configure, choose_linux_wheel_types, build_sdist] 60 | name: ${{ matrix.wheel_type }} wheels 61 | runs-on: ubuntu-latest 62 | strategy: 63 | matrix: 64 | wheel_type: ${{ fromJSON(needs.choose_linux_wheel_types.outputs.wheel_types) }} 65 | steps: 66 | - uses: actions/download-artifact@v4 67 | with: 68 | name: sdist 69 | path: dist 70 | - name: Extract sdist 71 | run: | 72 | tar zxvf dist/*.tar.gz --strip-components=1 73 | - uses: docker/setup-qemu-action@v3 74 | if: runner.os == 'Linux' 75 | name: Set up QEMU 76 | - name: Build wheels 77 | uses: pypa/cibuildwheel@v2.22.0 78 | env: 79 | CIBW_BUILD: cp{${{ needs.configure.outputs.python_versions }}}-${{ matrix.wheel_type }} 80 | CIBW_ARCHS_LINUX: auto 81 | CIBW_BEFORE_BUILD: cd {project}; pip install -e . # is there a better way to build the .so files? 82 | CIBW_TEST_COMMAND: cd {project}; python -m unittest 83 | - uses: actions/upload-artifact@v4 84 | with: 85 | name: ${{ matrix.wheel_type }}-wheels 86 | path: ./wheelhouse/*.whl 87 | 88 | pypi-publish: 89 | name: Upload release to PyPI 90 | needs: [build_sdist, build_linux_wheels] 91 | runs-on: ubuntu-latest 92 | #if: startsWith(github.ref, 'refs/tags/v') # removed until pypi-test-publish is working 93 | environment: 94 | name: pypi 95 | url: https://pypi.org/p/jsonobject 96 | permissions: 97 | id-token: write 98 | steps: 99 | - name: Download all the dists 100 | uses: actions/download-artifact@v4 101 | with: 102 | # with no name set, it downloads all of the artifacts 103 | path: dist/ 104 | - run: | 105 | mv dist/sdist/*.tar.gz dist/ 106 | mv dist/*-wheels/*.whl dist/ 107 | rmdir dist/{sdist,*-wheels} 108 | ls -R dist 109 | - name: Publish package distributions to PyPI 110 | uses: pypa/gh-action-pypi-publish@release/v1 111 | 112 | # https://github.com/finpassbr/json-object/issues/1 113 | #pypi-test-publish: 114 | # name: Upload release to test PyPI 115 | # needs: [build_sdist, build_linux_wheels] 116 | # runs-on: ubuntu-latest 117 | # environment: 118 | # name: testpypi 119 | # url: https://test.pypi.org/p/jsonobject 120 | # permissions: 121 | # id-token: write 122 | # steps: 123 | # - name: Download all the dists 124 | # uses: actions/download-artifact@v4 125 | # with: 126 | # # with no name set, it downloads all of the artifacts 127 | # path: dist/ 128 | # - run: | 129 | # mv dist/sdist/*.tar.gz dist/ 130 | # mv dist/*-wheels/*.whl dist/ 131 | # rmdir dist/{sdist,*-wheels} 132 | # ls -R dist 133 | # - name: Publish package distributions to PyPI 134 | # uses: pypa/gh-action-pypi-publish@release/v1 135 | # with: 136 | # repository-url: https://test.pypi.org/legacy/ 137 | -------------------------------------------------------------------------------- /jsonobject/properties.pyx: -------------------------------------------------------------------------------- 1 | # DateTimeProperty, DateProperty, and TimeProperty 2 | # include code copied from couchdbkit 3 | import inspect 4 | import sys 5 | import datetime 6 | import time 7 | import decimal 8 | from jsonobject.base_properties import ( 9 | AbstractDateProperty, 10 | AssertTypeProperty, 11 | JsonContainerProperty, 12 | JsonProperty, 13 | DefaultProperty, 14 | ) 15 | from jsonobject.containers import JsonArray, JsonDict, JsonSet 16 | 17 | 18 | if sys.version > '3': 19 | unicode = str 20 | long = int 21 | 22 | 23 | class StringProperty(AssertTypeProperty): 24 | _type = (unicode, str) 25 | 26 | def selective_coerce(self, obj): 27 | if isinstance(obj, str): 28 | obj = unicode(obj) 29 | return obj 30 | 31 | 32 | class BooleanProperty(AssertTypeProperty): 33 | _type = bool 34 | 35 | 36 | class IntegerProperty(AssertTypeProperty): 37 | _type = (int, long) 38 | 39 | 40 | class FloatProperty(AssertTypeProperty): 41 | _type = float 42 | 43 | def selective_coerce(self, obj): 44 | if isinstance(obj, (int, long)): 45 | obj = float(obj) 46 | return obj 47 | 48 | 49 | class DecimalProperty(JsonProperty): 50 | 51 | def wrap(self, obj): 52 | return decimal.Decimal(obj) 53 | 54 | def unwrap(self, obj): 55 | if isinstance(obj, (int, long)): 56 | obj = decimal.Decimal(obj) 57 | elif isinstance(obj, float): 58 | # python 2.6 doesn't allow a float to Decimal 59 | obj = decimal.Decimal(unicode(obj)) 60 | assert isinstance(obj, decimal.Decimal) 61 | return obj, unicode(obj) 62 | 63 | 64 | class DateProperty(AbstractDateProperty): 65 | 66 | _type = datetime.date 67 | 68 | def _wrap(self, value): 69 | fmt = '%Y-%m-%d' 70 | try: 71 | return datetime.date(*time.strptime(value, fmt)[:3]) 72 | except ValueError as e: 73 | raise ValueError('Invalid ISO date {0!r} [{1}]'.format(value, e)) 74 | 75 | def _unwrap(self, value): 76 | return value, value.isoformat() 77 | 78 | 79 | class DateTimeProperty(AbstractDateProperty): 80 | 81 | _type = datetime.datetime 82 | 83 | def _wrap(self, value): 84 | if not self.exact: 85 | value = value.split('.', 1)[0] # strip out microseconds 86 | value = value[0:19] # remove timezone 87 | fmt = '%Y-%m-%dT%H:%M:%S' 88 | else: 89 | fmt = '%Y-%m-%dT%H:%M:%S.%fZ' 90 | try: 91 | return datetime.datetime.strptime(value, fmt) 92 | except ValueError as e: 93 | raise ValueError( 94 | 'Invalid ISO date/time {0!r} [{1}]'.format(value, e)) 95 | 96 | def _unwrap(self, value): 97 | if not self.exact: 98 | value = value.replace(microsecond=0) 99 | padding = '' 100 | else: 101 | padding = '' if value.microsecond else '.000000' 102 | return value, value.isoformat() + padding + 'Z' 103 | 104 | 105 | class TimeProperty(AbstractDateProperty): 106 | 107 | _type = datetime.time 108 | 109 | def _wrap(self, value): 110 | if not self.exact: 111 | value = value.split('.', 1)[0] # strip out microseconds 112 | fmt = '%H:%M:%S' 113 | else: 114 | fmt = '%H:%M:%S.%f' 115 | try: 116 | return datetime.time(*time.strptime(value, fmt)[3:6]) 117 | except ValueError as e: 118 | raise ValueError('Invalid ISO time {0!r} [{1}]'.format(value, e)) 119 | 120 | def _unwrap(self, value): 121 | if not self.exact: 122 | value = value.replace(microsecond=0) 123 | return value, value.isoformat() 124 | 125 | 126 | class ObjectProperty(JsonProperty): 127 | 128 | _type = None 129 | default = lambda self: self.item_type() 130 | 131 | def __init__(self, item_type=None, **kwargs): 132 | self._item_type_deferred = item_type 133 | super(ObjectProperty, self).__init__(**kwargs) 134 | 135 | @property 136 | def item_type(self): 137 | from .base import JsonObjectBase 138 | if hasattr(self, '_item_type_deferred'): 139 | if inspect.isfunction(self._item_type_deferred): 140 | self._item_type = self._item_type_deferred() 141 | else: 142 | self._item_type = self._item_type_deferred 143 | del self._item_type_deferred 144 | if not issubclass(self._item_type, JsonObjectBase): 145 | raise ValueError("item_type {0!r} not a JsonObject subclass".format( 146 | self._item_type, 147 | self.type_config.properties, 148 | )) 149 | return self._item_type 150 | 151 | def wrap(self, obj, string_conversions=None): 152 | return self.item_type.wrap(obj) 153 | 154 | def unwrap(self, obj): 155 | assert isinstance(obj, self.item_type), \ 156 | '{0} is not an instance of {1}'.format(obj, self.item_type) 157 | return obj, obj._obj 158 | 159 | 160 | class ListProperty(JsonContainerProperty): 161 | 162 | _type = default = list 163 | container_class = JsonArray 164 | 165 | def _update(self, container, extension): 166 | container.extend(extension) 167 | 168 | 169 | class DictProperty(JsonContainerProperty): 170 | 171 | _type = default = dict 172 | container_class = JsonDict 173 | 174 | def _update(self, container, extension): 175 | container.update(extension) 176 | 177 | 178 | class SetProperty(JsonContainerProperty): 179 | 180 | _type = default = set 181 | container_class = JsonSet 182 | 183 | def _update(self, container, extension): 184 | container.update(extension) 185 | -------------------------------------------------------------------------------- /test/couchdbkit/application.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | from .couchdbkit import * 3 | 4 | 5 | class BuildSpec(DocumentSchema): 6 | version = StringProperty() 7 | build_number = IntegerProperty(required=False) 8 | latest = BooleanProperty() 9 | 10 | 11 | class BuildRecord(BuildSpec): 12 | signed = BooleanProperty(default=True) 13 | datetime = DateTimeProperty(required=False) 14 | 15 | 16 | class TranslationMixin(Document): 17 | translations = DictProperty() 18 | 19 | 20 | class HQMediaMapItem(DocumentSchema): 21 | multimedia_id = StringProperty() 22 | media_type = StringProperty() 23 | output_size = DictProperty() 24 | version = IntegerProperty() 25 | 26 | 27 | class HQMediaMixin(Document): 28 | multimedia_map = SchemaDictProperty(HQMediaMapItem) 29 | 30 | 31 | class SnapshotMixin(DocumentSchema): 32 | copy_history = StringListProperty() 33 | 34 | 35 | class FormActionCondition(DocumentSchema): 36 | type = StringProperty(choices=["if", "always", "never"], default="never") 37 | question = StringProperty() 38 | answer = StringProperty() 39 | 40 | class FormAction(DocumentSchema): 41 | condition = SchemaProperty(FormActionCondition) 42 | 43 | 44 | class UpdateCaseAction(FormAction): 45 | update = DictProperty() 46 | 47 | class PreloadAction(FormAction): 48 | preload = DictProperty() 49 | 50 | class UpdateReferralAction(FormAction): 51 | followup_date = StringProperty() 52 | 53 | 54 | class OpenReferralAction(UpdateReferralAction): 55 | name_path = StringProperty() 56 | 57 | 58 | class OpenCaseAction(FormAction): 59 | name_path = StringProperty() 60 | external_id = StringProperty() 61 | 62 | 63 | class OpenSubCaseAction(FormAction): 64 | case_type = StringProperty() 65 | case_name = StringProperty() 66 | case_properties = DictProperty() 67 | repeat_context = StringProperty() 68 | 69 | 70 | class FormActions(DocumentSchema): 71 | open_case = SchemaProperty(OpenCaseAction) 72 | update_case = SchemaProperty(UpdateCaseAction) 73 | close_case = SchemaProperty(FormAction) 74 | open_referral = SchemaProperty(OpenReferralAction) 75 | update_referral = SchemaProperty(UpdateReferralAction) 76 | close_referral = SchemaProperty(FormAction) 77 | 78 | case_preload = SchemaProperty(PreloadAction) 79 | referral_preload = SchemaProperty(PreloadAction) 80 | 81 | subcases = SchemaListProperty(OpenSubCaseAction) 82 | 83 | 84 | class FormBase(DocumentSchema): 85 | """ 86 | Part of a Managed Application; configuration for a form. 87 | Translates to a second-level menu on the phone 88 | 89 | """ 90 | 91 | name = DictProperty() 92 | unique_id = StringProperty() 93 | requires = StringProperty(choices=["case", "referral", "none"], default="none") 94 | actions = SchemaProperty(FormActions) 95 | show_count = BooleanProperty(default=False) 96 | xmlns = StringProperty() 97 | version = IntegerProperty() 98 | 99 | 100 | class JRResourceProperty(StringProperty): 101 | pass 102 | 103 | 104 | class NavMenuItemMediaMixin(DocumentSchema): 105 | media_image = JRResourceProperty(required=False) 106 | media_audio = JRResourceProperty(required=False) 107 | 108 | 109 | class Form(FormBase, NavMenuItemMediaMixin): 110 | form_filter = StringProperty() 111 | 112 | 113 | class UserRegistrationForm(FormBase): 114 | username_path = StringProperty(default='username') 115 | password_path = StringProperty(default='password') 116 | data_paths = DictProperty() 117 | 118 | 119 | class DetailColumn(DocumentSchema): 120 | header = DictProperty() 121 | model = StringProperty() 122 | field = StringProperty() 123 | format = StringProperty() 124 | 125 | enum = DictProperty() 126 | late_flag = IntegerProperty(default=30) 127 | advanced = StringProperty(default="") 128 | filter_xpath = StringProperty(default="") 129 | time_ago_interval = FloatProperty(default=365.25) 130 | 131 | 132 | class SortElement(DocumentSchema): 133 | field = StringProperty() 134 | type = StringProperty() 135 | direction = StringProperty() 136 | 137 | 138 | class Detail(DocumentSchema): 139 | type = StringProperty(choices=['case_short', 'case_long', 'ref_short', 'ref_long']) 140 | 141 | columns = SchemaListProperty(DetailColumn) 142 | sort_elements = SchemaListProperty(SortElement) 143 | 144 | 145 | class CaseList(DocumentSchema): 146 | label = DictProperty() 147 | show = BooleanProperty(default=False) 148 | 149 | 150 | class ParentSelect(DocumentSchema): 151 | active = BooleanProperty(default=False) 152 | relationship = StringProperty(default='parent') 153 | module_id = StringProperty() 154 | 155 | 156 | class Module(NavMenuItemMediaMixin): 157 | name = DictProperty() 158 | case_label = DictProperty() 159 | referral_label = DictProperty() 160 | forms = SchemaListProperty(Form) 161 | details = SchemaListProperty(Detail) 162 | case_type = StringProperty() 163 | put_in_root = BooleanProperty(default=False) 164 | case_list = SchemaProperty(CaseList) 165 | referral_list = SchemaProperty(CaseList) 166 | task_list = SchemaProperty(CaseList) 167 | parent_select = SchemaProperty(ParentSelect) 168 | unique_id = StringProperty() 169 | 170 | 171 | class VersionedDoc(DocumentSchema): 172 | """ 173 | A document that keeps an auto-incrementing version number, knows how to make copies of itself, 174 | delete a copy of itself, and revert back to an earlier copy of itself. 175 | 176 | """ 177 | domain = StringProperty() 178 | copy_of = StringProperty() 179 | version = IntegerProperty() 180 | short_url = StringProperty() 181 | short_odk_url = StringProperty() 182 | 183 | 184 | class ApplicationBase(VersionedDoc, SnapshotMixin): 185 | """ 186 | Abstract base class for Application and RemoteApp. 187 | Contains methods for generating the various files and zipping them into CommCare.jar 188 | 189 | """ 190 | 191 | recipients = StringProperty(default="") 192 | 193 | # this is the supported way of specifying which commcare build to use 194 | build_spec = SchemaProperty(BuildSpec) 195 | platform = StringProperty( 196 | choices=["nokia/s40", "nokia/s60", "winmo", "generic"], 197 | default="nokia/s40" 198 | ) 199 | text_input = StringProperty( 200 | choices=['roman', 'native', 'custom-keys', 'qwerty'], 201 | default="roman" 202 | ) 203 | success_message = DictProperty() 204 | 205 | # The following properties should only appear on saved builds 206 | # built_with stores a record of CommCare build used in a saved app 207 | built_with = SchemaProperty(BuildRecord) 208 | build_signed = BooleanProperty(default=True) 209 | built_on = DateTimeProperty(required=False) 210 | build_comment = StringProperty() 211 | comment_from = StringProperty() 212 | build_broken = BooleanProperty(default=False) 213 | 214 | # watch out for a past bug: 215 | # when reverting to a build that happens to be released 216 | # that got copied into into the new app doc, and when new releases were made, 217 | # they were automatically starred 218 | # AFAIK this is fixed in code, but my rear its ugly head in an as-yet-not-understood 219 | # way for apps that already had this problem. Just keep an eye out 220 | is_released = BooleanProperty(default=False) 221 | 222 | # django-style salted hash of the admin password 223 | admin_password = StringProperty() 224 | # a=Alphanumeric, n=Numeric, x=Neither (not allowed) 225 | admin_password_charset = StringProperty(choices=['a', 'n', 'x'], default='n') 226 | 227 | # This is here instead of in Application because it needs to be available in stub representation 228 | application_version = StringProperty(default='1.0', choices=['1.0', '2.0'], required=False) 229 | 230 | langs = StringListProperty() 231 | # only the languages that go in the build 232 | build_langs = StringListProperty() 233 | 234 | # exchange properties 235 | cached_properties = DictProperty() 236 | description = StringProperty() 237 | deployment_date = DateTimeProperty() 238 | phone_model = StringProperty() 239 | user_type = StringProperty() 240 | attribution_notes = StringProperty() 241 | 242 | # always false for RemoteApp 243 | case_sharing = BooleanProperty(default=False) 244 | 245 | 246 | class Profile(DocumentSchema): 247 | features = DictProperty() 248 | _properties = DictProperty(name='properties_') 249 | 250 | 251 | class Application(ApplicationBase, TranslationMixin, HQMediaMixin): 252 | user_registration = SchemaProperty(UserRegistrationForm) 253 | show_user_registration = BooleanProperty(default=False, required=True) 254 | modules = SchemaListProperty(Module) 255 | name = StringProperty() 256 | profile = SchemaProperty(Profile) 257 | use_custom_suite = BooleanProperty(default=False) 258 | force_http = BooleanProperty(default=False) 259 | cloudcare_enabled = BooleanProperty(default=False) 260 | 261 | 262 | class RemoteApp(ApplicationBase): 263 | profile_url = StringProperty(default="http://") 264 | name = StringProperty() 265 | manage_urls = BooleanProperty(default=False) 266 | 267 | questions_map = DictProperty(required=False) 268 | -------------------------------------------------------------------------------- /jsonobject/containers.pyx: -------------------------------------------------------------------------------- 1 | from jsonobject.base_properties import DefaultProperty 2 | from jsonobject.utils import check_type, SimpleDict 3 | 4 | 5 | class JsonArray(list): 6 | def __new__(cls, _obj=None, wrapper=None, type_config=None): 7 | if _obj is not None and wrapper is None and type_config is None: 8 | return list(_obj) if not isinstance(_obj, list) else _obj 9 | return super().__new__(cls, _obj, wrapper, type_config) 10 | 11 | def __init__(self, _obj, wrapper, type_config): 12 | super(JsonArray, self).__init__() 13 | self._obj = check_type(_obj, list, 14 | 'JsonArray must wrap a list or None') 15 | 16 | assert type_config is not None 17 | self._type_config = type_config 18 | self._wrapper = ( 19 | wrapper or 20 | DefaultProperty(type_config=self._type_config) 21 | ) 22 | for item in self._obj: 23 | super(JsonArray, self).append(self._wrapper.wrap(item)) 24 | 25 | def __getstate__(self): 26 | # This breaks symmetry with JsonObject.__getstate__(), which 27 | # calls and returns self.to_json(). Here the JSON-y value, 28 | # self._obj, is not copied because it will be repopulated by 29 | # deepcopy/pickle list reconstruction logic, which is done 30 | # after obj.__setstate__(data). 31 | data = self.__dict__.copy() 32 | data["_obj"] = [] 33 | return data 34 | 35 | def validate(self, required=True): 36 | for obj in self: 37 | self._wrapper.validate(obj, required=required) 38 | 39 | def append(self, wrapped): 40 | wrapped, unwrapped = self._wrapper.unwrap(wrapped) 41 | self._obj.append(unwrapped) 42 | super(JsonArray, self).append(wrapped) 43 | 44 | def __delitem__(self, i): 45 | super(JsonArray, self).__delitem__(i) 46 | del self._obj[i] 47 | 48 | def __setitem__(self, i, wrapped): 49 | if isinstance(i, slice): 50 | new_wrapped = [] 51 | unwrapped = [] 52 | for _wrapped in wrapped: 53 | _wrapped, _unwrapped = self._wrapper.unwrap(_wrapped) 54 | new_wrapped.append(_wrapped) 55 | unwrapped.append(_unwrapped) 56 | else: 57 | new_wrapped, unwrapped = self._wrapper.unwrap(wrapped) 58 | self._obj[i] = unwrapped 59 | super(JsonArray, self).__setitem__(i, new_wrapped) 60 | 61 | def extend(self, wrapped_list): 62 | if wrapped_list: 63 | wrapped_list, unwrapped_list = zip( 64 | *map(self._wrapper.unwrap, wrapped_list) 65 | ) 66 | else: 67 | unwrapped_list = [] 68 | self._obj.extend(unwrapped_list) 69 | super(JsonArray, self).extend(wrapped_list) 70 | 71 | def insert(self, index, wrapped): 72 | wrapped, unwrapped = self._wrapper.unwrap(wrapped) 73 | self._obj.insert(index, unwrapped) 74 | super(JsonArray, self).insert(index, wrapped) 75 | 76 | def remove(self, value): 77 | i = self.index(value) 78 | super(JsonArray, self).remove(value) 79 | self._obj.pop(i) 80 | 81 | def pop(self, index=-1): 82 | self._obj.pop(index) 83 | return super(JsonArray, self).pop(index) 84 | 85 | def sort(self, cmp=None, key=None, reverse=False): 86 | zipped = list(zip(self, self._obj)) 87 | if key: 88 | new_key = lambda pair: key(pair[0]) 89 | zipped.sort(key=new_key, reverse=reverse) 90 | elif cmp: 91 | new_cmp = lambda pair1, pair2: cmp(pair1[0], pair2[0]) 92 | zipped.sort(cmp=new_cmp, reverse=reverse) 93 | else: 94 | zipped.sort(reverse=reverse) 95 | 96 | wrapped_list, unwrapped_list = list(zip(*zipped)) 97 | while self: 98 | self.pop() 99 | super(JsonArray, self).extend(wrapped_list) 100 | self._obj.extend(unwrapped_list) 101 | 102 | def reverse(self): 103 | self._obj.reverse() 104 | super(JsonArray, self).reverse() 105 | 106 | def __fix_slice(self, i, j): 107 | length = len(self) 108 | if j < 0: 109 | j += length 110 | if i < 0: 111 | i += length 112 | if i > length: 113 | i = length 114 | if j > length: 115 | j = length 116 | return i, j 117 | 118 | def __setslice__(self, i, j, sequence): 119 | i, j = self.__fix_slice(i, j) 120 | for _ in range(j - i): 121 | self.pop(i) 122 | for k, wrapped in enumerate(sequence): 123 | self.insert(i + k, wrapped) 124 | 125 | def __delslice__(self, i, j): 126 | i, j = self.__fix_slice(i, j) 127 | for _ in range(j - i): 128 | self.pop(i) 129 | 130 | def __iadd__(self, b): 131 | self.extend(b) 132 | return self 133 | 134 | 135 | class JsonDict(SimpleDict): 136 | 137 | def __init__(self, _obj=None, wrapper=None, type_config=None): 138 | super(JsonDict, self).__init__() 139 | self._obj = check_type(_obj, dict, 'JsonDict must wrap a dict or None') 140 | assert type_config is not None 141 | self._type_config = type_config 142 | self._wrapper = ( 143 | wrapper or 144 | DefaultProperty(type_config=self._type_config) 145 | ) 146 | for key, value in self._obj.items(): 147 | self[key] = self.__wrap(key, value) 148 | 149 | def validate(self, required=True): 150 | for obj in self.values(): 151 | self._wrapper.validate(obj, required=required) 152 | 153 | def __wrap(self, key, unwrapped): 154 | return self._wrapper.wrap(unwrapped) 155 | 156 | def __unwrap(self, key, wrapped): 157 | return self._wrapper.unwrap(wrapped) 158 | 159 | def __setitem__(self, key, value): 160 | if isinstance(key, int): 161 | key = unicode(key) 162 | 163 | wrapped, unwrapped = self.__unwrap(key, value) 164 | self._obj[key] = unwrapped 165 | super(JsonDict, self).__setitem__(key, wrapped) 166 | 167 | def __delitem__(self, key): 168 | del self._obj[key] 169 | super(JsonDict, self).__delitem__(key) 170 | 171 | def __getitem__(self, key): 172 | if isinstance(key, int): 173 | key = unicode(key) 174 | return super(JsonDict, self).__getitem__(key) 175 | 176 | 177 | class JsonSet(set): 178 | def __init__(self, _obj=None, wrapper=None, type_config=None): 179 | super(JsonSet, self).__init__() 180 | if isinstance(_obj, set): 181 | _obj = list(_obj) 182 | self._obj = check_type(_obj, list, 'JsonSet must wrap a list or None') 183 | assert type_config is not None 184 | self._type_config = type_config 185 | self._wrapper = ( 186 | wrapper or 187 | DefaultProperty(type_config=self._type_config) 188 | ) 189 | for item in self._obj: 190 | super(JsonSet, self).add(self._wrapper.wrap(item)) 191 | 192 | def validate(self, required=True): 193 | for obj in self: 194 | self._wrapper.validate(obj, required=required) 195 | 196 | def add(self, wrapped): 197 | wrapped, unwrapped = self._wrapper.unwrap(wrapped) 198 | if wrapped not in self: 199 | self._obj.append(unwrapped) 200 | super(JsonSet, self).add(wrapped) 201 | 202 | def remove(self, wrapped): 203 | wrapped, unwrapped = self._wrapper.unwrap(wrapped) 204 | if wrapped in self: 205 | self._obj.remove(unwrapped) 206 | super(JsonSet, self).remove(wrapped) 207 | else: 208 | raise KeyError(wrapped) 209 | 210 | def discard(self, wrapped): 211 | try: 212 | self.remove(wrapped) 213 | except KeyError: 214 | pass 215 | 216 | def pop(self): 217 | # get first item 218 | for wrapped in self: 219 | break 220 | else: 221 | raise KeyError() 222 | wrapped_, unwrapped = self._wrapper.unwrap(wrapped) 223 | assert wrapped is wrapped_ 224 | self.remove(unwrapped) 225 | return wrapped 226 | 227 | def clear(self): 228 | while self: 229 | self.pop() 230 | 231 | def __ior__(self, other): 232 | for wrapped in other: 233 | self.add(wrapped) 234 | return self 235 | 236 | def update(self, *args): 237 | for wrapped_list in args: 238 | self |= set(wrapped_list) 239 | 240 | union_update = update 241 | 242 | def __iand__(self, other): 243 | for wrapped in list(self): 244 | if wrapped not in other: 245 | self.remove(wrapped) 246 | return self 247 | 248 | def intersection_update(self, *args): 249 | for wrapped_list in args: 250 | self &= set(wrapped_list) 251 | 252 | def __isub__(self, other): 253 | for wrapped in list(self): 254 | if wrapped in other: 255 | self.remove(wrapped) 256 | return self 257 | 258 | def difference_update(self, *args): 259 | for wrapped_list in args: 260 | self -= set(wrapped_list) 261 | 262 | def __ixor__(self, other): 263 | removed = set() 264 | for wrapped in list(self): 265 | if wrapped in other: 266 | self.remove(wrapped) 267 | removed.add(wrapped) 268 | self.update(other - removed) 269 | return self 270 | 271 | def symmetric_difference_update(self, *args): 272 | for wrapped_list in args: 273 | self ^= set(wrapped_list) 274 | -------------------------------------------------------------------------------- /jsonobject/base_properties.pyx: -------------------------------------------------------------------------------- 1 | import inspect 2 | from jsonobject.exceptions import BadValueError 3 | 4 | def function_name(f): 5 | return f.__name__ 6 | 7 | 8 | class JsonProperty(object): 9 | 10 | default = None 11 | type_config = None 12 | 13 | def __init__(self, default=Ellipsis, name=None, choices=None, 14 | required=False, exclude_if_none=False, validators=None, 15 | verbose_name=None, type_config=None): 16 | validators = validators or () 17 | self.name = name 18 | if default is Ellipsis: 19 | default = self.default 20 | if callable(default): 21 | self.default = default 22 | else: 23 | self.default = lambda: default 24 | self.choices = choices 25 | self.choice_keys = [] 26 | if choices: 27 | for choice in choices: 28 | if isinstance(choice, tuple): 29 | choice, _ = choice 30 | self.choice_keys.append(choice) 31 | self.required = required 32 | self.exclude_if_none = exclude_if_none 33 | self._validators = validators 34 | self.verbose_name = verbose_name 35 | if type_config: 36 | self.type_config = type_config 37 | 38 | def init_property(self, default_name, type_config): 39 | self.name = self.name or default_name 40 | self.type_config = self.type_config or type_config 41 | 42 | def wrap(self, obj): 43 | raise NotImplementedError() 44 | 45 | def unwrap(self, obj): 46 | """ 47 | must return tuple of (wrapped, unwrapped) 48 | 49 | If obj is already a fully wrapped object, 50 | it must be returned as the first element. 51 | 52 | For an example where the first element is relevant see ListProperty 53 | 54 | """ 55 | raise NotImplementedError() 56 | 57 | def to_json(self, value): 58 | _, unwrapped = self.unwrap(value) 59 | return unwrapped 60 | 61 | def to_python(self, value): 62 | return self.wrap(value) 63 | 64 | def __get__(self, instance, owner): 65 | if instance: 66 | assert self.name in instance 67 | return instance[self.name] 68 | else: 69 | return self 70 | 71 | def __set__(self, instance, value): 72 | instance[self.name] = value 73 | 74 | def __call__(self, method): 75 | """ 76 | use a property as a decorator to set its default value 77 | 78 | class Document(JsonObject): 79 | @StringProperty() 80 | def doc_type(self): 81 | return self.__class__.__name__ 82 | """ 83 | assert self.default() is None 84 | self.default = method 85 | self.name = self.name or function_name(method) 86 | return self 87 | 88 | def exclude(self, value): 89 | return self.exclude_if_none and not value 90 | 91 | def empty(self, value): 92 | return value is None 93 | 94 | def validate(self, value, required=True, recursive=True): 95 | if (self.choice_keys and value not in self.choice_keys 96 | and value is not None): 97 | raise BadValueError( 98 | '{0!r} not in choices: {1!r}'.format(value, self.choice_keys) 99 | ) 100 | 101 | if not self.empty(value): 102 | self._custom_validate(value) 103 | elif required and self.required: 104 | raise BadValueError( 105 | 'Property {0} is required.'.format(self.name) 106 | ) 107 | if recursive and hasattr(value, 'validate'): 108 | value.validate(required=required) 109 | 110 | def _custom_validate(self, value): 111 | if self._validators: 112 | if hasattr(self._validators, '__iter__'): 113 | for validator in self._validators: 114 | validator(value) 115 | else: 116 | self._validators(value) 117 | 118 | 119 | class JsonContainerProperty(JsonProperty): 120 | 121 | _type = default = None 122 | container_class = None 123 | 124 | def __init__(self, item_type=None, **kwargs): 125 | self._item_type_deferred = item_type 126 | super(JsonContainerProperty, self).__init__(**kwargs) 127 | 128 | def init_property(self, **kwargs): 129 | super(JsonContainerProperty, self).init_property(**kwargs) 130 | if not inspect.isfunction(self._item_type_deferred): 131 | # trigger validation 132 | self.item_wrapper 133 | 134 | def to_item_wrapper(self, item_type): 135 | from jsonobject.base import JsonObjectMeta 136 | from .properties import ObjectProperty 137 | if item_type is None: 138 | return None 139 | if isinstance(item_type, JsonObjectMeta): 140 | return ObjectProperty(item_type, type_config=self.type_config) 141 | elif isinstance(item_type, JsonProperty): 142 | item_wrapper = item_type 143 | if item_wrapper.type_config is None: 144 | item_wrapper.type_config = self.type_config 145 | return item_wrapper 146 | elif issubclass(item_type, JsonProperty): 147 | return item_type(type_config=self.type_config, required=True) 148 | elif item_type in self.type_config.properties: 149 | return self.type_config.properties[item_type](type_config=self.type_config, required=True) 150 | else: 151 | for general_type, property_cls in self.type_config.properties.items(): 152 | if issubclass(item_type, general_type): 153 | return property_cls(type_config=self.type_config, required=True) 154 | raise ValueError("item_type {0!r} not in {1!r}".format( 155 | item_type, 156 | self.type_config.properties, 157 | )) 158 | 159 | @property 160 | def item_wrapper(self): 161 | if hasattr(self, '_item_type_deferred'): 162 | if inspect.isfunction(self._item_type_deferred): 163 | self._item_wrapper = self.to_item_wrapper(self._item_type_deferred()) 164 | else: 165 | self._item_wrapper = self.to_item_wrapper(self._item_type_deferred) 166 | del self._item_type_deferred 167 | return self._item_wrapper 168 | 169 | def empty(self, value): 170 | return not value 171 | 172 | def wrap(self, obj): 173 | return self.container_class(obj, wrapper=self.item_wrapper, 174 | type_config=self.type_config) 175 | 176 | def unwrap(self, obj): 177 | if not isinstance(obj, self._type): 178 | raise BadValueError( 179 | '{0!r} is not an instance of {1!r}'.format( 180 | obj, self._type.__name__) 181 | ) 182 | if isinstance(obj, self.container_class): 183 | return obj, obj._obj 184 | else: 185 | wrapped = self.wrap(self._type()) 186 | self._update(wrapped, obj) 187 | return self.unwrap(wrapped) 188 | 189 | def _update(self, container, extension): 190 | raise NotImplementedError() 191 | 192 | 193 | class DefaultProperty(JsonProperty): 194 | 195 | def wrap(self, obj): 196 | assert self.type_config.string_conversions is not None 197 | value = self.value_to_python(obj) 198 | property_ = self.value_to_property(value) 199 | 200 | if property_: 201 | return property_.wrap(obj) 202 | 203 | def unwrap(self, obj): 204 | property_ = self.value_to_property(obj) 205 | if property_: 206 | return property_.unwrap(obj) 207 | else: 208 | return obj, None 209 | 210 | def value_to_property(self, value): 211 | map_types_properties = self.type_config.properties 212 | if value is None: 213 | return None 214 | elif type(value) in map_types_properties: 215 | return map_types_properties[type(value)]( 216 | type_config=self.type_config) 217 | else: 218 | for value_type, prop_class in map_types_properties.items(): 219 | if isinstance(value, value_type): 220 | return prop_class(type_config=self.type_config) 221 | else: 222 | raise BadValueError( 223 | 'value {0!r} not in allowed types: {1!r}'.format( 224 | value, map_types_properties.keys()) 225 | ) 226 | 227 | def value_to_python(self, value): 228 | """ 229 | convert encoded string values to the proper python type 230 | 231 | ex: 232 | >>> DefaultProperty().value_to_python('2013-10-09T10:05:51Z') 233 | datetime.datetime(2013, 10, 9, 10, 5, 51) 234 | 235 | other values will be passed through unmodified 236 | Note: containers' items are NOT recursively converted 237 | 238 | """ 239 | if isinstance(value, str): 240 | convert = None 241 | for pattern, _convert in self.type_config.string_conversions: 242 | if pattern.match(value): 243 | convert = _convert 244 | break 245 | 246 | if convert is not None: 247 | try: 248 | #sometimes regex fail so return value 249 | value = convert(value) 250 | except Exception: 251 | pass 252 | return value 253 | 254 | 255 | class AssertTypeProperty(JsonProperty): 256 | _type = None 257 | 258 | def assert_type(self, obj): 259 | if obj is None: 260 | return 261 | elif not isinstance(obj, self._type): 262 | raise BadValueError( 263 | '{0!r} not of type {1!r}'.format(obj, self._type) 264 | ) 265 | 266 | def selective_coerce(self, obj): 267 | return obj 268 | 269 | def wrap(self, obj): 270 | obj = self.selective_coerce(obj) 271 | self.assert_type(obj) 272 | return obj 273 | 274 | def unwrap(self, obj): 275 | obj = self.selective_coerce(obj) 276 | self.assert_type(obj) 277 | return obj, obj 278 | 279 | 280 | class AbstractDateProperty(JsonProperty): 281 | 282 | _type = None 283 | 284 | def __init__(self, exact=False, *args, **kwargs): 285 | super(AbstractDateProperty, self).__init__(*args, **kwargs) 286 | self.exact = exact 287 | 288 | def wrap(self, obj): 289 | try: 290 | if not isinstance(obj, str): 291 | raise ValueError() 292 | return self._wrap(obj) 293 | except ValueError: 294 | raise BadValueError('{0!r} is not a {1}-formatted string'.format( 295 | obj, 296 | self._type.__name__, 297 | )) 298 | 299 | def unwrap(self, obj): 300 | if not isinstance(obj, self._type): 301 | raise BadValueError('{0!r} is not a {1} object'.format( 302 | obj, 303 | self._type.__name__, 304 | )) 305 | return self._unwrap(obj) 306 | 307 | def _wrap(self, obj): 308 | raise NotImplementedError() 309 | 310 | def _unwrap(self, obj): 311 | raise NotImplementedError() 312 | -------------------------------------------------------------------------------- /test/couchdbkit/data/basic.json: -------------------------------------------------------------------------------- 1 | { 2 | "_attachments": { 3 | "ff930385d83e83ca24731a6d1515fa1f6152b305.xml": { 4 | "content_type": "application/xml", 5 | "digest": "md5-Tm/ldqpYk8uCJgKnJWoO4g==", 6 | "length": 1126, 7 | "revpos": 10, 8 | "stub": true 9 | } 10 | }, 11 | "_id": "f2915ef4a890682962ae41f3f0bcc765", 12 | "_rev": "20-604f92e665920f4db6bcacc4c6c44bac", 13 | "admin_password": null, 14 | "admin_password_charset": "n", 15 | "application_version": "2.0", 16 | "attribution_notes": null, 17 | "build_comment": null, 18 | "build_langs": [ 19 | "en" 20 | ], 21 | "build_signed": true, 22 | "build_spec": { 23 | "build_number": null, 24 | "doc_type": "BuildSpec", 25 | "latest": true, 26 | "version": "2.3.0" 27 | }, 28 | "built_on": null, 29 | "built_with": { 30 | "build_number": null, 31 | "datetime": null, 32 | "doc_type": "BuildRecord", 33 | "latest": null, 34 | "signed": true, 35 | "version": null 36 | }, 37 | "cached_properties": {}, 38 | "case_sharing": false, 39 | "cloudcare_enabled": false, 40 | "comment_from": null, 41 | "copy_history": [], 42 | "copy_of": null, 43 | "deployment_date": null, 44 | "description": null, 45 | "doc_type": "Application", 46 | "domain": "droberts", 47 | "force_http": false, 48 | "include_media_resources": false, 49 | "is_released": false, 50 | "langs": [ 51 | "en" 52 | ], 53 | "modules": [ 54 | { 55 | "case_label": { 56 | "en": "Cases" 57 | }, 58 | "case_list": { 59 | "doc_type": "CaseList", 60 | "label": { 61 | "en": "x" 62 | }, 63 | "show": false 64 | }, 65 | "case_type": "x", 66 | "details": [ 67 | { 68 | "columns": [], 69 | "doc_type": "Detail", 70 | "type": "case_short" 71 | }, 72 | { 73 | "columns": [], 74 | "doc_type": "Detail", 75 | "type": "case_long" 76 | }, 77 | { 78 | "columns": [], 79 | "doc_type": "Detail", 80 | "type": "ref_short" 81 | }, 82 | { 83 | "columns": [], 84 | "doc_type": "Detail", 85 | "type": "ref_long" 86 | } 87 | ], 88 | "doc_type": "Module", 89 | "forms": [ 90 | { 91 | "actions": { 92 | "case_preload": { 93 | "condition": { 94 | "answer": null, 95 | "question": null, 96 | "type": "never" 97 | }, 98 | "preload": {} 99 | }, 100 | "close_case": { 101 | "condition": { 102 | "answer": null, 103 | "doc_type": "FormActionCondition", 104 | "question": null, 105 | "type": "never" 106 | } 107 | }, 108 | "close_referral": { 109 | "condition": { 110 | "answer": null, 111 | "doc_type": "FormActionCondition", 112 | "question": null, 113 | "type": "never" 114 | }, 115 | "doc_type": "FormAction" 116 | }, 117 | "open_case": { 118 | "condition": { 119 | "answer": null, 120 | "doc_type": "FormActionCondition", 121 | "question": null, 122 | "type": "never" 123 | }, 124 | "external_id": null, 125 | "name_path": null 126 | }, 127 | "open_referral": { 128 | "condition": { 129 | "answer": null, 130 | "doc_type": "FormActionCondition", 131 | "question": null, 132 | "type": "never" 133 | }, 134 | "doc_type": "OpenReferralAction", 135 | "followup_date": null, 136 | "name_path": null 137 | }, 138 | "referral_preload": { 139 | "condition": { 140 | "answer": null, 141 | "doc_type": "FormActionCondition", 142 | "question": null, 143 | "type": "never" 144 | }, 145 | "doc_type": "PreloadAction", 146 | "preload": {} 147 | }, 148 | "subcases": [], 149 | "update_case": { 150 | "condition": { 151 | "answer": null, 152 | "question": null, 153 | "type": "never" 154 | }, 155 | "update": { 156 | "question": "/data/question" 157 | } 158 | }, 159 | "update_referral": { 160 | "condition": { 161 | "answer": null, 162 | "doc_type": "FormActionCondition", 163 | "question": null, 164 | "type": "never" 165 | }, 166 | "doc_type": "UpdateReferralAction", 167 | "followup_date": null 168 | } 169 | }, 170 | "doc_type": "Form", 171 | "form_filter": "", 172 | "media_audio": null, 173 | "media_image": null, 174 | "name": { 175 | "en": "Form" 176 | }, 177 | "requires": "none", 178 | "show_count": false, 179 | "unique_id": "ff930385d83e83ca24731a6d1515fa1f6152b305", 180 | "version": null, 181 | "xmlns": "http://openrosa.org/formdesigner/1D49DAA1-C2D6-4CFB-A005-CE2151CD632A" 182 | } 183 | ], 184 | "media_audio": null, 185 | "media_image": null, 186 | "name": { 187 | "en": "Module" 188 | }, 189 | "put_in_root": false, 190 | "referral_label": { 191 | "en": "Referrals" 192 | }, 193 | "referral_list": { 194 | "doc_type": "CaseList", 195 | "label": {}, 196 | "show": false 197 | }, 198 | "task_list": { 199 | "doc_type": "CaseList", 200 | "label": {}, 201 | "show": false 202 | } 203 | } 204 | ], 205 | "multimedia_map": {}, 206 | "name": "Minimum Viable Application", 207 | "phone_model": null, 208 | "platform": "nokia/s40", 209 | "profile": { 210 | "features": { 211 | "sense": "false", 212 | "users": "true" 213 | }, 214 | "properties": { 215 | "ViewStyle": "v_chatterbox", 216 | "cc-autoup-freq": "freq-never", 217 | "cc-content-valid": "no", 218 | "cc-entry-mode": "cc-entry-quick", 219 | "cc-review-days": "7", 220 | "cc-send-procedure": "cc-send-http", 221 | "cc-send-unsent": "cc-su-auto", 222 | "cc-user-mode": "cc-u-normal", 223 | "extra_key_action": "audio", 224 | "log_prop_daily": "log_never", 225 | "log_prop_weekly": "log_short", 226 | "logenabled": "Enabled", 227 | "loose_media": "no", 228 | "password_format": "n", 229 | "purge-freq": "0", 230 | "restore-tolerance": "loose", 231 | "server-tether": "push-only", 232 | "unsent-number-limit": "5", 233 | "user_reg_server": "required" 234 | } 235 | }, 236 | "recipients": "", 237 | "short_odk_url": null, 238 | "short_url": null, 239 | "show_user_registration": false, 240 | "success_message": {}, 241 | "text_input": "roman", 242 | "translations": {}, 243 | "use_custom_suite": false, 244 | "user_registration": { 245 | "actions": { 246 | "case_preload": { 247 | "condition": { 248 | "answer": null, 249 | "doc_type": "FormActionCondition", 250 | "question": null, 251 | "type": "never" 252 | }, 253 | "doc_type": "PreloadAction", 254 | "preload": {} 255 | }, 256 | "close_case": { 257 | "condition": { 258 | "answer": null, 259 | "doc_type": "FormActionCondition", 260 | "question": null, 261 | "type": "never" 262 | }, 263 | "doc_type": "FormAction" 264 | }, 265 | "close_referral": { 266 | "condition": { 267 | "answer": null, 268 | "doc_type": "FormActionCondition", 269 | "question": null, 270 | "type": "never" 271 | }, 272 | "doc_type": "FormAction" 273 | }, 274 | "doc_type": "FormActions", 275 | "open_case": { 276 | "condition": { 277 | "answer": null, 278 | "doc_type": "FormActionCondition", 279 | "question": null, 280 | "type": "never" 281 | }, 282 | "doc_type": "OpenCaseAction", 283 | "external_id": null, 284 | "name_path": null 285 | }, 286 | "open_referral": { 287 | "condition": { 288 | "answer": null, 289 | "doc_type": "FormActionCondition", 290 | "question": null, 291 | "type": "never" 292 | }, 293 | "doc_type": "OpenReferralAction", 294 | "followup_date": null, 295 | "name_path": null 296 | }, 297 | "referral_preload": { 298 | "condition": { 299 | "answer": null, 300 | "doc_type": "FormActionCondition", 301 | "question": null, 302 | "type": "never" 303 | }, 304 | "doc_type": "PreloadAction", 305 | "preload": {} 306 | }, 307 | "subcases": [], 308 | "update_case": { 309 | "condition": { 310 | "answer": null, 311 | "doc_type": "FormActionCondition", 312 | "question": null, 313 | "type": "never" 314 | }, 315 | "doc_type": "UpdateCaseAction", 316 | "update": {} 317 | }, 318 | "update_referral": { 319 | "condition": { 320 | "answer": null, 321 | "doc_type": "FormActionCondition", 322 | "question": null, 323 | "type": "never" 324 | }, 325 | "doc_type": "UpdateReferralAction", 326 | "followup_date": null 327 | } 328 | }, 329 | "data_paths": {}, 330 | "doc_type": "UserRegistrationForm", 331 | "name": {}, 332 | "password_path": "password", 333 | "requires": "none", 334 | "show_count": false, 335 | "unique_id": null, 336 | "username_path": "username", 337 | "version": null, 338 | "xmlns": null 339 | }, 340 | "user_type": null, 341 | "version": 17 342 | } 343 | -------------------------------------------------------------------------------- /jsonobject/base.pyx: -------------------------------------------------------------------------------- 1 | from collections import namedtuple, OrderedDict 2 | import copy 3 | import inspect 4 | from jsonobject.exceptions import ( 5 | DeleteNotAllowed, 6 | WrappingAttributeError, 7 | ) 8 | from jsonobject.base_properties import JsonProperty, DefaultProperty 9 | from jsonobject.utils import check_type 10 | 11 | 12 | JsonObjectClassSettings = namedtuple('JsonObjectClassSettings', ['type_config']) 13 | 14 | CLASS_SETTINGS_ATTR = '_$_class_settings' 15 | 16 | 17 | def get_settings(cls): 18 | return getattr(cls, CLASS_SETTINGS_ATTR, 19 | JsonObjectClassSettings(type_config=TypeConfig())) 20 | 21 | 22 | def set_settings(cls, settings): 23 | setattr(cls, CLASS_SETTINGS_ATTR, settings) 24 | 25 | 26 | class TypeConfig(object): 27 | """ 28 | This class allows the user to configure dynamic 29 | type handlers and string conversions for their JsonObject. 30 | 31 | properties is a map from python types to JsonProperty subclasses 32 | string_conversions is a list or tuple of (regex, python type)-tuples 33 | 34 | This class is used to store the configuration but is not part of the API. 35 | To configure: 36 | 37 | class Foo(JsonObject): 38 | # property definitions go here 39 | # ... 40 | 41 | class Meta(object): 42 | update_properties = { 43 | datetime.datetime: MySpecialDateTimeProperty 44 | } 45 | # this is already set by default 46 | # but you can override with your own modifications 47 | string_conversions = ((date_re, datetime.date), 48 | (datetime_re, datetime.datetime), 49 | (time_re, datetime.time), 50 | (decimal_re, decimal.Decimal)) 51 | 52 | If you now do 53 | 54 | foo = Foo() 55 | foo.timestamp = datetime.datetime(1988, 7, 7, 11, 8, 0) 56 | 57 | timestamp will be governed by a MySpecialDateTimeProperty 58 | instead of the default. 59 | 60 | """ 61 | def __init__(self, properties=None, string_conversions=None): 62 | self._properties = properties if properties is not None else {} 63 | 64 | self._string_conversions = ( 65 | OrderedDict(string_conversions) if string_conversions is not None 66 | else OrderedDict() 67 | ) 68 | # cache this 69 | self.string_conversions = self._get_string_conversions() 70 | self.properties = self._properties 71 | 72 | def replace(self, properties=None, string_conversions=None): 73 | return TypeConfig( 74 | properties=(properties if properties is not None 75 | else self._properties), 76 | string_conversions=(string_conversions if string_conversions is not None 77 | else self._string_conversions) 78 | ) 79 | 80 | def updated(self, properties=None, string_conversions=None): 81 | """ 82 | update properties and string_conversions with the paramenters 83 | keeping all non-mentioned items the same as before 84 | returns a new TypeConfig with these changes 85 | (does not modify original) 86 | 87 | """ 88 | _properties = self._properties.copy() 89 | _string_conversions = self.string_conversions[:] 90 | if properties: 91 | _properties.update(properties) 92 | if string_conversions: 93 | _string_conversions.extend(string_conversions) 94 | return TypeConfig( 95 | properties=_properties, 96 | string_conversions=_string_conversions, 97 | ) 98 | 99 | def _get_string_conversions(self): 100 | result = [] 101 | for pattern, conversion in self._string_conversions.items(): 102 | conversion = ( 103 | conversion if conversion not in self._properties 104 | else self._properties[conversion](type_config=self).to_python 105 | ) 106 | result.append((pattern, conversion)) 107 | return result 108 | 109 | META_ATTRS = ('properties', 'string_conversions', 'update_properties') 110 | 111 | 112 | class JsonObjectMeta(type): 113 | 114 | class Meta(object): 115 | pass 116 | 117 | def __new__(mcs, name, bases, dct): 118 | cls = super(JsonObjectMeta, mcs).__new__(mcs, name, bases, dct) 119 | 120 | cls.__configure(**{key: value 121 | for key, value in cls.Meta.__dict__.items() 122 | if key in META_ATTRS}) 123 | cls_settings = get_settings(cls) 124 | 125 | properties = {} 126 | properties_by_name = {} 127 | for key, value in dct.items(): 128 | if isinstance(value, JsonProperty): 129 | properties[key] = value 130 | elif key.startswith('_'): 131 | continue 132 | elif type(value) in cls_settings.type_config.properties: 133 | property_ = cls_settings.type_config.properties[type(value)](default=value) 134 | properties[key] = dct[key] = property_ 135 | setattr(cls, key, property_) 136 | 137 | for key, property_ in properties.items(): 138 | property_.init_property(default_name=key, 139 | type_config=cls_settings.type_config) 140 | assert property_.name is not None, property_ 141 | assert property_.name not in properties_by_name, \ 142 | 'You can only have one property named {0}'.format( 143 | property_.name) 144 | properties_by_name[property_.name] = property_ 145 | 146 | for base in bases: 147 | if getattr(base, '_properties_by_attr', None): 148 | for key, value in base._properties_by_attr.items(): 149 | if key not in properties: 150 | properties[key] = value 151 | properties_by_name[value.name] = value 152 | 153 | cls._properties_by_attr = properties 154 | cls._properties_by_key = properties_by_name 155 | return cls 156 | 157 | def __configure(cls, properties=None, string_conversions=None, 158 | update_properties=None): 159 | super_settings = get_settings(super(cls, cls)) 160 | assert not properties or not update_properties, \ 161 | "{} {}".format(properties, update_properties) 162 | type_config = super_settings.type_config 163 | if update_properties is not None: 164 | type_config = type_config.updated(properties=update_properties) 165 | elif properties is not None: 166 | type_config = type_config.replace(properties=properties) 167 | if string_conversions is not None: 168 | type_config = type_config.replace( 169 | string_conversions=string_conversions) 170 | set_settings(cls, super_settings._replace(type_config=type_config)) 171 | return cls 172 | 173 | 174 | class _JsonObjectPrivateInstanceVariables(object): 175 | 176 | def __init__(self, dynamic_properties=None): 177 | self.dynamic_properties = dynamic_properties or {} 178 | 179 | 180 | class JsonObjectBase(object, metaclass=JsonObjectMeta): 181 | 182 | _allow_dynamic_properties = True 183 | _validate_required_lazily = False 184 | 185 | _properties_by_attr = None 186 | _properties_by_key = None 187 | 188 | _string_conversions = () 189 | 190 | def __init__(self, _obj=None, **kwargs): 191 | setattr(self, '_$', _JsonObjectPrivateInstanceVariables()) 192 | 193 | self._obj = check_type(_obj, dict, 194 | 'JsonObject must wrap a dict or None') 195 | self._wrapped = {} 196 | 197 | for key, value in list(self._obj.items()): 198 | try: 199 | self.set_raw_value(key, value) 200 | except AttributeError: 201 | raise WrappingAttributeError( 202 | "can't set attribute corresponding to {key!r} " 203 | "on a {cls} while wrapping {data!r}".format( 204 | cls=self.__class__, 205 | key=key, 206 | data=_obj, 207 | ) 208 | ) 209 | 210 | for attr, value in kwargs.items(): 211 | try: 212 | setattr(self, attr, value) 213 | except AttributeError: 214 | raise WrappingAttributeError( 215 | "can't set attribute {key!r} " 216 | "on a {cls} while wrapping {data!r}".format( 217 | cls=self.__class__, 218 | key=attr, 219 | data=_obj, 220 | ) 221 | ) 222 | 223 | for key, value in self._properties_by_key.items(): 224 | if key not in self._obj: 225 | try: 226 | d = value.default() 227 | except TypeError: 228 | d = value.default(self) 229 | self[key] = d 230 | 231 | def set_raw_value(self, key, value): 232 | wrapped = self.__wrap(key, value) 233 | if key in self._properties_by_key: 234 | self[key] = wrapped 235 | else: 236 | setattr(self, key, wrapped) 237 | 238 | @classmethod 239 | def properties(cls): 240 | return cls._properties_by_attr.copy() 241 | 242 | @property 243 | def __dynamic_properties(self): 244 | return getattr(self, '_$').dynamic_properties 245 | 246 | @classmethod 247 | def wrap(cls, obj): 248 | self = cls(obj) 249 | return self 250 | 251 | def validate(self, required=True): 252 | for key, value in self._wrapped.items(): 253 | self.__get_property(key).validate(value, required=required) 254 | 255 | def to_json(self): 256 | self.validate() 257 | return copy.deepcopy(self._obj) 258 | 259 | def __get_property(self, key): 260 | try: 261 | return self._properties_by_key[key] 262 | except KeyError: 263 | return DefaultProperty(type_config=get_settings(self).type_config) 264 | 265 | def __wrap(self, key, value): 266 | property_ = self.__get_property(key) 267 | 268 | if value is None: 269 | return None 270 | 271 | return property_.wrap(value) 272 | 273 | def __unwrap(self, key, value): 274 | property_ = self.__get_property(key) 275 | if value is None: 276 | wrapped, unwrapped = None, None 277 | else: 278 | wrapped, unwrapped = property_.unwrap(value) 279 | 280 | if isinstance(wrapped, JsonObjectBase): 281 | # validate containers but not objects 282 | recursive_kwargs = {'recursive': False} 283 | else: 284 | # omit the argument for backwards compatibility of custom properties 285 | # that do not contain `recursive` in their signature 286 | # and let the default of True shine through 287 | recursive_kwargs = {} 288 | property_.validate( 289 | wrapped, 290 | required=not self._validate_required_lazily, 291 | **recursive_kwargs, 292 | ) 293 | return wrapped, unwrapped 294 | 295 | def __setitem__(self, key, value): 296 | wrapped, unwrapped = self.__unwrap(key, value) 297 | self._wrapped[key] = wrapped 298 | if self.__get_property(key).exclude(unwrapped): 299 | self._obj.pop(key, None) 300 | else: 301 | self._obj[key] = unwrapped 302 | if key not in self._properties_by_key: 303 | assert key not in self._properties_by_attr 304 | self.__dynamic_properties[key] = wrapped 305 | super(JsonObjectBase, self).__setattr__(key, wrapped) 306 | 307 | def __is_dynamic_property(self, name): 308 | return ( 309 | name not in self._properties_by_attr and 310 | not name.startswith('_') and 311 | not inspect.isdatadescriptor(getattr(self.__class__, name, None)) 312 | ) 313 | 314 | def __setattr__(self, name, value): 315 | if self.__is_dynamic_property(name): 316 | if self._allow_dynamic_properties: 317 | self[name] = value 318 | else: 319 | raise AttributeError( 320 | "{0!r} is not defined in schema " 321 | "(not a valid property)".format(name) 322 | ) 323 | else: 324 | super(JsonObjectBase, self).__setattr__(name, value) 325 | 326 | def __delitem__(self, key): 327 | if key in self._properties_by_key: 328 | raise DeleteNotAllowed(key) 329 | else: 330 | if not self.__is_dynamic_property(key): 331 | raise KeyError(key) 332 | del self._obj[key] 333 | del self._wrapped[key] 334 | del self.__dynamic_properties[key] 335 | super(JsonObjectBase, self).__delattr__(key) 336 | 337 | def __delattr__(self, name): 338 | if name in self._properties_by_attr: 339 | raise DeleteNotAllowed(name) 340 | elif self.__is_dynamic_property(name): 341 | del self[name] 342 | else: 343 | super(JsonObjectBase, self).__delattr__(name) 344 | 345 | def __repr__(self): 346 | name = self.__class__.__name__ 347 | predefined_properties = self._properties_by_attr.keys() 348 | predefined_property_keys = set(self._properties_by_attr[p].name 349 | for p in predefined_properties) 350 | dynamic_properties = (set(self._wrapped.keys()) 351 | - predefined_property_keys) 352 | properties = sorted(predefined_properties) + sorted(dynamic_properties) 353 | return u'{name}({keyword_args})'.format( 354 | name=name, 355 | keyword_args=', '.join('{key}={value!r}'.format( 356 | key=key, 357 | value=getattr(self, key) 358 | ) for key in properties), 359 | ) 360 | 361 | 362 | class _LimitedDictInterfaceMixin(object): 363 | """ 364 | mindlessly farms selected dict methods out to an internal dict 365 | 366 | really only a separate class from JsonObject 367 | to keep this mindlessness separate from the methods 368 | that need to be more carefully understood 369 | 370 | """ 371 | _wrapped = None 372 | 373 | def keys(self): 374 | return self._wrapped.keys() 375 | 376 | def items(self): 377 | return self._wrapped.items() 378 | 379 | def iteritems(self): 380 | return self._wrapped.iteritems() 381 | 382 | def __contains__(self, item): 383 | return item in self._wrapped 384 | 385 | def __getitem__(self, item): 386 | return self._wrapped[item] 387 | 388 | def __iter__(self): 389 | return iter(self._wrapped) 390 | 391 | def __len__(self): 392 | return len(self._wrapped) 393 | 394 | 395 | def get_dynamic_properties(obj): 396 | return getattr(obj, '_$').dynamic_properties.copy() 397 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # jsonobject 2 | 3 | [![Build Status](https://github.com/dimagi/jsonobject/actions/workflows/tests.yml/badge.svg)](https://github.com/dimagi/jsonobject/actions/workflows/tests.yml) 4 | [![Downloads](https://pepy.tech/badge/jsonobject/month)](https://pepy.tech/project/jsonobject) 5 | [![Supported Versions](https://img.shields.io/pypi/pyversions/jsonobject.svg)](https://pypi.org/project/jsonobject) 6 | [![Contributors](https://img.shields.io/github/contributors/dimagi/jsonobject.svg)](https://github.com/dimagi/jsonobject/graphs/contributors) 7 | 8 | jsonobject is a python library for handling deeply nested JSON objects 9 | as well-schema'd python objects. 10 | 11 | jsonobject is made by [Dimagi](https://www.dimagi.com/), where we build, use, and contribute to OSS in our mission to reduce inequity in the world. 12 | 13 | jsonobject is inspired by and largely API compatible with 14 | the `Document`/`DocumentSchema` portion of `couchdbkit`. 15 | Because jsonobject is not only simpler and standalone, but also faster, 16 | we also maintain a fork of `couchdbkit`, [jsonobject-couchdbkit](https://pypi.python.org/pypi/jsonobject-couchdbkit), 17 | that is backed by `jsonobject` and works seamlessly as a swap-in replacement 18 | for the main library. 19 | 20 | It is used heavily in [CommCare HQ](https://www.commcarehq.org/) ([source](https://github.com/dimagi/commcare-hq)), 21 | and the API is largely stable, 22 | but more advanced features may change in the future. 23 | 24 | ## Getting Started 25 | 26 | To install using pip, simply run 27 | 28 | ``` 29 | pip install jsonobject 30 | ``` 31 | 32 | 33 | ### Example 34 | 35 | The code below defines a simple user model, and its natural mapping to JSON. 36 | 37 | ```python 38 | from jsonobject import * 39 | 40 | class User(JsonObject): 41 | username = StringProperty() 42 | name = StringProperty() 43 | active = BooleanProperty(default=False) 44 | date_joined = DateTimeProperty() 45 | tags = ListProperty(unicode) 46 | 47 | ``` 48 | 49 | Once it is defined, it can be used to wrap or produce deserialized JSON. 50 | 51 | ```python 52 | >>> user1 = User( 53 | name='John Doe', 54 | username='jdoe', 55 | date_joined=datetime.datetime.utcnow(), 56 | tags=['generic', 'anonymous'] 57 | ) 58 | >>> user1.to_json() 59 | { 60 | 'name': u'John Doe', 61 | 'username': u'jdoe', 62 | 'active': False, 63 | 'date_joined': '2013-08-05T02:46:58Z', 64 | 'tags': [u'generic', u'anonymous'] 65 | } 66 | ``` 67 | 68 | Notice that the datetime is converted to an ISO format string in JSON, but is a real datetime on the object: 69 | 70 | ```python 71 | >>> user1.date_joined 72 | datetime.datetime(2013, 8, 5, 2, 46, 58) 73 | ``` 74 | 75 | ### The jsonobject Constructor 76 | 77 | A JsonObject subclass that has been defined as `User` above 78 | comes with a lot of built-in functionality. 79 | The basic operations are 80 | 81 | 1. Make a new object from deserialized JSON (e.g. the output of `json.loads`) 82 | 2. Construct a new object with given values 83 | 3. Modify an object 84 | 4. Dump to deserialized json (e.g. the input of `json.dumps`) 85 | 86 | 1 & 2 are accomplished with the constructor. There are two main ways to call 87 | the constructor: 88 | 89 | ```python 90 | User( 91 | name='John Doe', 92 | username='jdoe', 93 | date_joined=datetime.datetime.utcnow(), 94 | tags=['generic', 'anonymous'] 95 | ) 96 | ``` 97 | 98 | as above (satisfies #2) and 99 | 100 | ```python 101 | User({ 102 | 'name': u'John Doe', 103 | 'username': u'jdoe', 104 | 'active': False, 105 | 'date_joined': '2013-08-05T02:46:58Z', 106 | 'tags': [u'generic', u'anonymous'] 107 | }) 108 | ``` 109 | 110 | (satisfies #1). These two styles can also be mixed and matched: 111 | 112 | ```python 113 | User({ 114 | 'name': u'John Doe', 115 | 'username': u'jdoe', 116 | 'active': False, 117 | 'tags': [u'generic', u'anonymous'] 118 | }, date_joined=datetime.datetime.utcnow()) 119 | ``` 120 | 121 | Notice how datetimes are stored as strings in the deserialized JSON, but as 122 | `datetime.datetime`s in the nice python object—we will refer to these as the 123 | "json" representation and the "python" representation, or alternatively the 124 | "unwrapped" representation and the "wrapped" representation. 125 | 126 | **Gotcha**. 127 | When calling the constructor, remember that the keyword argument style 128 | requires you to pass in the "python" representation (e.g. a `datetime`) 129 | while the json-wrapping style of passing in a `dict` requires you to give it 130 | in the "json" representation (e.g. a datetime-formatted string). 131 | 132 | ## Property Types 133 | 134 | There are two main kinds of property types: 135 | scalar types (like string, bool, int, datetime, etc.) 136 | and container types (list, dict, set). 137 | They are dealt with separately below. 138 | 139 | ### Scalar Types 140 | 141 | All scalar properties can take the value `None` in addition to 142 | the values particular to their type (strings, bools, etc). 143 | If set to the wrong type, 144 | properties raise a `jsonobject.exceptions.BadValueError`: 145 | 146 | ```python 147 | class Foo(jsonobject.JsonObject): 148 | b = jsonobject.BooleanProperty() 149 | ``` 150 | 151 | ```python 152 | >>> Foo(b=0) 153 | Traceback (most recent call last): 154 | [...] 155 | jsonobject.exceptions.BadValueError: 0 not of type 156 | ``` 157 | 158 | #### `jsonobject.StringProperty` 159 | 160 | Maps to a `unicode`. Usage: 161 | 162 | ```python 163 | class Foo(jsonobject.JsonObject): 164 | s = jsonobject.StringProperty() 165 | ``` 166 | 167 | If you set it to an ascii `str` it will implicitly convert to `unicode`: 168 | 169 | ```python 170 | >>> Foo(s='hi') # converts to unicode 171 | Foo(s=u'hi') 172 | ``` 173 | 174 | If you set it to a non-ascii `str`, it will fail with a `UnicodeDecodeError`: 175 | 176 | ```python 177 | >>> Foo(s='\xff') 178 | Traceback (most recent call last): 179 | [...] 180 | UnicodeDecodeError: 'ascii' codec can't decode byte 0xff in position 0: ordinal not in range(128) 181 | ``` 182 | 183 | #### `jsonobject.BooleanProperty` 184 | 185 | Maps to a `bool`. 186 | 187 | 188 | #### `jsonobject.IntegerProperty` 189 | 190 | Maps to an `int` or `long`. 191 | 192 | #### `jsonobject.FloatProperty` 193 | 194 | Maps to a `float`. 195 | 196 | #### `jsonobject.DecimalProperty` 197 | 198 | Maps to a `decimal.Decimal` and stored as a JSON string. 199 | This type, unlike `FloatProperty`, 200 | stores the "human" representation of the digits. Usage: 201 | 202 | ```python 203 | class Foo(jsonobject.JsonObject): 204 | number = jsonobject.DecimalProperty() 205 | ``` 206 | 207 | If you set it to an `int` or `float`, it will implicitly convert to `Decimal`: 208 | 209 | ```python 210 | >>> Foo(number=1) 211 | Foo(number=Decimal('1')) 212 | >>> Foo(number=1.2) 213 | Foo(number=Decimal('1.2')) 214 | ``` 215 | 216 | If you set it to a `str` or `unicode`, however, it raises an `AssertionError`: 217 | 218 | ```python 219 | >>> Foo(number='1.0') 220 | Traceback (most recent call last): 221 | [...] 222 | AssertionError 223 | ``` 224 | 225 | Todo: this should really raise a `BadValueError`. 226 | 227 | If you pass in json in which the Decimal value is a `str` or `unicode`, 228 | but it is malformed, it throws the same errors as `decimal.Decimal`. 229 | 230 | ```python 231 | >>> Foo({'number': '1.0'}) 232 | Foo(number=Decimal('1.0')) 233 | >>> Foo({'number': '1.0.0'}) 234 | Traceback (most recent call last): 235 | [...] 236 | decimal.InvalidOperation: Invalid literal for Decimal: '1.0.0' 237 | ``` 238 | 239 | #### `jsonobject.DateProperty` 240 | 241 | Maps to a `datetime.date` and stored as a JSON string of the format 242 | `'%Y-%m-%d'`. Usage: 243 | 244 | ```python 245 | class Foo(jsonobject.JsonObject): 246 | date = jsonobject.DateProperty() 247 | ``` 248 | 249 | Wrapping a badly formatted string raises a `BadValueError`: 250 | 251 | ```python 252 | >>> Foo({'date': 'foo'}) 253 | Traceback (most recent call last): 254 | [...] 255 | jsonobject.exceptions.BadValueError: 'foo' is not a date-formatted string 256 | ``` 257 | 258 | #### `jsonobject.DateTimeProperty` 259 | 260 | Maps to a timezone-unaware `datetime.datetime` 261 | and stored as a JSON string of the format 262 | `'%Y-%m-%dT%H:%M:%SZ'`. 263 | 264 | While it works perfectly with good inputs, it is extremely sloppy when it comes 265 | to dealing with inputs that don't match the exact specified format. 266 | Rather than matching stricty, it simply truncates the string 267 | to the first 19 characters and tries to parse that as `'%Y-%m-%dT%H:%M:%S'`. 268 | This ignores both microseconds and, even worse, *the timezone*. 269 | This is a holdover from `couchdbkit`. 270 | 271 | In newer versions of jsonboject, you may optionally specify 272 | a `DateTimeProperty` as `exact`: 273 | 274 | ```python 275 | class Foo(jsonobject.JsonObject): 276 | date = jsonobject.DateTimeProperty(exact=True) 277 | ``` 278 | 279 | This provides a much cleaner conversion model 280 | that has the following properties: 281 | 282 | 1. It preserves microseconds 283 | 2. The incoming JSON representation **must** match `'%Y-%m-%dT%H:%M:%S.%fZ'` 284 | exactly. (This is similar to the default output, 285 | except for the mandatory 6 decimal places, i.e. milliseconds.) 286 | 3. Representations that don't match exactly will be rejected with a 287 | `BadValueError`. 288 | 289 | **Recommendation**: 290 | If you are not locked into `couchdbkit`'s earlier bad behavior, 291 | you should **always** use the `exact=True` flag on `DateTimeProperty`s 292 | and `TimeProperty`s (below). 293 | 294 | #### `jsonobject.TimeProperty` 295 | 296 | Maps to a `datetime.time`, stored as a JSON string of the format 297 | `'%H:%M:%S'`. 298 | 299 | To get access to milliseconds and strict behavior, use the `exact=True` setting 300 | which strictly accepts the format `'%H:%M:%S.%f'`. This is always recommended. 301 | For more information please read the previous section on `DateTimeProperty`. 302 | 303 | ### Container Types 304 | 305 | Container types generally take a first argument, `item_type`, 306 | specifying the type of the contained objects. 307 | 308 | 309 | #### `jsonobject.ObjectProperty(item_type)` 310 | 311 | Maps to a `dict` that has a schema specified by `item_type`, 312 | which must be itself a subclass of `JsonObject`. Usage: 313 | 314 | ```python 315 | class Bar(jsonobject.JsonObject): 316 | name = jsonobject.StringProperty() 317 | 318 | 319 | class Foo(jsonobject.JsonObject): 320 | bar = jsonobject.ObjectProperty(Bar) 321 | ``` 322 | 323 | If not specified, it will be set to a new object with default values: 324 | 325 | ```python 326 | >>> Foo() 327 | Foo(bar=Bar(name=None)) 328 | ``` 329 | 330 | If you want it set to `None` you must do so explicitly. 331 | 332 | #### `jsonobject.ListProperty(item_type)` 333 | 334 | Maps to a `list` with items of type `item_type`, 335 | which can be any of the following: 336 | 337 | - an _instance_ of a property class. This is the most flexible option, 338 | and all validation (`required`, etc.) will be done as as specified by the property instance. 339 | - a property class, which will be instantiated with `required=True` 340 | - one of their corresponding python types (i.e. `int` for `IntegerProperty`, etc.) 341 | - a `JsonObject` subclass 342 | 343 | Note that a property _class_ (as well as the related python type syntax) 344 | will be instantiated with `required=True`, 345 | so `ListProperty(IntegerProperty)` and `ListProperty(int)` do not allow `None`, and 346 | `ListProperty(IntegerProperty())` _does_ allow `None`. 347 | 348 | The serialization behavior of whatever item type is given is recursively 349 | applied to each member of the list. 350 | 351 | If not specified, it will be set to an empty list. 352 | 353 | #### `jsonobject.SetProperty(item_type)` 354 | 355 | Maps to a `set` and stored as a list (with only unique elements). 356 | Otherwise its behavior is very much like `ListProperty`'s. 357 | 358 | #### `jsonobject.DictProperty(item_type)` 359 | 360 | Maps to a `dict` with string keys and values specified by `item_type`. 361 | Otherwise its behavior is very much like `ListProperty`'s. 362 | 363 | If not specified, it will be set to an empty dict. 364 | 365 | ### Other 366 | 367 | #### `jsonobject.DefaultProperty()` 368 | 369 | This flexibly wraps any valid JSON, including all scalar and container types, 370 | dynamically detecting the value's type and treating it 371 | with the corresponding property. 372 | 373 | ## Property options 374 | 375 | Certain parameters may be passed in to any property. 376 | 377 | For example, `required` is one such parameter in the example below: 378 | 379 | ```python 380 | 381 | class User(JsonObject): 382 | username = StringProperty(required=True) 383 | 384 | ``` 385 | 386 | Here is a complete list of properties: 387 | 388 | - `default` 389 | 390 | Specifies a default value for the property 391 | 392 | - `name` 393 | 394 | The name of the property within the JSON representation\*. 395 | This defaults to the name of the python property, but you can override it 396 | if you wish. This can be useful, for example, to get around conflicting 397 | with python keywords: 398 | ```python 399 | >>> class Route(JsonObject): 400 | ... from_ = StringProperty(name='from') 401 | ... to = StringProperty() # name='to' by default 402 | >>> Route(from_='me', to='you').to_json() 403 | {'from': u'me', 'to': u'you'} 404 | ``` 405 | Notice how an underscore is present in the python property name ('from_'), 406 | but absent in the JSON property name ('from'). 407 | 408 | 409 | 410 | \*If you're wondering how `StringProperty`'s `name` parameter 411 | could possibly default to `to` in the example above, 412 | when it doesn't have access to the `Route` class's properties at init time, 413 | you're completely right. 414 | The behavior described is implemented in `JsonObject`'s `__metaclass__`, 415 | which *does* have access to the `Route` class's properties. 416 | 417 | 418 | - `choices` 419 | 420 | A list of allowed values for the property. 421 | (Unless otherwise specified, `None` is also an allowed value.) 422 | 423 | - `required` 424 | 425 | Defaults to `False`. 426 | For scalar properties `requires` means that the value `None` may not be used. 427 | For container properties it means they may not be empty 428 | or take the value `None`. 429 | 430 | - `exclude_if_none` 431 | 432 | Defaults to `False`. When set to true, this property will be excluded 433 | from the JSON output when its value is falsey. 434 | (Note that currently this is at odds with the parameter's name, 435 | since the condition is that it is falsey, not that it is `None`). 436 | 437 | - `validators` 438 | 439 | A single validator function or list of validator functions. 440 | Each validator function should raise an exception on invalid input 441 | and do nothing otherwise. 442 | 443 | - `verbose_name` 444 | 445 | This property does nothing and was added to match couchdbkit's API. 446 | 447 | 448 | ## Performance Comparison with Couchdbkit 449 | 450 | In order to do a direct comparison with couchdbkit, the test suite includes a large sample schema originally written with couchdbkit. It is easy to swap in jsonobject for couchdbkit and run the tests with each. Here are the results: 451 | 452 | ``` 453 | $ python -m unittest test.test_couchdbkit 454 | .... 455 | ---------------------------------------------------------------------- 456 | Ran 4 tests in 1.403s 457 | 458 | OK 459 | $ python -m unittest test.test_couchdbkit 460 | .... 461 | ---------------------------------------------------------------------- 462 | Ran 4 tests in 0.153s 463 | 464 | OK 465 | ``` 466 | 467 | # Development Lifecycle 468 | `jsonobject` versions follow [semantic versioning](https://semver.org/). 469 | Version information can be found in [CHANGES.md](CHANGES.md). 470 | 471 | Information for developers and maintainers, such as how to run tests and release new versions, 472 | can be found in [LIFECYCLE.md](LIFECYCLE.md). 473 | -------------------------------------------------------------------------------- /test/couchdbkit/data/multimedia_map.json: -------------------------------------------------------------------------------- 1 | { 2 | "_attachments": { 3 | "e487c0d7a0f782d05eadd5882682ad656772616.xml": { 4 | "content_type": "application/xml", 5 | "digest": "md5-2ynv5f4YcN8dJ0Kciah0Uw==", 6 | "length": 1095, 7 | "revpos": 2, 8 | "stub": true 9 | } 10 | }, 11 | "_id": "4c450063751d23e3c20dca4e19174848", 12 | "_rev": "6-cf3eb8f6e4956c6618e380ee12429548", 13 | "admin_password": null, 14 | "admin_password_charset": "n", 15 | "application_version": "2.0", 16 | "attribution_notes": null, 17 | "build_comment": null, 18 | "build_langs": [ 19 | "en", 20 | "es" 21 | ], 22 | "build_signed": true, 23 | "build_spec": { 24 | "build_number": null, 25 | "doc_type": "BuildSpec", 26 | "latest": true, 27 | "version": "2.3.0" 28 | }, 29 | "built_on": null, 30 | "built_with": { 31 | "build_number": null, 32 | "datetime": null, 33 | "doc_type": "BuildSpec", 34 | "latest": null, 35 | "signed": true, 36 | "version": null 37 | }, 38 | "cached_properties": {}, 39 | "case_sharing": false, 40 | "cloudcare_enabled": false, 41 | "comment_from": null, 42 | "copy_history": [], 43 | "copy_of": null, 44 | "deployment_date": null, 45 | "description": null, 46 | "doc_type": "Application", 47 | "domain": "droberts", 48 | "force_http": false, 49 | "include_media_resources": false, 50 | "is_released": false, 51 | "langs": [ 52 | "en", 53 | "es" 54 | ], 55 | "modules": [ 56 | { 57 | "case_label": { 58 | "en": "Cases", 59 | "es": "Cases" 60 | }, 61 | "case_list": { 62 | "doc_type": "CaseList", 63 | "label": {}, 64 | "show": false 65 | }, 66 | "case_type": "", 67 | "details": [ 68 | { 69 | "columns": [], 70 | "doc_type": "Detail", 71 | "type": "case_short" 72 | }, 73 | { 74 | "columns": [], 75 | "doc_type": "Detail", 76 | "type": "case_long" 77 | }, 78 | { 79 | "columns": [], 80 | "doc_type": "Detail", 81 | "type": "ref_short" 82 | }, 83 | { 84 | "columns": [], 85 | "doc_type": "Detail", 86 | "type": "ref_long" 87 | } 88 | ], 89 | "doc_type": "Module", 90 | "forms": [ 91 | { 92 | "actions": { 93 | "case_preload": { 94 | "condition": { 95 | "answer": null, 96 | "doc_type": "FormActionCondition", 97 | "question": null, 98 | "type": "never" 99 | }, 100 | "doc_type": "PreloadAction", 101 | "preload": {} 102 | }, 103 | "close_case": { 104 | "condition": { 105 | "answer": null, 106 | "doc_type": "FormActionCondition", 107 | "question": null, 108 | "type": "never" 109 | }, 110 | "doc_type": "FormAction" 111 | }, 112 | "close_referral": { 113 | "condition": { 114 | "answer": null, 115 | "doc_type": "FormActionCondition", 116 | "question": null, 117 | "type": "never" 118 | }, 119 | "doc_type": "UpdateReferralAction", 120 | "followup_date": null 121 | }, 122 | "doc_type": "FormActions", 123 | "open_case": { 124 | "condition": { 125 | "answer": null, 126 | "doc_type": "FormActionCondition", 127 | "question": null, 128 | "type": "never" 129 | }, 130 | "doc_type": "OpenCaseAction", 131 | "external_id": null, 132 | "name_path": null 133 | }, 134 | "open_referral": { 135 | "condition": { 136 | "answer": null, 137 | "doc_type": "FormActionCondition", 138 | "question": null, 139 | "type": "never" 140 | }, 141 | "doc_type": "OpenReferralAction", 142 | "followup_date": null, 143 | "name_path": null 144 | }, 145 | "referral_preload": { 146 | "condition": { 147 | "answer": null, 148 | "doc_type": "FormActionCondition", 149 | "question": null, 150 | "type": "never" 151 | }, 152 | "doc_type": "PreloadAction", 153 | "preload": {} 154 | }, 155 | "subcases": [], 156 | "update_case": { 157 | "condition": { 158 | "answer": null, 159 | "doc_type": "FormActionCondition", 160 | "question": null, 161 | "type": "never" 162 | }, 163 | "doc_type": "UpdateCaseAction", 164 | "update": {} 165 | }, 166 | "update_referral": { 167 | "condition": { 168 | "answer": null, 169 | "doc_type": "FormActionCondition", 170 | "question": null, 171 | "type": "never" 172 | }, 173 | "doc_type": "FormAction", 174 | "followup_date": null 175 | } 176 | }, 177 | "contents": "\n \n New Form1\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n Vive junto con su pareja?\n \n \n No\n \n \n Su pareja esta embarazada?\n \n \n Su pareja esta en cuarentena?\n \n \n Es padre de hijos menores de 2 a\u00f1os?\n \n \n Como se llama su pareja? (nombres y apellidos)\n \n \n Que edad en a\u00f1os tiene su pareja?\n \n \n Cuantos embarazos ha tenido su pareja? \n \n \n Si esta embarazada, cuantos meses tiene? \n \n \n Vive junto con su pareja?\n \n \n Tiene ficha de Plan de Parto?\n \n \n Cuantos controles prenatales se ha realizado su pareja?\n \n \n Tiene ficha de Plan de Parto?\n \n \n Cual es su condicion actual?\n \n \n Cual es su condicion actual\n \n \n Tiene ficha de Plan de Parto?\n \n \n Numero de Casa\n \n \n Cuantos a\u00f1os tiene Usted?\n \n \n Como se llama Usted? (nombres y apellidos)\n \n \n Donde trabaja Usted?\n \n \n Dentro de la comunidad\n \n \n Fuera de la comunidad\n \n \n Si trabaja fuera, Como mide el tiempo que pasa fuera de la comunidad?\n \n \n Horas por dia\n \n \n Dias por semana\n \n \n Semanas por mes\n \n \n Meses por a\u00f1o\n \n \n Cantidad de horas fuera de la comunidad?\n \n \n Cantidad de dias fuera de la comunidad?\n \n \n Cantidad de semanas fuera de la comunidad?\n \n \n Cantidad de meses fuera de la comunidad?\n \n \n Tienen ahorros para su parto y cuarentena?\n \n \n Le gustar\u00eda estar con ella al momento del parto?\n \n \n Si\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n dentro\n \n \n \n fuera\n \n \n \n \n \n \n horas\n \n \n \n dias\n \n \n \n semanas\n \n \n \n meses\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n Yes\n \n \n \n No\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n Yes\n \n \n \n No\n \n \n \n \n \n \n \n \n \n Yes\n \n \n \n No\n \n \n \n \n \n \n Yes\n \n \n \n No\n \n \n \n \n \n \n Yes\n \n \n \n No\n \n \n \n \n \n \n Yes\n \n \n \n No\n \n \n \n \n \n \n Yes\n \n \n \n No\n \n \n \n", 178 | "doc_type": "Form", 179 | "form_filter": null, 180 | "media_audio": null, 181 | "media_image": null, 182 | "name": { 183 | "en": "Yes/No" 184 | }, 185 | "requires": "none", 186 | "show_count": false, 187 | "unique_id": "8ec979eb3e6e8ef986179d519231f9c4bc6e169", 188 | "version": null, 189 | "xmlns": "http://openrosa.org/formdesigner/B72674A9-27CA-4FAE-AD49-72F0C1F9AB38" 190 | }, 191 | { 192 | "actions": { 193 | "case_preload": { 194 | "condition": { 195 | "answer": null, 196 | "doc_type": "FormActionCondition", 197 | "question": null, 198 | "type": "never" 199 | }, 200 | "doc_type": "PreloadAction", 201 | "preload": {} 202 | }, 203 | "close_case": { 204 | "condition": { 205 | "answer": null, 206 | "doc_type": "FormActionCondition", 207 | "question": null, 208 | "type": "never" 209 | }, 210 | "doc_type": "FormAction" 211 | }, 212 | "close_referral": { 213 | "condition": { 214 | "answer": null, 215 | "doc_type": "FormActionCondition", 216 | "question": null, 217 | "type": "never" 218 | }, 219 | "doc_type": "FormAction" 220 | }, 221 | "doc_type": "FormActions", 222 | "open_case": { 223 | "condition": { 224 | "answer": null, 225 | "doc_type": "FormActionCondition", 226 | "question": null, 227 | "type": "never" 228 | }, 229 | "doc_type": "OpenCaseAction", 230 | "external_id": null, 231 | "name_path": null 232 | }, 233 | "open_referral": { 234 | "condition": { 235 | "answer": null, 236 | "doc_type": "FormActionCondition", 237 | "question": null, 238 | "type": "never" 239 | }, 240 | "doc_type": "OpenReferralAction", 241 | "followup_date": null, 242 | "name_path": null 243 | }, 244 | "referral_preload": { 245 | "condition": { 246 | "answer": null, 247 | "doc_type": "FormActionCondition", 248 | "question": null, 249 | "type": "never" 250 | }, 251 | "doc_type": "PreloadAction", 252 | "preload": {} 253 | }, 254 | "subcases": [], 255 | "update_case": { 256 | "condition": { 257 | "answer": null, 258 | "doc_type": "FormActionCondition", 259 | "question": null, 260 | "type": "never" 261 | }, 262 | "doc_type": "UpdateCaseAction", 263 | "update": {} 264 | }, 265 | "update_referral": { 266 | "condition": { 267 | "answer": null, 268 | "doc_type": "FormActionCondition", 269 | "question": null, 270 | "type": "never" 271 | }, 272 | "doc_type": "UpdateReferralAction", 273 | "followup_date": null 274 | } 275 | }, 276 | "doc_type": "Form", 277 | "form_filter": null, 278 | "media_audio": null, 279 | "media_image": null, 280 | "name": { 281 | "en": "PIL" 282 | }, 283 | "requires": "none", 284 | "show_count": false, 285 | "unique_id": "e487c0d7a0f782d05eadd5882682ad656772616", 286 | "version": null, 287 | "xmlns": "http://openrosa.org/formdesigner/6AD5FBBB-E50A-4076-89D1-045FDFAF811C" 288 | } 289 | ], 290 | "media_audio": null, 291 | "media_image": "jr://file/commcare_media/image/image.jpeg", 292 | "name": { 293 | "en": "Untitled Module" 294 | }, 295 | "put_in_root": false, 296 | "referral_label": { 297 | "en": "Referrals", 298 | "es": "Referrals" 299 | }, 300 | "referral_list": { 301 | "doc_type": "CaseList", 302 | "label": {}, 303 | "show": false 304 | }, 305 | "task_list": { 306 | "doc_type": "CaseList", 307 | "label": {}, 308 | "show": false 309 | } 310 | } 311 | ], 312 | "multimedia_map": { 313 | "jr://file/commcare/image/data/image.png": { 314 | "doc_type": "HQMediaMapItem", 315 | "media_type": "CommCareImage", 316 | "multimedia_id": "f9de787a5cb113898c19a0f17d442654", 317 | "output_size": {} 318 | } 319 | }, 320 | "name": "Yes / No", 321 | "phone_model": null, 322 | "platform": "nokia/s40", 323 | "profile": { 324 | "features": { 325 | "sense": "false", 326 | "users": "true" 327 | }, 328 | "properties": { 329 | "ViewStyle": "v_chatterbox", 330 | "cc-autoup-freq": "freq-never", 331 | "cc-content-valid": "no", 332 | "cc-entry-mode": "cc-entry-quick", 333 | "cc-review-days": "7", 334 | "cc-send-procedure": "cc-send-http", 335 | "cc-send-unsent": "cc-su-auto", 336 | "cc-user-mode": "cc-u-normal", 337 | "extra_key_action": "audio", 338 | "log_prop_daily": "log_never", 339 | "log_prop_weekly": "log_short", 340 | "logenabled": "Enabled", 341 | "loose_media": "no", 342 | "password_format": "n", 343 | "purge-freq": "0", 344 | "restore-tolerance": "loose", 345 | "server-tether": "push-only", 346 | "unsent-number-limit": "5", 347 | "user_reg_server": "required" 348 | } 349 | }, 350 | "recipients": "", 351 | "short_description": null, 352 | "short_odk_url": null, 353 | "short_url": null, 354 | "show_user_registration": true, 355 | "success_message": { 356 | "en": "" 357 | }, 358 | "text_input": "roman", 359 | "translations": {}, 360 | "use_custom_suite": false, 361 | "user_registration": { 362 | "actions": { 363 | "case_preload": { 364 | "condition": { 365 | "answer": null, 366 | "doc_type": "FormActionCondition", 367 | "question": null, 368 | "type": "never" 369 | }, 370 | "doc_type": "PreloadAction", 371 | "preload": {} 372 | }, 373 | "close_case": { 374 | "condition": { 375 | "answer": null, 376 | "doc_type": "FormActionCondition", 377 | "question": null, 378 | "type": "never" 379 | }, 380 | "doc_type": "FormAction" 381 | }, 382 | "close_referral": { 383 | "condition": { 384 | "answer": null, 385 | "doc_type": "FormActionCondition", 386 | "question": null, 387 | "type": "never" 388 | }, 389 | "doc_type": "FormAction" 390 | }, 391 | "doc_type": "FormActions", 392 | "open_case": { 393 | "condition": { 394 | "answer": null, 395 | "doc_type": "FormActionCondition", 396 | "question": null, 397 | "type": "never" 398 | }, 399 | "doc_type": "OpenCaseAction", 400 | "external_id": null, 401 | "name_path": null 402 | }, 403 | "open_referral": { 404 | "condition": { 405 | "answer": null, 406 | "doc_type": "FormActionCondition", 407 | "question": null, 408 | "type": "never" 409 | }, 410 | "doc_type": "OpenReferralAction", 411 | "followup_date": null, 412 | "name_path": null 413 | }, 414 | "referral_preload": { 415 | "condition": { 416 | "answer": null, 417 | "doc_type": "FormActionCondition", 418 | "question": null, 419 | "type": "never" 420 | }, 421 | "doc_type": "PreloadAction", 422 | "preload": {} 423 | }, 424 | "subcases": [], 425 | "update_case": { 426 | "condition": { 427 | "answer": null, 428 | "doc_type": "FormActionCondition", 429 | "question": null, 430 | "type": "never" 431 | }, 432 | "doc_type": "UpdateCaseAction", 433 | "update": {} 434 | }, 435 | "update_referral": { 436 | "condition": { 437 | "answer": null, 438 | "doc_type": "FormActionCondition", 439 | "question": null, 440 | "type": "never" 441 | }, 442 | "doc_type": "UpdateReferralAction", 443 | "followup_date": null 444 | } 445 | }, 446 | "data_paths": {}, 447 | "doc_type": "UserRegistrationForm", 448 | "name": {}, 449 | "password_path": "password", 450 | "requires": "none", 451 | "show_count": false, 452 | "unique_id": "a96d3aa6f81aa79b6e840dbbf2a40e82097022d0", 453 | "username_path": "username", 454 | "version": null, 455 | "xmlns": null 456 | }, 457 | "user_type": null, 458 | "version": 5 459 | } 460 | -------------------------------------------------------------------------------- /test/tests.py: -------------------------------------------------------------------------------- 1 | from copy import deepcopy 2 | import unittest 3 | import jsonobject 4 | from jsonobject import * 5 | from jsonobject.exceptions import ( 6 | BadValueError, 7 | DeleteNotAllowed, 8 | WrappingAttributeError, 9 | ) 10 | 11 | 12 | class Features(JsonObject): 13 | """ 14 | Make sure doc string isn't treated as a property called __doc__! 15 | 16 | """ 17 | 18 | hair = StringProperty(choices=['brown', ('blond', 'Blond'), 'grey']) 19 | eyes = StringProperty() 20 | 21 | 22 | class FeatureMap(JsonObject): 23 | feature_map = DictProperty(Features) 24 | 25 | 26 | class Document(JsonObject): 27 | 28 | @StringProperty() 29 | def doc_type(self): 30 | return self.__class__.__name__ 31 | 32 | 33 | class Person(Document): 34 | 35 | first_name = StringProperty(required=True) 36 | last_name = StringProperty() 37 | features = ObjectProperty(Features) 38 | favorite_numbers = ListProperty(int) 39 | tags = ListProperty(str) 40 | 41 | @property 42 | def full_name(self): 43 | return '{self.first_name} {self.last_name}'.format(self=self) 44 | 45 | 46 | class FamilyMember(Person): 47 | base_doc = 'Person' 48 | brothers = ListProperty(lambda: FamilyMember) 49 | 50 | 51 | class JunkCD(JsonObject): 52 | c_property = IntegerProperty(name='c') 53 | 54 | @StringProperty(name='d') 55 | def d_property(self): 56 | return None 57 | 58 | 59 | class JunkAB(JsonObject): 60 | a_property = ListProperty(int, name='a') 61 | b_property = ObjectProperty(JunkCD, name='b') 62 | 63 | 64 | class ObjectWithDictProperty(JsonObject): 65 | mapping = DictProperty() 66 | 67 | 68 | class JsonObjectTestCase(unittest.TestCase): 69 | def _danny_data(self): 70 | return { 71 | 'first_name': 'Danny', 72 | 'last_name': 'Roberts', 73 | 'brothers': [{ 74 | 'first_name': 'Alex', 75 | 'last_name': 'Roberts', 76 | }, { 77 | 'first_name': 'Nicky', 78 | 'last_name': 'Roberts', 79 | }], 80 | 'features': {'hair': 'brown', 'eyes': 'brown'}, 81 | 'favorite_numbers': [1, 1, 2, 3, 5, 8], 82 | 'tags': ['happy', 'know it'], 83 | } 84 | 85 | def test_wrap(self): 86 | data = self._danny_data() 87 | danny = FamilyMember.wrap(data) 88 | self.assertEqual(danny.doc_type, 'FamilyMember') 89 | self.assertIsInstance(danny.doc_type, str) 90 | self.assertEqual(danny.first_name, 'Danny') 91 | self.assertEqual(danny.last_name, 'Roberts') 92 | self.assertEqual(danny.brothers[0].full_name, 'Alex Roberts') 93 | self.assertEqual(danny.brothers[1].full_name, 'Nicky Roberts') 94 | self.assertEqual(danny.features.hair, 'brown') 95 | self.assertEqual(danny.features.eyes, 'brown') 96 | self.assertEqual(danny.favorite_numbers, [1, 1, 2, 3, 5, 8]) 97 | self.assertEqual(danny.tags, ['happy', 'know it']) 98 | 99 | danny.brothers[1].first_name = 'Nick' 100 | self.assertEqual(danny.brothers[1].full_name, 'Nick Roberts') 101 | 102 | brothers_json = [{ 103 | 'first_name': 'Alex', 104 | 'last_name': 'Roberts', 105 | }, { 106 | 'first_name': 'Nicky', 107 | 'last_name': 'Roberts', 108 | }] 109 | with self.assertRaises(AssertionError): 110 | danny.brothers = brothers_json 111 | 112 | brothers = list(map(FamilyMember.wrap, brothers_json)) 113 | danny.brothers = brothers 114 | 115 | self.assertEqual(danny.brothers, brothers) 116 | self.assertTrue(isinstance(danny.brothers, JsonArray)) 117 | self.assertEqual(danny.to_json(), data) 118 | 119 | new_brothers = list(map(FamilyMember.wrap, brothers_json)) 120 | danny.brothers[2:3] = new_brothers 121 | self.assertEqual(len(danny.brothers), 4) 122 | 123 | danny.features.hair = 'blond' 124 | self.assertEqual(danny.features.hair, 'blond') 125 | with self.assertRaises(BadValueError): 126 | danny.features.hair = 'green' 127 | 128 | features = {'hair': 'grey', 'eyes': 'blue'} 129 | with self.assertRaises(AssertionError): 130 | danny.features = features 131 | 132 | features = Features.wrap(features) 133 | danny.features = features 134 | self.assertEqual(dict(danny.features), {'hair': 'grey', 'eyes': 'blue'}) 135 | 136 | numbers = [1, 2, 3, 4, 5] 137 | danny.favorite_numbers = numbers 138 | self.assertEqual(danny.favorite_numbers, numbers) 139 | self.assertEqual(danny.to_json()['favorite_numbers'], numbers) 140 | 141 | def test_bad_wrap(self): 142 | for error_type in (AttributeError, WrappingAttributeError): 143 | with self.assertRaises(error_type) as cm: 144 | Person.wrap({'full_name': 'Danny Roberts'}) 145 | self.assertEqual( 146 | str(cm.exception), 147 | "can't set attribute corresponding to " 148 | "'full_name' on a " 149 | "while wrapping {'full_name': 'Danny Roberts'}" 150 | ) 151 | 152 | def test_pickle(self): 153 | import pickle 154 | f1 = FamilyMember.wrap(self._danny_data()) 155 | f2 = FamilyMember.wrap(self._danny_data()) 156 | self.assertEqual(f2.to_json(), pickle.loads(pickle.dumps(f1)).to_json()) 157 | 158 | def test_default(self): 159 | p = FamilyMember(first_name='PJ') 160 | self.assertEqual(p.to_json(), { 161 | 'doc_type': 'FamilyMember', 162 | 'base_doc': 'Person', 163 | 'first_name': 'PJ', 164 | 'last_name': None, 165 | 'brothers': [], 166 | 'features': {'hair': None, 'eyes': None}, 167 | 'favorite_numbers': [], 168 | 'tags': [], 169 | }) 170 | 171 | def test_float(self): 172 | class Foo(JsonObject): 173 | f = FloatProperty() 174 | 175 | foo = Foo.wrap({'f': 1.0}) 176 | self.assertEqual(foo.f, 1.0) 177 | foo.f = 3 178 | self.assertEqual(foo.f, 3.0) 179 | self.assertIsInstance(foo.f, float) 180 | 181 | def test_name(self): 182 | class Wack(JsonObject): 183 | underscore_obj = StringProperty(name='_obj') 184 | w = Wack() 185 | self.assertEqual(w.to_json(), {'_obj': None}) 186 | w.underscore_obj = 'new_value' 187 | self.assertEqual(w.underscore_obj, 'new_value') 188 | self.assertEqual(w.to_json(), {'_obj': 'new_value'}) 189 | 190 | def test_mapping(self): 191 | 192 | json_end = { 193 | 'a': [1, 2, 3], 194 | 'b': { 195 | 'c': 1, 196 | 'd': 'string', 197 | } 198 | } 199 | 200 | p = JunkAB(deepcopy(json_end)) 201 | self.assertEqual(p.to_json(), json_end) 202 | p.a_property.append(4) 203 | self.assertEqual(p.to_json(), {'a': [1, 2, 3, 4], 'b': json_end['b']}) 204 | p.a_property = [] 205 | self.assertEqual(p.to_json(), {'a': [], 'b': json_end['b']}) 206 | p.a_property = None 207 | self.assertEqual(p.to_json(), {'a': None, 'b': json_end['b']}) 208 | p['a'] = [1, 2, 3] 209 | self.assertEqual(p.to_json(), json_end) 210 | self.assertEqual(list(p.keys()), list(p.to_json().keys())) 211 | 212 | def test_competing_names(self): 213 | with self.assertRaises(AssertionError): 214 | class Bad(JsonObject): 215 | a = IntegerProperty(name='ay') 216 | eh = StringProperty(name='ay') 217 | 218 | def test_init(self): 219 | from jsonobject.base import get_dynamic_properties 220 | self.assertEqual(JunkCD(c_property=1, d_property='yyy').to_json(), 221 | JunkCD({'c': 1, 'd': 'yyy'}).to_json()) 222 | x = JunkCD(non_existent_property=2) 223 | self.assertEqual(get_dynamic_properties(x), 224 | {'non_existent_property': 2}) 225 | 226 | 227 | ab = JunkAB(a_property=[1, 2, 3], 228 | b_property=JunkCD({'c': 1, 'd': 'string'})) 229 | self.assertEqual(ab.to_json(), { 230 | 'a': [1, 2, 3], 231 | 'b': { 232 | 'c': 1, 233 | 'd': 'string', 234 | } 235 | }) 236 | 237 | def test_choices(self): 238 | 239 | with self.assertRaises(BadValueError): 240 | Features(hair='blue') 241 | with self.assertRaises(BadValueError): 242 | Features.wrap({'hair': 'blue'}) 243 | with self.assertRaises(BadValueError): 244 | f = Features() 245 | f.hair = 'blue' 246 | 247 | def test_required(self): 248 | with self.assertRaises(BadValueError): 249 | Person() 250 | Person(first_name='') 251 | Person(first_name='James') 252 | 253 | def test_dynamic_properties(self): 254 | p = Features.wrap({'platypus': 'James'}) 255 | p.marmot = 'Sally' 256 | p._nope = 10 257 | self.assertEqual(p.to_json(), { 258 | 'platypus': 'James', 259 | 'marmot': 'Sally', 260 | 'eyes': None, 261 | 'hair': None, 262 | }) 263 | self.assertEqual(p.platypus, 'James') 264 | self.assertEqual(p.marmot, 'Sally') 265 | self.assertEqual(p._nope, 10) 266 | 267 | def test_delete_dynamic(self): 268 | def assertReallyThere(): 269 | self.assertEqual(p.a, 1) 270 | self.assertEqual(p['a'], 1) 271 | self.assertEqual(p.to_json()['a'], 1) 272 | 273 | def assertReallyDeleted(): 274 | with self.assertRaises(AttributeError): 275 | p.a 276 | with self.assertRaises(KeyError): 277 | p['a'] 278 | with self.assertRaises(KeyError): 279 | p.to_json()['a'] 280 | 281 | # delete attribute 282 | p = Features.wrap({'a': 1}) 283 | assertReallyThere() 284 | del p.a 285 | assertReallyDeleted() 286 | 287 | # delete dict item 288 | p = Features.wrap({'a': 1}) 289 | assertReallyThere() 290 | del p['a'] 291 | assertReallyDeleted() 292 | 293 | with self.assertRaises(DeleteNotAllowed): 294 | del p.hair 295 | 296 | with self.assertRaises(DeleteNotAllowed): 297 | del p['hair'] 298 | 299 | def test_dict_clear(self): 300 | class Foo(JsonObject): 301 | dct = DictProperty() 302 | dct = {'mydict': 'yay'} 303 | foo = Foo(dct=dct) 304 | json_dict = foo.dct 305 | self.assertEqual(json_dict, dct) 306 | json_dict.clear() 307 | self.assertEqual(json_dict, {}) 308 | self.assertEqual(json_dict._obj, {}) 309 | 310 | def test_dynamic_container(self): 311 | class Foo(JsonObject): 312 | pass 313 | foo = Foo(my_list=[]) 314 | self.assertIs(foo.my_list._obj, foo._obj['my_list']) 315 | foo = Foo(my_dict={}) 316 | self.assertIs(foo.my_dict._obj, foo._obj['my_dict']) 317 | foo = Foo(my_set=set()) 318 | self.assertIs(foo.my_set._obj, foo._obj['my_set']) 319 | 320 | def test_dynamic_dict_property(self): 321 | "dates copied from couchdbkit" 322 | import datetime 323 | 324 | class Foo(JsonObject): 325 | my_datetime = DateTimeProperty() 326 | my_dict = DictProperty() 327 | foo = Foo() 328 | full_datetime = datetime.datetime(2009, 5, 10, 21, 19, 21, 127380) 329 | normalized_datetime = datetime.datetime(2009, 5, 10, 21, 19, 21) 330 | 331 | foo.my_datetime = full_datetime 332 | self.assertEqual(foo.my_datetime, normalized_datetime) 333 | 334 | foo.my_dict['test'] = { 335 | 'a': full_datetime 336 | } 337 | self.assertEqual(foo.my_dict, { 338 | 'test': { 339 | 'a': normalized_datetime 340 | } 341 | }) 342 | 343 | def test_access_to_descriptor(self): 344 | p = StringProperty() 345 | class Foo(JsonObject): 346 | string = p 347 | 348 | self.assertIs(Foo.string, p) 349 | 350 | def test_recursive_validation(self): 351 | class Baz(JsonObject): 352 | string = StringProperty() 353 | 354 | class Bar(JsonObject): 355 | baz = ObjectProperty(Baz) 356 | 357 | class Foo(JsonObject): 358 | bar = ObjectProperty(Bar) 359 | 360 | with self.assertRaises(BadValueError): 361 | Foo.wrap({'bar': {'baz': {'string': 1}}}) 362 | with self.assertRaises(BadValueError): 363 | Foo.wrap({'bar': {'baz': []}}) 364 | with self.assertRaises(BadValueError): 365 | Foo.wrap({'bar': {'baz': 1}}) 366 | with self.assertRaises(BadValueError): 367 | Foo.wrap({'bar': []}) 368 | Foo.wrap({'bar': {'baz': {'string': ''}}}) 369 | 370 | def test_long(self): 371 | class Dummy(JsonObject): 372 | i = IntegerProperty() 373 | l = ListProperty(int) 374 | l2 = ListProperty(IntegerProperty) 375 | d = Dummy() 376 | longint = 2 ** 63 377 | self.assertIsInstance(longint, int) 378 | d.i = longint 379 | self.assertEqual(d.i, longint) 380 | d.l = [longint] 381 | self.assertEqual(d.l, [longint]) 382 | d.l2 = [longint] 383 | self.assertEqual(d.l2, [longint]) 384 | 385 | def test_string_list_property(self): 386 | 387 | class Foo(JsonObject): 388 | string_list = ListProperty(StringProperty) 389 | 390 | foo = Foo({'string_list': ['a', 'b', 'c']}) 391 | self.assertEqual(foo.string_list, ['a', 'b' , 'c']) 392 | 393 | def test_string_list_property_deepcopy(self): 394 | class Foo(JsonObject): 395 | strings = ListProperty(StringProperty) 396 | 397 | foo = Foo(strings=['a', 'b', 'c']) 398 | bar = Foo(strings=deepcopy(foo.strings)) 399 | self.assertEqual(bar.to_json(), {'strings': ['a', 'b', 'c']}) 400 | 401 | def test_typed_dict_of_dict(self): 402 | 403 | class City(JsonObject): 404 | _allow_dynamic_properties = False 405 | name = StringProperty() 406 | 407 | class Foo(JsonObject): 408 | _allow_dynamic_properties = False 409 | cities_by_state_by_country = DictProperty(DictProperty(City)) 410 | 411 | # testing an internal assumption; can remove if internals change 412 | self.assertEqual(Foo.cities_by_state_by_country.item_wrapper.item_wrapper.item_type, City) 413 | 414 | city = City.wrap({'name': 'Boston'}) 415 | with self.assertRaises(AttributeError): 416 | city.off_spec = 'bar' 417 | 418 | foo = Foo.wrap({'cities_by_state_by_country': {'USA': {'MA': {'name': 'Boston'}}}}) 419 | self.assertIsInstance(foo.cities_by_state_by_country['USA']['MA'], City) 420 | with self.assertRaises(AttributeError): 421 | foo.cities_by_state_by_country['USA']['MA'].off_spec = 'bar' 422 | 423 | def test_object_property_with_lambda(self): 424 | class Bar(JsonObject): 425 | string = StringProperty() 426 | 427 | class Foo(JsonObject): 428 | bar = ObjectProperty(lambda: Bar) 429 | 430 | foo = Foo() 431 | self.assertIsInstance(foo.bar, Bar) 432 | 433 | def test_module_has_jsonobjectmeta(self): 434 | # regression test 435 | self.assertIsInstance(jsonobject.JsonObjectMeta, type) 436 | 437 | 438 | class TestJsonArray(unittest.TestCase): 439 | 440 | def test_init_with_list(self): 441 | data = [1, 2, 3] 442 | value = JsonArray(data, wrapper=None, type_config=self.type_config) 443 | self.assertEqual(value, data) 444 | self.assertIs(value._obj, data) 445 | 446 | def test_init_with_none(self): 447 | value = JsonArray(None, wrapper=None, type_config=self.type_config) 448 | self.assertEqual(value, []) 449 | self.assertEqual(value._obj, []) 450 | 451 | def test_init_with_str(self): 452 | with self.assertRaises(BadValueError): 453 | JsonArray("abc", wrapper=None, type_config=self.type_config) 454 | 455 | def test_single_arg_constructor_with_list(self): 456 | data = [1, 2, 3] 457 | value = JsonArray(data) 458 | self.assertIs(value, data) 459 | 460 | def test_single_arg_constructor_with_generator(self): 461 | value = JsonArray(x for x in range(3)) 462 | self.assertEqual(value, [0, 1, 2]) 463 | 464 | def test_deep_copy(self): 465 | data = [1, 2, 3] 466 | array = JsonArray(data, wrapper=None, type_config=self.type_config) 467 | value = deepcopy(array) 468 | self.assertEqual(value, data) 469 | self.assertEqual(value._obj, data) 470 | 471 | @property 472 | def type_config(self): 473 | from jsonobject.base import TypeConfig 474 | return TypeConfig(JsonObject.Meta.properties) 475 | 476 | 477 | class PropertyInsideContainerTest(unittest.TestCase): 478 | 479 | def test_default_is_required(self): 480 | class Foo(JsonObject): 481 | container = ListProperty(int) 482 | 483 | with self.assertRaises(BadValueError): 484 | Foo(container=[None]) 485 | 486 | def test_property_class_required(self): 487 | class Foo(JsonObject): 488 | container = ListProperty(IntegerProperty) 489 | 490 | with self.assertRaises(BadValueError): 491 | Foo(container=[None]) 492 | 493 | def test_property(self): 494 | class Foo(JsonObject): 495 | container = ListProperty(IntegerProperty()) 496 | 497 | # assert does not error 498 | Foo(container=[None]) 499 | 500 | def test_required_property(self): 501 | 502 | class Foo(JsonObject): 503 | container = ListProperty(IntegerProperty(required=True)) 504 | 505 | with self.assertRaises(BadValueError): 506 | Foo(container=[None]) 507 | 508 | 509 | class LazyValidationTest(unittest.TestCase): 510 | 511 | def _validate_raises(self, foo): 512 | with self.assertRaises(BadValueError): 513 | foo.validate() 514 | 515 | with self.assertRaises(BadValueError): 516 | foo.to_json() 517 | 518 | def _validate_not_raises(self, foo): 519 | foo.validate() 520 | foo.to_json() 521 | 522 | def test_string(self): 523 | class Foo(JsonObject): 524 | _validate_required_lazily = True 525 | string = StringProperty(required=True) 526 | 527 | foo = Foo() 528 | self._validate_raises(foo) 529 | foo.string = 'hi' 530 | self._validate_not_raises(foo) 531 | 532 | def test_object(self): 533 | class Bar(JsonObject): 534 | _validate_required_lazily = True 535 | string = StringProperty(required=True) 536 | 537 | class Foo(JsonObject): 538 | _validate_required_lazily = True 539 | bar = ObjectProperty(Bar) 540 | 541 | foo = Foo() 542 | self._validate_raises(foo) 543 | foo.bar.string = 'hi' 544 | self._validate_not_raises(foo) 545 | 546 | def test_list(self): 547 | class Bar(JsonObject): 548 | _validate_required_lazily = True 549 | string = StringProperty(required=True) 550 | 551 | class Foo(JsonObject): 552 | _validate_required_lazily = True 553 | bars = ListProperty(Bar, required=True) 554 | 555 | foo = Foo() 556 | self._validate_raises(foo) 557 | foo.bars.append(Bar()) 558 | self._validate_raises(foo) 559 | foo.bars[0].string = 'hi' 560 | self._validate_not_raises(foo) 561 | 562 | def test_list_update_by_index(self): 563 | class Foo(JsonObject): 564 | bar = ListProperty() 565 | 566 | foo = Foo() 567 | foo.bar.append(1) 568 | self.assertEqual(foo.bar, foo.to_json()['bar']) 569 | foo.bar[0] = 2 570 | self.assertEqual(foo.bar, foo.to_json()['bar']) 571 | self.assertEqual(2, foo.to_json()['bar'][0]) 572 | 573 | def test_schema_list_update_by_index(self): 574 | class Bar(JsonObject): 575 | string = StringProperty() 576 | 577 | class Foo(JsonObject): 578 | bar = ListProperty(Bar) 579 | 580 | foo = Foo() 581 | foo.bar.append(Bar(string='hi')) 582 | self.assertEqual(foo.bar[0].string, foo.to_json()['bar'][0]['string']) 583 | self.assertIsInstance(foo.bar[0], Bar) 584 | self.assertIsInstance(foo.to_json()['bar'][0], dict) 585 | foo.bar[0] = Bar(string='lo') 586 | self.assertEqual(foo.bar[0].string, foo.to_json()['bar'][0]['string']) 587 | self.assertEqual('lo', foo.to_json()['bar'][0]['string']) 588 | self.assertIsInstance(foo.bar[0], Bar) 589 | self.assertIsInstance(foo.to_json()['bar'][0], dict) 590 | 591 | def test_list_plus_equals(self): 592 | class Foo(JsonObject): 593 | bar = ListProperty() 594 | 595 | foo = Foo() 596 | foo.bar = [1, 2, 3] 597 | self.assertEqual(foo.bar, [1, 2, 3]) 598 | self.assertEqual(foo.to_json()['bar'], [1, 2, 3]) 599 | foo.bar += [4] 600 | self.assertEqual(foo.bar, [1, 2, 3, 4]) 601 | self.assertEqual(foo.to_json()['bar'], [1, 2, 3, 4]) 602 | 603 | def test_dict(self): 604 | class Bar(JsonObject): 605 | _validate_required_lazily = True 606 | string = StringProperty(required=True) 607 | 608 | class Foo(JsonObject): 609 | _validate_required_lazily = True 610 | bar_map = DictProperty(Bar, required=True) 611 | 612 | foo = Foo() 613 | self._validate_raises(foo) 614 | foo.bar_map['hi'] = Bar() 615 | self._validate_raises(foo) 616 | foo.bar_map['hi'].string = 'hi' 617 | self._validate_not_raises(foo) 618 | 619 | 620 | class PropertyTestCase(unittest.TestCase): 621 | def test_date(self): 622 | import datetime 623 | p = DateProperty() 624 | for string, date in [('1988-07-07', datetime.date(1988, 7, 7))]: 625 | self.assertEqual(p.wrap(string), date) 626 | self.assertEqual(p.unwrap(date), (date, string)) 627 | with self.assertRaises(BadValueError): 628 | p.wrap('1234-05-90') 629 | with self.assertRaises(BadValueError): 630 | p.wrap('2000-01-01T00:00:00Z') 631 | 632 | def test_datetime(self): 633 | import datetime 634 | p = DateTimeProperty() 635 | for string, dt in [('2011-01-18T12:38:09Z', datetime.datetime(2011, 1, 18, 12, 38, 9))]: 636 | self.assertEqual(p.wrap(string), dt) 637 | self.assertEqual(p.unwrap(dt), (dt, string)) 638 | with self.assertRaises(BadValueError): 639 | p.wrap('1234-05-90T00:00:00Z') 640 | with self.assertRaises(BadValueError): 641 | p.wrap('1988-07-07') 642 | 643 | def test_time(self): 644 | import datetime 645 | p = TimeProperty() 646 | for string, time in [('12:38:09', datetime.time(12, 38, 9))]: 647 | self.assertEqual(p.wrap(string), time) 648 | self.assertEqual(p.unwrap(time), (time, string)) 649 | with self.assertRaises(BadValueError): 650 | p.wrap('25:00:00') 651 | with self.assertRaises(BadValueError): 652 | p.wrap('2011-01-18T12:38:09Z') 653 | with self.assertRaises(BadValueError): 654 | p.wrap('1988-07-07') 655 | 656 | def test_decimal(self): 657 | import decimal 658 | 659 | class Foo(JsonObject): 660 | decimal = DecimalProperty() 661 | 662 | foo = Foo(decimal=decimal.Decimal('2.0')) 663 | self.assertEqual(foo.decimal, decimal.Decimal('2.0')) 664 | self.assertEqual(foo.to_json()['decimal'], '2.0') 665 | 666 | foo.decimal = 3 667 | self.assertEqual(foo.decimal, decimal.Decimal(3)) 668 | self.assertEqual(foo.to_json()['decimal'], '3') 669 | 670 | foo.decimal = 4 671 | self.assertEqual(foo.decimal, decimal.Decimal(4)) 672 | self.assertEqual(foo.to_json()['decimal'], '4') 673 | 674 | foo.decimal = 5.25 675 | self.assertEqual(foo.decimal, decimal.Decimal('5.25')) 676 | self.assertEqual(foo.to_json()['decimal'], '5.25') 677 | 678 | def test_dict(self): 679 | mapping = {'one': 1, 'two': 2} 680 | o = ObjectWithDictProperty(mapping=mapping) 681 | self.assertEqual(o.mapping, mapping) 682 | self.assertEqual(o.to_json()['mapping'], mapping) 683 | 684 | def test_dict_update(self): 685 | mapping = {'one': 1, 'two': 2} 686 | o = ObjectWithDictProperty(mapping=mapping) 687 | o.mapping.update({'three': 3}, four=4) 688 | self.assertEqual(o.mapping, {'one': 1, 'two': 2, 'three': 3, 'four': 4}) 689 | self.assertEqual(o.to_json()['mapping'], {'one': 1, 'two': 2, 'three': 3, 'four': 4}) 690 | 691 | def test_dict_pop(self): 692 | mapping = {'one': 1, 'two': 2} 693 | o = ObjectWithDictProperty(mapping=mapping) 694 | val = o.mapping.pop('two') 695 | self.assertEqual(val, 2) 696 | self.assertEqual(o.mapping, {'one': 1}) 697 | self.assertEqual(o.to_json()['mapping'], {'one': 1}) 698 | 699 | def test_dict_setdefault(self): 700 | mapping = {'one': 1, 'two': 2} 701 | o = ObjectWithDictProperty(mapping=mapping) 702 | val = o.mapping.setdefault('three', 3) 703 | self.assertEqual(val, 3) 704 | self.assertEqual(o.mapping, {'one': 1, 'two': 2, 'three': 3}) 705 | self.assertEqual(o.to_json()['mapping'], {'one': 1, 'two': 2, 'three': 3}) 706 | 707 | def test_dict_popitem(self): 708 | mapping = {'one': 1, 'two': 2} 709 | o = ObjectWithDictProperty(mapping=mapping) 710 | old_dict = o.mapping.copy() 711 | val = o.mapping.popitem() 712 | self.assertTrue(val[0] in old_dict) 713 | self.assertTrue(val[1] == old_dict[val[0]]) 714 | self.assertTrue(val[0] not in o.mapping) 715 | self.assertTrue(val[0] not in o.to_json()['mapping']) 716 | 717 | def test_typed_dict(self): 718 | features = FeatureMap({'feature_map': {'lala': {}, 'foo': None}}) 719 | self.assertEqual(features.to_json(), { 720 | 'feature_map': { 721 | 'lala': {'hair': None, 'eyes': None}, 722 | 'foo': {'hair': None, 'eyes': None}, 723 | }, 724 | }) 725 | with self.assertRaises(BadValueError): 726 | FeatureMap({'feature_map': {'lala': 10}}) 727 | 728 | features.feature_map.update({'hoho': Features(eyes='brown')}) 729 | self.assertEqual(features.to_json(), { 730 | 'feature_map': { 731 | 'lala': {'hair': None, 'eyes': None}, 732 | 'foo': {'hair': None, 'eyes': None}, 733 | 'hoho': {'hair': None, 'eyes': 'brown'}, 734 | }, 735 | }) 736 | 737 | def test_allow_dynamic(self): 738 | class Foo(JsonObject): 739 | _allow_dynamic_properties = False 740 | 741 | foo = Foo() 742 | with self.assertRaises(AttributeError): 743 | foo.blah = 3 744 | foo._blah = 5 745 | self.assertEqual(dict(foo), {}) 746 | self.assertEqual(foo.to_json(), {}) 747 | self.assertEqual(foo._blah, 5) 748 | 749 | def test_exclude_if_none(self): 750 | class Foo(JsonObject): 751 | _id = StringProperty(exclude_if_none=True) 752 | name = StringProperty() 753 | 754 | foo = Foo() 755 | self.assertEqual(foo.to_json(), {'name': None}) 756 | self.assertEqual(foo._id, None) 757 | foo = Foo(_id='xxx') 758 | self.assertEqual(dict(foo), {'name': None, '_id': 'xxx'}) 759 | foo._id = None 760 | self.assertEqual(foo.to_json(), {'name': None}) 761 | self.assertEqual(foo._id, None) 762 | 763 | def test_descriptor(self): 764 | class Desc(object): 765 | def __get__(self, instance, owner): 766 | if not instance: 767 | return self 768 | return instance._string 769 | 770 | def __set__(self, instance, value): 771 | instance._string = value 772 | 773 | class Foo(JsonObject): 774 | _string = StringProperty() 775 | string = Desc() 776 | 777 | foo = Foo(_string='hello') 778 | self.assertEqual(foo._string, 'hello') 779 | self.assertEqual(foo.string, 'hello') 780 | foo.string = 'goodbye' 781 | self.assertEqual(foo.string, 'goodbye') 782 | self.assertEqual(foo._string, 'goodbye') 783 | self.assertEqual(foo.to_json(), { 784 | '_string': 'goodbye', 785 | }) 786 | def test_key_error(self): 787 | class Foo(JsonObject): 788 | pass 789 | foo = Foo() 790 | 791 | with self.assertRaises(KeyError): 792 | foo['hello'] 793 | 794 | def test_attribute_error(self): 795 | class Foo(JsonObject): 796 | pass 797 | foo = Foo() 798 | 799 | with self.assertRaises(AttributeError): 800 | foo.hello 801 | 802 | 803 | class DynamicConversionTestCase(unittest.TestCase): 804 | import datetime 805 | 806 | class Foo(JsonObject): 807 | pass 808 | string_date = '2012-01-01' 809 | date_date = datetime.date(2012, 1, 1) 810 | 811 | def _test_dynamic_conversion(self, foo): 812 | string_date = self.string_date 813 | date_date = self.date_date 814 | self.assertEqual(foo.to_json()['my_date'], string_date) 815 | self.assertEqual(foo.my_date, date_date) 816 | 817 | self.assertEqual(foo.to_json()['my_list'], [1, 2, [string_date]]) 818 | self.assertEqual(foo.my_list, [1, 2, [date_date]]) 819 | 820 | self.assertEqual(foo.to_json()['my_dict'], {'a': {'b': string_date}}) 821 | self.assertEqual(foo.my_dict, {'a': {'b': date_date}}) 822 | 823 | def test_wrapping(self): 824 | foo = self.Foo({ 825 | 'my_date': self.string_date, 826 | 'my_list': [1, 2, [self.string_date]], 827 | 'my_dict': {'a': {'b': self.string_date}}, 828 | }) 829 | self._test_dynamic_conversion(foo) 830 | 831 | def test_kwargs(self): 832 | 833 | foo = self.Foo( 834 | my_date=self.date_date, 835 | my_list=[1, 2, [self.date_date]], 836 | my_dict={'a': {'b': self.date_date}}, 837 | ) 838 | self._test_dynamic_conversion(foo) 839 | 840 | def test_assignment(self): 841 | foo = self.Foo() 842 | foo.my_date = self.date_date 843 | foo.my_list = [1, 2, [self.date_date]] 844 | foo.my_dict = {'a': {'b': self.date_date}} 845 | self._test_dynamic_conversion(foo) 846 | 847 | def test_manipulation(self): 848 | foo = self.Foo() 849 | foo.my_date = self.date_date 850 | foo.my_list = [1, 2, []] 851 | foo.my_list[2].append(self.date_date) 852 | foo.my_dict = {'a': {}} 853 | foo.my_dict['a']['b'] = self.date_date 854 | self._test_dynamic_conversion(foo) 855 | 856 | def test_properties(self): 857 | class Foo(JsonObject): 858 | string = StringProperty() 859 | date = DateProperty() 860 | dict = DictProperty() 861 | 862 | self.assertEqual(Foo.properties(), Foo().properties()) 863 | self.assertEqual(Foo.properties(), { 864 | 'string': Foo.string, 865 | 'date': Foo.date, 866 | 'dict': Foo.dict, 867 | }) 868 | 869 | def test_change_type(self): 870 | class Foo(JsonObject): 871 | my_list = ('bar',) 872 | 873 | foo = Foo() 874 | foo.my_list = list(foo.my_list) 875 | self.assertEqual(foo.to_json(), {'my_list': ['bar']}) 876 | 877 | 878 | class User(JsonObject): 879 | username = StringProperty() 880 | name = StringProperty() 881 | active = BooleanProperty(default=False, required=True) 882 | date_joined = DateTimeProperty() 883 | tags = ListProperty(str) 884 | 885 | 886 | class TestExactDateTime(unittest.TestCase): 887 | def test_exact(self): 888 | class DateObj(JsonObject): 889 | date = DateTimeProperty(exact=True) 890 | import datetime 891 | date = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None) 892 | date_obj = DateObj(date=date) 893 | self.assertEqual(date_obj.date, date) 894 | self.assertEqual(date_obj.to_json()['date'], date.isoformat() + 'Z') 895 | self.assertEqual(len(date_obj.to_json()['date']), 27) 896 | 897 | date = date.replace(microsecond=0) 898 | date_obj = DateObj(date=date) 899 | self.assertEqual(date_obj.date, date) 900 | self.assertEqual(date_obj.to_json()['date'], 901 | date.isoformat() + '.000000Z') 902 | self.assertEqual(len(date_obj.to_json()['date']), 27) 903 | 904 | 905 | class IntegerTest(unittest.TestCase): 906 | @classmethod 907 | def setUpClass(cls): 908 | class Foo(JsonObject): 909 | my_int = IntegerProperty(default=30) 910 | cls.Foo = Foo 911 | 912 | def test_default(self): 913 | self.assertEqual(self.Foo().my_int, 30) 914 | 915 | def test_init_zero(self): 916 | self.assertEqual(self.Foo(my_int=0).my_int, 0) 917 | self.assertEqual(self.Foo.wrap({'my_int': 0}).my_int, 0) 918 | 919 | def test_set_zero(self): 920 | foo = self.Foo() 921 | foo.my_int = 0 922 | self.assertEqual(foo.my_int, 0) 923 | self.assertEqual(foo.to_json()['my_int'], 0) 924 | 925 | 926 | class TestReadmeExamples(unittest.TestCase): 927 | def test(self): 928 | import datetime 929 | user1 = User( 930 | name='John Doe', 931 | username='jdoe', 932 | date_joined=datetime.datetime(2013, 8, 5, 2, 46, 58), 933 | tags=['generic', 'anonymous'] 934 | ) 935 | self.assertEqual( 936 | user1.to_json(), { 937 | 'name': 'John Doe', 938 | 'username': 'jdoe', 939 | 'active': False, 940 | 'date_joined': '2013-08-05T02:46:58Z', 941 | 'tags': ['generic', 'anonymous'] 942 | } 943 | ) 944 | --------------------------------------------------------------------------------