├── .gitignore ├── LICENSE ├── README.md ├── docs ├── Makefile ├── advanced.rst ├── compat.rst ├── conf.py ├── index.rst ├── install.rst ├── make.bat ├── quickstart.rst └── ref │ ├── batch.rst │ ├── exceptions.rst │ ├── field.rst │ ├── index.rst │ ├── metadata.rst │ ├── query.rst │ └── table.rst ├── kiwi ├── __init__.py ├── batch.py ├── dynamo.py ├── exceptions.py ├── expression.py ├── field.py ├── mapper.py ├── metadata.py ├── query.py └── table.py ├── requirements.txt ├── setup.py ├── tests ├── __init__.py ├── conftest.py ├── test_field.py ├── test_mapper.py ├── test_metadata.py ├── test_query.py └── test_table.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | *pyc 3 | __pycache__ 4 | .cache 5 | *.swp 6 | .coverage 7 | .tox 8 | *.egg-info 9 | docs/_build 10 | dist 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/papaya-mobile/kiwi/e3d334514ed51734383657c963b9046349da85cc/LICENSE -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # KIWI - DynamoDB ORM for Python 2 | 3 | Kiwi is an AWS DynamoDB ORM for Python. Kiwi depends on boto and supports Python2.7, Python3.4 at least. 4 | 5 | ### Installation 6 | 7 | Install Kiwi from source: 8 | 9 | $ git clone https://github.com/papaya-mobile/kiwi.git 10 | $ cd kiwi 11 | $ python setup.py sdist 12 | $ pip install dist/kiwi-x.x.x.tar.gz 13 | 14 | ### Basic usage 15 | 16 | The ```table```, ```Field``` of Kiwi will be used to define a DynamoDB Model: 17 | 18 | from boto.dynamodb2 import connect_to_region 19 | from boto.dynamodb2.types import NUMBER 20 | from kiwi import metadata 21 | from kiwi import Table 22 | from kiwi import Field 23 | from kiwi import HashKeyField, RangeKeyField 24 | 25 | connection = connect_to_region("us-east-1") 26 | metadata.configure(connection=connection) 27 | 28 | class User(Table): 29 | id = HashKeyField(data_type=NUMBER) 30 | name = Field() 31 | 32 | class UserTask(Table): 33 | user_id = HashKeyField(data_type=NUMBER) 34 | task_id = RangeKeyField() 35 | name = Field() 36 | time = Field() 37 | done = Field(data_type=NUMBER, default=0) 38 | 39 | After define a model, it's very easy to insert items: 40 | 41 | >>> User(id=1, name='Aaron').save() 42 | >>> UserTask(user_id=1, task_id='first', name='test').save() 43 | >>> UserTask(user_id=1, task_id='second', name='test').save() 44 | >>> UserTask(user_id=1, task_id='fifth', name='test').save() 45 | 46 | You may also get and query items easily: 47 | 48 | >>> User.get(1) 49 | 50 | >>> UserTask.get(1, 'second') 51 | 52 | 53 | >>> UserTask.query().filter(UserTask.user_id==1, UserTask.task_id.beginswith_('f')).all() 54 | [, ] 55 | 56 | 57 | ### Documentation 58 | 59 | You can read the docs online: http://papaya-kiwi.readthedocs.org/en/latest/ 60 | 61 | You can also generate documentation by yourself. 62 | The docs are created by sphinx, which can be installed via pip. 63 | 64 | pip install sphinx 65 | cd kiwi/docs 66 | make html 67 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest coverage gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " applehelp to make an Apple Help Book" 34 | @echo " devhelp to make HTML files and a Devhelp project" 35 | @echo " epub to make an epub" 36 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 37 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 38 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 39 | @echo " text to make text files" 40 | @echo " man to make manual pages" 41 | @echo " texinfo to make Texinfo files" 42 | @echo " info to make Texinfo files and run them through makeinfo" 43 | @echo " gettext to make PO message catalogs" 44 | @echo " changes to make an overview of all changed/added/deprecated items" 45 | @echo " xml to make Docutils-native XML files" 46 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 47 | @echo " linkcheck to check all external links for integrity" 48 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 49 | @echo " coverage to run coverage check of the documentation (if enabled)" 50 | 51 | clean: 52 | rm -rf $(BUILDDIR)/* 53 | 54 | html: 55 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 56 | @echo 57 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 58 | 59 | dirhtml: 60 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 61 | @echo 62 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 63 | 64 | singlehtml: 65 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 66 | @echo 67 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 68 | 69 | pickle: 70 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 71 | @echo 72 | @echo "Build finished; now you can process the pickle files." 73 | 74 | json: 75 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 76 | @echo 77 | @echo "Build finished; now you can process the JSON files." 78 | 79 | htmlhelp: 80 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 81 | @echo 82 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 83 | ".hhp project file in $(BUILDDIR)/htmlhelp." 84 | 85 | qthelp: 86 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 87 | @echo 88 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 89 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 90 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/Kiwi.qhcp" 91 | @echo "To view the help file:" 92 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/Kiwi.qhc" 93 | 94 | applehelp: 95 | $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp 96 | @echo 97 | @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." 98 | @echo "N.B. You won't be able to view it unless you put it in" \ 99 | "~/Library/Documentation/Help or install it in your application" \ 100 | "bundle." 101 | 102 | devhelp: 103 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 104 | @echo 105 | @echo "Build finished." 106 | @echo "To view the help file:" 107 | @echo "# mkdir -p $$HOME/.local/share/devhelp/Kiwi" 108 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/Kiwi" 109 | @echo "# devhelp" 110 | 111 | epub: 112 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 113 | @echo 114 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 115 | 116 | latex: 117 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 118 | @echo 119 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 120 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 121 | "(use \`make latexpdf' here to do that automatically)." 122 | 123 | latexpdf: 124 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 125 | @echo "Running LaTeX files through pdflatex..." 126 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 127 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 128 | 129 | latexpdfja: 130 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 131 | @echo "Running LaTeX files through platex and dvipdfmx..." 132 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 133 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 134 | 135 | text: 136 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 137 | @echo 138 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 139 | 140 | man: 141 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 142 | @echo 143 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 144 | 145 | texinfo: 146 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 147 | @echo 148 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 149 | @echo "Run \`make' in that directory to run these through makeinfo" \ 150 | "(use \`make info' here to do that automatically)." 151 | 152 | info: 153 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 154 | @echo "Running Texinfo files through makeinfo..." 155 | make -C $(BUILDDIR)/texinfo info 156 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 157 | 158 | gettext: 159 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 160 | @echo 161 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 162 | 163 | changes: 164 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 165 | @echo 166 | @echo "The overview file is in $(BUILDDIR)/changes." 167 | 168 | linkcheck: 169 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 170 | @echo 171 | @echo "Link check complete; look for any errors in the above output " \ 172 | "or in $(BUILDDIR)/linkcheck/output.txt." 173 | 174 | doctest: 175 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 176 | @echo "Testing of doctests in the sources finished, look at the " \ 177 | "results in $(BUILDDIR)/doctest/output.txt." 178 | 179 | coverage: 180 | $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage 181 | @echo "Testing of coverage in the sources finished, look at the " \ 182 | "results in $(BUILDDIR)/coverage/python.txt." 183 | 184 | xml: 185 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 186 | @echo 187 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 188 | 189 | pseudoxml: 190 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 191 | @echo 192 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 193 | -------------------------------------------------------------------------------- /docs/advanced.rst: -------------------------------------------------------------------------------- 1 | 2 | Advanced Usage 3 | ============== 4 | 5 | .. _adv-multi-metadata: 6 | 7 | Multiple Metadata 8 | ----------------- 9 | 10 | Usually, the default metadata is enough. In some case, you may need more 11 | than one metadata to involve different dynamodb endpoint. You can do it 12 | as following:: 13 | 14 | from kiwi import Metadata 15 | 16 | another_metadata = Metadata(connection=connect_to_region("us-east-1")) 17 | 18 | class User(Table): 19 | __metadata__ = another_metadata 20 | ... 21 | 22 | inherit table 23 | ------------- 24 | 25 | You can also inherit tables, for example:: 26 | 27 | class UserInfoBase(object): 28 | __throughput__ = {'read': 2, 'write': 1} 29 | 30 | user_id = HashKeyField(data_type=NUMBER) 31 | 32 | 33 | class UserTask(Table, UserInfoBase): 34 | task_id = RangeKeyField() 35 | 36 | class UserName(Table, UserInfoBase): 37 | name = Field() 38 | 39 | class UserNameExtend(UserName): 40 | ext = Field() 41 | 42 | 43 | custom table api 44 | ---------------- 45 | 46 | At some time you may want to custom table api, just declare a customised 47 | ``Table``:: 48 | 49 | from kiwi import TableBase 50 | from kiwi import TableMeta 51 | 52 | class MyTableBase(TableBase): 53 | # custom the api 54 | ... 55 | 56 | class MyTable(with_metaclass(TableMeta, TableBase)): 57 | pass 58 | 59 | class User(MyTable): 60 | ... 61 | 62 | 63 | custom Dynamizer 64 | ---------------- 65 | ``Dynamizer`` is a transfer between python type and dynamodb API types. 66 | The default dynamizer is `boto.dynamodb.types.Dynamizer`_. However, at some 67 | times you may want to use a customized dynamizer. It's simple:: 68 | 69 | from boto.dynamodb2.types import Dynamizer 70 | from kiwi import metadata 71 | 72 | class MyDynamizer(Dynamizer): 73 | ... 74 | 75 | metadata.configure(dynamizer=MyDynamizer) 76 | 77 | .. Caution:: 78 | ``boto.dynamodb2`` **dose not** provide public interface to custom 79 | dynamizer for tables. Kiwi provides this by accessing the priavte 80 | property ``boto.dynamodb2.table.Table._dynamizer``. So use this feature 81 | at your own risk! 82 | 83 | .. _boto.dynamodb.types.Dynamizer: https://boto.readthedocs.org/en/latest/ref/dynamodb.html#boto.dynamodb.types.Dynamizer 84 | -------------------------------------------------------------------------------- /docs/compat.rst: -------------------------------------------------------------------------------- 1 | 2 | Compatibility 3 | ============= 4 | 5 | Kiwi supports python2.7, python3.4 and pypy. 6 | 7 | Kiwi use `future`_ package to deal with the py2 and py3 compat issue. 8 | `future`_ is a compatibility layer between Python 2 and Python 3. The 9 | code follows Python3 style and behaves as the same as python3 even in 10 | python2. 11 | 12 | .. _future: http://python-future.torg 13 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Kiwi documentation build configuration file, created by 4 | # sphinx-quickstart on Mon Jul 6 11:37:38 2015. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | import sys 16 | import os 17 | import shlex 18 | 19 | sys.path.insert(0, os.path.abspath("..")) 20 | 21 | # If extensions (or modules to document with autodoc) are in another directory, 22 | # add these directories to sys.path here. If the directory is relative to the 23 | # documentation root, use os.path.abspath to make it absolute, like shown here. 24 | #sys.path.insert(0, os.path.abspath('.')) 25 | 26 | # -- General configuration ------------------------------------------------ 27 | 28 | # If your documentation needs a minimal Sphinx version, state it here. 29 | #needs_sphinx = '1.0' 30 | 31 | # Add any Sphinx extension module names here, as strings. They can be 32 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 33 | # ones. 34 | extensions = [ 35 | 'sphinx.ext.autodoc', 36 | 'sphinx.ext.intersphinx', 37 | 'sphinx.ext.coverage', 38 | ] 39 | 40 | # Add any paths that contain templates here, relative to this directory. 41 | templates_path = ['_templates'] 42 | 43 | # The suffix(es) of source filenames. 44 | # You can specify multiple suffix as a list of string: 45 | # source_suffix = ['.rst', '.md'] 46 | source_suffix = '.rst' 47 | 48 | # The encoding of source files. 49 | #source_encoding = 'utf-8-sig' 50 | 51 | # The master toctree document. 52 | master_doc = 'index' 53 | 54 | # General information about the project. 55 | project = u'Kiwi' 56 | copyright = u'2015, Papaya Backend Team' 57 | author = u'Papaya Backend Team' 58 | 59 | # The version info for the project you're documenting, acts as replacement for 60 | # |version| and |release|, also used in various other places throughout the 61 | # built documents. 62 | # 63 | # The short X.Y version. 64 | version = '0.0.1' 65 | # The full version, including alpha/beta/rc tags. 66 | release = '0.0.1' 67 | 68 | # The language for content autogenerated by Sphinx. Refer to documentation 69 | # for a list of supported languages. 70 | # 71 | # This is also used if you do content translation via gettext catalogs. 72 | # Usually you set "language" from the command line for these cases. 73 | language = None 74 | 75 | # There are two options for replacing |today|: either, you set today to some 76 | # non-false value, then it is used: 77 | #today = '' 78 | # Else, today_fmt is used as the format for a strftime call. 79 | #today_fmt = '%B %d, %Y' 80 | 81 | # List of patterns, relative to source directory, that match files and 82 | # directories to ignore when looking for source files. 83 | exclude_patterns = ['_build'] 84 | 85 | # The reST default role (used for this markup: `text`) to use for all 86 | # documents. 87 | #default_role = None 88 | 89 | # If true, '()' will be appended to :func: etc. cross-reference text. 90 | #add_function_parentheses = True 91 | 92 | # If true, the current module name will be prepended to all description 93 | # unit titles (such as .. function::). 94 | #add_module_names = True 95 | 96 | # If true, sectionauthor and moduleauthor directives will be shown in the 97 | # output. They are ignored by default. 98 | #show_authors = False 99 | 100 | # The name of the Pygments (syntax highlighting) style to use. 101 | pygments_style = 'sphinx' 102 | 103 | # A list of ignored prefixes for module index sorting. 104 | #modindex_common_prefix = [] 105 | 106 | # If true, keep warnings as "system message" paragraphs in the built documents. 107 | #keep_warnings = False 108 | 109 | # If true, `todo` and `todoList` produce output, else they produce nothing. 110 | todo_include_todos = False 111 | 112 | 113 | # -- Options for HTML output ---------------------------------------------- 114 | 115 | # The theme to use for HTML and HTML Help pages. See the documentation for 116 | # a list of builtin themes. 117 | html_theme = 'alabaster' 118 | 119 | # Theme options are theme-specific and customize the look and feel of a theme 120 | # further. For a list of options available for each theme, see the 121 | # documentation. 122 | #html_theme_options = {} 123 | 124 | # Add any paths that contain custom themes here, relative to this directory. 125 | #html_theme_path = [] 126 | 127 | # The name for this set of Sphinx documents. If None, it defaults to 128 | # " v documentation". 129 | #html_title = None 130 | 131 | # A shorter title for the navigation bar. Default is the same as html_title. 132 | #html_short_title = None 133 | 134 | # The name of an image file (relative to this directory) to place at the top 135 | # of the sidebar. 136 | #html_logo = None 137 | 138 | # The name of an image file (within the static path) to use as favicon of the 139 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 140 | # pixels large. 141 | #html_favicon = None 142 | 143 | # Add any paths that contain custom static files (such as style sheets) here, 144 | # relative to this directory. They are copied after the builtin static files, 145 | # so a file named "default.css" will overwrite the builtin "default.css". 146 | html_static_path = ['_static'] 147 | 148 | # Add any extra paths that contain custom files (such as robots.txt or 149 | # .htaccess) here, relative to this directory. These files are copied 150 | # directly to the root of the documentation. 151 | #html_extra_path = [] 152 | 153 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 154 | # using the given strftime format. 155 | #html_last_updated_fmt = '%b %d, %Y' 156 | 157 | # If true, SmartyPants will be used to convert quotes and dashes to 158 | # typographically correct entities. 159 | #html_use_smartypants = True 160 | 161 | # Custom sidebar templates, maps document names to template names. 162 | #html_sidebars = {} 163 | 164 | # Additional templates that should be rendered to pages, maps page names to 165 | # template names. 166 | #html_additional_pages = {} 167 | 168 | # If false, no module index is generated. 169 | #html_domain_indices = True 170 | 171 | # If false, no index is generated. 172 | #html_use_index = True 173 | 174 | # If true, the index is split into individual pages for each letter. 175 | #html_split_index = False 176 | 177 | # If true, links to the reST sources are added to the pages. 178 | #html_show_sourcelink = True 179 | 180 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 181 | #html_show_sphinx = True 182 | 183 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 184 | #html_show_copyright = True 185 | 186 | # If true, an OpenSearch description file will be output, and all pages will 187 | # contain a tag referring to it. The value of this option must be the 188 | # base URL from which the finished HTML is served. 189 | #html_use_opensearch = '' 190 | 191 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 192 | #html_file_suffix = None 193 | 194 | # Language to be used for generating the HTML full-text search index. 195 | # Sphinx supports the following languages: 196 | # 'da', 'de', 'en', 'es', 'fi', 'fr', 'hu', 'it', 'ja' 197 | # 'nl', 'no', 'pt', 'ro', 'ru', 'sv', 'tr' 198 | #html_search_language = 'en' 199 | 200 | # A dictionary with options for the search language support, empty by default. 201 | # Now only 'ja' uses this config value 202 | #html_search_options = {'type': 'default'} 203 | 204 | # The name of a javascript file (relative to the configuration directory) that 205 | # implements a search results scorer. If empty, the default will be used. 206 | #html_search_scorer = 'scorer.js' 207 | 208 | # Output file base name for HTML help builder. 209 | htmlhelp_basename = 'Kiwidoc' 210 | 211 | # -- Options for LaTeX output --------------------------------------------- 212 | 213 | latex_elements = { 214 | # The paper size ('letterpaper' or 'a4paper'). 215 | #'papersize': 'letterpaper', 216 | 217 | # The font size ('10pt', '11pt' or '12pt'). 218 | #'pointsize': '10pt', 219 | 220 | # Additional stuff for the LaTeX preamble. 221 | #'preamble': '', 222 | 223 | # Latex figure (float) alignment 224 | #'figure_align': 'htbp', 225 | } 226 | 227 | # Grouping the document tree into LaTeX files. List of tuples 228 | # (source start file, target name, title, 229 | # author, documentclass [howto, manual, or own class]). 230 | latex_documents = [ 231 | (master_doc, 'Kiwi.tex', u'Kiwi Documentation', 232 | u'Papaya Backend Team', 'manual'), 233 | ] 234 | 235 | # The name of an image file (relative to this directory) to place at the top of 236 | # the title page. 237 | #latex_logo = None 238 | 239 | # For "manual" documents, if this is true, then toplevel headings are parts, 240 | # not chapters. 241 | #latex_use_parts = False 242 | 243 | # If true, show page references after internal links. 244 | #latex_show_pagerefs = False 245 | 246 | # If true, show URL addresses after external links. 247 | #latex_show_urls = False 248 | 249 | # Documents to append as an appendix to all manuals. 250 | #latex_appendices = [] 251 | 252 | # If false, no module index is generated. 253 | #latex_domain_indices = True 254 | 255 | 256 | # -- Options for manual page output --------------------------------------- 257 | 258 | # One entry per manual page. List of tuples 259 | # (source start file, name, description, authors, manual section). 260 | man_pages = [ 261 | (master_doc, 'kiwi', u'Kiwi Documentation', 262 | [author], 1) 263 | ] 264 | 265 | # If true, show URL addresses after external links. 266 | #man_show_urls = False 267 | 268 | 269 | # -- Options for Texinfo output ------------------------------------------- 270 | 271 | # Grouping the document tree into Texinfo files. List of tuples 272 | # (source start file, target name, title, author, 273 | # dir menu entry, description, category) 274 | texinfo_documents = [ 275 | (master_doc, 'Kiwi', u'Kiwi Documentation', 276 | author, 'Kiwi', 'One line description of project.', 277 | 'Miscellaneous'), 278 | ] 279 | 280 | # Documents to append as an appendix to all manuals. 281 | #texinfo_appendices = [] 282 | 283 | # If false, no module index is generated. 284 | #texinfo_domain_indices = True 285 | 286 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 287 | #texinfo_show_urls = 'footnote' 288 | 289 | # If true, do not generate a @detailmenu in the "Top" node's menu. 290 | #texinfo_no_detailmenu = False 291 | 292 | 293 | # Example configuration for intersphinx: refer to the Python standard library. 294 | intersphinx_mapping = {'https://docs.python.org/': None} 295 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. Kiwi documentation master file, created by 2 | sphinx-quickstart on Mon Jul 6 11:37:38 2015. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to Kiwi 7 | ================================ 8 | 9 | Welcome to Kiwi's documentation. 10 | 11 | Kiwi is a declarative layer on top of `boto.dynamodb2`_, or in other words, 12 | it is a simple object-relational mapping (ORM) layer through dynamodb is 13 | not a relational database. 14 | 15 | 16 | .. toctree:: 17 | :maxdepth: 2 18 | 19 | install 20 | quickstart 21 | advanced 22 | compat 23 | ref/index 24 | 25 | 26 | .. _boto.dynamodb2: https://boto.readthedocs.org/en/latest/dynamodb2_tut.html 27 | -------------------------------------------------------------------------------- /docs/install.rst: -------------------------------------------------------------------------------- 1 | 2 | Installation 3 | ============ 4 | 5 | Kiwi depends on `boto`_ and `future`_. You can install it from source:: 6 | 7 | git clone https://github.com/papaya-mobile/kiwi.git 8 | cd kiwi 9 | python setup.py sdist 10 | pip install dist/kiwi-x.x.x.tar.gz 11 | 12 | 13 | .. _boto: https://boto.readthedocs.org/en/latest/ 14 | .. _future: http://python-future.org 15 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=_build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . 10 | set I18NSPHINXOPTS=%SPHINXOPTS% . 11 | if NOT "%PAPER%" == "" ( 12 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 13 | set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% 14 | ) 15 | 16 | if "%1" == "" goto help 17 | 18 | if "%1" == "help" ( 19 | :help 20 | echo.Please use `make ^` where ^ is one of 21 | echo. html to make standalone HTML files 22 | echo. dirhtml to make HTML files named index.html in directories 23 | echo. singlehtml to make a single large HTML file 24 | echo. pickle to make pickle files 25 | echo. json to make JSON files 26 | echo. htmlhelp to make HTML files and a HTML help project 27 | echo. qthelp to make HTML files and a qthelp project 28 | echo. devhelp to make HTML files and a Devhelp project 29 | echo. epub to make an epub 30 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 31 | echo. text to make text files 32 | echo. man to make manual pages 33 | echo. texinfo to make Texinfo files 34 | echo. gettext to make PO message catalogs 35 | echo. changes to make an overview over all changed/added/deprecated items 36 | echo. xml to make Docutils-native XML files 37 | echo. pseudoxml to make pseudoxml-XML files for display purposes 38 | echo. linkcheck to check all external links for integrity 39 | echo. doctest to run all doctests embedded in the documentation if enabled 40 | echo. coverage to run coverage check of the documentation if enabled 41 | goto end 42 | ) 43 | 44 | if "%1" == "clean" ( 45 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 46 | del /q /s %BUILDDIR%\* 47 | goto end 48 | ) 49 | 50 | 51 | REM Check if sphinx-build is available and fallback to Python version if any 52 | %SPHINXBUILD% 2> nul 53 | if errorlevel 9009 goto sphinx_python 54 | goto sphinx_ok 55 | 56 | :sphinx_python 57 | 58 | set SPHINXBUILD=python -m sphinx.__init__ 59 | %SPHINXBUILD% 2> nul 60 | if errorlevel 9009 ( 61 | echo. 62 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 63 | echo.installed, then set the SPHINXBUILD environment variable to point 64 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 65 | echo.may add the Sphinx directory to PATH. 66 | echo. 67 | echo.If you don't have Sphinx installed, grab it from 68 | echo.http://sphinx-doc.org/ 69 | exit /b 1 70 | ) 71 | 72 | :sphinx_ok 73 | 74 | 75 | if "%1" == "html" ( 76 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 77 | if errorlevel 1 exit /b 1 78 | echo. 79 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 80 | goto end 81 | ) 82 | 83 | if "%1" == "dirhtml" ( 84 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 85 | if errorlevel 1 exit /b 1 86 | echo. 87 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 88 | goto end 89 | ) 90 | 91 | if "%1" == "singlehtml" ( 92 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 93 | if errorlevel 1 exit /b 1 94 | echo. 95 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 96 | goto end 97 | ) 98 | 99 | if "%1" == "pickle" ( 100 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 101 | if errorlevel 1 exit /b 1 102 | echo. 103 | echo.Build finished; now you can process the pickle files. 104 | goto end 105 | ) 106 | 107 | if "%1" == "json" ( 108 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 109 | if errorlevel 1 exit /b 1 110 | echo. 111 | echo.Build finished; now you can process the JSON files. 112 | goto end 113 | ) 114 | 115 | if "%1" == "htmlhelp" ( 116 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 117 | if errorlevel 1 exit /b 1 118 | echo. 119 | echo.Build finished; now you can run HTML Help Workshop with the ^ 120 | .hhp project file in %BUILDDIR%/htmlhelp. 121 | goto end 122 | ) 123 | 124 | if "%1" == "qthelp" ( 125 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 126 | if errorlevel 1 exit /b 1 127 | echo. 128 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 129 | .qhcp project file in %BUILDDIR%/qthelp, like this: 130 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\Kiwi.qhcp 131 | echo.To view the help file: 132 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\Kiwi.ghc 133 | goto end 134 | ) 135 | 136 | if "%1" == "devhelp" ( 137 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 138 | if errorlevel 1 exit /b 1 139 | echo. 140 | echo.Build finished. 141 | goto end 142 | ) 143 | 144 | if "%1" == "epub" ( 145 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 146 | if errorlevel 1 exit /b 1 147 | echo. 148 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 149 | goto end 150 | ) 151 | 152 | if "%1" == "latex" ( 153 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 154 | if errorlevel 1 exit /b 1 155 | echo. 156 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 157 | goto end 158 | ) 159 | 160 | if "%1" == "latexpdf" ( 161 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 162 | cd %BUILDDIR%/latex 163 | make all-pdf 164 | cd %~dp0 165 | echo. 166 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 167 | goto end 168 | ) 169 | 170 | if "%1" == "latexpdfja" ( 171 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 172 | cd %BUILDDIR%/latex 173 | make all-pdf-ja 174 | cd %~dp0 175 | echo. 176 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 177 | goto end 178 | ) 179 | 180 | if "%1" == "text" ( 181 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 182 | if errorlevel 1 exit /b 1 183 | echo. 184 | echo.Build finished. The text files are in %BUILDDIR%/text. 185 | goto end 186 | ) 187 | 188 | if "%1" == "man" ( 189 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 190 | if errorlevel 1 exit /b 1 191 | echo. 192 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 193 | goto end 194 | ) 195 | 196 | if "%1" == "texinfo" ( 197 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo 198 | if errorlevel 1 exit /b 1 199 | echo. 200 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. 201 | goto end 202 | ) 203 | 204 | if "%1" == "gettext" ( 205 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale 206 | if errorlevel 1 exit /b 1 207 | echo. 208 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale. 209 | goto end 210 | ) 211 | 212 | if "%1" == "changes" ( 213 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 214 | if errorlevel 1 exit /b 1 215 | echo. 216 | echo.The overview file is in %BUILDDIR%/changes. 217 | goto end 218 | ) 219 | 220 | if "%1" == "linkcheck" ( 221 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 222 | if errorlevel 1 exit /b 1 223 | echo. 224 | echo.Link check complete; look for any errors in the above output ^ 225 | or in %BUILDDIR%/linkcheck/output.txt. 226 | goto end 227 | ) 228 | 229 | if "%1" == "doctest" ( 230 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 231 | if errorlevel 1 exit /b 1 232 | echo. 233 | echo.Testing of doctests in the sources finished, look at the ^ 234 | results in %BUILDDIR%/doctest/output.txt. 235 | goto end 236 | ) 237 | 238 | if "%1" == "coverage" ( 239 | %SPHINXBUILD% -b coverage %ALLSPHINXOPTS% %BUILDDIR%/coverage 240 | if errorlevel 1 exit /b 1 241 | echo. 242 | echo.Testing of coverage in the sources finished, look at the ^ 243 | results in %BUILDDIR%/coverage/python.txt. 244 | goto end 245 | ) 246 | 247 | if "%1" == "xml" ( 248 | %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml 249 | if errorlevel 1 exit /b 1 250 | echo. 251 | echo.Build finished. The XML files are in %BUILDDIR%/xml. 252 | goto end 253 | ) 254 | 255 | if "%1" == "pseudoxml" ( 256 | %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml 257 | if errorlevel 1 exit /b 1 258 | echo. 259 | echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. 260 | goto end 261 | ) 262 | 263 | :end 264 | -------------------------------------------------------------------------------- /docs/quickstart.rst: -------------------------------------------------------------------------------- 1 | 2 | Quick Start 3 | =========== 4 | 5 | declare a table 6 | --------------- 7 | 8 | Let's begin with this simple example:: 9 | 10 | from boto.dynamodb2 import connect_to_region 11 | from boto.dynamodb2.types import NUMBER 12 | from kiwi import metadata 13 | from kiwi import Table 14 | from kiwi import Field 15 | from kiwi import HashKeyField 16 | 17 | metadata.configure(connection=connect_to_region("us-east-1")) 18 | 19 | class User(Table): 20 | id = HashKeyField(data_type=NUMBER) 21 | name = Field() 22 | birth = Field(data_type=NUMBER, default=0) 23 | 24 | In this example, we declare a class ``User`` which maps to a dynamodb table 25 | named ``user``. Let's go through it step by step. 26 | 27 | metadata 28 | ++++++++ 29 | 30 | ``metadata`` is a collection of dynamodb configure, includes connection, 31 | default tablename factory and default throughput. 32 | 33 | Before declaring tables, we need configure the metadata by using 34 | ``metadata.configure``. 35 | 36 | Besides the default metadata instance, you can also create ``Metadata`` 37 | instance for more than one dynamodb endpoint or configure, see 38 | :ref:`adv-multi-metadata`. 39 | 40 | tablename 41 | +++++++++ 42 | Defaultly, the tablename in dynamodb is generated as:: 43 | 44 | re.sub('([^A-Z])([A-Z])', '\\1_\\2', cls.__name__).lower() 45 | 46 | That is, class ``User`` is mapped to dynamodb table ``user`` in the above 47 | example. And a class named ``UserTask`` will be mapped to the table named 48 | ``user_task``. 49 | 50 | You can define your own tablename generator and apply it via 51 | ``metadata.configure``:: 52 | 53 | def my_tablename_factory(cls): 54 | return cls.__name__ 55 | 56 | metadata.configure(tablename_factory=my_tablename_factory) 57 | 58 | Also, you can assign a special tablename for each Table:: 59 | 60 | class User(Table): 61 | __tablename__ = 'users' 62 | ... 63 | 64 | 65 | throughput 66 | ++++++++++ 67 | Another dynamodb configure is ``throughput``. As the same, you can 68 | config it globally via ``metadata``:: 69 | 70 | metadata.configure(throughput={'read':5, 'write':5}) 71 | 72 | or specially on each table:: 73 | 74 | class User(Table): 75 | __throughput__ = {'read':20, 'write': 30} 76 | ... 77 | 78 | 79 | Field 80 | +++++ 81 | ``Field`` represents a field in the record of a table. You can define 82 | its name, data_type, and default value. 83 | 84 | ``KeyField``, which include ``HashKeyField`` and ``RangeKeyField``, 85 | represents the primary key field of the dynamodb table. Dynamodb table 86 | must have primary key, either a hash key, or combined hash-range key. 87 | So you must provide a ``HashKeyField`` at least. 88 | 89 | 90 | Index 91 | +++++ 92 | To declare a dynamodb ``Secordary Index``, you can use ``Index``:: 93 | 94 | from kiwi import GlobalAllIndex, LocalAllIndex 95 | 96 | class UserTask(Table): 97 | user_id = HashKeyField(data_type=NUMBER) 98 | task_id = RangeKeyField() 99 | name = Field() 100 | time = Field() 101 | done = Field() 102 | ... 103 | local_index = LocalAllIndex(parts=[user_id, name]) 104 | global_index = GlobalAllIndex(parts=[task_id, time]) 105 | ... 106 | 107 | 108 | create and drop tables 109 | ---------------------- 110 | To create and drop tables, you can use the two util ``create_all``, 111 | ``drop_all``:: 112 | 113 | from kiwi import create_all 114 | from kiwi import drop_all 115 | 116 | # table declarative 117 | class User(Table): 118 | ... 119 | 120 | create_all() 121 | 122 | drop_all() 123 | 124 | The two util will create or drop tables in all metadatas you declare. 125 | To create/drop tables in a metadata, use ``Metadata.create_all``, 126 | ``Metadata.drop_all``. 127 | 128 | To create/drop a special table, use ``Table.create`` and ``Table.drop``. 129 | 130 | 131 | basic read and write operation 132 | ------------------------------ 133 | 134 | Assume you have declared the two tables:: 135 | 136 | class User(Table): 137 | id = HashKeyField(data_type=NUMBER) 138 | name = Field() 139 | birth = Field(data_type=NUMBER, default=0) 140 | 141 | class UserTask(Table): 142 | user_id = HashKeyField(data_type=NUMBER) 143 | task_id = RangeKeyField() 144 | name = Field() 145 | time = Field() 146 | done = Field() 147 | 148 | 149 | create an item 150 | ++++++++++++++ 151 | 152 | Instantiate a declarative class to create an item:: 153 | 154 | u = User(id=1, name='test') 155 | task = UserTask(user_id=1, task_id='a', name='test') 156 | 157 | and use ``Table.save`` to write the item into dynamodb:: 158 | 159 | u.save() 160 | task.save() 161 | 162 | get an item 163 | +++++++++++ 164 | 165 | You can read a item from dynamodb by primary key:: 166 | 167 | u = User.get(1) 168 | assert u.name == 'test' 169 | task = UserTask.get(1, 'a') 170 | assert task.name == 'test' 171 | 172 | modify fields 173 | +++++++++++++ 174 | 175 | Simply use assign operation to modify fields:: 176 | 177 | u.name = 'modified' 178 | u.save() 179 | task.done = True 180 | task.save() 181 | 182 | Remeber using ``save`` method to flush changes into dynamodb. 183 | 184 | delete an item 185 | ++++++++++++++ 186 | 187 | Use ``destroySelf`` method to delete an item:: 188 | 189 | u.destroySelf() 190 | u = User.get(1) 191 | assert u is None 192 | 193 | 194 | query 195 | ----- 196 | 197 | For hash-range primary key tables, you can do a query operation:: 198 | 199 | query = UserTask.query().onkeys( 200 | UserTask.user_id==1, UserTask.task_id.beginswith_('a')) 201 | 202 | After create a ``query`` instance, ``onkeys`` method must be called to 203 | specify the primary key condition. The method accepts two expression at 204 | most: the first is the condition on hash key, and the second is the 205 | condition on range key, which can be ignored. Note that 206 | 207 | 1. ``onkeys`` must be called once and only once, 208 | 2. the hash key condition must be a ``==`` operation, 209 | 3. the range key condition only supports ``==``, ``<``, ``<=``, ``>``, 210 | ``>=``, ``between_``, ``beginswith_``. 211 | 212 | Then you can add filters on arbitrary field use ``filter`` method:: 213 | 214 | query.filter(UserTask.time > 100) 215 | 216 | 217 | And you can modify the query by call its methods:: 218 | 219 | # more filter 220 | query.filter(UserTask.done == 1) 221 | 222 | # reverse the order 223 | query.desc() 224 | 225 | # limit max return items 226 | query.limit(10) 227 | 228 | However, all above operations would not tigger a real db query, then are 229 | all `in-memory` operations. 230 | 231 | To tigger real db queries, try to get the query result by using these 232 | methods:: 233 | 234 | # get all items 235 | items = query.all() 236 | 237 | # or as an iterator 238 | for item in query: 239 | ... 240 | 241 | # only get the first item 242 | query.first() 243 | 244 | # only get the count: 245 | query.count() 246 | 247 | Remeber that the query can only be fired only once. To use the query 248 | multiple times, try to clone a new one:: 249 | 250 | # clone an unfired query from even an fired one 251 | query = query.clone() 252 | 253 | 254 | query on index 255 | ++++++++++++++ 256 | 257 | You can query on secondary index:: 258 | 259 | class UserAction(Table): 260 | ... 261 | local_index = LocalAllIndex(parts=[user_id, name]) 262 | global_index = GlobalAllIndex(parts=[task_id, time]) 263 | ... 264 | 265 | # use secondary local index 266 | query = UserAction.query(index=UserAction.local_index) 267 | query.onkeys(UserAction.user_id==1, UserAction.name.beginswith_('t')) 268 | 269 | # use secondary global index 270 | query = UserAction.query(index=UserAction.global_index) 271 | query.onkeys(UserAction.task_id==20, UserAction.time <= 20) 272 | 273 | 274 | some notice 275 | +++++++++++ 276 | 277 | - You can only query on hash-range primary key table. 278 | - The hashkey in the query must be in an ``equal`` condition. 279 | 280 | 281 | batch read and write 282 | -------------------- 283 | 284 | batch read 285 | ++++++++++ 286 | 287 | You can use ``Table.batch_get`` to read multiple items at once:: 288 | 289 | User.batch_get([1,2,3]) 290 | UserAction.batch_get([(1,'a'), (2,'b')]) 291 | 292 | batch write 293 | +++++++++++ 294 | 295 | Yan can use ``Table.batch_write`` to add and/or delete multiple items:: 296 | 297 | with User.batch_write() as batch: 298 | batch.add(User(id=100, name='100')) 299 | batch.add(User(id=101, name='101')) 300 | 301 | batch.delete({'id': 200}) 302 | batch.delete(User(id=201)) 303 | 304 | When leaving the context, the changes would be flush into dynamodb. 305 | 306 | -------------------------------------------------------------------------------- /docs/ref/batch.rst: -------------------------------------------------------------------------------- 1 | kiwi.batch 2 | ========== 3 | 4 | .. automodule:: kiwi.batch 5 | :members: 6 | :undoc-members: 7 | -------------------------------------------------------------------------------- /docs/ref/exceptions.rst: -------------------------------------------------------------------------------- 1 | kiwi.exceptions 2 | =============== 3 | 4 | .. automodule:: kiwi.exceptions 5 | :members: 6 | :undoc-members: 7 | -------------------------------------------------------------------------------- /docs/ref/field.rst: -------------------------------------------------------------------------------- 1 | kiwi.field 2 | ========== 3 | 4 | .. automodule:: kiwi.field 5 | :members: 6 | :undoc-members: 7 | -------------------------------------------------------------------------------- /docs/ref/index.rst: -------------------------------------------------------------------------------- 1 | Reference of API 2 | ================ 3 | 4 | .. In fact, there is little thing to autodoc. 5 | Still need more work 6 | 7 | .. toctree:: 8 | 9 | table 10 | metadata 11 | query 12 | batch 13 | field 14 | exceptions 15 | 16 | -------------------------------------------------------------------------------- /docs/ref/metadata.rst: -------------------------------------------------------------------------------- 1 | kiwi.metadata 2 | ============= 3 | 4 | .. automodule:: kiwi.metadata 5 | :members: 6 | :undoc-members: 7 | -------------------------------------------------------------------------------- /docs/ref/query.rst: -------------------------------------------------------------------------------- 1 | kiwi.query 2 | ========== 3 | 4 | .. automodule:: kiwi.query 5 | :members: 6 | :undoc-members: 7 | -------------------------------------------------------------------------------- /docs/ref/table.rst: -------------------------------------------------------------------------------- 1 | kiwi.table 2 | ========== 3 | 4 | .. automodule:: kiwi.table 5 | :members: 6 | :undoc-members: 7 | -------------------------------------------------------------------------------- /kiwi/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from builtins import map 4 | 5 | __version__ = '0.0.5' 6 | 7 | from .table import * 8 | from .field import * 9 | from .metadata import * 10 | from .query import * 11 | from .exceptions import * 12 | 13 | 14 | __all__ = sum(map(lambda m: getattr(m, '__all__', []), 15 | [table, field, metadata, query, exceptions], 16 | ), []) 17 | 18 | metadata = MetaData() 19 | metadatas = set() 20 | 21 | def create_all(): 22 | for md in metadatas: 23 | md.create_all() 24 | 25 | def drop_all(): 26 | for md in metadatas: 27 | md.drop_all() 28 | 29 | 30 | __all__ += ['metadata', 'create_all', 'drop_all'] 31 | -------------------------------------------------------------------------------- /kiwi/batch.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from builtins import object 4 | 5 | from . import dynamo 6 | from .exceptions import * 7 | 8 | 9 | class BatchWrite(object): 10 | def __init__(self, mapper): 11 | self._mapper = mapper 12 | self._class = mapper.class_ 13 | 14 | def __enter__(self): 15 | self._batchtable = dynamo.BatchTable(self._mapper.table) 16 | return self 17 | 18 | def add(self, item): 19 | if not isinstance(item, self._class): 20 | raise ArgumentError( 21 | "item is not an instance of %s" % self._mapper.tablename) 22 | self._batchtable.put_item(dict(item.items())) 23 | 24 | def delete(self, item): 25 | if isinstance(item, self._class): 26 | kwargs = {} 27 | for key in self._mapper.schema: 28 | kwargs[key.name] = getattr(item, key.name) 29 | elif isinstance(item, dict): 30 | kwargs = item 31 | for key in self._mapper.schema: 32 | if key.name not in kwargs: 33 | raise ArgumentError("Primary key is NOT integral") 34 | else: 35 | raise ArgumentError("Invalid type of argument") 36 | self._batchtable.delete_item(**kwargs) 37 | 38 | def __exit__(self, type, value, traceback): 39 | self._batchtable.__exit__(type, value, traceback) 40 | -------------------------------------------------------------------------------- /kiwi/dynamo.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | ''' 4 | all boto.dynamodb2 import comes here 5 | ''' 6 | 7 | from boto.dynamodb2.table import Table, BatchTable 8 | from boto.dynamodb2.fields import (HashKey, RangeKey, 9 | AllIndex, KeysOnlyIndex, IncludeIndex, 10 | GlobalAllIndex, GlobalKeysOnlyIndex, 11 | GlobalIncludeIndex) 12 | from boto.dynamodb2.items import Item 13 | from boto.dynamodb2.layer1 import DynamoDBConnection 14 | from boto.dynamodb2.results import ResultSet, BatchGetResultSet 15 | from boto.dynamodb2.types import (NonBooleanDynamizer, Dynamizer, 16 | FILTER_OPERATORS, QUERY_OPERATORS, STRING) 17 | 18 | from boto.exception import JSONResponseError 19 | from boto.dynamodb2.exceptions import (DynamoDBError, ItemNotFound) 20 | -------------------------------------------------------------------------------- /kiwi/exceptions.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | __all__ = ['KiwiError', 4 | 'ArgumentError', 5 | 'InvalidRequestError', 6 | 'NoPrimaryKeyError', ] 7 | 8 | 9 | class KiwiError(Exception): 10 | """ Generic error class.""" 11 | 12 | 13 | class ArgumentError(KiwiError): 14 | pass 15 | 16 | 17 | class InvalidRequestError(KiwiError): 18 | pass 19 | 20 | 21 | class NoPrimaryKeyError(InvalidRequestError): 22 | pass 23 | -------------------------------------------------------------------------------- /kiwi/expression.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | ''' 4 | DynamoDB KeyConditionExpression and FilterExpression 5 | ''' 6 | 7 | __all__ = ['Filterable', 'Expression'] 8 | 9 | from builtins import object 10 | 11 | from . import dynamo 12 | 13 | 14 | class Filterable(object): 15 | def __eq__(self, other): 16 | return Expression(self, 'eq', other) 17 | 18 | def __lt__(self, other): 19 | return Expression(self, 'lt', other) 20 | 21 | def __le__(self, other): 22 | return Expression(self, 'lte', other) 23 | 24 | def __gt__(self, other): 25 | return Expression(self, 'gt', other) 26 | 27 | def __ge__(self, other): 28 | return Expression(self, 'gte', other) 29 | 30 | def between_(self, left, right): 31 | return Expression(self, 'between', (left, right)) 32 | 33 | def beginswith_(self, prefix): 34 | return Expression(self, 'beginswith', prefix) 35 | 36 | def __ne__(self, other): 37 | return Expression(self, 'ne', other) 38 | 39 | def in_(self, other): 40 | return Expression(self, 'in', other) 41 | 42 | def notnone_(self): 43 | return Expression(self, 'nnull', None) 44 | 45 | def isnone_(self): 46 | return Expression(self, 'null', None) 47 | 48 | def contains_(self, other): 49 | return Expression(self, 'contains', other) 50 | 51 | def notcontains_(self, other): 52 | return Expression(self, 'ncontains', other) 53 | 54 | 55 | class Expression(object): 56 | def __init__(self, field, op, other): 57 | assert isinstance(field, Filterable) 58 | self.field = field 59 | self.op = op 60 | self.other = other 61 | 62 | def schema(self): 63 | return ('%s__%s' % (self.field.name, self.op), self.other) 64 | 65 | def is_eq(self): 66 | return self.op == 'eq' 67 | 68 | def is_key_condition(self): 69 | return self.op in dynamo.QUERY_OPERATORS 70 | -------------------------------------------------------------------------------- /kiwi/field.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from builtins import zip 4 | from builtins import object 5 | 6 | __all__ = ['Field', 'KeyField', 'HashKeyField', 'RangeKeyField', 7 | 'Index', 'LocalIndex', 'GlobalIndex', 'IncludeIndex', 8 | 'LocalAllIndex', 'LocalKeysOnlyIndex', 'LocalIncludeIndex', 9 | 'GlobalAllIndex', 'GlobalKeysOnlyIndex', 'GlobalIncludeIndex', 10 | ] 11 | 12 | 13 | from . import dynamo 14 | from .expression import Filterable 15 | from .exceptions import * 16 | 17 | 18 | class SchemaBase(object): 19 | def __init__(self, key=None, name=None): 20 | self.key = key 21 | self.name = name 22 | 23 | def configure(self, name=None): 24 | if name: 25 | if not self.name: 26 | self.key = self.name = name 27 | elif name != self.name: 28 | raise ArgumentError("Try to configure schema again") 29 | 30 | 31 | class Field(SchemaBase, Filterable): 32 | attr_type = None 33 | 34 | def __init__(self, name=None, data_type=dynamo.STRING, default=None): 35 | super(Field, self).__init__() 36 | self.key = self.name = name 37 | self.data_type = data_type 38 | 39 | if not callable(default): 40 | orig_default = default 41 | default = lambda: orig_default 42 | self.default = default 43 | 44 | def __get__(self, obj, owner=None): 45 | if obj is None: 46 | return self 47 | 48 | if self.key not in obj._item: 49 | obj._item[self.key] = self.default() 50 | 51 | return obj._item[self.key] 52 | 53 | def __set__(self, obj, value): 54 | obj._item[self.key] = value 55 | 56 | def __delete__(self, obj): 57 | raise InvalidRequestError("Unsupport Operation") 58 | 59 | 60 | class KeyField(Field): 61 | def map_key(self): 62 | assert self.attr_type 63 | return self.attr_type(self.name, data_type=self.data_type) 64 | 65 | 66 | class HashKeyField(KeyField): 67 | attr_type = dynamo.HashKey 68 | 69 | 70 | class RangeKeyField(KeyField): 71 | attr_type = dynamo.RangeKey 72 | 73 | 74 | class Index(SchemaBase): 75 | idx_type = None 76 | 77 | def __init__(self, name=None, parts=None): 78 | super(Index, self).__init__() 79 | if not parts: 80 | raise ArgumentError('Argument `parts` of Index must be specified') 81 | self.name = name 82 | self.parts = parts 83 | 84 | def prepare(self): 85 | parts = [] 86 | for key_cls, field in zip( 87 | (dynamo.HashKey, dynamo.RangeKey), self.parts): 88 | key = key_cls(field.name, data_type=field.data_type) 89 | parts.append(key) 90 | return dict(name=self.name, parts=parts) 91 | 92 | def map(self): 93 | return self.idx_type(**self.prepare()) 94 | 95 | 96 | class IncludeIndex(Index): 97 | def __init__(self, **kwargs): 98 | self.includes = kwargs.pop('includes', []) 99 | super(IncludeIndex, self).__init__(**kwargs) 100 | 101 | def prepare(self): 102 | idx_kwargs = super(IncludeIndex, self).prepare() 103 | 104 | includes = [f.name for f in self.includes] 105 | idx_kwargs['includes'] = includes 106 | 107 | return idx_kwargs 108 | 109 | 110 | class LocalIndex(Index): 111 | pass 112 | 113 | 114 | class GlobalIndex(Index): 115 | def __init__(self, **kwargs): 116 | self.throughput = kwargs.pop('throughput', None) 117 | super(GlobalIndex, self).__init__(**kwargs) 118 | 119 | def prepare(self): 120 | idx_kwargs = super(GlobalIndex, self).prepare() 121 | 122 | if self.throughput: 123 | idx_kwargs['throughput'] = self.throughput 124 | return idx_kwargs 125 | 126 | 127 | class LocalAllIndex(LocalIndex): 128 | idx_type = dynamo.AllIndex 129 | 130 | 131 | class LocalKeysOnlyIndex(LocalIndex): 132 | idx_type = dynamo.KeysOnlyIndex 133 | 134 | 135 | class LocalIncludeIndex(LocalIndex, IncludeIndex): 136 | idx_type = dynamo.IncludeIndex 137 | 138 | 139 | class GlobalAllIndex(GlobalIndex): 140 | idx_type = dynamo.GlobalAllIndex 141 | 142 | 143 | class GlobalKeysOnlyIndex(GlobalIndex): 144 | idx_type = dynamo.GlobalKeysOnlyIndex 145 | 146 | 147 | class GlobalIncludeIndex(GlobalIndex, IncludeIndex): 148 | idx_type = dynamo.GlobalIncludeIndex 149 | -------------------------------------------------------------------------------- /kiwi/mapper.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from builtins import str 4 | from builtins import zip 5 | from builtins import object 6 | 7 | __all__ = ['Mapper', 'setup_mapping'] 8 | 9 | from . import dynamo 10 | from .metadata import MetaData 11 | from .field import * 12 | from .exceptions import * 13 | 14 | import kiwi 15 | 16 | 17 | class Mapper(object): 18 | def __init__(self, class_, tablename, schema, 19 | throughput=None, attributes=None, 20 | indexes=None, global_indexes=None, 21 | metadata=None): 22 | 23 | self.metadata = metadata or kiwi.metadata 24 | assert isinstance(self.metadata, MetaData) 25 | 26 | self.class_ = class_ 27 | self.tablename = tablename 28 | self.schema = schema 29 | self.throughput = throughput 30 | 31 | self.attributes = attributes 32 | self.indexes = indexes or {} 33 | self.global_indexes = global_indexes or {} 34 | 35 | self.metadata.add(self) 36 | 37 | @property 38 | def table(self): 39 | if not hasattr(self, '_table'): 40 | self._table = self._build_table(dynamo.Table) 41 | return self._table 42 | 43 | def _build_table(self, builder): 44 | kwargs = {} 45 | kwargs['throughput'] = self.throughput 46 | kwargs['connection'] = self.metadata.connection 47 | 48 | if self.indexes: 49 | kwargs['indexes'] = [idx.map() for idx in self.indexes.values()] 50 | if self.global_indexes: 51 | kwargs['global_indexes'] = [idx.map() for idx in 52 | self.global_indexes.values()] 53 | 54 | table = builder(self.tablename, self.schema, **kwargs) 55 | 56 | if self.metadata.dynamizer: 57 | # However, boto.dynamodb2 does not provide a public interface 58 | # to custom dynamizer. Here we do it at a risk. 59 | assert issubclass(self.metadata.dynamizer, dynamo.Dynamizer) 60 | table._dynamizer = self.metadata.dynamizer() 61 | 62 | return table 63 | 64 | def create_table(self): 65 | self._table = self._build_table(dynamo.Table.create) 66 | 67 | def drop_table(self): 68 | self.table.delete() 69 | 70 | def new_item(self, _item=None, **kwargs): 71 | item = _item or dynamo.Item(self.table, data=kwargs) 72 | for name, field in self.attributes.items(): 73 | if name not in item: 74 | item[name] = field.default() 75 | return item 76 | 77 | def get_item(self, *args): 78 | ''' 79 | not support `consistent`, `attributes` yet 80 | ''' 81 | if len(self.schema) != len(args): 82 | raise ArgumentError("args can not match the table's schema") 83 | kwargs = dict() 84 | for key, value in zip(self.schema, args): 85 | kwargs[key.name] = value 86 | try: 87 | return self.table.get_item(**kwargs) 88 | except dynamo.ItemNotFound: # ItemNotFound 89 | return None 90 | 91 | def delete_item(self, **kwargs): 92 | for key in self.schema: 93 | if key.name not in kwargs: 94 | raise ArgumentError("Primary key is NOT integral") 95 | self.table.delete_item(**kwargs) 96 | 97 | def batch_get(self, keys): 98 | schema_len = len(self.schema) 99 | schema_names = [k.name for k in self.schema] 100 | dictkeys = [] 101 | for key in keys: 102 | if not isinstance(key, (tuple, list)): 103 | key = [key] 104 | if schema_len != len(key): 105 | raise ArgumentError("key `%s` can not match " 106 | "the table's schema" % str(key)) 107 | dictkeys.append(dict(zip(schema_names, key))) 108 | 109 | if not dictkeys: 110 | return [] 111 | 112 | results = self.table.batch_get(dictkeys) 113 | return self.wrap_result(results) 114 | 115 | def wrap_result(self, results): 116 | return (self.class_(_item=item) for item in results) 117 | 118 | 119 | def setup_mapping(cls, clsname, dict_): 120 | _MapperConfig(cls, clsname, dict_) 121 | 122 | 123 | class _MapperConfig(object): 124 | 125 | def __init__(self, cls_, clsname, dict_): 126 | self.cls = cls_ 127 | self.clsname = clsname 128 | self.dict_ = dict(dict_) 129 | 130 | self.metadata = None 131 | self.tablename = None 132 | self.throughput = None 133 | self.attributes = {} 134 | self.schema = [] 135 | self.indexes = {} 136 | self.global_indexes = {} 137 | 138 | self._scan_metadata() 139 | self._scan_tablename() 140 | self._scan_throughput() 141 | self._scan_attributes() 142 | self._scan_indexes() 143 | 144 | self._setup_mapper() 145 | 146 | def _scan_metadata(self): 147 | self.metadata = getattr(self.cls, '__metadata__', None) 148 | 149 | def _scan_tablename(self): 150 | self.tablename = getattr(self.cls, '__tablename__', None) 151 | 152 | def _scan_throughput(self): 153 | self.throughput = getattr(self.cls, '__throughput__', None) 154 | 155 | def _scan_attributes(self): 156 | cls = self.cls 157 | attributes = self.attributes 158 | schema = self.schema 159 | hashkey = None 160 | rangekey = None 161 | 162 | for base in cls.__mro__: 163 | for name, obj in vars(base).items(): 164 | if isinstance(obj, Field): 165 | if name in attributes: 166 | continue 167 | 168 | obj.configure(name) 169 | attributes[name] = obj 170 | 171 | if obj.attr_type == dynamo.HashKey: 172 | if not hashkey: 173 | hashkey = obj.map_key() 174 | elif obj.attr_type == dynamo.RangeKey: 175 | if not rangekey: 176 | rangekey = obj.map_key() 177 | 178 | if not hashkey: 179 | raise NoPrimaryKeyError("PrimaryKey must be provided." 180 | " A primary key can be hashkey " 181 | "or hashkey+rangekey") 182 | schema.append(hashkey) 183 | if rangekey: 184 | schema.append(rangekey) 185 | 186 | def _scan_indexes(self): 187 | cls = self.cls 188 | indexes = self.indexes 189 | global_indexes = self.global_indexes 190 | 191 | for base in cls.__mro__: 192 | for name, obj in vars(base).items(): 193 | if isinstance(obj, Index): 194 | if name in indexes or name in global_indexes: 195 | continue 196 | 197 | obj.configure(name) 198 | 199 | if isinstance(obj, LocalIndex): 200 | indexes[name] = obj 201 | elif isinstance(obj, GlobalIndex): 202 | global_indexes[name] = obj 203 | else: 204 | pass 205 | # TODO: check indexes for 206 | # 1. Hash primary key vs Hask & Range primary key 207 | # 2. local indexes only for Hask & Range primary ?? 208 | # 3. other 209 | 210 | def _setup_mapper(self): 211 | cls = self.cls 212 | 213 | mapper = Mapper(cls, 214 | self.tablename, 215 | schema=self.schema, 216 | throughput=self.throughput, 217 | attributes=self.attributes, 218 | indexes=self.indexes, 219 | global_indexes=self.global_indexes, 220 | metadata=self.metadata) 221 | cls.__mapper__ = mapper 222 | -------------------------------------------------------------------------------- /kiwi/metadata.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from builtins import object 4 | 5 | __all__ = ['MetaData'] 6 | 7 | import re 8 | from threading import RLock 9 | 10 | import kiwi 11 | from . import dynamo 12 | from .exceptions import * 13 | 14 | 15 | def _tablename_factory(cls): 16 | clsname = cls.__name__ 17 | return re.sub('([^A-Z])([A-Z])', '\\1_\\2', clsname).lower() 18 | 19 | 20 | class MetaData(object): 21 | def __init__(self, connection=None, 22 | tablename_factory=None, 23 | throughput=None, 24 | dynamizer=None): 25 | self.connection = connection or None 26 | self.throughput = throughput or None 27 | self.tablename_factory = _tablename_factory 28 | if tablename_factory: 29 | self.tablename_factory = tablename_factory 30 | 31 | self.dynamizer = None 32 | if dynamizer: 33 | self._set_dynamizer(dynamizer) 34 | 35 | self._lock = RLock() 36 | self._unconfigurable = False 37 | self._tables = {} # tablename: mapper 38 | 39 | def configure(self, connection=None, 40 | tablename_factory=None, 41 | throughput=None, 42 | dynamizer=None): 43 | with self._lock: 44 | if self._unconfigurable: 45 | raise InvalidRequestError("The metadata can NOT be " 46 | "configured after some table " 47 | "has been attached") 48 | if connection is not None: 49 | self.connection = connection or None 50 | if tablename_factory is not None and tablename_factory: 51 | self.tablename_factory = tablename_factory 52 | if throughput is not None: 53 | self.throughput = throughput or None 54 | if dynamizer is not None: 55 | self._set_dynamizer(dynamizer) 56 | 57 | def _set_dynamizer(self, dynamizer): 58 | if not issubclass(dynamizer, dynamo.Dynamizer): 59 | raise ArgumentError("dynamizer must be a subclass of " 60 | "boto.dynamodb2.Dynamizer") 61 | self.dynamizer = dynamizer 62 | 63 | def add(self, mapper): 64 | with self._lock: 65 | self._unconfigurable = True 66 | 67 | if mapper.tablename in self._tables: 68 | raise InvalidRequestError("Table with name `%s` has " 69 | "been added!" % mapper.tablename) 70 | 71 | if not mapper.tablename: 72 | mapper.tablename = self.tablename_factory(mapper.class_) 73 | mapper.throughput = mapper.throughput or self.throughput 74 | self._tables[mapper.tablename] = mapper 75 | 76 | kiwi.metadatas.add(self) 77 | 78 | def __contains__(self, mapper): 79 | return mapper.tablename in self._tables 80 | 81 | def remove(self, mapper): 82 | with self._lock: 83 | self._tables.pop(mapper.tablename, None) 84 | 85 | def clear(self): 86 | with self._lock: 87 | self._tables.clear() 88 | 89 | def __iter__(self): 90 | return iter(self._tables) 91 | 92 | def items(self): 93 | return self._tables.items() 94 | 95 | def values(self): 96 | return self._tables.values() 97 | 98 | def create_all(self): 99 | for m in self.values(): 100 | m.create_table() 101 | 102 | def drop_all(self): 103 | for m in self.values(): 104 | m.drop_table() 105 | -------------------------------------------------------------------------------- /kiwi/query.py: -------------------------------------------------------------------------------- 1 | from builtins import object 2 | # -*- coding: utf-8 -*- 3 | 4 | __all__ = ['Query'] 5 | 6 | from .field import Index 7 | from .expression import Expression 8 | from .exceptions import * 9 | 10 | 11 | class Query(object): 12 | def __init__(self, mapper, index=None, 13 | attributes=None, consistent=False, 14 | max_page_size=None, reverse=False, limit=None): 15 | self._mapper = mapper 16 | 17 | self._index = self._check_index(index) 18 | self._attributes = self._check_attributes(attributes) 19 | 20 | if self._index: 21 | idx = getattr(self._mapper.class_, self._index) 22 | keyfields = idx.parts 23 | else: 24 | keyfields = self._mapper.schema 25 | self._keyfields = [f.name for f in keyfields] 26 | 27 | self._consistent = consistent 28 | self._max_page_size = max_page_size 29 | 30 | self._reverse = reverse 31 | self._limit = limit 32 | 33 | self._key_conds = [] 34 | self._filters = [] 35 | 36 | self._fired = False 37 | 38 | def clone(self): 39 | cloned = Query(mapper=self._mapper) 40 | cloned._index = self._index 41 | if self._attributes: 42 | cloned._attributes = self._attributes[:] 43 | cloned._keyfields = self._keyfields[:] 44 | cloned._consistent = self._consistent 45 | cloned._max_page_size = self._max_page_size 46 | cloned._reverse = self._reverse 47 | cloned._limit = self._limit 48 | cloned._key_conds = self._key_conds[:] 49 | cloned._filters = self._filters[:] 50 | return cloned 51 | 52 | def _check_index(self, index): 53 | if index is None: 54 | return index 55 | if isinstance(index, Index): 56 | index = index.name 57 | if index in self._mapper.indexes: 58 | return index 59 | if index in self._mapper.global_indexes: 60 | return index 61 | raise ArgumentError("Unknown index `%s`" % index) 62 | 63 | def _check_attributes(self, attrs): 64 | if not attrs: 65 | return None 66 | else: 67 | return [f.name for f in attrs] 68 | 69 | def _build_raw_filters(self, filters): 70 | return dict(map(lambda exp: exp.schema(), filters)) 71 | 72 | def _fire(self): 73 | self._fired = True 74 | 75 | query = self._mapper.table.query_2 76 | 77 | query_filter = self._build_raw_filters(self._filters) 78 | filter_kwargs = self._build_raw_filters(self._key_conds) 79 | 80 | if not filter_kwargs: 81 | raise InvalidRequestError("Key Condition should be specified " 82 | "via the method `query.onkeys`") 83 | 84 | results = query(limit=self._limit, 85 | index=self._index, 86 | reverse=self._reverse, 87 | consistent=self._consistent, 88 | attributes=self._attributes, 89 | max_page_size=self._max_page_size, 90 | query_filter=query_filter, 91 | conditional_operator=None, 92 | **filter_kwargs) 93 | return self._mapper.wrap_result(results) 94 | 95 | def __iter__(self): 96 | return self._fire() 97 | 98 | def onkeys(self, hashkey_cond, rangekey_cond=None): 99 | ''' Specify KeyConditionExpression 100 | ''' 101 | assert not self._fired 102 | if self._key_conds: 103 | raise InvalidRequestError("Key Conditions have been specified") 104 | 105 | if not isinstance(hashkey_cond, Expression): 106 | raise ArgumentError("hashkey_cond should be an Expression") 107 | if hashkey_cond.field.name != self._keyfields[0]: 108 | msg = "The hashkey_cond has a wrong field `%s`, "\ 109 | "which should be `%s`" 110 | msg = msg % (hashkey_cond.field.name, self._keyfields[0]) 111 | raise ArgumentError(msg) 112 | if not hashkey_cond.is_eq(): 113 | raise ArgumentError("hashkey_cond should be an equal operation") 114 | 115 | if rangekey_cond: 116 | if not isinstance(rangekey_cond, Expression): 117 | raise ArgumentError("rangekey_cond should be an Expression") 118 | if rangekey_cond.field.name != self._keyfields[1]: 119 | msg = "The rangekey_cond has a wrong field `%s`, "\ 120 | "which should be `%s`" 121 | msg = msg % (rangekey_cond.field.name, self._keyfields[1]) 122 | raise ArgumentError(msg) 123 | if not rangekey_cond.is_key_condition(): 124 | msg = "The condtion `%s` should not apply to a range key" 125 | msg = msg % rangekey_cond.op 126 | raise ArgumentError(msg) 127 | 128 | self._key_conds.append(hashkey_cond) 129 | if rangekey_cond: 130 | self._key_conds.append(rangekey_cond) 131 | 132 | return self 133 | 134 | def filter(self, *args): 135 | ''' filter on another keys 136 | ''' 137 | assert not self._fired 138 | for exp in args: 139 | if not isinstance(exp, Expression): 140 | raise ArgumentError("filter must be an Expression") 141 | self._filters.append(exp) 142 | return self 143 | 144 | def asc(self): 145 | assert not self._fired 146 | self._reverse = False 147 | return self 148 | 149 | def desc(self): 150 | assert not self._fired 151 | self._reverse = True 152 | return self 153 | 154 | def limit(self, limit): 155 | assert not self._fired 156 | self._limit = limit 157 | return self 158 | 159 | def count(self): 160 | assert not self._fired 161 | return len(list(self)) 162 | 163 | def all(self): 164 | return list(self) 165 | 166 | def first(self): 167 | self = self.limit(1) 168 | ret = list(self) 169 | return ret[0] if ret else None 170 | -------------------------------------------------------------------------------- /kiwi/table.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from builtins import object 4 | from future.utils import with_metaclass 5 | 6 | __all__ = ['TableMeta', 'TableBase', 'Table'] 7 | 8 | from .mapper import setup_mapping 9 | from .query import Query 10 | from .batch import BatchWrite 11 | 12 | 13 | def is_table(cls): 14 | for base in cls.__bases__: 15 | if isinstance(base, TableMeta): 16 | return True 17 | return False 18 | 19 | 20 | class TableMeta(type): 21 | ''' 22 | Metaclass/Type of declarative base (that is, ``Table`` here) 23 | ''' 24 | def __init__(cls, name, bases, dict_): 25 | if is_table(cls): 26 | setup_mapping(cls, name, dict_) 27 | type.__init__(cls, name, bases, dict_) 28 | 29 | ''' Basic Table API 30 | ''' 31 | def create(tbl): 32 | '''create the table 33 | ''' 34 | tbl.__mapper__.create_table() 35 | 36 | def drop(tbl): 37 | '''drop the table 38 | ''' 39 | tbl.__mapper__.drop_table() 40 | 41 | def get(tbl, *args): 42 | ''' Get item by primary key 43 | ''' 44 | item = tbl.__mapper__.get_item(*args) 45 | if item is not None: 46 | return tbl(_item=item) 47 | else: 48 | return None 49 | 50 | def batch_get(tbl, keys): 51 | return tbl.__mapper__.batch_get(keys) 52 | 53 | def batch_write(tbl): 54 | return BatchWrite(tbl.__mapper__) 55 | 56 | def delete(tbl, **kwargs): 57 | return tbl.__mapper__.delete_item(**kwargs) 58 | 59 | def query(tbl, **kwargs): 60 | return Query(tbl.__mapper__, **kwargs) 61 | 62 | 63 | class TableBase(object): 64 | ''' Basic Item API 65 | ''' 66 | def __init__(self, _item=None, **kwargs): 67 | self._item = self.__mapper__.new_item(_item, **kwargs) 68 | 69 | def save(self, overwrite=False): 70 | assert hasattr(self, '_item') 71 | self._item.save(overwrite) 72 | 73 | def destroySelf(self): 74 | assert hasattr(self, '_item') 75 | self._item.delete() 76 | 77 | def items(self): 78 | assert hasattr(self, '_item') 79 | return self._item.items() 80 | 81 | 82 | class Table(with_metaclass(TableMeta, TableBase)): 83 | """ 84 | Base class of declarative class definitions. 85 | 86 | To declare a class mapping to a dynamodb table, simply derive from it. 87 | """ 88 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/papaya-mobile/kiwi/e3d334514ed51734383657c963b9046349da85cc/requirements.txt -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import os 4 | import re 5 | import codecs 6 | from setuptools import setup 7 | 8 | ROOT = os.path.dirname(__file__) 9 | VERSION_RE = re.compile(r'''__version__ = ['"]([0-9.]+)['"]''') 10 | 11 | with codecs.open( 12 | os.path.join(ROOT, 'kiwi', '__init__.py'), 'r', 'utf-8') as init: 13 | try: 14 | version = VERSION_RE.search(init.read()).group(1) 15 | except IndexError: 16 | raise RuntimeError('Unable to determine version.') 17 | 18 | 19 | setup(name='kiwi', 20 | version=version, 21 | packages=['kiwi'], 22 | description='Simple dynamodb ORM', 23 | author='Papaya Backend', 24 | author_email='backend@papayamobile.com', 25 | install_requires=['future', 'boto>=2.38.0'], 26 | ) 27 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/papaya-mobile/kiwi/e3d334514ed51734383657c963b9046349da85cc/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import pytest 4 | import time 5 | 6 | from boto.dynamodb2.types import NUMBER 7 | 8 | from kiwi import MetaData, Table 9 | from kiwi import Field, HashKeyField, RangeKeyField 10 | from kiwi import LocalAllIndex, GlobalAllIndex 11 | 12 | 13 | @pytest.fixture(scope="session") 14 | def local_db(): 15 | from boto.dynamodb2.layer1 import DynamoDBConnection 16 | conn = DynamoDBConnection(host="localhost", port=8000, 17 | aws_access_key_id="kiwi", 18 | aws_secret_access_key="kiwi", 19 | is_secure=False) 20 | return conn 21 | 22 | 23 | @pytest.fixture(scope="class") 24 | def metadata(request, local_db): 25 | md = MetaData(connection=local_db) 26 | 27 | def teardown(): 28 | md.drop_all() 29 | md.clear() 30 | 31 | request.addfinalizer(teardown) 32 | return md 33 | 34 | 35 | @pytest.fixture(scope="class") 36 | def User(metadata): 37 | class User(Table): 38 | __metadata__ = metadata 39 | 40 | id = HashKeyField(data_type=NUMBER) 41 | name = Field() 42 | birth = Field(data_type=NUMBER, default=lambda: time.time()) 43 | 44 | User.__mapper__.create_table() 45 | 46 | User(id=1, name='a').save() 47 | User(id=2, name='b').save() 48 | User(id=3, name='c').save() 49 | User(id=4, name='d').save() 50 | User(id=5, name='e').save() 51 | User(id=6, name='f').save() 52 | User(id=7, name='g').save() 53 | User(id=8, name='h').save() 54 | User(id=9, name='i').save() 55 | 56 | return User 57 | 58 | 59 | @pytest.fixture(scope="class") 60 | def UserAction(metadata): 61 | class UserAction(Table): 62 | __metadata__ = metadata 63 | id = HashKeyField(data_type=NUMBER) 64 | time = RangeKeyField(data_type=NUMBER) 65 | name = Field() 66 | duration = Field(data_type=NUMBER) 67 | result = Field() 68 | 69 | dur_index = GlobalAllIndex(parts=[time, duration]) 70 | result_index = LocalAllIndex(parts=[id, result]) 71 | 72 | UserAction.__mapper__.create_table() 73 | 74 | UserAction(id=1, time=1, name="hello", duration=2, result="ok").save() 75 | UserAction(id=2, time=2, name="hillo", duration=5, result="ko").save() 76 | UserAction(id=2, time=3, name="hello", duration=1, result="ok").save() 77 | UserAction(id=1, time=4, name="hillo", duration=4, result="fail").save() 78 | UserAction(id=3, time=5, name="23ewsx", duration=2, result="ok").save() 79 | UserAction(id=5, time=6, name="okljhye", duration=2, result="ok").save() 80 | UserAction(id=3, time=7, name="qsgs", duration=8, result="hi").save() 81 | UserAction(id=1, time=8, name="efgh", duration=2, result="ok").save() 82 | UserAction(id=2, time=9, name="abcd", duration=1, result="yes").save() 83 | UserAction(id=2, time=10, name="ki", duration=2, result="nono").save() 84 | UserAction(id=5, time=11, name="48jrj", duration=3, result="enen").save() 85 | UserAction(id=4, time=12, name="ello", duration=2, result="ok").save() 86 | UserAction(id=4, time=13, name="h-4-13", duration=2, result="bye").save() 87 | 88 | return UserAction 89 | -------------------------------------------------------------------------------- /tests/test_field.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from builtins import object 4 | 5 | import pytest 6 | 7 | from kiwi.field import * 8 | from kiwi.exceptions import * 9 | from kiwi import dynamo 10 | 11 | 12 | class TestField(object): 13 | def test_basic(self): 14 | f = Field('f') 15 | assert isinstance(f, Field) 16 | assert f.name == 'f' 17 | assert f.key == 'f' 18 | assert f.data_type == dynamo.STRING 19 | assert f.default() is None 20 | assert f.attr_type is None 21 | 22 | kf = HashKeyField('f') 23 | assert kf.attr_type is dynamo.HashKey 24 | 25 | rf = RangeKeyField('f') 26 | assert rf.attr_type is dynamo.RangeKey 27 | 28 | def test_descriptor(self): 29 | class Owner(object): 30 | f = Field('f') 31 | def __init__(self): 32 | self._item = {} 33 | Owner.f.owner = Owner 34 | 35 | owner = Owner() 36 | assert owner.f is None 37 | owner.f = 3 38 | assert owner.f == 3 39 | owner.f = 5 40 | assert owner.f == 5 41 | assert owner._item['f'] == 5 42 | 43 | with pytest.raises(InvalidRequestError): 44 | del owner.f 45 | 46 | def test_expression(self): 47 | f = Field('f') 48 | 49 | assert (f == 3).schema() == ('f__eq', 3) 50 | assert (f < 3).schema() == ('f__lt', 3) 51 | assert (f <= 3).schema() == ('f__lte', 3) 52 | assert (f >= 3).schema() == ('f__gte', 3) 53 | assert (f > 3).schema() == ('f__gt', 3) 54 | assert (f.between_(2, 4)).schema() == ('f__between', (2, 4)) 55 | assert (f.beginswith_('a')).schema() == ('f__beginswith', 'a') 56 | 57 | f = KeyField('f') 58 | assert (f != 3).schema() == ('f__ne', 3) 59 | assert (f.in_(3)).schema() == ('f__in', 3) 60 | assert (f.notnone_()).schema() == ('f__nnull', None) 61 | assert (f.isnone_()).schema() == ('f__null', None) 62 | assert (f.contains_(3)).schema() == ('f__contains', 3) 63 | assert (f.notcontains_(3)).schema() == ('f__ncontains', 3) 64 | 65 | 66 | class TestLocalAllIndex(object): 67 | def test_basic(self): 68 | parts = [Field('a'), Field('b')] 69 | idx = LocalAllIndex(name='a', parts=parts) 70 | assert idx.idx_type == dynamo.AllIndex 71 | assert idx.name == 'a' 72 | assert idx.parts == parts 73 | 74 | def test_map(self): 75 | parts = [Field('a'), Field('b')] 76 | idx = LocalAllIndex(name='a', parts=parts) 77 | 78 | didx = idx.map() 79 | 80 | assert isinstance(didx, dynamo.AllIndex) 81 | assert didx.name == 'a' 82 | hk, rk = didx.parts 83 | 84 | assert isinstance(hk, dynamo.HashKey) 85 | assert hk.name == 'a' 86 | assert hk.data_type == dynamo.STRING 87 | assert isinstance(rk, dynamo.RangeKey) 88 | assert rk.name == 'b' 89 | assert rk.data_type == dynamo.STRING 90 | 91 | 92 | class TestLocalKeysOnlyIndex(object): 93 | def test_basic(self): 94 | parts = [Field('a'), Field('b')] 95 | idx = LocalKeysOnlyIndex(name='a', parts=parts) 96 | assert idx.idx_type == dynamo.KeysOnlyIndex 97 | assert idx.name == 'a' 98 | assert idx.parts == parts 99 | 100 | def test_map(self): 101 | parts = [Field('a'), Field('b')] 102 | idx = LocalKeysOnlyIndex(name='a', parts=parts) 103 | 104 | didx = idx.map() 105 | 106 | assert isinstance(didx, dynamo.KeysOnlyIndex) 107 | assert didx.name == 'a' 108 | hk, rk = didx.parts 109 | 110 | assert isinstance(hk, dynamo.HashKey) 111 | assert hk.name == 'a' 112 | assert hk.data_type == dynamo.STRING 113 | assert isinstance(rk, dynamo.RangeKey) 114 | assert rk.name == 'b' 115 | assert rk.data_type == dynamo.STRING 116 | 117 | 118 | class TestLocalIncludeIndex(object): 119 | def test_basic(self): 120 | parts = ['a', 'b'] 121 | includes = ['c', 'd'] 122 | idx = LocalIncludeIndex(name='a', parts=parts, includes=includes) 123 | assert idx.idx_type == dynamo.IncludeIndex 124 | assert idx.name == 'a' 125 | assert idx.parts == parts 126 | assert idx.includes == includes 127 | 128 | def test_map(self): 129 | parts = [Field('a'), Field('b')] 130 | includes = [Field('c'), Field('d')] 131 | idx = LocalIncludeIndex(name='a', parts=parts, includes=includes) 132 | 133 | didx = idx.map() 134 | 135 | assert isinstance(didx, dynamo.IncludeIndex) 136 | assert didx.name == 'a' 137 | assert didx.includes_fields == ['c', 'd'] 138 | 139 | 140 | class TestGlobalAllIndex(object): 141 | def test_basic(self): 142 | parts = ['a', 'b'] 143 | throughput = {'read': 1, 'write': 2} 144 | idx = GlobalAllIndex(name='a', parts=parts, throughput=throughput) 145 | assert idx.idx_type == dynamo.GlobalAllIndex 146 | assert idx.name == 'a' 147 | assert idx.parts == parts 148 | assert idx.throughput == throughput 149 | 150 | def test_map(self): 151 | parts = [Field('a'), Field('b')] 152 | throughput = {'read': 1, 'write': 2} 153 | idx = GlobalAllIndex(name='a', parts=parts, throughput=throughput) 154 | 155 | didx = idx.map() 156 | 157 | assert isinstance(didx, dynamo.GlobalAllIndex) 158 | assert didx.name == 'a' 159 | assert didx.throughput == throughput 160 | 161 | 162 | class TestGlobalKeysOnlyIndex(object): 163 | def test_basic(self): 164 | parts = ['a', 'b'] 165 | throughput = {'read': 1, 'write': 2} 166 | idx = GlobalKeysOnlyIndex(name='a', parts=parts, throughput=throughput) 167 | assert idx.idx_type == dynamo.GlobalKeysOnlyIndex 168 | assert idx.name == 'a' 169 | assert idx.parts == parts 170 | assert idx.throughput == throughput 171 | 172 | def test_map(self): 173 | parts = [Field('a'), Field('b')] 174 | throughput = {'read': 1, 'write': 2} 175 | idx = GlobalKeysOnlyIndex(name='a', parts=parts, throughput=throughput) 176 | 177 | didx = idx.map() 178 | 179 | assert isinstance(didx, dynamo.GlobalKeysOnlyIndex) 180 | assert didx.name == 'a' 181 | assert didx.throughput == throughput 182 | 183 | 184 | class TestGlobalIncludeIndex(object): 185 | def test_basic(self): 186 | parts = ['a', 'b'] 187 | includes = [Field('c'), Field('d')] 188 | throughput = {'read': 1, 'write': 2} 189 | idx = GlobalIncludeIndex(name='a', parts=parts, 190 | includes=includes, 191 | throughput=throughput) 192 | 193 | assert idx.idx_type == dynamo.GlobalIncludeIndex 194 | assert idx.name == 'a' 195 | assert idx.parts == parts 196 | assert idx.includes == includes 197 | assert idx.throughput == throughput 198 | 199 | def test_map(self): 200 | parts = [Field('a'), Field('b')] 201 | includes = [Field('c'), Field('d')] 202 | throughput = {'read': 1, 'write': 2} 203 | idx = GlobalIncludeIndex(name='a', parts=parts, 204 | includes=includes, 205 | throughput=throughput) 206 | 207 | didx = idx.map() 208 | 209 | assert isinstance(didx, dynamo.GlobalIncludeIndex) 210 | assert didx.name == 'a' 211 | assert didx.includes_fields == ['c', 'd'] 212 | assert didx.throughput == throughput 213 | -------------------------------------------------------------------------------- /tests/test_mapper.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from builtins import object 4 | from future.utils import with_metaclass 5 | 6 | import pytest 7 | import time 8 | 9 | from boto.dynamodb2.types import * 10 | from boto.dynamodb2.fields import HashKey, RangeKey 11 | 12 | import kiwi 13 | from kiwi import * 14 | from kiwi import dynamo 15 | 16 | 17 | @pytest.fixture(autouse=True) 18 | def clear_metadatas(request): 19 | def clear(): 20 | for md in kiwi.metadatas: 21 | md.clear() 22 | request.addfinalizer(clear) 23 | 24 | 25 | def test_basic(): 26 | class User(Table): 27 | __tablename__ = 'user' 28 | 29 | id = HashKeyField() 30 | name = Field(data_type=NUMBER) 31 | 32 | assert hasattr(User, '__mapper__') 33 | 34 | mapper = User.__mapper__ 35 | assert mapper.class_ == User 36 | 37 | 38 | class TestMetaData(object): 39 | def test_default(self): 40 | class User(Table): 41 | id = HashKeyField() 42 | mapper = User.__mapper__ 43 | 44 | assert mapper.metadata == metadata 45 | assert mapper in metadata 46 | 47 | def test_assign(self): 48 | md = MetaData() 49 | 50 | class User(Table): 51 | id = HashKeyField() 52 | __metadata__ = md 53 | mapper = User.__mapper__ 54 | 55 | assert mapper.metadata == md 56 | assert mapper in md 57 | 58 | def test_inherit_1(self): 59 | md = MetaData() 60 | 61 | class Hi(object): 62 | __metadata__ = md 63 | 64 | class User(Table, Hi): 65 | id = HashKeyField() 66 | 67 | mapper = User.__mapper__ 68 | assert mapper.metadata == md 69 | assert mapper in md 70 | 71 | def test_inherit_2(self): 72 | md = MetaData() 73 | 74 | class Hi(Table): 75 | __metadata__ = md 76 | id = HashKeyField() 77 | 78 | class User(Hi): 79 | name = Field() 80 | 81 | for cls in [Hi, User]: 82 | mapper = cls.__mapper__ 83 | assert mapper.metadata == md 84 | assert mapper in md 85 | 86 | def test_table(self): 87 | class DummyDynamizer(Dynamizer): 88 | pass 89 | 90 | md = MetaData(connection='DummyConnection', 91 | dynamizer=DummyDynamizer) 92 | 93 | class User(Table): 94 | id = HashKeyField() 95 | __metadata__ = md 96 | mapper = User.__mapper__ 97 | 98 | assert isinstance(mapper.table, dynamo.Table) 99 | 100 | 101 | class TestTablename(object): 102 | def test_basic(self): 103 | class User(Table): 104 | __tablename__ = 'iamname' 105 | id = HashKeyField() 106 | 107 | mapper = User.__mapper__ 108 | assert mapper.tablename == 'iamname' 109 | 110 | def test_default(self): 111 | class User(Table): 112 | id = HashKeyField() 113 | assert User.__mapper__.tablename == 'user' 114 | 115 | class UserName(Table): 116 | id = HashKeyField() 117 | assert UserName.__mapper__.tablename == 'user_name' 118 | 119 | def test_inherit(self): 120 | class Hi(object): 121 | __tablename__ = 'iamname' 122 | name = Field() 123 | 124 | class User(Table, Hi): 125 | id = HashKeyField() 126 | 127 | class BoomUser(User): 128 | __tablename__ = "boom" 129 | boom = Field() 130 | 131 | assert User.__mapper__.tablename == 'iamname' 132 | assert BoomUser.__mapper__.tablename == 'boom' 133 | 134 | 135 | class TestThroughput(object): 136 | def test_default(self): 137 | class User(Table): 138 | id = HashKeyField() 139 | 140 | mapper = User.__mapper__ 141 | assert mapper.throughput is None 142 | 143 | def test_basic(self): 144 | tp = {'read': 1, 'write': 5} 145 | 146 | class User(Table): 147 | __throughput__ = tp 148 | id = HashKeyField() 149 | 150 | mapper = User.__mapper__ 151 | assert mapper.throughput == tp 152 | 153 | def test_inherit_1(self): 154 | tp = {'read': 1, 'write': 6} 155 | Table.__throughput__ = tp 156 | 157 | class User(Table): 158 | id = HashKeyField() 159 | 160 | mapper = User.__mapper__ 161 | assert mapper.throughput == tp 162 | 163 | del Table.__throughput__ 164 | 165 | def test_inherit_2(self): 166 | tp = {'read': 1, 'write': 6} 167 | 168 | class Hi(object): 169 | __throughput__ = tp 170 | 171 | class User(Table, Hi): 172 | id = HashKeyField() 173 | 174 | mapper = User.__mapper__ 175 | assert mapper.throughput == tp 176 | 177 | 178 | class TestSchema(object): 179 | def test_not_schema(self): 180 | with pytest.raises(NoPrimaryKeyError): 181 | class User(Table): 182 | pass 183 | 184 | def test_hashkey(self): 185 | class User(Table): 186 | id = HashKeyField() 187 | 188 | mapper = User.__mapper__ 189 | assert 1 == len(mapper.schema) 190 | hk = mapper.schema[0] 191 | assert isinstance(hk, HashKey) 192 | assert hk.name == 'id' 193 | assert hk.data_type == STRING 194 | 195 | def test_rangekey(self): 196 | class User(Table): 197 | id = HashKeyField() 198 | name = RangeKeyField(data_type=NUMBER) 199 | 200 | self._check_rangekey(User) 201 | 202 | def _check_rangekey(self, User): 203 | mapper = User.__mapper__ 204 | assert 2 == len(mapper.schema) 205 | 206 | hk = mapper.schema[0] 207 | assert isinstance(hk, HashKey) 208 | assert hk.name == 'id' 209 | assert hk.data_type == STRING 210 | 211 | rk = mapper.schema[1] 212 | assert isinstance(rk, RangeKey) 213 | assert rk.name == 'name' 214 | assert rk.data_type == NUMBER 215 | 216 | def test_inherit_1(self): 217 | class Hi(object): 218 | id = HashKeyField() 219 | 220 | class User(Table, Hi): 221 | name = RangeKeyField(data_type=NUMBER) 222 | 223 | self._check_rangekey(User) 224 | 225 | def test_inherit_2(self): 226 | class Hi(TableBase): 227 | id = HashKeyField() 228 | 229 | class Table(with_metaclass(TableMeta, Hi)): 230 | pass 231 | 232 | class User(Table, Hi): 233 | name = RangeKeyField(data_type=NUMBER) 234 | 235 | self._check_rangekey(User) 236 | 237 | def test_inherit_3(self): 238 | class Hi(Table): 239 | id = HashKeyField() 240 | 241 | class User(Hi): 242 | name = RangeKeyField(data_type=NUMBER) 243 | 244 | self._check_rangekey(User) 245 | 246 | 247 | class TestAttribute(object): 248 | def test_basic(self): 249 | T = 42 250 | 251 | class User(Table): 252 | id = HashKeyField() 253 | name = Field() 254 | birth = Field(data_type=NUMBER, default=T); 255 | 256 | mapper = User.__mapper__ 257 | assert len(mapper.attributes) == 3 258 | assert User.id is mapper.attributes['id'] 259 | assert User.name is mapper.attributes['name'] 260 | assert User.birth is mapper.attributes['birth'] 261 | 262 | u = User(id='3', name='name') 263 | assert u.id == '3' 264 | assert u.name == 'name' 265 | assert u.birth == T 266 | 267 | def test_inherit_1(self): 268 | class Hi(object): 269 | id = HashKeyField() 270 | 271 | class User(Table, Hi): 272 | name = Field(data_type=NUMBER) 273 | 274 | mapper = User.__mapper__ 275 | assert User.id is mapper.attributes['id'] 276 | assert User.name is mapper.attributes['name'] 277 | 278 | def test_inherit_2(self): 279 | class Hi(Table): 280 | id = HashKeyField() 281 | 282 | class User(Hi): 283 | name = Field(data_type=NUMBER) 284 | 285 | mapper = User.__mapper__ 286 | assert User.id is mapper.attributes['id'] 287 | assert User.name is mapper.attributes['name'] 288 | 289 | 290 | class TestIndex(object): 291 | def test_default(self): 292 | class User(Table): 293 | id = HashKeyField() 294 | mapper = User.__mapper__ 295 | assert mapper.indexes == {} 296 | assert mapper.global_indexes == {} 297 | 298 | def test_basic(self): 299 | class User(Table): 300 | id = HashKeyField() 301 | name = RangeKeyField(data_type=NUMBER) 302 | birth = Field(data_type=NUMBER, default=lambda: time.time()) 303 | 304 | i1 = GlobalAllIndex(parts=[id, birth]) 305 | -------------------------------------------------------------------------------- /tests/test_metadata.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from builtins import object 4 | 5 | import pytest 6 | 7 | from kiwi.metadata import MetaData 8 | from kiwi.exceptions import * 9 | from kiwi.dynamo import Dynamizer 10 | 11 | 12 | class DummyMapper(object): 13 | def __init__(self, tname): 14 | self.tablename = tname 15 | self.throughput = None 16 | 17 | 18 | class DummyDynamizer(Dynamizer): 19 | pass 20 | 21 | 22 | def test_basic(): 23 | md = MetaData() 24 | assert md._tables == {} 25 | assert md.connection is None 26 | assert md.throughput is None 27 | 28 | md.configure(connection=3, throughput=4) 29 | assert md.connection == 3 30 | assert md.throughput == 4 31 | 32 | f = lambda cls: cls.__name__ 33 | md.configure(tablename_factory=f) 34 | assert md.tablename_factory is f 35 | 36 | md.configure() 37 | assert md.connection == 3 38 | assert md.throughput == 4 39 | assert md.tablename_factory is f 40 | 41 | a = DummyMapper('a') 42 | md.add(a) 43 | 44 | with pytest.raises(InvalidRequestError): 45 | md.configure() 46 | 47 | 48 | def test_tables(): 49 | md = MetaData() 50 | assert md._tables == {} 51 | 52 | md.add(DummyMapper('a')) 53 | md.add(DummyMapper('b')) 54 | md.add(DummyMapper('c')) 55 | md.add(DummyMapper('d')) 56 | 57 | with pytest.raises(InvalidRequestError): 58 | md.add(DummyMapper('a')) 59 | 60 | assert DummyMapper('a') in md 61 | md.remove(DummyMapper('a')) 62 | assert DummyMapper('a') not in md 63 | 64 | assert set(md) == set(['b', 'c', 'd']) 65 | assert len(list(md.values())) == 3 66 | assert len(list(md.items())) == 3 67 | 68 | md.clear() 69 | assert len(list(md)) == 0 70 | 71 | 72 | def test_dynamizer(): 73 | md = MetaData() 74 | assert md.dynamizer is None 75 | 76 | with pytest.raises(ArgumentError): 77 | md.configure(dynamizer=object) 78 | 79 | md.configure(dynamizer=DummyDynamizer) 80 | assert md.dynamizer is DummyDynamizer 81 | -------------------------------------------------------------------------------- /tests/test_query.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from builtins import object 4 | 5 | import pytest 6 | 7 | from boto.dynamodb2.types import * 8 | from kiwi import * 9 | 10 | 11 | class TestQuery(object): 12 | def test_basic(self, UserAction): 13 | assert 3 == UserAction.query().onkeys(UserAction.id == 1).count() 14 | 15 | def test_construct(self, UserAction): 16 | query = UserAction.query() 17 | assert query._mapper is UserAction.__mapper__ 18 | assert query._index is None 19 | assert query._attributes is None 20 | assert query._consistent is False 21 | assert query._max_page_size is None 22 | assert query._reverse is False 23 | assert query._limit is None 24 | 25 | query = UserAction.query(consistent=True, limit=5, max_page_size=3) 26 | assert query._max_page_size == 3 27 | assert query._consistent is True 28 | assert query._limit == 5 29 | 30 | def test_check_index(self, UserAction): 31 | query = UserAction.query(index=UserAction.dur_index) 32 | assert query._index == UserAction.dur_index.name 33 | 34 | query = UserAction.query(index=UserAction.dur_index.name) 35 | assert query._index == UserAction.dur_index.name 36 | 37 | query = UserAction.query(index=UserAction.result_index) 38 | assert query._index == UserAction.result_index.name 39 | 40 | with pytest.raises(ArgumentError): 41 | UserAction.query(index='abc') 42 | 43 | with pytest.raises(ArgumentError): 44 | UserAction.query(index=Index()) 45 | 46 | def test_check_attributes(self, UserAction): 47 | query = UserAction.query(attributes=[UserAction.time]) 48 | assert query._attributes == ['time'] 49 | 50 | def test_keyconds_primary(self, UserAction): 51 | # query on primary key 52 | query = UserAction.query() 53 | f = query._build_raw_filters(query._key_conds) 54 | assert f == {} 55 | 56 | with pytest.raises(ArgumentError): 57 | query.onkeys(4) 58 | with pytest.raises(ArgumentError): 59 | query.onkeys(UserAction.time == 4) 60 | with pytest.raises(ArgumentError): 61 | query.onkeys(UserAction.id < 4) 62 | with pytest.raises(ArgumentError): 63 | query.onkeys(UserAction.id == 3, 4) 64 | with pytest.raises(ArgumentError): 65 | query.onkeys(UserAction.id == 3, UserAction.name > 3) 66 | with pytest.raises(ArgumentError): 67 | query.onkeys(UserAction.id == 3, UserAction.time.isnone_()) 68 | 69 | query = UserAction.query().onkeys(UserAction.id == 3) 70 | f = query._build_raw_filters(query._key_conds) 71 | assert f == {'id__eq': 3} 72 | 73 | with pytest.raises(InvalidRequestError): 74 | query.onkeys(UserAction.id == 4) 75 | 76 | query = UserAction.query().onkeys( 77 | UserAction.id == 3, UserAction.time < 8) 78 | f = query._build_raw_filters(query._key_conds) 79 | assert f == {'id__eq': 3, 'time__lt': 8} 80 | 81 | def test_keyconds_local_idx(self, UserAction): 82 | # query on local index 83 | query = UserAction.query(index=UserAction.result_index) 84 | f = query._build_raw_filters(query._key_conds) 85 | assert f == {} 86 | 87 | with pytest.raises(ArgumentError): 88 | query.onkeys(4) 89 | with pytest.raises(ArgumentError): 90 | query.onkeys(UserAction.time == 4) 91 | with pytest.raises(ArgumentError): 92 | query.onkeys(UserAction.id < 4) 93 | with pytest.raises(ArgumentError): 94 | query.onkeys(UserAction.id == 3, 4) 95 | with pytest.raises(ArgumentError): 96 | query.onkeys(UserAction.id == 3, UserAction.name > 3) 97 | with pytest.raises(ArgumentError): 98 | query.onkeys(UserAction.id == 3, UserAction.result.isnone_()) 99 | 100 | query = UserAction.query( 101 | index=UserAction.result_index).onkeys(UserAction.id == 3) 102 | f = query._build_raw_filters(query._key_conds) 103 | assert f == {'id__eq': 3} 104 | 105 | with pytest.raises(InvalidRequestError): 106 | query.onkeys(UserAction.id == 4) 107 | 108 | query = UserAction.query(index=UserAction.result_index).onkeys( 109 | UserAction.id == 3, UserAction.result == 'a') 110 | f = query._build_raw_filters(query._key_conds) 111 | assert f == {'id__eq': 3, 'result__eq': 'a'} 112 | 113 | def test_keyconds_global_idx(self, UserAction): 114 | # query on global index 115 | query = UserAction.query(index=UserAction.dur_index) 116 | f = query._build_raw_filters(query._key_conds) 117 | assert f == {} 118 | 119 | with pytest.raises(ArgumentError): 120 | query.onkeys(4) 121 | with pytest.raises(ArgumentError): 122 | query.onkeys(UserAction.id == 4) 123 | with pytest.raises(ArgumentError): 124 | query.onkeys(UserAction.time < 4) 125 | with pytest.raises(ArgumentError): 126 | query.onkeys(UserAction.time == 3, 4) 127 | with pytest.raises(ArgumentError): 128 | query.onkeys(UserAction.time == 3, UserAction.name > 3) 129 | with pytest.raises(ArgumentError): 130 | query.onkeys(UserAction.time == 3, UserAction.duration.isnone_()) 131 | 132 | query = UserAction.query( 133 | index=UserAction.dur_index).onkeys(UserAction.time == 8) 134 | f = query._build_raw_filters(query._key_conds) 135 | assert f == {'time__eq': 8} 136 | 137 | with pytest.raises(InvalidRequestError): 138 | query.onkeys(UserAction.time == 4) 139 | 140 | query = UserAction.query(index=UserAction.dur_index).onkeys( 141 | UserAction.time == 8, UserAction.duration > 10) 142 | f = query._build_raw_filters(query._key_conds) 143 | assert f == {'time__eq': 8, 'duration__gt': 10} 144 | 145 | def test_filters(self, UserAction): 146 | query = UserAction.query().filter(UserAction.id == 3) 147 | q = query._build_raw_filters(query._filters) 148 | assert q == {'id__eq': 3} 149 | 150 | query = UserAction.query().filter(UserAction.duration > 3) 151 | q = query._build_raw_filters(query._filters) 152 | assert q == {'duration__gt': 3} 153 | 154 | with pytest.raises(InvalidRequestError): 155 | query.all() 156 | 157 | with pytest.raises(ArgumentError): 158 | UserAction.query().filter(3, 2) 159 | 160 | def test_reverse(self, UserAction): 161 | query = UserAction.query() 162 | assert query._reverse is False 163 | 164 | query.desc() 165 | assert query._reverse is True 166 | 167 | query.desc() 168 | assert query._reverse is True 169 | 170 | query.asc() 171 | assert query._reverse is False 172 | 173 | query.asc() 174 | assert query._reverse is False 175 | 176 | query.desc() 177 | assert query._reverse is True 178 | 179 | def test_limit(self, UserAction): 180 | query = UserAction.query() 181 | assert query._limit is None 182 | 183 | query.limit(3) 184 | assert query._limit == 3 185 | 186 | query.limit(5) 187 | assert query._limit == 5 188 | 189 | query = UserAction.query().onkeys(UserAction.id == 2) 190 | assert query.count() == 4 191 | 192 | query = query.clone().limit(2) 193 | assert query.count() == 2 194 | 195 | query = query.clone().limit(5) 196 | assert query.count() == 4 197 | 198 | query = query.clone().limit(1) 199 | assert query.count() == 1 200 | 201 | def test_first(self, UserAction): 202 | query = UserAction.query().onkeys(UserAction.id == 2) 203 | ua = query.first() 204 | assert ua.time == 2 205 | 206 | query = query.clone().desc() 207 | ua = query.first() 208 | assert ua.time == 10 209 | -------------------------------------------------------------------------------- /tests/test_table.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from builtins import zip 4 | from builtins import range 5 | from builtins import object 6 | 7 | import pytest 8 | 9 | from kiwi import * 10 | from boto.dynamodb2.types import * 11 | 12 | 13 | class TestTable(object): 14 | def test_hashkey(self, User): 15 | u = User(id=11, name='a') 16 | assert isinstance(u, User) 17 | assert u.id is 11 18 | assert u.name is 'a' 19 | u.save() 20 | 21 | u = User.get(11) 22 | assert isinstance(u, User) 23 | u.name = 'aa' 24 | u.save() 25 | 26 | u = User.get(11) 27 | assert isinstance(u, User) 28 | assert u.name == 'aa' 29 | u.destroySelf() 30 | 31 | u = User.get(11) 32 | assert u is None 33 | 34 | def test_hash_range(self, UserAction): 35 | u = UserAction(id=11, time=1111, name='bbbb') 36 | assert isinstance(u, UserAction) 37 | assert u.id is 11 38 | assert u.time is 1111 39 | assert u.name == 'bbbb' 40 | assert u.duration is None 41 | u.save() 42 | 43 | u = UserAction.get(11, 1111) 44 | assert isinstance(u, UserAction) 45 | u.name = 'cccc' 46 | u.save() 47 | 48 | u = UserAction.get(11, 1111) 49 | assert isinstance(u, UserAction) 50 | assert u.name == 'cccc' 51 | u.destroySelf() 52 | 53 | u = UserAction.get(11, 1111) 54 | assert u is None 55 | 56 | def test_default_value(self, User): 57 | u = User(id=20, name='20default') 58 | birth = u.birth 59 | 60 | assert birth == u.birth 61 | 62 | u.save() 63 | u = User.get(20) 64 | assert birth == u.birth 65 | 66 | u = User(id=21, name='21d', birth=3) 67 | assert 3 == u.birth 68 | 69 | u.save() 70 | u = User.get(21) 71 | assert 3 == u.birth 72 | 73 | def test_batch_get_1(self, User): 74 | def batch_get_keys(keys): 75 | return [u.id for u in User.batch_get(keys)] 76 | 77 | assert set(batch_get_keys([])) == set() 78 | assert set(batch_get_keys([1, 2, 3])) == set([1, 2, 3]) 79 | assert set(batch_get_keys([2, 8])) == set([2, 8]) 80 | assert set(batch_get_keys([5, 13])) == set([5]) 81 | assert set(batch_get_keys([222, 123])) == set() 82 | 83 | with pytest.raises(ArgumentError): 84 | User.batch_get([(2, 2)]) 85 | 86 | def test_batch_get_2(self, UserAction): 87 | def batch_get_keys(keys): 88 | return [(u.id, u.time) for u in UserAction.batch_get(keys)] 89 | 90 | assert set(batch_get_keys([])) == set() 91 | assert set(batch_get_keys([(1, 1), (2, 2)])) == set([(1, 1), (2, 2)]) 92 | assert set(batch_get_keys([(3, 5)])) == set([(3, 5)]) 93 | assert set(batch_get_keys([(4, 1222), (4, 12)])) == set([(4, 12)]) 94 | assert set(batch_get_keys([(222, 123)])) == set() 95 | 96 | with pytest.raises(ArgumentError): 97 | UserAction.batch_get([2, 2]) 98 | 99 | def test_batch_write_1(self, User): 100 | with User.batch_write() as batch: 101 | pass 102 | 103 | with User.batch_write() as batch: 104 | batch.add(User(id=100, name='100')) 105 | batch.add(User(id=101, name='101')) 106 | batch.add(User(id=102, name='102')) 107 | batch.add(User(id=103, name='103')) 108 | with pytest.raises(ArgumentError): 109 | batch.add(234) 110 | 111 | keys = list(range(100, 104)) 112 | assert set([u.id for u in User.batch_get(keys)]) == set(keys) 113 | 114 | with User.batch_write() as batch: 115 | batch.delete({'id': 100}) 116 | batch.delete(User(id=101)) 117 | 118 | with pytest.raises(ArgumentError): 119 | batch.delete(102) 120 | with pytest.raises(ArgumentError): 121 | batch.delete({'name': '103'}) 122 | 123 | assert set([u.id for u in User.batch_get(keys)]) == set([102, 103]) 124 | 125 | with User.batch_write() as batch: 126 | batch.add(User(id=100, name='100')) 127 | batch.delete(dict(id=102)) 128 | batch.delete(dict(id=103)) 129 | 130 | assert set([u.id for u in User.batch_get(keys)]) == set([100]) 131 | 132 | User.delete(id=100) 133 | assert set([u.id for u in User.batch_get(keys)]) == set() 134 | 135 | def test_batch_write_2(self, UserAction): 136 | UA = UserAction 137 | 138 | with UA.batch_write() as batch: 139 | pass 140 | 141 | with UA.batch_write() as batch: 142 | batch.add(UA(id=100, time=100, name='100')) 143 | batch.add(UA(id=101, time=101, name='101')) 144 | batch.add(UA(id=102, time=102, name='102')) 145 | batch.add(UA(id=103, time=103, name='103')) 146 | 147 | with pytest.raises(ArgumentError): 148 | batch.add(123444) 149 | 150 | keys = list(zip(list(range(100, 104)), list(range(100, 104)))) 151 | assert set([(u.id, u.time) for u in UA.batch_get(keys)]) == set(keys) 152 | 153 | with UA.batch_write() as batch: 154 | batch.delete(UA(id=100, time=100)) 155 | batch.delete({'id': 101, 'time': 101}) 156 | 157 | with pytest.raises(ArgumentError): 158 | batch.delete(102) 159 | with pytest.raises(ArgumentError): 160 | batch.delete({'time': 103}) 161 | 162 | assert set([(u.id, u.time) for u in UA.batch_get(keys)] 163 | ) == set([(102, 102), (103, 103)]) 164 | 165 | with UA.batch_write() as batch: 166 | batch.delete({'id': 102, 'time': 102}) 167 | batch.add(UA(id=100, time=100, name='100')) 168 | batch.delete({'id': 103, 'time': 103}) 169 | 170 | assert set([(u.id, u.time) for u in UA.batch_get(keys)] 171 | ) == set([(100, 100)]) 172 | 173 | UA.delete(id=100, time=100) 174 | assert set([(u.id, u.time) for u in UA.batch_get(keys)]) == set() 175 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py27,py34,py35,py36,pypy 3 | [testenv] 4 | deps=pytest 5 | commands=py.test 6 | --------------------------------------------------------------------------------