├── .gitignore ├── AUTHORS ├── LICENSE ├── MANIFEST.in ├── README.rst ├── doc ├── .gitignore ├── CHANGES ├── Makefile ├── _static │ └── .placeholder ├── conf.py ├── index.rst ├── make.bat └── python-api.rst ├── setup.py ├── test ├── __init__.py ├── conf │ ├── local.ini │ └── local_session.ini ├── couch_util.py ├── test_client.py ├── test_session │ ├── __init__.py │ └── test_session.py ├── test_usermgmt.py └── util.py └── trombi ├── __init__.py ├── client.py └── errors.py /.gitignore: -------------------------------------------------------------------------------- 1 | tmp 2 | *.py[co] 3 | /MANIFEST 4 | /build 5 | /dist 6 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | trombi was orignally developed at Inoi ltd by: 2 | 3 | - Petri Lehtinen (https://github.com/akheron) 4 | - Jyrki Pulliainen (https://github.com/nailor) 5 | 6 | Since open sourcing it following people have helped to develope it 7 | further (in alphabetical order by last name): 8 | 9 | - Jarrod Baumann (https://github.com/jarrodb) 10 | - David Björkevik (https://github.com/bjorkegeek) 11 | - Jeremy Kelley (https://github.com/nod) 12 | - Daniel Truemper (https://github.com/retresco) 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2011 Jyrki Pulliainen 2 | Copyright (c) 2010 Inoi Oy 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining 5 | a copy of this software and associated documentation files (the 6 | "Software"), to deal in the Software without restriction, including 7 | without limitation the rights to use, copy, modify, merge, publish, 8 | distribute, sublicense, and/or sell copies of the Software, and to 9 | permit persons to whom the Software is furnished to do so, subject to 10 | the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 17 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 18 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 19 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 20 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 21 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | recursive-include trombi *.py 2 | recursive-include test *.py 3 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Trombi README 2 | ============= 3 | 4 | Trombi is an asynchronous CouchDB_ client for Tornado_. 5 | 6 | *trombi* is Finnish for a small tornado, occuring in Europe. 7 | 8 | 9 | Requirements 10 | ------------ 11 | 12 | * Python_ 2.6+ 13 | 14 | * Tornado_ 1.0+ 15 | 16 | For running tests: 17 | 18 | * couchdb-python_ 19 | 20 | * nose_ 21 | 22 | 23 | Documentation 24 | ------------- 25 | 26 | Documentation created using Sphinx_ is available in *doc/* directory. 27 | Compiling documentation requires version 0.6.x of Sphinx. 28 | 29 | Online documentation can be seen on `Github pages`_. 30 | 31 | Issues are reported in `Github`_ and there's also `a mailing list`_ 32 | available in Google Groups. 33 | 34 | Example program 35 | --------------- 36 | 37 | :: 38 | 39 | import trombi 40 | from tornado.ioloop import IOLoop 41 | 42 | def main(): 43 | server = trombi.Server('http://localhost:5984') 44 | server.get('my_database', database_created, create=True) 45 | 46 | def database_created(db): 47 | if db.error: 48 | print 'Unable to create database!' 49 | print db.msg 50 | ioloop.stop() 51 | else: 52 | db.set('my_document', {'testvalue': 'something'}, doc_created) 53 | 54 | def doc_created(doc): 55 | if doc.error: 56 | print 'Unable to create document!' 57 | print doc.msg 58 | else: 59 | print 'Doc added!' 60 | 61 | ioloop.stop() 62 | 63 | if __name__ == '__main__': 64 | ioloop = IOLoop.instance() 65 | ioloop.add_callback(main) 66 | ioloop.start() 67 | 68 | 69 | More usage examples can be found in tests. 70 | 71 | Authors 72 | ------- 73 | 74 | Possibly incomplete list of authors can be found in AUTHORS file. 75 | 76 | License 77 | ------- 78 | 79 | Trombi is licensed under MIT License. See *LICENSE* for more 80 | information. 81 | 82 | .. _CouchDB: http://couchdb.apache.org/ 83 | 84 | .. _Python: http://python.org/ 85 | 86 | .. _Tornado: http://tornadoweb.org/ 87 | 88 | .. _couchdb-python: http://code.google.com/p/couchdb-python/ 89 | 90 | .. _nose: http://somethingaboutorange.com/mrl/projects/nose/ 91 | 92 | .. _sphinx: http://sphinx.pocoo.org/ 93 | 94 | .. _github pages: http://inoi.github.com/trombi/ 95 | 96 | .. _Github: http://github.com/inoi/trombi/ 97 | 98 | .. _a mailing list: http://groups.google.com/group/python-trombi?lnk=gcimh 99 | -------------------------------------------------------------------------------- /doc/.gitignore: -------------------------------------------------------------------------------- 1 | _build -------------------------------------------------------------------------------- /doc/CHANGES: -------------------------------------------------------------------------------- 1 | 0.9.2 2 | ----- 3 | 4 | * Really fix the bug the 0.9.1 says to fix this time 5 | 6 | 0.9.1 7 | ----- 8 | 9 | * Fix a major bug, where headers got accidentally reused between 10 | requests 11 | 12 | 0.9.0 13 | ----- 14 | 15 | Views: 16 | 17 | * Add support for querying _all_docs (Thanks Jeremy Kelley) 18 | * Add support for bulk_docs 19 | * Add support for changes feed 20 | * Introduce Paginator for paginating results (thanks Jarrod Baumann) 21 | 22 | Documents: 23 | 24 | * Drop support for Document._as_dict, which was deprecated in 0.8 25 | 26 | Other: 27 | 28 | * Improve error handling in various places 29 | * Support Tornado's SimpleHTTPClient 30 | * Various bug fixes 31 | 32 | 0.8 33 | --- 34 | 35 | Views: 36 | 37 | * Add support for passing keys argument to a view 38 | * Correctly count the length of the view using self._rows 39 | 40 | Documents: 41 | 42 | * Allow deletion of bare documents 43 | * Implement raw() method for returning raw CouchDB data 44 | 45 | This introduces a small, upcoming API change by deprecating 46 | Document._as_dict. 47 | 48 | Server: 49 | 50 | * Add correct Content-Type header to fetch 51 | * Correctly overwrite fetch_args with kwargs, not the other way around 52 | 53 | Other: 54 | 55 | * tests: Modify tests so that they work with 1.0.0+ CouchDB 56 | * readme: Add mention about issue tracker and mailing list 57 | * Add this CHANGES document 58 | -------------------------------------------------------------------------------- /doc/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 | # Internal variables. 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 14 | 15 | .PHONY: help clean html dirhtml pickle json htmlhelp qthelp latex changes linkcheck doctest 16 | 17 | help: 18 | @echo "Please use \`make ' where is one of" 19 | @echo " html to make standalone HTML files" 20 | @echo " dirhtml to make HTML files named index.html in directories" 21 | @echo " pickle to make pickle files" 22 | @echo " json to make JSON files" 23 | @echo " htmlhelp to make HTML files and a HTML help project" 24 | @echo " qthelp to make HTML files and a qthelp project" 25 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 26 | @echo " changes to make an overview of all changed/added/deprecated items" 27 | @echo " linkcheck to check all external links for integrity" 28 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 29 | 30 | clean: 31 | -rm -rf $(BUILDDIR)/* 32 | 33 | html: 34 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 35 | @echo 36 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 37 | 38 | dirhtml: 39 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 40 | @echo 41 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 42 | 43 | pickle: 44 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 45 | @echo 46 | @echo "Build finished; now you can process the pickle files." 47 | 48 | json: 49 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 50 | @echo 51 | @echo "Build finished; now you can process the JSON files." 52 | 53 | htmlhelp: 54 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 55 | @echo 56 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 57 | ".hhp project file in $(BUILDDIR)/htmlhelp." 58 | 59 | qthelp: 60 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 61 | @echo 62 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 63 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 64 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/trombi.qhcp" 65 | @echo "To view the help file:" 66 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/trombi.qhc" 67 | 68 | latex: 69 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 70 | @echo 71 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 72 | @echo "Run \`make all-pdf' or \`make all-ps' in that directory to" \ 73 | "run these through (pdf)latex." 74 | 75 | changes: 76 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 77 | @echo 78 | @echo "The overview file is in $(BUILDDIR)/changes." 79 | 80 | linkcheck: 81 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 82 | @echo 83 | @echo "Link check complete; look for any errors in the above output " \ 84 | "or in $(BUILDDIR)/linkcheck/output.txt." 85 | 86 | doctest: 87 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 88 | @echo "Testing of doctests in the sources finished, look at the " \ 89 | "results in $(BUILDDIR)/doctest/output.txt." 90 | -------------------------------------------------------------------------------- /doc/_static/.placeholder: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/inoi/trombi/f985d430d402717d54b40c320f1828edc2731397/doc/_static/.placeholder -------------------------------------------------------------------------------- /doc/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # trombi documentation build configuration file, created by 4 | # sphinx-quickstart on Mon Aug 30 20:00:26 2010. 5 | # 6 | # This file is execfile()d with the current directory set to its containing dir. 7 | # 8 | # Note that not all possible configuration values are present in this 9 | # autogenerated file. 10 | # 11 | # All configuration values have a default; values that are commented out 12 | # serve to show the default. 13 | 14 | import sys, os 15 | 16 | # If extensions (or modules to document with autodoc) are in another directory, 17 | # add these directories to sys.path here. If the directory is relative to the 18 | # documentation root, use os.path.abspath to make it absolute, like shown here. 19 | #sys.path.append(os.path.abspath('.')) 20 | 21 | # -- General configuration ----------------------------------------------------- 22 | 23 | # Add any Sphinx extension module names here, as strings. They can be extensions 24 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 25 | extensions = [] 26 | 27 | # Add any paths that contain templates here, relative to this directory. 28 | templates_path = ['_templates'] 29 | 30 | # The suffix of source filenames. 31 | source_suffix = '.rst' 32 | 33 | # The encoding of source files. 34 | #source_encoding = 'utf-8' 35 | 36 | # The master toctree document. 37 | master_doc = 'index' 38 | 39 | # General information about the project. 40 | project = u'trombi' 41 | copyright = u'2011, Jyrki Pulliainen; 2010, Inoi Oy' 42 | 43 | # The version info for the project you're documenting, acts as replacement for 44 | # |version| and |release|, also used in various other places throughout the 45 | # built documents. 46 | # 47 | # The short X.Y version. 48 | version = '0.9' 49 | # The full version, including alpha/beta/rc tags. 50 | release = '0.9.2' 51 | 52 | # The language for content autogenerated by Sphinx. Refer to documentation 53 | # for a list of supported languages. 54 | #language = None 55 | 56 | # There are two options for replacing |today|: either, you set today to some 57 | # non-false value, then it is used: 58 | #today = '' 59 | # Else, today_fmt is used as the format for a strftime call. 60 | #today_fmt = '%B %d, %Y' 61 | 62 | # List of documents that shouldn't be included in the build. 63 | #unused_docs = [] 64 | 65 | # List of directories, relative to source directory, that shouldn't be searched 66 | # for source files. 67 | exclude_trees = ['_build'] 68 | 69 | # The reST default role (used for this markup: `text`) to use for all documents. 70 | #default_role = None 71 | 72 | # If true, '()' will be appended to :func: etc. cross-reference text. 73 | #add_function_parentheses = True 74 | 75 | # If true, the current module name will be prepended to all description 76 | # unit titles (such as .. function::). 77 | #add_module_names = True 78 | 79 | # If true, sectionauthor and moduleauthor directives will be shown in the 80 | # output. They are ignored by default. 81 | #show_authors = False 82 | 83 | # The name of the Pygments (syntax highlighting) style to use. 84 | pygments_style = 'sphinx' 85 | 86 | # A list of ignored prefixes for module index sorting. 87 | #modindex_common_prefix = [] 88 | 89 | 90 | # -- Options for HTML output --------------------------------------------------- 91 | 92 | # The theme to use for HTML and HTML Help pages. Major themes that come with 93 | # Sphinx are currently 'default' and 'sphinxdoc'. 94 | html_theme = 'default' 95 | 96 | # Theme options are theme-specific and customize the look and feel of a theme 97 | # further. For a list of options available for each theme, see the 98 | # documentation. 99 | #html_theme_options = {} 100 | 101 | # Add any paths that contain custom themes here, relative to this directory. 102 | #html_theme_path = [] 103 | 104 | # The name for this set of Sphinx documents. If None, it defaults to 105 | # " v documentation". 106 | #html_title = None 107 | 108 | # A shorter title for the navigation bar. Default is the same as html_title. 109 | #html_short_title = None 110 | 111 | # The name of an image file (relative to this directory) to place at the top 112 | # of the sidebar. 113 | #html_logo = None 114 | 115 | # The name of an image file (within the static path) to use as favicon of the 116 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 117 | # pixels large. 118 | #html_favicon = None 119 | 120 | # Add any paths that contain custom static files (such as style sheets) here, 121 | # relative to this directory. They are copied after the builtin static files, 122 | # so a file named "default.css" will overwrite the builtin "default.css". 123 | html_static_path = ['_static'] 124 | 125 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 126 | # using the given strftime format. 127 | #html_last_updated_fmt = '%b %d, %Y' 128 | 129 | # If true, SmartyPants will be used to convert quotes and dashes to 130 | # typographically correct entities. 131 | #html_use_smartypants = True 132 | 133 | # Custom sidebar templates, maps document names to template names. 134 | #html_sidebars = {} 135 | 136 | # Additional templates that should be rendered to pages, maps page names to 137 | # template names. 138 | #html_additional_pages = {} 139 | 140 | # If false, no module index is generated. 141 | #html_use_modindex = True 142 | 143 | # If false, no index is generated. 144 | #html_use_index = True 145 | 146 | # If true, the index is split into individual pages for each letter. 147 | #html_split_index = False 148 | 149 | # If true, links to the reST sources are added to the pages. 150 | #html_show_sourcelink = True 151 | 152 | # If true, an OpenSearch description file will be output, and all pages will 153 | # contain a tag referring to it. The value of this option must be the 154 | # base URL from which the finished HTML is served. 155 | #html_use_opensearch = '' 156 | 157 | # If nonempty, this is the file name suffix for HTML files (e.g. ".xhtml"). 158 | #html_file_suffix = '' 159 | 160 | # Output file base name for HTML help builder. 161 | htmlhelp_basename = 'trombidoc' 162 | 163 | 164 | # -- Options for LaTeX output -------------------------------------------------- 165 | 166 | # The paper size ('letter' or 'a4'). 167 | #latex_paper_size = 'letter' 168 | 169 | # The font size ('10pt', '11pt' or '12pt'). 170 | #latex_font_size = '10pt' 171 | 172 | # Grouping the document tree into LaTeX files. List of tuples 173 | # (source start file, target name, title, author, documentclass [howto/manual]). 174 | latex_documents = [ 175 | ('index', 'trombi.tex', u'trombi Documentation', 176 | u'Inoi Oy', 'manual'), 177 | ] 178 | 179 | # The name of an image file (relative to this directory) to place at the top of 180 | # the title page. 181 | #latex_logo = None 182 | 183 | # For "manual" documents, if this is true, then toplevel headings are parts, 184 | # not chapters. 185 | #latex_use_parts = False 186 | 187 | # Additional stuff for the LaTeX preamble. 188 | #latex_preamble = '' 189 | 190 | # Documents to append as an appendix to all manuals. 191 | #latex_appendices = [] 192 | 193 | # If false, no module index is generated. 194 | #latex_use_modindex = True 195 | -------------------------------------------------------------------------------- /doc/index.rst: -------------------------------------------------------------------------------- 1 | .. trombi documentation master file, created by 2 | sphinx-quickstart on Mon Aug 30 20:00:26 2010. 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 trombi's documentation! 7 | ================================== 8 | 9 | This is an API documentation for trombi |version|, last updated on 10 | |today|. 11 | 12 | Contents: 13 | 14 | .. toctree:: 15 | :maxdepth: 2 16 | 17 | python-api 18 | 19 | 20 | Introduction 21 | ============ 22 | 23 | Trombi provides an asynchronous CouchDB_ API for `tornado web server`_. 24 | Motivation behind trombi was the lack of an asynchronous API to 25 | CouchDB. As tornado ships with excellent :class:`AsyncHTTPClient` 26 | and the CouchDB has an excellent RESTful API, making our own seemed 27 | like a good idea. 28 | 29 | Idea of trombi is to ship a simple to use API (in terms of simple 30 | asynchronous APIs, of course) that requires minimum effort to use the 31 | CouchDB in a tornado application. API has evolved a bit in the history 32 | and probably will evolve in the future, so brace yourself for future 33 | API changes, if you plan to use the bleeding edge trombi. The API 34 | might still have some rough edges too but it is currently used in 35 | production environment. 36 | 37 | .. _CouchDB: http://couchdb.apache.org/ 38 | 39 | .. _tornado web server: http://tornadoweb.org/ 40 | 41 | 42 | 43 | Indices and tables 44 | ================== 45 | 46 | * :ref:`genindex` 47 | * :ref:`modindex` 48 | * :ref:`search` 49 | 50 | -------------------------------------------------------------------------------- /doc/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | set SPHINXBUILD=sphinx-build 6 | set BUILDDIR=_build 7 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . 8 | if NOT "%PAPER%" == "" ( 9 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 10 | ) 11 | 12 | if "%1" == "" goto help 13 | 14 | if "%1" == "help" ( 15 | :help 16 | echo.Please use `make ^` where ^ is one of 17 | echo. html to make standalone HTML files 18 | echo. dirhtml to make HTML files named index.html in directories 19 | echo. pickle to make pickle files 20 | echo. json to make JSON files 21 | echo. htmlhelp to make HTML files and a HTML help project 22 | echo. qthelp to make HTML files and a qthelp project 23 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 24 | echo. changes to make an overview over all changed/added/deprecated items 25 | echo. linkcheck to check all external links for integrity 26 | echo. doctest to run all doctests embedded in the documentation if enabled 27 | goto end 28 | ) 29 | 30 | if "%1" == "clean" ( 31 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 32 | del /q /s %BUILDDIR%\* 33 | goto end 34 | ) 35 | 36 | if "%1" == "html" ( 37 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 38 | echo. 39 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 40 | goto end 41 | ) 42 | 43 | if "%1" == "dirhtml" ( 44 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 45 | echo. 46 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 47 | goto end 48 | ) 49 | 50 | if "%1" == "pickle" ( 51 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 52 | echo. 53 | echo.Build finished; now you can process the pickle files. 54 | goto end 55 | ) 56 | 57 | if "%1" == "json" ( 58 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 59 | echo. 60 | echo.Build finished; now you can process the JSON files. 61 | goto end 62 | ) 63 | 64 | if "%1" == "htmlhelp" ( 65 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 66 | echo. 67 | echo.Build finished; now you can run HTML Help Workshop with the ^ 68 | .hhp project file in %BUILDDIR%/htmlhelp. 69 | goto end 70 | ) 71 | 72 | if "%1" == "qthelp" ( 73 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 74 | echo. 75 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 76 | .qhcp project file in %BUILDDIR%/qthelp, like this: 77 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\trombi.qhcp 78 | echo.To view the help file: 79 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\trombi.ghc 80 | goto end 81 | ) 82 | 83 | if "%1" == "latex" ( 84 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 85 | echo. 86 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 87 | goto end 88 | ) 89 | 90 | if "%1" == "changes" ( 91 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 92 | echo. 93 | echo.The overview file is in %BUILDDIR%/changes. 94 | goto end 95 | ) 96 | 97 | if "%1" == "linkcheck" ( 98 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 99 | echo. 100 | echo.Link check complete; look for any errors in the above output ^ 101 | or in %BUILDDIR%/linkcheck/output.txt. 102 | goto end 103 | ) 104 | 105 | if "%1" == "doctest" ( 106 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 107 | echo. 108 | echo.Testing of doctests in the sources finished, look at the ^ 109 | results in %BUILDDIR%/doctest/output.txt. 110 | goto end 111 | ) 112 | 113 | :end 114 | -------------------------------------------------------------------------------- /doc/python-api.rst: -------------------------------------------------------------------------------- 1 | .. highlight:: python 2 | 3 | .. _python-api: 4 | 5 | ************* 6 | API Reference 7 | ************* 8 | 9 | .. module:: trombi 10 | 11 | This module consists of two different classes indicating query result, 12 | :class:`TrombiObject` and :class:`TrombiError`, first for the 13 | succesful API response and the latter for errorneus API response. All 14 | classes representing different data objects of CouchDB_ subclass 15 | :class:`TrombiObject`. 16 | 17 | .. _CouchDB: http://couchdb.apache.org/ 18 | 19 | Helper methods 20 | ============== 21 | 22 | .. method:: trombi.from_uri(uri[, fetch_args={}, io_loop=None, **kwargs]) 23 | 24 | Constructs a :class:`Database` instance from *uri*. The *uri* 25 | format is the http-path to the database, for example 26 | ``http://localhost:5984/my-database``. Additional arguments can be 27 | given and they are passed to the :class:`Server` object upon 28 | creation. 29 | 30 | Result objects 31 | ============== 32 | 33 | .. class:: TrombiError 34 | 35 | A common error class indicating that an error has happened 36 | 37 | .. attribute:: error 38 | 39 | Indicates that error happened. Always *True*. 40 | 41 | .. class:: TrombiErrorResponse 42 | 43 | Returned upon errorneus CouchDB API call. This is generally a call 44 | that results in other than 2xx response code. 45 | 46 | Subclasses :class:`TrombiError`. 47 | 48 | .. attribute:: errno 49 | 50 | Error number. Trombi error numbers are available in 51 | :mod:`trombi.errors`. Unless something really odd happened, 52 | it's one of the following: 53 | 54 | .. attribute:: errors.BAD_REQUEST 55 | errors.NOT_FOUND 56 | errors.CONFLICT 57 | errors.PRECONDITION_FAILED 58 | errors.SERVER_ERROR 59 | 60 | These map to HTTP error codes respectively. 61 | 62 | .. attribute:: errors.INVALID_DATABASE_NAME 63 | 64 | A custom error code to distinct from overloaded 65 | :attr:`errors.SERVER_ERROR`. Means that the 66 | database name was invalid. **Note:** This can be returned 67 | without connecting to database, so your callback method might 68 | be called immediately without going back to the IOLoop. 69 | 70 | .. attribute:: msg 71 | 72 | Textual representation of error. This might be JSON_ as returned 73 | by CouchDB, but trombi makes no effort trying to decode it. 74 | 75 | .. _JSON: http://json.org/ 76 | 77 | .. class:: TrombiObject 78 | 79 | Returned upon succesful CouchDB call. This is also superclass for 80 | all data object classes presented below. 81 | 82 | .. attribute:: error 83 | 84 | Indicates succesful response, always *False*. 85 | 86 | .. class:: TrombiResult 87 | 88 | A generic result indicating a succesfull call. Used for example in 89 | :meth:`Database.list`. Subclasses :class:`TrombiObject`. 90 | 91 | .. attribute:: content 92 | 93 | Contains the result of the query. The result format is not 94 | specified. 95 | 96 | .. class:: TrombiDict 97 | 98 | A dict-like object for successful responses. Subclasses 99 | :class:`TrombiObject`. 100 | 101 | .. method:: to_basetype() 102 | 103 | Returns the copy of the contents of the :class:`TrombiDict` 104 | instance as a normal dict. 105 | 106 | .. class:: ViewResult 107 | 108 | A special result object that represents a succesful view result. 109 | Subclasses :class:`TrombiObject` and 110 | :class:`collections.Sequence`. 111 | 112 | Due to the subclassing of :class:`collections.Sequence`, behaves 113 | kind of like a tuple. Supports :func:`len`, accessing items with 114 | dictionary like syntax and iterating over result rows using 115 | :func:`iter`. 116 | 117 | .. attribute:: total_rows 118 | 119 | Total rows of the view as returned by CouchDB 120 | 121 | .. attribute:: offset 122 | 123 | Offset of the view as returned by CouchDB 124 | 125 | .. class:: BulkResult 126 | 127 | A special result object for CouchDB's bulk API responses. 128 | Subclasses :class:`TrombiObject` and :class:`collections.Sequence`. 129 | 130 | Due to the subclassing of :class:`collections.Sequence`, behaves 131 | kind of like a tuple. Supports :func:`len`, accessing items with 132 | dictionary like syntax and iterating over result :func:`iter`. 133 | 134 | .. attribute:: content 135 | 136 | The processed bulk API response content. Consists of instances 137 | of either :class:`BulkObject` or :class:`BulkError`. 138 | 139 | .. class:: BulkObject 140 | 141 | A special result object for a single successful CouchDB's bulk API 142 | response. Subclasses :class:`TrombiObject` and 143 | :class:`collections.Mapping`. 144 | 145 | Due to the subclassing of :class:`collections.Mapping`, behaves 146 | like a immutable dictionary. Can be converted to a dictionary 147 | object using built-in function :func:`dict`. 148 | 149 | .. class:: BulkError 150 | 151 | Indicates a single error response from bulk API. Subclasses 152 | :class:`TrombiError`. 153 | 154 | .. attribute:: error_type 155 | 156 | The error type given by bulk API 157 | 158 | .. attribute:: reason 159 | 160 | The reason given by bulk API 161 | 162 | 163 | Server 164 | ====== 165 | 166 | In case of an error, if not otherwise mentioned, all the following 167 | methods call callback function with :class:`TrombiError` as an 168 | argument. 169 | 170 | .. class:: Server(baseurl[, fetch_args={}, io_loop=None, json_encoder, **client_args]) 171 | 172 | Represents the connection to a CouchDB server. Subclass of 173 | :class:`TrombiObject`. 174 | 175 | Has one required argument *baseurl* which is an URI to CouchDB 176 | database. If the *baseurl* ends in a slash (``/``), it is removed. 177 | 178 | To ease testing a custom :class:`tornado.ioloop.IOLoop` instance 179 | can be passed as a keyword argument. 180 | 181 | .. attribute:: baseurl 182 | io_loop 183 | 184 | These two store the given arguments. 185 | 186 | .. attribute:: error 187 | 188 | Indicates an error, always *False*. 189 | 190 | .. attribute:: fetch_args 191 | 192 | Provides a way to pass in additional keyword arguments to the 193 | tornado's :meth:`AsyncHTTPClient.fetch()` call. In particular, 194 | by passing in ``auth_username`` and ``auth_password`` as keyword 195 | arguments, we can now use CouchDB servers using HTTP Basic 196 | Authentication. 197 | 198 | .. attribute:: json_encoder 199 | 200 | A custom json_encoder can be defined with parameter 201 | *json_encoder*. At this point, this encoder is only used when 202 | adding or modifying documents. 203 | 204 | .. attribute:: client_args 205 | 206 | These additional arguments are directly passed to the 207 | :meth:`AsyncHTTPClient` upon creation. This way the user can 208 | configure the underlying HTTP client, for example to allow more 209 | concurrent connections by passing 210 | ``max_simultaneous_connections`` keyword argument. 211 | 212 | .. method:: create(name, callback) 213 | 214 | Creates a new database. Has two required arguments, the *name* 215 | of the new database and the *callback* function. 216 | 217 | On success the callback function is called with newly created 218 | :class:`Database` as an argument. 219 | 220 | .. method:: get(name, callback[, create=False]) 221 | 222 | Tries to open database named *name*. Optional keyword argument 223 | *create* can be given to indicate that if the database does not 224 | exist, trombi tries to create it. As with 225 | :meth:`create`, calls the *callback* with a 226 | :class:`Database` on success. 227 | 228 | .. method:: delete(name, callback) 229 | 230 | Deletes a database named *name*. On success, calls *callback* 231 | with an empty :class:`TrombiObject` as an argument. 232 | 233 | .. method:: list(callback) 234 | 235 | Lists available databases. On success, calls *callback* with a 236 | generator object containing all databases. 237 | 238 | .. method:: add_user(name, password, callback, doc=None) 239 | 240 | Add a user with *name* and *password* to the *_users* database. 241 | On success, calls *callback* with the users :class:`Document`. 242 | If you want to store additional attributes in the user's 243 | document, provide them as a *doc* dict. 244 | 245 | .. method:: get_user(name, callback, attachments=False) 246 | 247 | Load the user's document identified by *name*. Optionally 248 | retrieve the *attachments*. 249 | 250 | .. method:: update_user(user_doc, callback) 251 | 252 | Update the document for the user. On success, *callback* is 253 | called with the new :class:`Document`. 254 | 255 | .. method:: update_user_password(username, password, callback) 256 | 257 | Only update the user's password. On success, *callback* is 258 | called with the new :class:`Document`. 259 | 260 | .. method:: delete_user(user_doc, callback) 261 | 262 | Delete a user from the CouchDB database. On success, *callback* 263 | will be called with :class:`Database` as an argument. 264 | 265 | .. method:: login(username, password, callback) 266 | 267 | This method performs a login against `CouchDB session API`_ using 268 | *username* and *password*. On succesfull login session cookie is 269 | stored for subsequent requests and *callback* is called with 270 | :class:`TrombiResult` as an argument. 271 | 272 | Note, that the username and password are sent unencrypted on the 273 | wire, so this method should be used either in fully trusted 274 | network or over HTTPS connection. 275 | 276 | .. method:: logout(callback) 277 | 278 | This method performs a logout against `CouchDB session API`_. If 279 | logout is succesfull, old session cookie is no longer used on 280 | subsequent requests and *callback* is called with 281 | :class:`TrombiResult` instance as an argument. 282 | 283 | .. method:: session(callback) 284 | 285 | This method fetches login details from `CouchDB Session API`_. 286 | On success the *callback* is called with :class:`TrombiResult` 287 | instance as an argument. 288 | 289 | .. _CouchDB session API: http://wiki.apache.org/couchdb/Session_API 290 | 291 | Database 292 | ======== 293 | 294 | In case of an error, if not otherwise mentioned, all the following 295 | methods call callback function with :class:`TrombiError` as an 296 | argument. 297 | 298 | .. class:: Database(server, name) 299 | 300 | Represents a CouchDB database. Has two required argument, *server* 301 | and *name* where *server* denotes the :class:`Server` where 302 | database is and *name* is the name of the database. 303 | 304 | Normally there's no need to create :class:`Database` objects 305 | as they are created via :meth:`Server.create` and 306 | :meth:`Server.get`. Subclass of :class:`TrombiObject`. 307 | 308 | .. method:: info(callback) 309 | 310 | Request database information. Calls callback with a 311 | :class:`TrombiDict` that contains the info (see `here`__ for the 312 | dict fields). 313 | 314 | __ http://techzone.couchbase.com/sites/default/files/uploads/all/documentation/couchbase-api-db.html#couchbase-api-db_db_get 315 | 316 | .. method:: set([doc_id, ]data, callback[, attachments=None]) 317 | 318 | Creates a new or modifies an existing document in the database. 319 | If called with two positional arguments, the first argument, 320 | *doc_id* is the document id of the new or existing document. If 321 | only one positional argument is given the document id is 322 | generated by the database. *data* is the data to the document, 323 | either a Python :class:`dict` or an instance of 324 | :class:`Document`. *doc_id* can be omitted if *data* is an 325 | existing document. 326 | 327 | This method makes distinction between creating a new document 328 | and updating an existing by inspecting the *data* argument. If 329 | *data* is a :class:`Document` with attributes *rev* and 330 | *id* set, it tries to update existing document. Otherwise it 331 | tries to create a new document containing *data*. 332 | 333 | Inline attachments can be passed to function with optional 334 | keyword argument *attachments*. *attachments* is a :class:`dict` 335 | with a format somewhat similiar to CouchDB:: 336 | 337 | {: (, )} 338 | 339 | If *content_type* is None, ``text/plain`` is assumed. 340 | 341 | On succesful creation or update the *callback* is called with 342 | :class:`Document` as an argument. 343 | 344 | .. method:: get(doc_id, callback[, attachments=False) 345 | 346 | Loads a document *doc_id* from the database. If optional keyword 347 | argument *attachments* is given the inline attachments of the 348 | document are loaded. 349 | 350 | On success calls *callback* with :class:`Document` as an 351 | argument. 352 | 353 | **Note:** If there's no document with document id *doc_id* this 354 | function calls *callback* with argument *None*. Implementer 355 | should always check for *None* before checking the *error* 356 | attribute of the result object. 357 | 358 | .. method:: get_attachment(doc_id, attachment_name, callback) 359 | 360 | Load the attachment *attachment_name* of the document *doc_id*. 361 | On success, *callback* is called with the raw attachment data. 362 | If the given document or attachment is not found, *callback* is 363 | called with *None* as an argument. On other errors, *callback* 364 | is called with a :class:`TrombiErrorResponse` object as an 365 | argument. 366 | 367 | .. method:: delete(doc, callback) 368 | 369 | Deletes a document in database. *doc* has to be a 370 | :class:`Document` with *rev* and *id* set or the deletion 371 | will fail. 372 | 373 | On success, calls *callback* with :class:`Database` (i.e. 374 | *self*) as an argument. 375 | 376 | .. method:: bulk_docs(bulk_data, callback[, all_or_nothing=False]) 377 | 378 | Performs a bulk update on database. *bulk_data* is a list of 379 | :class:`Document` or :class:`dict` objects. If the upgrade was 380 | succesfull (i.e. returned with 2xx HTTP response code) calls 381 | *callback* with :class:`BulkResult` as a parameter. 382 | 383 | If *all_or_nothing* is *True* the operation is done with the 384 | *all_or_nothing* flag set to *true*. For more information, see 385 | `CouchDB bulk document API`_. 386 | 387 | .. _CouchDB bulk document API: http://wiki.apache.org/couchdb/HTTP_Bulk_Document_API 388 | 389 | .. method:: view(design_doc, viewname, callback[, **kwargs]) 390 | 391 | Fetches view results from database. Both *design_doc* and 392 | *viewname* are string, which identify the view. Additional 393 | keyword arguments can be given and those are all sent as JSON 394 | encoded query parameters to CouchDB with one exception. If a 395 | keyword argument ``keys`` is given the query is transformed to 396 | *POST* and the payload will be JSON object ``{"keys": }``. 397 | For more information, see `CouchDB view API`_. 398 | 399 | **Note:** trombi does not yet support creating views through any 400 | special mechanism. Views should be created using 401 | :meth:`Database.set`. 402 | 403 | On success, a :class:`ViewResult` object is passed to 404 | *callback*. 405 | 406 | .. _CouchDB view API: http://wiki.apache.org/couchdb/HTTP_view_API 407 | 408 | .. method:: list(design_doc, listname, viewname, callback[, **kwargs]) 409 | 410 | Fetches view, identified by *design_doc* and *listname*, results 411 | and filters them using the *listname* list function. Additional 412 | keyword arguments can be given and they are sent as query 413 | parameters to CouchDB. 414 | 415 | On success, a :class:`TrombiResult` object is passed to 416 | *callback*. Note that the response content is not defined in any 417 | way, it solely depends on the list function. 418 | 419 | Additional keyword arguments can be given and those are all sent 420 | as JSON encoded query parameters to CouchDB. 421 | 422 | .. method:: changes(callback[, feed_type='normal', timeout=60, **kw]) 423 | 424 | Fetches the ``_changes`` feed for the database. 425 | 426 | *feed_type* is ``"normal"``, ``"longpoll"`` or ``"continuous"``. 427 | The different feed types are described here__. 428 | 429 | __ `changes feed API`_ 430 | 431 | With the continuous and longpoll feed types, the *timeout* 432 | parameter tells the server to close connection after this many 433 | seconds of idle time, even if there are no results. The default 434 | value of 60 seconds is also the default for CouchDB. 435 | 436 | Additional keyword arguments are converted to query parameters 437 | for the changes feed. For possible keyword arguments, see here__. 438 | 439 | __ `changes feed API`_ 440 | 441 | If *feed_type* is ``continous``, the callback is called for each 442 | line CouchDB sends. The line is JSON decoded and wrapped in a 443 | :class:`TrombiDict`, to denote a successful callback invocation. 444 | When the server timeout occurs, the callback is called with 445 | *None* as an argument. On error (e.g. HTTP client timeout), the 446 | callback is called with a :class:`TrombiErrorResponse` object. 447 | 448 | .. _changes feed API: http://wiki.apache.org/couchdb/HTTP_database_API#Changes 449 | 450 | .. method:: temporary_view(callback, map_fun[, reduce_fun=None, language='javascript', **kwargs]) 451 | 452 | Generates a temporary view and on success calls *callback* with 453 | :class:`ViewResult` as an argument. For more information 454 | on creating map function *map_fun* and reduce function 455 | *reduce_fun* see `CouchDB view API`_. 456 | 457 | Additional keyword arguments can be given and those are all sent 458 | as JSON encoded query parameters to CouchDB. 459 | 460 | Document 461 | ======== 462 | 463 | In case of an error, if not otherwise mentioned, all the following 464 | methods call callback function with :class:`TrombiError` as an 465 | argument. 466 | 467 | .. class:: Document(db, data) 468 | 469 | This class represents a CouchDB document. This subclasses both 470 | :class:`collections.MutableMapping` and 471 | :class:`TrombiObject`. Has two mandatory arguments, a 472 | :class:`Database` intance *db* and *data*, which is a 473 | representation of document data as :class:`dict`. 474 | 475 | .. attribute:: db 476 | data 477 | 478 | These two attribute store the given arguments 479 | 480 | .. attribute:: id 481 | rev 482 | attachments 483 | 484 | These contain CouchDB document id, revision and possible 485 | attachments. 486 | 487 | Normally there's no need to create Document objects as they are 488 | received as results of several different :class:`Database` 489 | operations. 490 | 491 | Document behaves like a :class:`dict` (not exactly, but not far 492 | anyway), as it implements an abstract base class 493 | :class:`collections.MutableMapping`. 494 | 495 | It supports :func:`len`, setting and getting values using the 496 | similiar notation as in dictionaries, e.g. ``doc[key] = val``. It 497 | also implements :func:`__contains__` so the presence of a key can 498 | be inspected using ``in`` operator. 499 | 500 | .. method:: copy(new_id, callback) 501 | 502 | Creates a copy of this document under new document id *new_id*. 503 | This operation is atomic as it is implemented using the custom 504 | ``COPY`` method provided by CouchDB. 505 | 506 | On success the *callback* function is called with a 507 | :class:`Document` denoting the newly created copy. 508 | 509 | .. method:: raw() 510 | 511 | Returns the document's content as a raw dict, containing 512 | CouchDB's internal variables like _id and _rev. 513 | 514 | .. method:: attach(name, data, callback[, type='text/plain']) 515 | 516 | Creates an attachment of name *name* to the document. *data* is 517 | the content of the attachment. These attachments are not so 518 | called inline attachments. *type* defaults to ``text/plain``. 519 | 520 | On success, *callback* is called with this 521 | :class:`Document` as an argument. 522 | 523 | .. method:: load_attachment(name, callback) 524 | 525 | Loads an attachment named *name*. On success the *callback* is 526 | called with the attachment data as an argument. 527 | 528 | .. method:: delete_attachment(name, callback) 529 | 530 | Deletes an attachment named *name*. On success, calls *callback* 531 | with this :class:`Document` as an argument. 532 | 533 | Paginator 534 | ========= 535 | 536 | .. class:: Paginator(db[, limit=10]) 537 | 538 | Represents a pseudo-page of documents returned from a CouchDB view 539 | calculated from total_rows and offset as well as a user-defined page 540 | limit. 541 | 542 | The one mandatory argument, db, is a :class:`Database` instance. 543 | 544 | .. attribute:: db 545 | 546 | Stores the given argument. 547 | 548 | .. attribute:: limit 549 | 550 | The number of documents returned for a given "page" 551 | 552 | .. attribute:: response 553 | 554 | Stores the actual :class:`ViewResult` instance. 555 | 556 | .. attribute:: count 557 | 558 | The total_rows attribute returned from the CouchDB view 559 | 560 | .. attribute:: start_index 561 | 562 | The document offset or position of the first item on the page. 563 | 564 | .. attribute:: end_index 565 | 566 | The document offset or position of the last item on the page. 567 | 568 | .. attribute:: num_pages 569 | 570 | The total number of pages (total_rows of view / limit) 571 | 572 | .. attribute:: current_page 573 | 574 | The current page number 575 | 576 | .. attribute:: previous_page 577 | 578 | The previous page number 579 | 580 | .. attribute:: next_page 581 | 582 | The next page number 583 | 584 | .. attribute:: rows 585 | 586 | An ordered array of the documents for the current page 587 | 588 | .. attribute:: has_next 589 | 590 | A Boolean member to determine if there is a next page 591 | 592 | .. attribute:: has_previous 593 | 594 | A Boolean member to determine if there is a previous page 595 | 596 | .. attribute:: page_range 597 | 598 | A list of the number of pages 599 | 600 | .. attribute:: start_doc_id 601 | 602 | The Document ID of the first document on the page 603 | 604 | .. attribute:: end_doc_id 605 | 606 | The Document ID of the last document on the page 607 | 608 | .. method:: get_page(design_doc, viewname, callback[, key=None, doc_id=None, forward=True, **kwargs]) 609 | 610 | Fetches the ``limit`` specified number of CouchDB documents from 611 | the view. 612 | 613 | ``key`` can be defined as a complex key by the calling function. 614 | If requesting a previous page, the ``key`` must be built using the 615 | first document on the current page. If requesting the next page, 616 | ``key`` must be built using the last document on the current page. 617 | 618 | ``doc_id`` uses the same logic as the above key, but is used to 619 | specify start_doc_id or end_doc_id (depending on forward) in 620 | case the CouchDB view returns duplicate keys. 621 | 622 | ``forward`` simply defines whether you are requesting to go 623 | to the next page or the previous page. If ``forward`` is False then 624 | it attempts to move backward from the key/doc_id given. If 625 | ``forward`` is True then it attempts to more forward. 626 | 627 | Additional keyword arguments can be given and those are all sent 628 | as JSON encoded query parameters to CouchDB and can override 629 | default values such as descending = true. 630 | 631 | On success, *callback* is called with this :class:`Paginator` as 632 | an argument. 633 | 634 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from distutils.core import setup 2 | 3 | 4 | setup( 5 | name='trombi', 6 | version='0.9.2', 7 | description='CouchDB client for Tornado', 8 | license='MIT', 9 | author='Jyrki Pulliainen', 10 | author_email='jyrki@dywypi.org', 11 | maintainer='Jyrki Pulliainen', 12 | maintainer_email='jyrki@dywypi.org', 13 | url='http://github.com/inoi/trombi/', 14 | packages=['trombi'], 15 | classifiers=[ 16 | 'Development Status :: 4 - Beta', 17 | 'Environment :: Web Environment', 18 | 'Intended Audience :: Developers', 19 | 'License :: OSI Approved :: MIT License', 20 | 'Programming Language :: Python', 21 | 'Programming Language :: Python :: 2.6', 22 | 'Programming Language :: Python :: 2.7', 23 | 'Programming Language :: Python :: 3', 24 | 'Programming Language :: Python :: 3.2', 25 | 'Topic :: Database', 26 | ] 27 | ) 28 | -------------------------------------------------------------------------------- /test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/inoi/trombi/f985d430d402717d54b40c320f1828edc2731397/test/__init__.py -------------------------------------------------------------------------------- /test/conf/local.ini: -------------------------------------------------------------------------------- 1 | [couchdb] 2 | database_dir = tmp/couch 3 | view_index_dir = tmp/couch 4 | max_dbs_open = 100 5 | uri_file = tmp/couch/couch.uri 6 | 7 | [httpd] 8 | port = 8921 9 | bind_address = 127.0.0.1 10 | authentication_handlers = {couch_httpd_oauth, oauth_authentication_handler}, {couch_httpd_auth, cookie_authentication_handler}, {couch_httpd_auth, default_authentication_handler} 11 | default_handler = {couch_httpd_db, handle_request} 12 | vhost_global_handlers = _utils, _uuids, _session, _oauth, _users 13 | 14 | [log] 15 | file = tmp/couch.log 16 | level = info 17 | 18 | [couch_httpd_auth] 19 | authentication_db = _users 20 | authentication_redirect = /_utils/session.html 21 | require_valid_user = false 22 | timeout = 43200 23 | auth_cache_size = 50 24 | 25 | [query_servers] 26 | javascript = couchjs /usr/share/couchdb/server/main.js 27 | 28 | [query_server_config] 29 | reduce_limit = true 30 | os_process_limit = 25 31 | 32 | [daemons] 33 | view_manager={couch_view, start_link, []} 34 | external_manager={couch_external_manager, start_link, []} 35 | db_update_notifier={couch_db_update_notifier_sup, start_link, []} 36 | query_servers={couch_query_servers, start_link, []} 37 | httpd={couch_httpd, start_link, []} 38 | stats_aggregator={couch_stats_aggregator, start, []} 39 | stats_collector={couch_stats_collector, start, []} 40 | uuids={couch_uuids, start, []} 41 | auth_cache={couch_auth_cache, start_link, []} 42 | replication_manager={couch_replication_manager, start_link, []} 43 | vhosts={couch_httpd_vhost, start_link, []} 44 | os_daemons={couch_os_daemons, start_link, []} 45 | 46 | [httpd_global_handlers] 47 | / = {couch_httpd_misc_handlers, handle_welcome_req, <<"Welcome">>} 48 | _utils = {couch_httpd_misc_handlers, handle_utils_dir_req, "/usr/share/couchdb/www"} 49 | _all_dbs = {couch_httpd_misc_handlers, handle_all_dbs_req} 50 | _active_tasks = {couch_httpd_misc_handlers, handle_task_status_req} 51 | _config = {couch_httpd_misc_handlers, handle_config_req} 52 | _replicate = {couch_httpd_misc_handlers, handle_replicate_req} 53 | _uuids = {couch_httpd_misc_handlers, handle_uuids_req} 54 | _restart = {couch_httpd_misc_handlers, handle_restart_req} 55 | _stats = {couch_httpd_stats_handlers, handle_stats_req} 56 | _log = {couch_httpd_misc_handlers, handle_log_req} 57 | _session = {couch_httpd_auth, handle_session_req} 58 | _oauth = {couch_httpd_oauth, handle_oauth_req} 59 | 60 | [httpd_db_handlers] 61 | _view_cleanup = {couch_httpd_db, handle_view_cleanup_req} 62 | _compact = {couch_httpd_db, handle_compact_req} 63 | _design = {couch_httpd_db, handle_design_req} 64 | _temp_view = {couch_httpd_view, handle_temp_view_req} 65 | _changes = {couch_httpd_db, handle_changes_req} 66 | 67 | [httpd_design_handlers] 68 | _view = {couch_httpd_view, handle_view_req} 69 | _show = {couch_httpd_show, handle_doc_show_req} 70 | _list = {couch_httpd_show, handle_view_list_req} 71 | _info = {couch_httpd_db, handle_design_info_req} 72 | _rewrite = {couch_httpd_rewrite, handle_rewrite_req} 73 | _update = {couch_httpd_show, handle_doc_update_req} 74 | 75 | [uuids] 76 | algorithm = sequential 77 | 78 | [attachments] 79 | compression_level = 0 80 | compressible_types = text/*, application/javascript, application/json, application/xml 81 | -------------------------------------------------------------------------------- /test/conf/local_session.ini: -------------------------------------------------------------------------------- 1 | [couch_httpd_auth] 2 | secret = bd42ab447cdaecb52f2b2dc3bda6ec10 3 | 4 | [httpd] 5 | port = 8922 6 | 7 | [admins] 8 | admin = -hashed-609ab15a7189304d14390b48876180f498a38008,35cee0c36d7a4bd5f1ba460eda70454f 9 | -------------------------------------------------------------------------------- /test/couch_util.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2011 Jyrki Pulliainen 2 | # Copyright (c) 2010 Inoi Oy 3 | # 4 | # Permission is hereby granted, free of charge, to any person 5 | # obtaining a copy of this software and associated documentation 6 | # files (the "Software"), to deal in the Software without 7 | # restriction, including without limitation the rights to use, copy, 8 | # modify, merge, publish, distribute, sublicense, and/or sell copies 9 | # of the Software, and to permit persons to whom the Software is 10 | # furnished to do so, subject to the following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be 13 | # included in all copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 17 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS 19 | # BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 20 | # ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 21 | # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | # SOFTWARE. 23 | 24 | import errno 25 | import json 26 | import os 27 | import shutil 28 | import subprocess 29 | import time 30 | import sys 31 | 32 | try: 33 | # Python 3 34 | from urllib import request 35 | from urllib.error import URLError 36 | except ImportError: 37 | # Python 3 38 | import urllib2 as request 39 | from urllib2 import URLError 40 | 41 | import nose.tools 42 | from tornado.httpclient import HTTPClient, HTTPError 43 | 44 | baseurl = '' 45 | 46 | _proc = None 47 | 48 | 49 | def setup_with_admin(): 50 | global _proc, baseurl 51 | try: 52 | shutil.rmtree('tmp') 53 | except OSError: 54 | # Python 3 55 | err = sys.exc_info()[1] 56 | if err.errno != errno.ENOENT: 57 | raise 58 | 59 | os.mkdir('tmp') 60 | os.mkdir('tmp/couch') 61 | 62 | port = 8922 63 | baseurl = 'http://localhost:%d/' % (port) 64 | 65 | dir = os.path.dirname(__file__) 66 | 67 | cmdline = 'couchdb -n -a %s -a %s' % ( 68 | os.path.join(dir, 'conf/local.ini'), 69 | os.path.join(dir, 'conf/local_session.ini'), 70 | ) 71 | 72 | null = open('/dev/null', 'w') 73 | _proc = subprocess.Popen( 74 | cmdline, shell=True, stdout=null, stderr=null 75 | ) 76 | 77 | # Wait for couchdb to start 78 | time.sleep(1) 79 | # Wait for couchdb to start 80 | 81 | while True: 82 | try: 83 | f = request.urlopen('http://localhost:%s' % port) 84 | except URLError: 85 | continue 86 | try: 87 | json.loads(f.read().decode('utf-8')) 88 | except ValueError: 89 | continue 90 | # Got a sensible response 91 | break 92 | 93 | 94 | def setup(): 95 | global _proc, baseurl 96 | try: 97 | shutil.rmtree('tmp') 98 | except OSError: 99 | # Python 3 100 | err = sys.exc_info()[1] 101 | if err.errno != errno.ENOENT: 102 | raise 103 | 104 | os.mkdir('tmp') 105 | os.mkdir('tmp/couch') 106 | 107 | port = 8921 108 | baseurl = 'http://localhost:%d/' % (port) 109 | 110 | dir = os.path.dirname(__file__) 111 | cmdline = 'couchdb -n -a %s' % os.path.join(dir, 'conf/local.ini') 112 | null = open('/dev/null', 'w') 113 | _proc = subprocess.Popen(cmdline, shell=True, stdout=null, stderr=null) 114 | 115 | # Wait for couchdb to start 116 | time.sleep(1) 117 | # Wait for couchdb to start 118 | 119 | while True: 120 | try: 121 | f = request.urlopen('http://localhost:%s' % port) 122 | except URLError: 123 | continue 124 | try: 125 | json.loads(f.read().decode('utf-8')) 126 | except ValueError: 127 | continue 128 | # Got a sensible response 129 | break 130 | 131 | 132 | def teardown(): 133 | global _proc 134 | _proc.terminate() 135 | _proc.wait() 136 | 137 | 138 | _couch_1_1_user_view = """ 139 | ( 140 | function(newDoc, oldDoc, userCtx) { 141 | if (newDoc._deleted === true) { 142 | // allow deletes by admins and matching users 143 | // without checking the other fields 144 | if ((userCtx.roles.indexOf('_admin') !== -1) || 145 | (userCtx.name == oldDoc.name)) { 146 | return; 147 | } else { 148 | throw({forbidden: 'Only admins may delete other user docs.'}); 149 | } 150 | } 151 | 152 | if ((oldDoc && oldDoc.type !== 'user') || newDoc.type !== 'user') { 153 | throw({forbidden : 'doc.type must be user'}); 154 | } // we only allow user docs for now 155 | 156 | if (!newDoc.name) { 157 | throw({forbidden: 'doc.name is required'}); 158 | } 159 | 160 | if (newDoc.roles && !isArray(newDoc.roles)) { 161 | throw({forbidden: 'doc.roles must be an array'}); 162 | } 163 | 164 | if (newDoc._id !== ('org.couchdb.user:' + newDoc.name)) { 165 | throw({ 166 | forbidden: 'Doc ID must be of the form org.couchdb.user:name' 167 | }); 168 | } 169 | 170 | if (oldDoc) { // validate all updates 171 | if (oldDoc.name !== newDoc.name) { 172 | throw({forbidden: 'Usernames can not be changed.'}); 173 | } 174 | } 175 | 176 | if (newDoc.password_sha && !newDoc.salt) { 177 | throw({ 178 | forbidden: 'Users with password_sha must have a salt.' + 179 | 'See /_utils/script/couch.js for example code.' 180 | }); 181 | } 182 | 183 | if (userCtx.roles.indexOf('_admin') === -1) { 184 | if (oldDoc) { // validate non-admin updates 185 | if (userCtx.name !== newDoc.name) { 186 | throw({ 187 | forbidden: 'You may only update your own user document.' 188 | }); 189 | } 190 | // validate role updates 191 | var oldRoles = oldDoc.roles.sort(); 192 | var newRoles = newDoc.roles.sort(); 193 | 194 | if (oldRoles.length !== newRoles.length) { 195 | throw({forbidden: 'Only _admin may edit roles'}); 196 | } 197 | 198 | for (var i = 0; i < oldRoles.length; i++) { 199 | if (oldRoles[i] !== newRoles[i]) { 200 | throw({forbidden: 'Only _admin may edit roles'}); 201 | } 202 | } 203 | } else if (newDoc.roles.length > 0) { 204 | throw({forbidden: 'Only _admin may set roles'}); 205 | } 206 | } 207 | 208 | // no system roles in users db 209 | for (var i = 0; i < newDoc.roles.length; i++) { 210 | if (newDoc.roles[i][0] === '_') { 211 | throw({ 212 | forbidden: 213 | 'No system roles (starting with underscore) in users db.' 214 | }); 215 | } 216 | } 217 | 218 | // no system names as names 219 | if (newDoc.name[0] === '_') { 220 | throw({forbidden: 'Username may not start with underscore.'}); 221 | } 222 | } 223 | ) 224 | """ 225 | 226 | 227 | def with_couchdb(func): 228 | @nose.tools.make_decorator(func) 229 | def inner(*args, **kwargs): 230 | global baseurl 231 | 232 | cli = HTTPClient() 233 | # Delete all old databases 234 | response = cli.fetch('%s_all_dbs' % baseurl) 235 | try: 236 | dbs = json.loads(response.body.decode('utf-8')) 237 | except ValueError: 238 | print >> sys.stderr, \ 239 | "CouchDB's response was invalid JSON: %s" % response.body 240 | sys.exit(2) 241 | 242 | for database in dbs: 243 | if database.startswith('_'): 244 | # Skip special databases like _users 245 | continue 246 | cli.fetch( 247 | '%s%s' % (baseurl, database), 248 | method='DELETE', 249 | ) 250 | 251 | # Update _auth with parenthesis, in case we are running too 252 | # new spidermonkey, which fails in evaluation 253 | 254 | user_auth_doc = json.loads( 255 | cli.fetch('%s/_users/_design/_auth' % baseurl).body 256 | ) 257 | 258 | user_auth_doc['validate_doc_update'] = _couch_1_1_user_view 259 | 260 | try: 261 | response = cli.fetch('%s_session' % baseurl, 262 | headers={ 263 | 'Content-Type': 'application/x-www-form-urlencoded', 264 | }, 265 | method='POST', 266 | body='name=admin&password=admin', 267 | ) 268 | cookie = response.headers['Set-Cookie'] 269 | except HTTPError: 270 | cookie = '' 271 | 272 | cli.fetch( 273 | '%s/_users/_design/_auth/' % baseurl, 274 | body=json.dumps(user_auth_doc), 275 | method='PUT', 276 | headers={'Cookie': cookie}, 277 | ) 278 | 279 | return func(baseurl, *args, **kwargs) 280 | 281 | return inner 282 | -------------------------------------------------------------------------------- /test/test_client.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2011 Jyrki Pulliainen 2 | # Copyright (c) 2010 Inoi Oy 3 | # 4 | # Permission is hereby granted, free of charge, to any person 5 | # obtaining a copy of this software and associated documentation 6 | # files (the "Software"), to deal in the Software without 7 | # restriction, including without limitation the rights to use, copy, 8 | # modify, merge, publish, distribute, sublicense, and/or sell copies 9 | # of the Software, and to permit persons to whom the Software is 10 | # furnished to do so, subject to the following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be 13 | # included in all copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 17 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS 19 | # BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 20 | # ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 21 | # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | # SOFTWARE. 23 | 24 | from __future__ import with_statement 25 | 26 | from datetime import datetime 27 | import sys 28 | 29 | from nose.tools import eq_ as eq 30 | from .couch_util import setup, teardown, with_couchdb 31 | from .util import with_ioloop, DatetimeEncoder 32 | 33 | try: 34 | import json 35 | except ImportError: 36 | import simplejson as json 37 | 38 | try: 39 | # Python 3 40 | from urllib.request import urlopen 41 | from urllib.error import HTTPError 42 | except ImportError: 43 | # Python 2 44 | from urllib2 import urlopen 45 | from urllib2 import HTTPError 46 | 47 | import trombi 48 | import trombi.errors 49 | 50 | 51 | def test_from_uri(): 52 | db = trombi.from_uri('http://1.2.3.4/foobar') 53 | assert isinstance(db.server, trombi.Server) 54 | eq(db.baseurl, 'http://1.2.3.4/foobar') 55 | eq(db.name, 'foobar') 56 | 57 | db = trombi.from_uri('http://1.2.3.4:1122/foobar/') 58 | assert isinstance(db.server, trombi.Server) 59 | eq(db.baseurl, 'http://1.2.3.4:1122/foobar') 60 | eq(db.name, 'foobar') 61 | 62 | 63 | @with_ioloop 64 | def test_cannot_connect(ioloop): 65 | def create_callback(db): 66 | eq(db.error, True) 67 | eq(db.errno, 599) 68 | eq(db.msg, 'Unable to connect to CouchDB') 69 | ioloop.stop() 70 | 71 | s = trombi.Server('http://localhost:39998', io_loop=ioloop) 72 | s.create('couchdb-database', callback=create_callback) 73 | ioloop.start() 74 | 75 | 76 | @with_ioloop 77 | @with_couchdb 78 | def test_create_db(baseurl, ioloop): 79 | def create_callback(db): 80 | eq(db.error, False) 81 | assert isinstance(db, trombi.Database) 82 | f = urlopen('%s_all_dbs' % baseurl) 83 | assert 'couchdb-database' in json.loads(f.read().decode('utf-8')) 84 | ioloop.stop() 85 | 86 | s = trombi.Server(baseurl, io_loop=ioloop) 87 | s.create('couchdb-database', callback=create_callback) 88 | ioloop.start() 89 | 90 | 91 | @with_ioloop 92 | @with_couchdb 93 | def test_db_exists(baseurl, ioloop): 94 | s = trombi.Server(baseurl, io_loop=ioloop) 95 | 96 | def first_callback(db): 97 | s.create( 98 | 'couchdb-database', 99 | callback=callback, 100 | ) 101 | 102 | def callback(result): 103 | eq(result.error, True) 104 | eq(result.errno, trombi.errors.PRECONDITION_FAILED) 105 | eq(result.msg, "Database already exists: 'couchdb-database'") 106 | f = urlopen('%s_all_dbs' % baseurl) 107 | assert 'couchdb-database' in json.loads(f.read().decode('utf-8')) 108 | ioloop.stop() 109 | 110 | s.create('couchdb-database', callback=first_callback) 111 | ioloop.start() 112 | 113 | 114 | @with_ioloop 115 | @with_couchdb 116 | def test_invalid_db_name(baseurl, ioloop): 117 | def callback(result): 118 | eq(result.error, True) 119 | eq(result.errno, trombi.errors.INVALID_DATABASE_NAME) 120 | eq(result.msg, "Invalid database name: 'this name is invalid'") 121 | ioloop.stop() 122 | 123 | s = trombi.Server(baseurl, io_loop=ioloop) 124 | s.create('this name is invalid', callback=callback) 125 | ioloop.start() 126 | 127 | 128 | @with_ioloop 129 | @with_couchdb 130 | def test_get_create_doesnt_yet_exist(baseurl, ioloop): 131 | def callback(db): 132 | eq(db.error, False) 133 | eq(db.name, 'nonexistent') 134 | ioloop.stop() 135 | 136 | s = trombi.Server(baseurl, io_loop=ioloop) 137 | s.get('nonexistent', create=True, callback=callback) 138 | ioloop.start() 139 | 140 | 141 | @with_ioloop 142 | @with_couchdb 143 | def test_get_create_already_exists(baseurl, ioloop): 144 | def create_callback(db): 145 | eq(db.name, 'new') 146 | s.get('new', create=True, callback=get_callback) 147 | 148 | def get_callback(db): 149 | eq(db.error, False) 150 | eq(db.name, 'new') 151 | ioloop.stop() 152 | 153 | s = trombi.Server(baseurl, io_loop=ioloop) 154 | s.create('new', callback=create_callback) 155 | ioloop.start() 156 | 157 | 158 | @with_ioloop 159 | @with_couchdb 160 | def test_delete_db(baseurl, ioloop): 161 | s = trombi.Server(baseurl, io_loop=ioloop) 162 | 163 | def create_callback(db): 164 | s.delete('testdatabase', callback=delete_callback) 165 | 166 | def delete_callback(result): 167 | eq(result.error, False) 168 | f = urlopen('%s_all_dbs' % baseurl) 169 | data = f.read().decode('utf-8') 170 | eq([x for x in json.loads(data) if not x.startswith('_')], []) 171 | ioloop.stop() 172 | 173 | s.create('testdatabase', callback=create_callback) 174 | ioloop.start() 175 | 176 | 177 | @with_ioloop 178 | @with_couchdb 179 | def test_delete_db_not_exists(baseurl, ioloop): 180 | def callback(result): 181 | eq(result.error, True) 182 | eq(result.errno, trombi.errors.NOT_FOUND) 183 | eq(result.msg, "Database does not exist: 'testdatabase'") 184 | ioloop.stop() 185 | 186 | s = trombi.Server(baseurl, io_loop=ioloop) 187 | s.delete('testdatabase', callback=callback) 188 | ioloop.start() 189 | 190 | 191 | @with_ioloop 192 | @with_couchdb 193 | def test_list_databases(baseurl, ioloop): 194 | def create_first(db): 195 | s.create('testdb2', callback=create_second) 196 | 197 | def create_second(db): 198 | s.list(callback=list_callback) 199 | 200 | def list_callback(databases): 201 | databases = list(databases) 202 | assert all(isinstance(x, trombi.Database) for x in databases) 203 | eq( 204 | set(['testdb2', 'testdb1']), 205 | set([x.name for x in databases if not x.name.startswith('_')]), 206 | ) 207 | ioloop.stop() 208 | 209 | s = trombi.Server(baseurl, io_loop=ioloop) 210 | s.create('testdb1', callback=create_first) 211 | ioloop.start() 212 | 213 | 214 | @with_ioloop 215 | @with_couchdb 216 | def test_open_database(baseurl, ioloop): 217 | s = trombi.Server(baseurl, io_loop=ioloop) 218 | 219 | def create_callback(db): 220 | s.get('testdb1', callback=get_callback) 221 | 222 | def get_callback(db): 223 | eq(db.error, False) 224 | eq(db.name, 'testdb1') 225 | eq(db.server, s) 226 | ioloop.stop() 227 | 228 | s.create('testdb1', callback=create_callback) 229 | ioloop.start() 230 | 231 | 232 | @with_ioloop 233 | @with_couchdb 234 | def test_open_nonexisting_database(baseurl, ioloop): 235 | s = trombi.Server(baseurl, io_loop=ioloop) 236 | 237 | def callback(result): 238 | eq(result.error, True) 239 | eq(result.errno, trombi.errors.NOT_FOUND) 240 | eq(result.msg, "Database not found: testdb1") 241 | ioloop.stop() 242 | 243 | s.get('testdb1', callback=callback) 244 | ioloop.start() 245 | 246 | 247 | @with_ioloop 248 | @with_couchdb 249 | def test_open_database_bad_name(baseurl, ioloop): 250 | s = trombi.Server(baseurl, io_loop=ioloop) 251 | 252 | def callback(result): 253 | eq(result.error, True) 254 | eq(result.errno, trombi.errors.INVALID_DATABASE_NAME) 255 | eq(result.msg, "Invalid database name: 'not a valid name'") 256 | ioloop.stop() 257 | 258 | s.get('not a valid name', callback=callback) 259 | ioloop.start() 260 | 261 | 262 | @with_ioloop 263 | @with_couchdb 264 | def test_db_info(baseurl, ioloop): 265 | s = trombi.Server(baseurl, io_loop=ioloop) 266 | 267 | def get_info(db): 268 | db.info(check_info) 269 | 270 | def check_info(info): 271 | eq(info.error, False) 272 | eq(info['db_name'], 'testdb') 273 | eq(info['doc_count'], 0) 274 | assert 'update_seq' in info 275 | assert 'disk_size' in info 276 | ioloop.stop() 277 | 278 | s.create('testdb', callback=get_info) 279 | ioloop.start() 280 | 281 | 282 | @with_ioloop 283 | @with_couchdb 284 | def test_create_document(baseurl, ioloop): 285 | def create_db_callback(db): 286 | db.set( 287 | {'testvalue': 'something'}, 288 | create_doc_callback, 289 | ) 290 | 291 | def create_doc_callback(doc): 292 | eq(doc.error, False) 293 | assert isinstance(doc, trombi.Document) 294 | assert doc.id 295 | assert doc.rev 296 | 297 | eq(doc['testvalue'], 'something') 298 | ioloop.stop() 299 | 300 | s = trombi.Server(baseurl, io_loop=ioloop) 301 | s.create('testdb', callback=create_db_callback) 302 | ioloop.start() 303 | 304 | 305 | @with_ioloop 306 | @with_couchdb 307 | def test_create_document_with_slash(baseurl, ioloop): 308 | def create_db_callback(db): 309 | db.set( 310 | 'something/with/slash', 311 | {'testvalue': 'something'}, 312 | create_doc_callback, 313 | ) 314 | 315 | def create_doc_callback(doc): 316 | eq(doc.error, False) 317 | assert isinstance(doc, trombi.Document) 318 | assert doc.id 319 | assert doc.rev 320 | 321 | eq(doc.id, 'something/with/slash') 322 | eq(doc['testvalue'], 'something') 323 | ioloop.stop() 324 | 325 | s = trombi.Server(baseurl, io_loop=ioloop) 326 | s.create('testdb', callback=create_db_callback) 327 | ioloop.start() 328 | 329 | 330 | @with_ioloop 331 | @with_couchdb 332 | def test_get_document(baseurl, ioloop): 333 | def do_test(db): 334 | def create_doc_callback(doc): 335 | db.get(doc.id, callback=get_doc_callback) 336 | 337 | def get_doc_callback(doc): 338 | eq(doc.error, False) 339 | assert isinstance(doc, trombi.Document) 340 | assert doc.id 341 | assert doc.rev 342 | 343 | eq(doc['testvalue'], 'something') 344 | ioloop.stop() 345 | 346 | db.set( 347 | {'testvalue': 'something'}, 348 | create_doc_callback, 349 | ) 350 | 351 | s = trombi.Server(baseurl, io_loop=ioloop) 352 | s.create('testdb', callback=do_test) 353 | ioloop.start() 354 | 355 | 356 | @with_ioloop 357 | @with_couchdb 358 | def test_get_document_with_attachments(baseurl, ioloop): 359 | def do_test(db): 360 | def create_doc_callback(doc): 361 | db.get(doc.id, callback=get_doc_callback, attachments=True) 362 | 363 | def get_doc_callback(doc): 364 | assert isinstance(doc, trombi.Document) 365 | assert doc.id 366 | assert doc.rev 367 | 368 | eq(doc['testvalue'], 'something') 369 | 370 | def _assert_on_fetch(*a, **kw): 371 | assert False, 'Fetch detected, failing test!' 372 | 373 | doc.db._fetch = _assert_on_fetch 374 | 375 | doc.load_attachment('foo', got_attachment) 376 | 377 | def got_attachment(data): 378 | eq(data, b'bar') 379 | ioloop.stop() 380 | 381 | db.set( 382 | {'testvalue': 'something'}, 383 | create_doc_callback, 384 | attachments={'foo': (None, b'bar')} 385 | ) 386 | 387 | s = trombi.Server(baseurl, io_loop=ioloop) 388 | s.create('testdb', callback=do_test) 389 | ioloop.start() 390 | 391 | 392 | @with_ioloop 393 | @with_couchdb 394 | def test_get_attachment(baseurl, ioloop): 395 | def do_test(db): 396 | def start(): 397 | db.set( 398 | {'testvalue': 'something'}, 399 | doc_created, 400 | attachments={'foo': (None, b'bar')}, 401 | ) 402 | 403 | def doc_created(doc): 404 | db.get_attachment(doc.id, 'foo', check_attachment) 405 | 406 | def check_attachment(data): 407 | eq(data, b'bar') 408 | ioloop.stop() 409 | 410 | start() 411 | 412 | s = trombi.Server(baseurl, io_loop=ioloop) 413 | s.create('testdb', callback=do_test) 414 | ioloop.start() 415 | 416 | 417 | @with_ioloop 418 | @with_couchdb 419 | def test_get_attachment_doc_doesnt_exist(baseurl, ioloop): 420 | def do_test(db): 421 | def start(): 422 | db.get_attachment('bar', 'foo', check_attachment) 423 | 424 | def check_attachment(data): 425 | eq(data, None) 426 | ioloop.stop() 427 | 428 | start() 429 | 430 | s = trombi.Server(baseurl, io_loop=ioloop) 431 | s.create('testdb', callback=do_test) 432 | ioloop.start() 433 | 434 | 435 | @with_ioloop 436 | @with_couchdb 437 | def test_get_attachment_doc_exists_attachment_doesnt(baseurl, ioloop): 438 | def do_test(db): 439 | def start(): 440 | db.set( 441 | {'testvalue': 'something'}, 442 | doc_created, 443 | ) 444 | 445 | def doc_created(doc): 446 | db.get_attachment(doc.id, 'foo', check_attachment) 447 | 448 | def check_attachment(data): 449 | eq(data, None) 450 | ioloop.stop() 451 | 452 | start() 453 | 454 | s = trombi.Server(baseurl, io_loop=ioloop) 455 | s.create('testdb', callback=do_test) 456 | ioloop.start() 457 | 458 | 459 | @with_ioloop 460 | @with_couchdb 461 | def test_create_document_custom_id(baseurl, ioloop): 462 | def do_test(db): 463 | def create_doc_callback(doc): 464 | eq(doc.error, False) 465 | assert isinstance(doc, trombi.Document) 466 | eq(doc.id, 'testid') 467 | assert '_id' not in doc 468 | assert '_rev' not in doc 469 | assert doc.rev 470 | 471 | eq(doc['testvalue'], 'something') 472 | 473 | f = urlopen('%stestdb/testid' % baseurl) 474 | data = f.read().decode('utf-8') 475 | eq(json.loads(data), 476 | {'_id': 'testid', 477 | '_rev': doc.rev, 478 | 'testvalue': 'something', 479 | }) 480 | ioloop.stop() 481 | 482 | db.set( 483 | 'testid', 484 | {'testvalue': 'something'}, 485 | create_doc_callback, 486 | ) 487 | 488 | s = trombi.Server(baseurl, io_loop=ioloop) 489 | s.create('testdb', callback=do_test) 490 | ioloop.start() 491 | 492 | 493 | @with_ioloop 494 | @with_couchdb 495 | def test_delete_document(baseurl, ioloop): 496 | def do_test(db): 497 | def create_doc_callback(doc): 498 | eq(db.error, False) 499 | db.delete(doc, callback=delete_doc_callback) 500 | 501 | def delete_doc_callback(db): 502 | eq(db.error, False) 503 | assert isinstance(db, trombi.Database) 504 | 505 | try: 506 | urlopen('%stestdb/testid' % baseurl) 507 | except HTTPError: 508 | # Python 3 509 | e = sys.exc_info()[1] 510 | eq(e.code, 404) 511 | else: 512 | assert 0 513 | 514 | ioloop.stop() 515 | 516 | db.set( 517 | 'testid', 518 | {'testvalue': 'something'}, 519 | create_doc_callback, 520 | ) 521 | 522 | s = trombi.Server(baseurl, io_loop=ioloop) 523 | s.create('testdb', callback=do_test) 524 | ioloop.start() 525 | 526 | 527 | @with_ioloop 528 | @with_couchdb 529 | def test_delete_document_not_existing(baseurl, ioloop): 530 | def do_test(db): 531 | def create_doc_callback(doc): 532 | doc.id = 'wrongid' 533 | db.delete(doc, callback=delete_doc_errback) 534 | 535 | def delete_doc_errback(response): 536 | eq(response.error, True) 537 | eq(response.errno, trombi.errors.NOT_FOUND) 538 | eq(response.msg, 'missing') 539 | ioloop.stop() 540 | 541 | db.set( 542 | 'testid', 543 | {'testvalue': 'something'}, 544 | create_doc_callback, 545 | ) 546 | 547 | s = trombi.Server(baseurl, io_loop=ioloop) 548 | s.create('testdb', callback=do_test) 549 | ioloop.start() 550 | 551 | 552 | @with_ioloop 553 | @with_couchdb 554 | def test_delete_document_wrong_rev(baseurl, ioloop): 555 | def do_test(db): 556 | def create_doc_callback(doc): 557 | doc.rev = '1-eabf' 558 | db.delete(doc, callback=delete_doc_callback) 559 | 560 | def delete_doc_callback(result): 561 | eq(result.error, True) 562 | eq(result.errno, trombi.errors.CONFLICT) 563 | eq(result.msg, 'Document update conflict.') 564 | ioloop.stop() 565 | 566 | db.set( 567 | 'testid', 568 | {'testvalue': 'something'}, 569 | create_doc_callback, 570 | ) 571 | 572 | s = trombi.Server(baseurl, io_loop=ioloop) 573 | s.create('testdb', callback=do_test) 574 | ioloop.start() 575 | 576 | 577 | @with_ioloop 578 | @with_couchdb 579 | def test_delete_document_invalid_rev(baseurl, ioloop): 580 | def do_test(db): 581 | def create_doc_callback(doc): 582 | doc.rev = 'invalid' 583 | db.delete(doc, callback=delete_doc_callback) 584 | 585 | def delete_doc_callback(result): 586 | eq(result.error, True) 587 | eq(result.errno, trombi.errors.BAD_REQUEST) 588 | eq(result.msg, 'Invalid rev format') 589 | ioloop.stop() 590 | 591 | db.set( 592 | 'testid', 593 | {'testvalue': 'something'}, 594 | create_doc_callback, 595 | ) 596 | 597 | s = trombi.Server(baseurl, io_loop=ioloop) 598 | s.create('testdb', callback=do_test) 599 | ioloop.start() 600 | 601 | 602 | @with_ioloop 603 | @with_couchdb 604 | def test_create_document_custom_id_exists(baseurl, ioloop): 605 | def do_test(db): 606 | def create_doc_callback(doc): 607 | db.set( 608 | 'testid', 609 | {'testvalue': 'something'}, 610 | update_doc_error, 611 | ) 612 | 613 | def update_doc_error(result): 614 | eq(result.error, True) 615 | eq(result.errno, trombi.errors.CONFLICT) 616 | eq(result.msg, 'Document update conflict.') 617 | ioloop.stop() 618 | 619 | db.set( 620 | 'testid', 621 | {'testvalue': 'something'}, 622 | create_doc_callback, 623 | ) 624 | 625 | s = trombi.Server(baseurl, io_loop=ioloop) 626 | s.create('testdb', callback=do_test) 627 | ioloop.start() 628 | 629 | 630 | @with_ioloop 631 | @with_couchdb 632 | def test_update_document(baseurl, ioloop): 633 | def do_test(db): 634 | def update_doc(doc): 635 | doc['newvalue'] = 'somethingelse' 636 | db.set(doc, doc_updated) 637 | 638 | def doc_updated(doc): 639 | eq(doc, { 640 | 'testvalue': 'something', 641 | 'newvalue': 'somethingelse', 642 | }) 643 | ioloop.stop() 644 | 645 | db.set( 646 | 'testid', 647 | {'testvalue': 'something'}, 648 | update_doc, 649 | ) 650 | 651 | s = trombi.Server(baseurl, io_loop=ioloop) 652 | s.create('testdb', do_test) 653 | ioloop.start() 654 | 655 | 656 | @with_ioloop 657 | @with_couchdb 658 | def test_set_document_change_id(baseurl, ioloop): 659 | def do_test(db): 660 | def update_doc(doc): 661 | doc['newvalue'] = 'somethingelse' 662 | db.set('otherid', doc, doc_updated) 663 | 664 | def doc_updated(doc): 665 | eq(doc, { 666 | 'testvalue': 'something', 667 | 'newvalue': 'somethingelse', 668 | }) 669 | eq(doc.id, 'otherid') 670 | 671 | # Check that the original didn't change 672 | db.get('testid', check_original) 673 | 674 | def check_original(doc): 675 | eq(doc, {'testvalue': 'something'}) 676 | eq(doc.id, 'testid') 677 | ioloop.stop() 678 | 679 | db.set('testid', {'testvalue': 'something'}, update_doc) 680 | 681 | s = trombi.Server(baseurl, io_loop=ioloop) 682 | s.create('testdb', do_test) 683 | ioloop.start() 684 | 685 | 686 | @with_ioloop 687 | @with_couchdb 688 | def test_set_document_with_kw_callback(baseurl, ioloop): 689 | def do_test(db): 690 | def update_doc(doc): 691 | ioloop.stop() 692 | 693 | db.set('testid', {'testvalue': 'something'}, callback=update_doc) 694 | 695 | s = trombi.Server(baseurl, io_loop=ioloop) 696 | s.create('testdb', do_test) 697 | ioloop.start() 698 | 699 | 700 | @with_ioloop 701 | @with_couchdb 702 | def test_get_document_does_not_exist(baseurl, ioloop): 703 | def create_db_callback(db): 704 | db.get('foo', callback=get_callback) 705 | 706 | def get_callback(doc): 707 | eq(doc, None) 708 | ioloop.stop() 709 | 710 | s = trombi.Server(baseurl, io_loop=ioloop) 711 | s.create('testdb', callback=create_db_callback) 712 | ioloop.start() 713 | 714 | 715 | @with_ioloop 716 | @with_couchdb 717 | def test_save_attachment_inline(baseurl, ioloop): 718 | def create_db_callback(db): 719 | db.set( 720 | 'testid', 721 | {'testvalue': 'something'}, 722 | data_callback, 723 | attachments={'foobar': (None, b'some textual data')}, 724 | ) 725 | 726 | def data_callback(doc): 727 | f = urlopen('%stestdb/testid/foobar' % baseurl) 728 | eq(f.read(), b'some textual data') 729 | ioloop.stop() 730 | 731 | s = trombi.Server(baseurl, io_loop=ioloop) 732 | s.create('testdb', callback=create_db_callback) 733 | ioloop.start() 734 | 735 | 736 | @with_ioloop 737 | @with_couchdb 738 | def test_save_attachment_inline_custom_content_type(baseurl, ioloop): 739 | def create_db_callback(db): 740 | db.set( 741 | 'testid', 742 | {'testvalue': 'something'}, 743 | data_callback, 744 | attachments={'foobar': 745 | ('application/x-custom', b'some textual data') 746 | }, 747 | ) 748 | 749 | def data_callback(doc): 750 | f = urlopen('%stestdb/testid/foobar' % baseurl) 751 | eq(f.info()['Content-Type'], 'application/x-custom') 752 | eq(f.read(), b'some textual data') 753 | ioloop.stop() 754 | 755 | s = trombi.Server(baseurl, io_loop=ioloop) 756 | s.create('testdb', callback=create_db_callback) 757 | ioloop.start() 758 | 759 | 760 | @with_ioloop 761 | @with_couchdb 762 | def test_save_attachment(baseurl, ioloop): 763 | def create_db_callback(db): 764 | db.set( 765 | 'testid', 766 | {'testvalue': 'something'}, 767 | create_doc_callback, 768 | ) 769 | 770 | def create_doc_callback(doc): 771 | data = b'some textual data' 772 | doc.attach('foobar', data, callback=data_callback) 773 | 774 | def data_callback(doc): 775 | f = urlopen('%stestdb/testid/foobar' % baseurl) 776 | eq(f.read(), b'some textual data') 777 | ioloop.stop() 778 | 779 | s = trombi.Server(baseurl, io_loop=ioloop) 780 | s.create('testdb', callback=create_db_callback) 781 | ioloop.start() 782 | 783 | 784 | @with_ioloop 785 | @with_couchdb 786 | def test_save_attachment_wrong_rev(baseurl, ioloop): 787 | def do_test(db): 788 | def create_doc_callback(doc): 789 | doc.rev = '1-deadbeef' 790 | data = 'some textual data' 791 | doc.attach('foobar', data, callback=data_callback) 792 | 793 | def data_callback(doc): 794 | eq(doc.error, True) 795 | ioloop.stop() 796 | 797 | db.set( 798 | 'testid', 799 | {'testvalue': 'something'}, 800 | create_doc_callback, 801 | ) 802 | 803 | s = trombi.Server(baseurl, io_loop=ioloop) 804 | s.create('testdb', callback=do_test) 805 | ioloop.start() 806 | 807 | 808 | @with_ioloop 809 | @with_couchdb 810 | def test_load_attachment(baseurl, ioloop): 811 | def create_db_callback(db): 812 | db.set( 813 | 'testid', 814 | {'testvalue': 'something'}, 815 | create_doc_callback, 816 | ) 817 | 818 | def create_doc_callback(doc): 819 | data = b'some textual data' 820 | doc.attach('foobar', data, callback=attach_callback) 821 | 822 | def attach_callback(doc): 823 | doc.load_attachment('foobar', callback=data_callback) 824 | 825 | def data_callback(data): 826 | eq(data, b'some textual data') 827 | ioloop.stop() 828 | 829 | s = trombi.Server(baseurl, io_loop=ioloop) 830 | s.create('testdb', callback=create_db_callback) 831 | ioloop.start() 832 | 833 | 834 | @with_ioloop 835 | @with_couchdb 836 | def test_load_unkonwn_attachment(baseurl, ioloop): 837 | def create_db_callback(db): 838 | db.set( 839 | 'testid', 840 | {'testvalue': 'something'}, 841 | create_doc_callback, 842 | ) 843 | 844 | def create_doc_callback(doc): 845 | doc.load_attachment('foobar', callback=data_callback) 846 | 847 | def data_callback(result): 848 | eq(result.error, True) 849 | eq(result.errno, trombi.errors.NOT_FOUND) 850 | eq(result.msg, 'Document is missing attachment') 851 | ioloop.stop() 852 | 853 | s = trombi.Server(baseurl, io_loop=ioloop) 854 | s.create('testdb', callback=create_db_callback) 855 | ioloop.start() 856 | 857 | 858 | @with_ioloop 859 | @with_couchdb 860 | def test_load_inline_attachment(baseurl, ioloop): 861 | def create_db_callback(db): 862 | db.set( 863 | 'testid', 864 | {'testvalue': 'something'}, 865 | attach_callback, 866 | attachments={'foobar': (None, b'some textual data')}, 867 | ) 868 | 869 | def attach_callback(doc): 870 | doc.load_attachment('foobar', callback=data_callback) 871 | 872 | def data_callback(data): 873 | eq(data, b'some textual data') 874 | ioloop.stop() 875 | 876 | s = trombi.Server(baseurl, io_loop=ioloop) 877 | s.create('testdb', callback=create_db_callback) 878 | ioloop.start() 879 | 880 | 881 | @with_ioloop 882 | @with_couchdb 883 | def test_load_inline_attachment_no_fetch(baseurl, ioloop): 884 | def create_db_callback(db): 885 | db.set( 886 | 'testid', 887 | {'testvalue': 'something'}, 888 | attach_callback, 889 | attachments={'foobar': (None, b'some textual data')}, 890 | ) 891 | 892 | def attach_callback(doc): 893 | def _broken_fetch(*a, **kw): 894 | assert False, 'Fetch called when not needed!' 895 | 896 | doc.db._fetch = _broken_fetch 897 | doc.load_attachment('foobar', callback=data_callback) 898 | 899 | def data_callback(data): 900 | eq(data, b'some textual data') 901 | ioloop.stop() 902 | 903 | s = trombi.Server(baseurl, io_loop=ioloop) 904 | s.create('testdb', callback=create_db_callback) 905 | ioloop.start() 906 | 907 | 908 | @with_ioloop 909 | @with_couchdb 910 | def test_delete_attachment(baseurl, ioloop): 911 | def create_db_callback(db): 912 | db.set( 913 | 'testid', 914 | {'testvalue': 'something'}, 915 | create_doc_callback, 916 | ) 917 | 918 | def create_doc_callback(doc): 919 | data = b'some textual data' 920 | doc.attach('foobar', data, callback=attach_callback) 921 | 922 | def attach_callback(doc): 923 | doc.delete_attachment('foobar', callback=delete_callback) 924 | 925 | def delete_callback(doc): 926 | try: 927 | urlopen('%stestdb/testid/foobar' % baseurl) 928 | except HTTPError: 929 | # Python 3 930 | e = sys.exc_info()[1] 931 | eq(e.code, 404) 932 | else: 933 | assert 0 934 | 935 | ioloop.stop() 936 | 937 | s = trombi.Server(baseurl, io_loop=ioloop) 938 | s.create('testdb', callback=create_db_callback) 939 | ioloop.start() 940 | 941 | 942 | @with_ioloop 943 | @with_couchdb 944 | def test_delete_attachment_wrong_rev(baseurl, ioloop): 945 | def create_db_callback(db): 946 | db.set( 947 | 'testid', 948 | {'testvalue': 'something'}, 949 | create_doc_callback, 950 | ) 951 | 952 | def create_doc_callback(doc): 953 | doc.rev = '1-deadwrong' 954 | data = 'some textual data' 955 | doc.attach('foobar', data, callback=attach_callback) 956 | 957 | def attach_callback(doc): 958 | eq(doc.error, True) 959 | ioloop.stop() 960 | 961 | s = trombi.Server(baseurl, io_loop=ioloop) 962 | s.create('testdb', callback=create_db_callback) 963 | ioloop.start() 964 | 965 | 966 | @with_ioloop 967 | @with_couchdb 968 | def test_load_view_empty_results(baseurl, ioloop): 969 | def do_test(db): 970 | def create_view_callback(response): 971 | eq(response.code, 201) 972 | db.view('testview', 'all', load_view_cb) 973 | 974 | def load_view_cb(result): 975 | assert isinstance(result, trombi.ViewResult) 976 | eq(result.error, False) 977 | eq(len(result), 0) 978 | ioloop.stop() 979 | 980 | db.server._fetch( 981 | '%stestdb/_design/testview' % baseurl, 982 | create_view_callback, 983 | method='PUT', 984 | body=json.dumps( 985 | { 986 | 'language': 'javascript', 987 | 'views': { 988 | 'all': { 989 | 'map': 'function (doc) { emit(null, doc) }', 990 | } 991 | } 992 | } 993 | ) 994 | ) 995 | 996 | s = trombi.Server(baseurl, io_loop=ioloop) 997 | s.create('testdb', callback=do_test) 998 | ioloop.start() 999 | 1000 | 1001 | @with_ioloop 1002 | @with_couchdb 1003 | def test_load_view_with_results(baseurl, ioloop): 1004 | def do_test(db): 1005 | def create_view_callback(response): 1006 | eq(response.code, 201) 1007 | db.set({'data': 'data'}, create_doc_cb) 1008 | 1009 | def create_doc_cb(doc): 1010 | db.view('testview', 'all', load_view_cb) 1011 | 1012 | def load_view_cb(result): 1013 | eq(result.error, False) 1014 | eq(len(result), 1) 1015 | del result[0]['value']['_rev'] 1016 | del result[0]['value']['_id'] 1017 | del result[0]['id'] 1018 | eq(list(result), [{'value': {'data': 'data'}, 'key': None}]) 1019 | ioloop.stop() 1020 | 1021 | db.server._fetch( 1022 | '%stestdb/_design/testview' % baseurl, 1023 | create_view_callback, 1024 | method='PUT', 1025 | body=json.dumps( 1026 | { 1027 | 'language': 'javascript', 1028 | 'views': { 1029 | 'all': { 1030 | 'map': '(function (doc) { emit(null, doc) })', 1031 | } 1032 | } 1033 | } 1034 | ) 1035 | ) 1036 | 1037 | s = trombi.Server(baseurl, io_loop=ioloop) 1038 | s.create('testdb', callback=do_test) 1039 | ioloop.start() 1040 | 1041 | 1042 | @with_ioloop 1043 | @with_couchdb 1044 | def test_load_view_with_grouping_reduce(baseurl, ioloop): 1045 | def do_test(db): 1046 | def create_view_callback(response): 1047 | eq(response.code, 201) 1048 | db.set({'data': 'data'}, create_1st_doc_cb) 1049 | 1050 | def create_1st_doc_cb(doc): 1051 | db.set({'data': 'other'}, create_2nd_doc_cb) 1052 | 1053 | def create_2nd_doc_cb(doc): 1054 | db.view('testview', 'all', load_view_cb, group=True) 1055 | 1056 | def load_view_cb(result): 1057 | eq(result.error, False) 1058 | eq(list(result), [{'value': 1, 'key': 'data'}, 1059 | {'value': 1, 'key': 'other'}]) 1060 | ioloop.stop() 1061 | 1062 | db.server._fetch( 1063 | '%stestdb/_design/testview' % baseurl, 1064 | create_view_callback, 1065 | method='PUT', 1066 | body=json.dumps( 1067 | { 1068 | 'language': 'javascript', 1069 | 'views': { 1070 | 'all': { 1071 | 'map': '(function (doc) { emit(doc.data, doc) })', 1072 | 'reduce': '(function (key, value) { return \ 1073 | value.length })', 1074 | } 1075 | } 1076 | } 1077 | ) 1078 | ) 1079 | 1080 | s = trombi.Server(baseurl, io_loop=ioloop) 1081 | s.create('testdb', callback=do_test) 1082 | ioloop.start() 1083 | 1084 | 1085 | @with_ioloop 1086 | @with_couchdb 1087 | def test_load_view_with_keys(baseurl, ioloop): 1088 | def do_test(db): 1089 | def create_view_callback(response): 1090 | eq(response.code, 201) 1091 | db.set({'data': 'data'}, create_1st_doc_cb) 1092 | 1093 | def create_1st_doc_cb(doc): 1094 | db.set({'data': 'other'}, create_2nd_doc_cb) 1095 | 1096 | def create_2nd_doc_cb(doc): 1097 | db.view('testview', 'all', load_view_cb, keys=['data']) 1098 | 1099 | def load_view_cb(result): 1100 | eq(result.error, False) 1101 | eq(len(result), 1) 1102 | eq(result[0]['key'], 'data') 1103 | ioloop.stop() 1104 | 1105 | db.server._fetch( 1106 | '%stestdb/_design/testview' % baseurl, 1107 | create_view_callback, 1108 | method='PUT', 1109 | body=json.dumps( 1110 | { 1111 | 'language': 'javascript', 1112 | 'views': { 1113 | 'all': { 1114 | 'map': '(function (doc) { emit(doc.data, doc) })', 1115 | } 1116 | } 1117 | } 1118 | ) 1119 | ) 1120 | 1121 | s = trombi.Server(baseurl, io_loop=ioloop) 1122 | s.create('testdb', callback=do_test) 1123 | ioloop.start() 1124 | 1125 | 1126 | @with_ioloop 1127 | @with_couchdb 1128 | def test_load_view_no_design_doc(baseurl, ioloop): 1129 | def create_db_callback(db): 1130 | def load_view_cb(result): 1131 | eq(result.error, True) 1132 | eq(result.errno, trombi.errors.NOT_FOUND) 1133 | eq(result.msg, 'missing') 1134 | ioloop.stop() 1135 | db.view('testview', 'all', load_view_cb, group='true') 1136 | 1137 | s = trombi.Server(baseurl, io_loop=ioloop) 1138 | s.create('testdb', callback=create_db_callback) 1139 | ioloop.start() 1140 | 1141 | 1142 | @with_ioloop 1143 | @with_couchdb 1144 | def test_load_view_no_such_view(baseurl, ioloop): 1145 | def do_test(db): 1146 | def create_view_callback(useless): 1147 | db.view('testview', 'all', load_view_cb) 1148 | 1149 | def load_view_cb(result): 1150 | eq(result.error, True) 1151 | eq(result.errno, trombi.errors.NOT_FOUND) 1152 | eq(result.msg, 'missing_named_view') 1153 | ioloop.stop() 1154 | 1155 | db.server._fetch( 1156 | '%stestdb/_design/testview' % baseurl, 1157 | create_view_callback, 1158 | method='PUT', 1159 | body=json.dumps( 1160 | { 1161 | 'language': 'javascript', 1162 | 'views': { 1163 | 'foobar': { 1164 | 'map': '(function (doc) { emit(doc.data, doc) })', 1165 | 'reduce': '(function (key, value) { return \ 1166 | value.length })', 1167 | } 1168 | } 1169 | } 1170 | ) 1171 | ) 1172 | 1173 | s = trombi.Server(baseurl, io_loop=ioloop) 1174 | s.create('testdb', callback=do_test) 1175 | ioloop.start() 1176 | 1177 | 1178 | @with_ioloop 1179 | @with_couchdb 1180 | def test_temporary_view_empty_results(baseurl, ioloop): 1181 | def create_db_callback(db): 1182 | db.temporary_view(view_results, 'function(doc) { emit(null, doc); }') 1183 | 1184 | def view_results(result): 1185 | assert isinstance(result, trombi.ViewResult) 1186 | eq(result.error, False) 1187 | eq(list(result), []) 1188 | ioloop.stop() 1189 | 1190 | s = trombi.Server(baseurl, io_loop=ioloop) 1191 | s.create('testdb', callback=create_db_callback) 1192 | ioloop.start() 1193 | 1194 | 1195 | @with_ioloop 1196 | @with_couchdb 1197 | def test_temporary_view_no_such_db(baseurl, ioloop): 1198 | def view_results(result): 1199 | eq(result.error, True) 1200 | eq(result.errno, trombi.errors.NOT_FOUND) 1201 | eq(result.msg, 'no_db_file') 1202 | ioloop.stop() 1203 | 1204 | s = trombi.Server(baseurl, io_loop=ioloop) 1205 | db = trombi.Database(s, 'doesnotexist') 1206 | db.temporary_view(view_results, '(function() { emit(null);})') 1207 | ioloop.start() 1208 | 1209 | 1210 | @with_ioloop 1211 | @with_couchdb 1212 | def test_temporary_view_nonempty_results(baseurl, ioloop): 1213 | def do_test(db): 1214 | def doc_ready(doc): 1215 | db.temporary_view(view_results, 1216 | '(function(doc) { emit(null, doc); })') 1217 | 1218 | def view_results(results): 1219 | eq(len(results), 1) 1220 | result = results[0] 1221 | 1222 | # Remove keys starting with _ 1223 | eq( 1224 | dict((k, v) for k, v in result['value'].items() 1225 | if k[0] != '_'), 1226 | {'foo': 'bar'} 1227 | ) 1228 | eq(result['key'], None) 1229 | 1230 | ioloop.stop() 1231 | 1232 | db.set('testid', {'foo': 'bar'}, doc_ready) 1233 | 1234 | s = trombi.Server(baseurl, io_loop=ioloop) 1235 | s.create('testdb', callback=do_test) 1236 | ioloop.start() 1237 | 1238 | 1239 | @with_ioloop 1240 | @with_couchdb 1241 | def test_temporary_view_with_reduce_fun(baseurl, ioloop): 1242 | def do_test(db): 1243 | def doc_ready(doc): 1244 | db.set({'value': 2}, doc2_ready) 1245 | 1246 | def doc2_ready(doc): 1247 | db.temporary_view( 1248 | view_results, 1249 | map_fun='(function(doc) { emit(null, doc.value); })', 1250 | reduce_fun='(function(key, values) { return sum(values); })' 1251 | ) 1252 | 1253 | def view_results(result): 1254 | eq(result.error, False) 1255 | eq(list(result), [{'key': None, 'value': 3}]) 1256 | ioloop.stop() 1257 | 1258 | db.set({'value': 1}, doc_ready) 1259 | 1260 | s = trombi.Server(baseurl, io_loop=ioloop) 1261 | s.create('testdb', callback=do_test) 1262 | ioloop.start() 1263 | 1264 | 1265 | @with_ioloop 1266 | @with_couchdb 1267 | def test_copy_document(baseurl, ioloop): 1268 | def create_db_callback(db): 1269 | db.set( 1270 | {'testvalue': 'something'}, 1271 | create_doc_callback, 1272 | ) 1273 | 1274 | def create_doc_callback(doc): 1275 | doc.copy('newname', copy_done) 1276 | 1277 | def copy_done(doc): 1278 | eq(doc.id, 'newname') 1279 | eq(dict(doc), {'testvalue': 'something'}) 1280 | ioloop.stop() 1281 | 1282 | s = trombi.Server(baseurl, io_loop=ioloop) 1283 | s.create('testdb', callback=create_db_callback) 1284 | ioloop.start() 1285 | 1286 | 1287 | @with_ioloop 1288 | @with_couchdb 1289 | def test_copy_document_exists(baseurl, ioloop): 1290 | def do_test(db): 1291 | def create_doc(doc): 1292 | db.set( 1293 | {'testvalue': 'something'}, 1294 | copy_doc, 1295 | ) 1296 | 1297 | def copy_doc(doc): 1298 | doc.copy('newname', copy_done) 1299 | 1300 | def copy_done(result): 1301 | eq(result.error, True) 1302 | eq(result.errno, trombi.errors.CONFLICT) 1303 | eq(result.msg, 'Document update conflict.') 1304 | ioloop.stop() 1305 | 1306 | db.set('newname', {'something': 'else'}, create_doc) 1307 | 1308 | s = trombi.Server(baseurl, io_loop=ioloop) 1309 | s.create('testdb', callback=do_test) 1310 | ioloop.start() 1311 | 1312 | 1313 | @with_ioloop 1314 | @with_couchdb 1315 | def test_copy_document_with_attachments(baseurl, ioloop): 1316 | def create_db_callback(db): 1317 | db.set( 1318 | {'testvalue': 'something'}, 1319 | create_doc_callback, 1320 | attachments={'foo': (None, b'bar')} 1321 | ) 1322 | 1323 | def create_doc_callback(doc): 1324 | doc.copy('newname', copy_done) 1325 | 1326 | def copy_done(doc): 1327 | eq(doc.id, 'newname') 1328 | eq(dict(doc), {'testvalue': 'something'}) 1329 | eq(list(doc.attachments.keys()), ['foo']) 1330 | eq(doc.attachments['foo']['content_type'], 'text/plain') 1331 | ioloop.stop() 1332 | 1333 | s = trombi.Server(baseurl, io_loop=ioloop) 1334 | s.create('testdb', callback=create_db_callback) 1335 | ioloop.start() 1336 | 1337 | 1338 | @with_ioloop 1339 | @with_couchdb 1340 | def test_copy_loaded_document_with_attachments_false(baseurl, ioloop): 1341 | def create_db_callback(db): 1342 | db.set( 1343 | {'testvalue': 'something'}, 1344 | create_doc_callback, 1345 | attachments={'foo': (None, b'bar')} 1346 | ) 1347 | 1348 | def create_doc_callback(doc): 1349 | doc.db.get(doc.id, got_doc) 1350 | 1351 | def got_doc(doc): 1352 | doc.copy('newname', copy_done) 1353 | 1354 | def copy_done(doc): 1355 | eq(doc.id, 'newname') 1356 | eq(dict(doc), {'testvalue': 'something'}) 1357 | doc.load_attachment('foo', loaded_attachment) 1358 | 1359 | def loaded_attachment(attach): 1360 | eq(attach, b'bar') 1361 | ioloop.stop() 1362 | 1363 | s = trombi.Server(baseurl, io_loop=ioloop) 1364 | s.create('testdb', callback=create_db_callback) 1365 | ioloop.start() 1366 | 1367 | 1368 | @with_ioloop 1369 | @with_couchdb 1370 | def test_copy_loaded_document_with_attachments_true(baseurl, ioloop): 1371 | def create_db_callback(db): 1372 | db.set( 1373 | {'testvalue': 'something'}, 1374 | create_doc_callback, 1375 | attachments={'foo': (None, b'bar')} 1376 | ) 1377 | 1378 | def create_doc_callback(doc): 1379 | doc.db.get(doc.id, got_doc, attachments=True) 1380 | 1381 | def got_doc(doc): 1382 | doc.copy('newname', copy_done) 1383 | 1384 | def copy_done(doc): 1385 | eq(doc.id, 'newname') 1386 | eq(dict(doc), {'testvalue': 'something'}) 1387 | eq(list(doc.attachments.keys()), ['foo']) 1388 | eq(doc.attachments['foo']['content_type'], 'text/plain') 1389 | ioloop.stop() 1390 | 1391 | s = trombi.Server(baseurl, io_loop=ioloop) 1392 | s.create('testdb', callback=create_db_callback) 1393 | ioloop.start() 1394 | 1395 | 1396 | @with_ioloop 1397 | @with_couchdb 1398 | def test_create_document_raw(baseurl, ioloop): 1399 | def create_db_callback(db): 1400 | db.set( 1401 | {'testvalue': 'something'}, 1402 | create_doc_callback, 1403 | ) 1404 | 1405 | def create_doc_callback(doc): 1406 | eq(doc.error, False) 1407 | assert isinstance(doc, trombi.Document) 1408 | assert doc.id 1409 | assert doc.rev 1410 | 1411 | eq(doc.raw(), 1412 | { 1413 | '_id': doc.id, 1414 | '_rev': doc.rev, 1415 | 'testvalue': 'something', 1416 | }) 1417 | ioloop.stop() 1418 | 1419 | s = trombi.Server(baseurl, io_loop=ioloop) 1420 | s.create('testdb', callback=create_db_callback) 1421 | ioloop.start() 1422 | 1423 | 1424 | @with_ioloop 1425 | @with_couchdb 1426 | def test_view_results_with_offset(baseurl, ioloop): 1427 | def do_test(db): 1428 | def create_view_callback(response): 1429 | eq(response.code, 201) 1430 | db.set({'data': 'data'}, create_first_doc_cb) 1431 | 1432 | def create_first_doc_cb(response): 1433 | db.set({'another': 'data'}, create_docs_cb) 1434 | 1435 | def create_docs_cb(doc): 1436 | db.view('testview', 'all', load_view_cb, skip=1) 1437 | 1438 | def load_view_cb(result): 1439 | eq(result.error, False) 1440 | eq(len(result), 1) 1441 | eq(result.total_rows, 2) 1442 | eq(result.offset, 1) 1443 | ioloop.stop() 1444 | 1445 | db.server._fetch( 1446 | '%stestdb/_design/testview' % baseurl, 1447 | create_view_callback, 1448 | method='PUT', 1449 | body=json.dumps( 1450 | { 1451 | 'language': 'javascript', 1452 | 'views': { 1453 | 'all': { 1454 | 'map': '(function (doc) { emit(null, doc) })', 1455 | } 1456 | } 1457 | } 1458 | ) 1459 | ) 1460 | 1461 | s = trombi.Server(baseurl, io_loop=ioloop) 1462 | s.create('testdb', callback=do_test) 1463 | ioloop.start() 1464 | 1465 | 1466 | @with_ioloop 1467 | @with_couchdb 1468 | def test_view_results_include_docs(baseurl, ioloop): 1469 | def do_test(db): 1470 | def create_view_callback(response): 1471 | eq(response.code, 201) 1472 | db.set({'data': 'data'}, create_first_doc_cb) 1473 | 1474 | def create_first_doc_cb(response): 1475 | db.set({'another': 'data'}, create_docs_cb) 1476 | 1477 | def create_docs_cb(doc): 1478 | db.view('testview', 'all', load_view_cb, include_docs=True) 1479 | 1480 | def load_view_cb(result): 1481 | eq(result.error, False) 1482 | eq(len(result), 2) 1483 | eq(result.total_rows, 2) 1484 | assert all(isinstance(x['doc'], trombi.Document) for x in result) 1485 | ioloop.stop() 1486 | 1487 | db.server._fetch( 1488 | '%stestdb/_design/testview' % baseurl, 1489 | create_view_callback, 1490 | method='PUT', 1491 | body=json.dumps( 1492 | { 1493 | 'language': 'javascript', 1494 | 'views': { 1495 | 'all': { 1496 | 'map': '(function (doc) { emit(null, doc) })', 1497 | } 1498 | } 1499 | } 1500 | ) 1501 | ) 1502 | 1503 | s = trombi.Server(baseurl, io_loop=ioloop) 1504 | s.create('testdb', callback=do_test) 1505 | ioloop.start() 1506 | 1507 | 1508 | @with_ioloop 1509 | @with_couchdb 1510 | def test_view_results_include_docs_with_bogus_docs(baseurl, ioloop): 1511 | def do_test(db): 1512 | def create_view_callback(response): 1513 | eq(response.code, 201) 1514 | db.set({'data': 'data'}, create_first_doc_cb) 1515 | 1516 | def create_first_doc_cb(response): 1517 | db.set({'another': 'data'}, create_docs_cb) 1518 | 1519 | def create_docs_cb(doc): 1520 | db.view('testview', 'all', load_view_cb, include_docs=True) 1521 | 1522 | def load_view_cb(result): 1523 | eq(result.error, False) 1524 | eq(len(result), 2) 1525 | eq(result.total_rows, 2) 1526 | assert all(x['doc'] == None for x in result) 1527 | ioloop.stop() 1528 | 1529 | db.server._fetch( 1530 | '%stestdb/_design/testview' % baseurl, 1531 | create_view_callback, 1532 | method='PUT', 1533 | body=json.dumps( 1534 | { 1535 | 'language': 'javascript', 1536 | 'views': { 1537 | 'all': { 1538 | 'map': '(function (doc) { emit(null, \ 1539 | {"_id": "bogus"}) })', 1540 | } 1541 | } 1542 | } 1543 | ) 1544 | ) 1545 | 1546 | s = trombi.Server(baseurl, io_loop=ioloop) 1547 | s.create('testdb', callback=do_test) 1548 | ioloop.start() 1549 | 1550 | 1551 | @with_ioloop 1552 | @with_couchdb 1553 | def test_bulk_insert(baseurl, ioloop): 1554 | def do_test(db): 1555 | datas = [ 1556 | {'key1': 'data1'}, 1557 | {'key2': 'data2'}, 1558 | ] 1559 | db.bulk_docs(datas, bulks_cb) 1560 | 1561 | def bulks_cb(response): 1562 | assert not response.error 1563 | eq(len(response), 2) 1564 | assert all(isinstance(x, trombi.BulkObject) for x in response) 1565 | ioloop.stop() 1566 | 1567 | s = trombi.Server(baseurl, io_loop=ioloop) 1568 | s.create('testdb', callback=do_test) 1569 | ioloop.start() 1570 | 1571 | 1572 | @with_ioloop 1573 | @with_couchdb 1574 | def test_bulk_delete(baseurl, ioloop): 1575 | def do_test(db): 1576 | def bulks_cb(response): 1577 | datas = [] 1578 | for doc in response: 1579 | datas.append(dict(doc)) 1580 | datas[-1]['_deleted'] = True 1581 | db.bulk_docs(datas, bulks_delete_cb) 1582 | 1583 | def bulks_delete_cb(response): 1584 | eq(response.error, False) 1585 | eq(len(response), 2) 1586 | assert all(isinstance(x, trombi.BulkObject) for x in response) 1587 | ioloop.stop() 1588 | 1589 | datas = [ 1590 | {'key1': 'data1'}, 1591 | {'key2': 'data2'}, 1592 | ] 1593 | db.bulk_docs(datas, bulks_cb) 1594 | 1595 | s = trombi.Server(baseurl, io_loop=ioloop) 1596 | s.create('testdb', callback=do_test) 1597 | ioloop.start() 1598 | 1599 | 1600 | @with_ioloop 1601 | @with_couchdb 1602 | def test_bulk_mixed(baseurl, ioloop): 1603 | def do_test(db): 1604 | def bulks_cb(response): 1605 | datas = [dict(response[0])] 1606 | datas[0]['_deleted'] = True 1607 | db.bulk_docs(datas, bulks_delete_cb) 1608 | 1609 | def bulks_delete_cb(response): 1610 | eq(response.error, False) 1611 | eq(len(response), 1) 1612 | assert all(isinstance(x, trombi.BulkObject) for x in response) 1613 | ioloop.stop() 1614 | 1615 | datas = [ 1616 | {'key1': 'data1'}, 1617 | {'key2': 'data2'}, 1618 | ] 1619 | db.bulk_docs(datas, bulks_cb) 1620 | 1621 | s = trombi.Server(baseurl, io_loop=ioloop) 1622 | s.create('testdb', callback=do_test) 1623 | ioloop.start() 1624 | 1625 | 1626 | @with_ioloop 1627 | @with_couchdb 1628 | def test_bulk_conflict(baseurl, ioloop): 1629 | def do_test(db): 1630 | def bulks_cb(response): 1631 | db.bulk_docs([{ 1632 | '_id': 'foobar', 'key1': 'data2' 1633 | }], bulks_update_cb) 1634 | 1635 | def bulks_update_cb(response): 1636 | eq(response.error, False) 1637 | eq(len(response), 1) 1638 | assert all(isinstance(x, trombi.BulkError) for x in response) 1639 | eq(response[0].reason, 'Document update conflict.') 1640 | ioloop.stop() 1641 | 1642 | datas = [ 1643 | {'_id': 'foobar', 'key1': 'data1'}, 1644 | ] 1645 | db.bulk_docs(datas, bulks_cb) 1646 | 1647 | s = trombi.Server(baseurl, io_loop=ioloop) 1648 | s.create('testdb', callback=do_test) 1649 | ioloop.start() 1650 | 1651 | 1652 | @with_ioloop 1653 | @with_couchdb 1654 | def test_bulk_insert_with_doc(baseurl, ioloop): 1655 | def do_test(db): 1656 | def doc_created_cb(response): 1657 | response['some'] = 'other' 1658 | db.bulk_docs([response], bulks_cb) 1659 | 1660 | def bulks_cb(response): 1661 | assert not response.error 1662 | eq(len(response), 1) 1663 | assert all(isinstance(x, trombi.BulkObject) for x in response) 1664 | ioloop.stop() 1665 | 1666 | db.set('mydoc', {'some': 'data'}, doc_created_cb) 1667 | 1668 | s = trombi.Server(baseurl, io_loop=ioloop) 1669 | s.create('testdb', callback=do_test) 1670 | ioloop.start() 1671 | 1672 | 1673 | @with_ioloop 1674 | @with_couchdb 1675 | def test_bulk_insert_mixed(baseurl, ioloop): 1676 | def do_test(db): 1677 | def doc_created_cb(response): 1678 | response['some'] = 'other' 1679 | db.bulk_docs([response, {'other': 'doc'}], bulks_cb) 1680 | 1681 | def bulks_cb(response): 1682 | assert not response.error 1683 | eq(len(response), 2) 1684 | assert all(isinstance(x, trombi.BulkObject) for x in response) 1685 | ioloop.stop() 1686 | 1687 | db.set('mydoc', {'some': 'data'}, doc_created_cb) 1688 | 1689 | s = trombi.Server(baseurl, io_loop=ioloop) 1690 | s.create('testdb', callback=do_test) 1691 | ioloop.start() 1692 | 1693 | 1694 | @with_ioloop 1695 | @with_couchdb 1696 | def test_continuous_changes_feed(baseurl, ioloop): 1697 | def do_test(db): 1698 | runs = [] 1699 | 1700 | def _got_change(change): 1701 | runs.append(True) 1702 | change['changes'][0].pop('rev') 1703 | 1704 | if len(runs) == 1: 1705 | # First change 1706 | eq(change, {'seq': 1, 'id': 'mydoc', 'changes': [{}]}) 1707 | 1708 | elif len(runs) == 2: 1709 | # Second change 1710 | eq(change, {'seq': 2, 'id': 'second_doc', 'changes': [{}]}) 1711 | 1712 | # Create another document 1713 | db.set('third_doc', {'still': 'more'}, lambda x: None) 1714 | 1715 | elif len(runs) == 3: 1716 | eq(change, {'seq': 3, 'id': 'third_doc', 'changes': [{}]}) 1717 | ioloop.stop() 1718 | 1719 | def doc_created(response): 1720 | assert not response.error 1721 | db.changes(_got_change, feed='continuous') 1722 | 1723 | # Create another document 1724 | db.set('second_doc', {'more': 'data'}, lambda x: None) 1725 | 1726 | db.set('mydoc', {'some': 'data'}, doc_created) 1727 | 1728 | s = trombi.Server(baseurl, io_loop=ioloop) 1729 | s.create('testdb', callback=do_test) 1730 | ioloop.start() 1731 | 1732 | 1733 | @with_ioloop 1734 | @with_couchdb 1735 | def test_long_polling_changes_feed(baseurl, ioloop): 1736 | changes = [] 1737 | 1738 | def do_test(db): 1739 | def _got_change(change): 1740 | changes.append(change.content) 1741 | ioloop.stop() 1742 | 1743 | def doc_created(response): 1744 | assert not response.error 1745 | db.changes(_got_change, feed='longpoll') 1746 | 1747 | db.set('mydoc', {'some': 'data'}, doc_created) 1748 | 1749 | s = trombi.Server(baseurl, io_loop=ioloop) 1750 | s.create('testdb', callback=do_test) 1751 | ioloop.start() 1752 | changes[0]['results'][0]['changes'][0].pop('rev') 1753 | eq(changes[0], {'last_seq': 1, 'results': [{ 1754 | 'changes': [{}], 'id': 'mydoc', 'seq': 1}]}) 1755 | 1756 | 1757 | @with_ioloop 1758 | @with_couchdb 1759 | def test_long_polling_before_doc_created(baseurl, ioloop): 1760 | changes = [] 1761 | 1762 | def do_test(db): 1763 | def _got_change(change): 1764 | changes.append(change.content) 1765 | ioloop.stop() 1766 | 1767 | def doc_created(response): 1768 | assert not response.error 1769 | 1770 | db.changes(_got_change, feed='longpoll', timeout=2) 1771 | db.set('mydoc', {'some': 'data'}, doc_created) 1772 | 1773 | s = trombi.Server(baseurl, io_loop=ioloop) 1774 | s.create('testdb', callback=do_test) 1775 | ioloop.start() 1776 | changes[0]['results'][0]['changes'][0].pop('rev') 1777 | eq(changes[0], {'last_seq': 1, 'results': [{ 1778 | 'changes': [{}], 'id': 'mydoc', 'seq': 1}]}) 1779 | 1780 | 1781 | def test_custom_encoder(): 1782 | s = trombi.Server('http://localhost:5984', json_encoder=DatetimeEncoder) 1783 | json.dumps({'foo': datetime.now()}, cls=s._json_encoder) 1784 | 1785 | 1786 | def test_custom_encoder_from_uri(): 1787 | db = trombi.from_uri('http://localhost:5984/testdb/', 1788 | json_encoder=DatetimeEncoder) 1789 | json.dumps({'foo': datetime.now()}, cls=db._json_encoder) 1790 | 1791 | 1792 | @with_ioloop 1793 | @with_couchdb 1794 | def test_create_document_with_custom_encoder(baseurl, ioloop): 1795 | def create_db_callback(db): 1796 | db.set( 1797 | {'testvalue': datetime(1900, 1, 1)}, 1798 | create_doc_callback, 1799 | ) 1800 | 1801 | def create_doc_callback(doc): 1802 | eq(doc.error, False) 1803 | assert isinstance(doc, trombi.Document) 1804 | assert doc.id 1805 | assert doc.rev 1806 | 1807 | eq(doc['testvalue'], datetime(1900, 1, 1)) 1808 | ioloop.stop() 1809 | 1810 | s = trombi.Server(baseurl, io_loop=ioloop, json_encoder=DatetimeEncoder) 1811 | s.create('testdb', callback=create_db_callback) 1812 | ioloop.start() 1813 | -------------------------------------------------------------------------------- /test/test_session/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/inoi/trombi/f985d430d402717d54b40c320f1828edc2731397/test/test_session/__init__.py -------------------------------------------------------------------------------- /test/test_session/test_session.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2011 Daniel Truemper truemped@googlemail.com 3 | # 4 | # test_session.py 13-Oct-2011 5 | # 6 | # Permission is hereby granted, free of charge, to any person 7 | # obtaining a copy of this software and associated documentation 8 | # files (the "Software"), to deal in the Software without 9 | # restriction, including without limitation the rights to use, copy, 10 | # modify, merge, publish, distribute, sublicense, and/or sell copies 11 | # of the Software, and to permit persons to whom the Software is 12 | # furnished to do so, subject to the following conditions: 13 | # 14 | # The above copyright notice and this permission notice shall be 15 | # included in all copies or substantial portions of the Software. 16 | # 17 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 18 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 19 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 20 | # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS 21 | # BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 22 | # ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 23 | # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 24 | # SOFTWARE. 25 | 26 | from __future__ import with_statement 27 | 28 | from nose.tools import eq_ as eq 29 | 30 | from ..couch_util import setup_with_admin as setup, teardown, with_couchdb 31 | from ..util import with_ioloop 32 | 33 | try: 34 | import json 35 | except ImportError: 36 | import simplejson as json 37 | 38 | try: 39 | # Python 3 40 | from urllib.request import urlopen 41 | from urllib.error import HTTPError 42 | except ImportError: 43 | # Python 2 44 | from urllib2 import urlopen 45 | from urllib2 import HTTPError 46 | 47 | import trombi 48 | import trombi.errors 49 | 50 | 51 | @with_ioloop 52 | @with_couchdb 53 | def test_session_api_with_wrong_credentials(baseurl, ioloop): 54 | s = trombi.Server(baseurl, io_loop=ioloop) 55 | 56 | def session_callback(response): 57 | assert response.error 58 | eq(response.msg, 'Name or password is incorrect.') 59 | ioloop.stop() 60 | 61 | s.login(username="daniel", password="daniel", callback=session_callback) 62 | ioloop.start() 63 | 64 | 65 | @with_ioloop 66 | @with_couchdb 67 | def test_session_with_user(baseurl, ioloop): 68 | s = trombi.Server(baseurl, io_loop=ioloop) 69 | result = {} 70 | 71 | def session_callback(session_info): 72 | result['session_info'] = session_info 73 | ioloop.stop() 74 | 75 | def add_user_callback(response): 76 | assert not response.error 77 | ioloop.stop() 78 | 79 | # add a user 80 | s.add_user('testuser', 'testpassword', add_user_callback) 81 | ioloop.start() 82 | 83 | # login 84 | s.login(username="testuser", password="testpassword", 85 | callback=session_callback) 86 | ioloop.start() 87 | 88 | # check for the cookie and user info 89 | eq(result['session_info'].content, {u'ok': True, u'name': u'testuser', 90 | u'roles': []}) 91 | assert s.session_cookie.startswith('AuthSession') 92 | 93 | # get the session info 94 | s.session(session_callback) 95 | ioloop.start() 96 | 97 | # check that no cookie has been sent and the session info is correct 98 | eq(result['session_info'].content, 99 | {u'info': {u'authentication_handlers': 100 | [u'oauth', u'cookie', u'default'], u'authentication_db': 101 | u'_users'}, u'userCtx': {u'name': None, u'roles': []}, 102 | u'ok': 103 | True}) 104 | 105 | # check that logout is working 106 | s.logout(session_callback) 107 | ioloop.start() 108 | 109 | assert not s.session_cookie 110 | eq(result['session_info'].content, {u'ok': True}) 111 | -------------------------------------------------------------------------------- /test/test_usermgmt.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2011 Daniel Truemper truemped@googlemail.com 3 | # 4 | # Permission is hereby granted, free of charge, to any person 5 | # obtaining a copy of this software and associated documentation 6 | # files (the "Software"), to deal in the Software without 7 | # restriction, including without limitation the rights to use, copy, 8 | # modify, merge, publish, distribute, sublicense, and/or sell copies 9 | # of the Software, and to permit persons to whom the Software is 10 | # furnished to do so, subject to the following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be 13 | # included in all copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 17 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS 19 | # BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 20 | # ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 21 | # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | # SOFTWARE. 23 | # 24 | # 25 | 26 | from __future__ import with_statement 27 | 28 | from datetime import datetime 29 | import hashlib 30 | import sys 31 | 32 | from nose.tools import eq_ as eq 33 | from .couch_util import setup, teardown, with_couchdb 34 | from .util import with_ioloop, DatetimeEncoder 35 | 36 | try: 37 | import json 38 | except ImportError: 39 | import simplejson as json 40 | 41 | try: 42 | # Python 3 43 | from urllib.request import urlopen 44 | from urllib.error import HTTPError 45 | except ImportError: 46 | # Python 2 47 | from urllib2 import urlopen 48 | from urllib2 import HTTPError 49 | 50 | import trombi 51 | import trombi.errors 52 | 53 | 54 | @with_ioloop 55 | @with_couchdb 56 | def test_add_user(baseurl, ioloop): 57 | s = trombi.Server(baseurl, io_loop=ioloop) 58 | 59 | def callback(doc): 60 | assert not doc.error 61 | ioloop.stop() 62 | 63 | s.add_user('test', 'test', callback) 64 | ioloop.start() 65 | 66 | 67 | @with_ioloop 68 | @with_couchdb 69 | def test_get_user(baseurl, ioloop): 70 | s = trombi.Server(baseurl, io_loop=ioloop) 71 | 72 | def callback(doc): 73 | assert not doc.error 74 | ioloop.stop() 75 | 76 | s.add_user('get_test', 'test', callback) 77 | ioloop.start() 78 | 79 | user = [] 80 | def callback(doc): 81 | assert not doc.error 82 | user.append(doc) 83 | ioloop.stop() 84 | 85 | s.get_user('get_test', callback) 86 | ioloop.start() 87 | 88 | eq(True, isinstance(user[0], trombi.Document)) 89 | 90 | @with_ioloop 91 | @with_couchdb 92 | def test_update_user(baseurl, ioloop): 93 | s = trombi.Server(baseurl, io_loop=ioloop) 94 | userdoc = [] 95 | 96 | def add_callback(doc): 97 | assert not doc.error 98 | userdoc.append(doc) 99 | ioloop.stop() 100 | 101 | s.add_user('updatetest', 'test', add_callback) 102 | ioloop.start() 103 | 104 | def update_callback(doc): 105 | assert not doc.error 106 | userdoc.append(doc) 107 | ioloop.stop() 108 | 109 | userdoc[0]['roles'].append('test') 110 | s.update_user(userdoc[0], update_callback) 111 | ioloop.start() 112 | 113 | eq(userdoc[1]['roles'], ['test']) 114 | 115 | def update_passwd_callback(doc): 116 | assert not doc.error 117 | userdoc.append(doc) 118 | ioloop.stop() 119 | 120 | s.update_user_password('updatetest', 'test2', update_passwd_callback) 121 | ioloop.start() 122 | 123 | eq(userdoc[1]['salt'], userdoc[2]['salt']) 124 | eq(userdoc[1]['password_sha'] != userdoc[2]['password_sha'], True) 125 | 126 | 127 | @with_ioloop 128 | @with_couchdb 129 | def test_delete_user(baseurl, ioloop): 130 | s = trombi.Server(baseurl, io_loop=ioloop) 131 | user = [] 132 | 133 | def add_callback(doc): 134 | assert not doc.error 135 | user.append(doc) 136 | ioloop.stop() 137 | 138 | s.add_user('deletetest', 'test', add_callback) 139 | ioloop.start() 140 | 141 | def delete_callback(db): 142 | assert not db.error 143 | assert isinstance(db, trombi.Database) 144 | ioloop.stop() 145 | 146 | s.delete_user(user[0], delete_callback) 147 | ioloop.start() 148 | -------------------------------------------------------------------------------- /test/util.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2010 Inoi Oy 2 | # 3 | # Permission is hereby granted, free of charge, to any person 4 | # obtaining a copy of this software and associated documentation 5 | # files (the "Software"), to deal in the Software without 6 | # restriction, including without limitation the rights to use, copy, 7 | # modify, merge, publish, distribute, sublicense, and/or sell copies 8 | # of the Software, and to permit persons to whom the Software is 9 | # furnished to do so, subject to the following conditions: 10 | # 11 | # The above copyright notice and this permission notice shall be 12 | # included in all copies or substantial portions of the Software. 13 | # 14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS 18 | # BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 19 | # ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | # SOFTWARE. 22 | 23 | import json 24 | import os 25 | import sys 26 | import errno 27 | import shutil 28 | import types 29 | import nose.tools 30 | 31 | from datetime import datetime 32 | from tornado.ioloop import IOLoop 33 | 34 | def unrandom(random=None): 35 | # Make os.urandom not so random. If user wants, a custom random 36 | # function can be used providing keyword argument `random` 37 | if random is None: 38 | random = lambda x: '1' * x 39 | 40 | def outer(func): 41 | @nose.tools.make_decorator(func) 42 | def wrap_urandom(*a, **kw): 43 | _old_urandom = os.urandom 44 | try: 45 | os.urandom = random 46 | func(*a, **kw) 47 | finally: 48 | os.urandom = _old_urandom 49 | return wrap_urandom 50 | return outer 51 | 52 | def with_ioloop(func): 53 | @nose.tools.make_decorator(func) 54 | def wrapper(*args, **kwargs): 55 | ioloop = IOLoop() 56 | 57 | # Override ioloop's _run_callback to let all exceptions through 58 | def run_callback(self, callback): 59 | callback() 60 | ioloop._run_callback = types.MethodType(run_callback, ioloop) 61 | 62 | return func(ioloop, *args, **kwargs) 63 | 64 | return wrapper 65 | 66 | def mkdir(*a, **kw): 67 | try: 68 | os.mkdir(*a, **kw) 69 | except OSError: 70 | # Python 3 71 | e = sys.exc_info()[1] 72 | if e.errno == errno.EEXIST: 73 | pass 74 | else: 75 | raise 76 | 77 | def find_test_name(): 78 | try: 79 | from nose.case import Test 80 | from nose.suite import ContextSuite 81 | import types 82 | def get_nose_name(its_self): 83 | if isinstance(its_self, Test): 84 | file_, module, class_ = its_self.address() 85 | name = '%s:%s' % (module, class_) 86 | return name 87 | elif isinstance(its_self, ContextSuite): 88 | if isinstance(its_self.context, types.ModuleType): 89 | return its_self.context.__name__ 90 | except ImportError: 91 | # older nose 92 | from nose.case import FunctionTestCase, MethodTestCase 93 | from nose.suite import TestModule 94 | from nose.util import test_address 95 | def get_nose_name(its_self): 96 | if isinstance(its_self, (FunctionTestCase, MethodTestCase)): 97 | file_, module, class_ = test_address(its_self) 98 | name = '%s:%s' % (module, class_) 99 | return name 100 | elif isinstance(its_self, TestModule): 101 | return its_self.moduleName 102 | 103 | i = 0 104 | while True: 105 | i += 1 106 | frame = sys._getframe(i) 107 | # kludge, hunt callers upwards until we find our nose 108 | if (frame.f_code.co_varnames 109 | and frame.f_code.co_varnames[0] == 'self'): 110 | its_self = frame.f_locals['self'] 111 | name = get_nose_name(its_self) 112 | if name is not None: 113 | return name 114 | 115 | def maketemp(): 116 | tmp = os.path.join(os.path.dirname(__file__), 'tmp') 117 | mkdir(tmp) 118 | 119 | name = find_test_name() 120 | tmp = os.path.join(tmp, name) 121 | try: 122 | shutil.rmtree(tmp) 123 | except OSError: 124 | # Python 3 125 | e = sys.exc_info()[1] 126 | if e.errno == errno.ENOENT: 127 | pass 128 | else: 129 | raise 130 | os.mkdir(tmp) 131 | return tmp 132 | 133 | 134 | def assert_raises(excClass, callableObj, *args, **kwargs): 135 | """ 136 | Like unittest.TestCase.assertRaises, but returns the exception. 137 | """ 138 | try: 139 | callableObj(*args, **kwargs) 140 | except excClass: 141 | # Python 3 142 | return sys.exc_info()[1] 143 | except: 144 | if hasattr(excClass,'__name__'): excName = excClass.__name__ 145 | else: excName = str(excClass) 146 | raise AssertionError("%s not raised" % excName) 147 | 148 | class DatetimeEncoder(json.JSONEncoder): 149 | def default(self, o): 150 | if isinstance(o, datetime): 151 | return o.isoformat() 152 | else: 153 | return super(DatetimeEncoder, self).default(o) 154 | -------------------------------------------------------------------------------- /trombi/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2011 Jyrki Pulliainen 2 | # Copyright (c) 2010 Inoi Oy 3 | # 4 | # Permission is hereby granted, free of charge, to any person obtaining a copy 5 | # of this software and associated documentation files (the "Software"), to deal 6 | # in the Software without restriction, including without limitation the rights 7 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | # copies of the Software, and to permit persons to whom the Software is 9 | # furnished to do so, subject to the following conditions: 10 | # 11 | # The above copyright notice and this permission notice shall be included in 12 | # all copies or substantial portions of the Software. 13 | # 14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | # THE SOFTWARE. 21 | 22 | from .client import * 23 | -------------------------------------------------------------------------------- /trombi/client.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2011 Jyrki Pulliainen 2 | # Copyright (c) 2010 Inoi Oy 3 | # 4 | # Permission is hereby granted, free of charge, to any person 5 | # obtaining a copy of this software and associated documentation 6 | # files (the "Software"), to deal in the Software without 7 | # restriction, including without limitation the rights to use, copy, 8 | # modify, merge, publish, distribute, sublicense, and/or sell copies 9 | # of the Software, and to permit persons to whom the Software is 10 | # furnished to do so, subject to the following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be 13 | # included in all copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 17 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS 19 | # BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 20 | # ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 21 | # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | # SOFTWARE. 23 | 24 | """Asynchronous CouchDB client""" 25 | 26 | import functools 27 | from hashlib import sha1 28 | import uuid 29 | import logging 30 | import re 31 | import collections 32 | import tornado.ioloop 33 | import urllib 34 | 35 | try: 36 | # Python 3 37 | from urllib.parse import quote as urlquote 38 | from urllib.parse import urlencode 39 | except ImportError: 40 | # Python 2 41 | from urllib import quote as urlquote 42 | from urllib import urlencode 43 | 44 | from base64 import b64encode, b64decode 45 | from tornado.httpclient import AsyncHTTPClient 46 | from tornado.httputil import HTTPHeaders 47 | 48 | log = logging.getLogger('trombi') 49 | 50 | try: 51 | import json 52 | except ImportError: 53 | import simplejson as json 54 | 55 | import trombi.errors 56 | 57 | 58 | def from_uri(uri, fetch_args=None, io_loop=None, **kwargs): 59 | try: 60 | # Python 3 61 | from urllib.parse import urlparse, urlunsplit 62 | except ImportError: 63 | # Python 2 64 | from urlparse import urlparse, urlunsplit 65 | 66 | p = urlparse(uri) 67 | if p.params or p.query or p.fragment: 68 | raise ValueError( 69 | 'Invalid database address: %s (extra query params)' % uri) 70 | if not p.scheme in ('http', 'https'): 71 | raise ValueError( 72 | 'Invalid database address: %s (only http:// and https:// are supported)' % uri) 73 | 74 | baseurl = urlunsplit((p.scheme, p.netloc, '', '', '')) 75 | server = Server(baseurl, fetch_args, io_loop=io_loop, **kwargs) 76 | 77 | db_name = p.path.lstrip('/').rstrip('/') 78 | return Database(server, db_name) 79 | 80 | 81 | class TrombiError(object): 82 | """ 83 | A common error class denoting an error that has happened 84 | """ 85 | error = True 86 | 87 | 88 | class TrombiErrorResponse(TrombiError): 89 | def __init__(self, errno, msg): 90 | self.error = True 91 | self.errno = errno 92 | self.msg = msg 93 | 94 | def __str__(self): 95 | return 'CouchDB reported an error: %s (%d)' % (self.msg, self.errno) 96 | 97 | 98 | class TrombiObject(object): 99 | """ 100 | Dummy result for queries that really don't have anything sane to 101 | return, like succesful database deletion. 102 | 103 | """ 104 | error = False 105 | 106 | 107 | class TrombiResult(TrombiObject): 108 | """ 109 | A generic result objects for Trombi queries that do not have any 110 | formal representation. 111 | """ 112 | 113 | def __init__(self, data): 114 | self.content = data 115 | super(TrombiResult, self).__init__() 116 | 117 | 118 | class TrombiDict(TrombiObject, dict): 119 | def to_basetype(self): 120 | return dict(self) 121 | 122 | 123 | def _jsonize_params(params): 124 | result = dict() 125 | for key, value in params.items(): 126 | result[key] = json.dumps(value) 127 | return urlencode(result) 128 | 129 | 130 | def _error_response(response): 131 | if response.code == 599: 132 | return TrombiErrorResponse(599, 'Unable to connect to CouchDB') 133 | 134 | try: 135 | content = json.loads(response.body.decode('utf-8')) 136 | except ValueError: 137 | return TrombiErrorResponse(response.code, response.body) 138 | try: 139 | return TrombiErrorResponse(response.code, content['reason']) 140 | except (KeyError, TypeError): 141 | # TypeError is risen if the result is a list 142 | return TrombiErrorResponse(response.code, content) 143 | 144 | 145 | class Server(TrombiObject): 146 | def __init__(self, baseurl, fetch_args=None, io_loop=None, 147 | json_encoder=None, **client_args): 148 | self.error = False 149 | self.session_cookie = None 150 | self.baseurl = baseurl 151 | if self.baseurl[-1] == '/': 152 | self.baseurl = self.baseurl[:-1] 153 | if fetch_args is None: 154 | self._fetch_args = dict() 155 | else: 156 | self._fetch_args = fetch_args 157 | 158 | if io_loop is None: 159 | self.io_loop = tornado.ioloop.IOLoop.instance() 160 | else: 161 | self.io_loop = io_loop 162 | # We can assign None to _json_encoder as the json (or 163 | # simplejson) then defaults to json.JSONEncoder 164 | self._json_encoder = json_encoder 165 | self._client = AsyncHTTPClient(self.io_loop, **client_args) 166 | 167 | def _invalid_db_name(self, name): 168 | return TrombiErrorResponse( 169 | trombi.errors.INVALID_DATABASE_NAME, 170 | 'Invalid database name: %r' % name, 171 | ) 172 | 173 | def _fetch(self, *args, **kwargs): 174 | # This is just a convenince wrapper for _client.fetch 175 | 176 | # Set default arguments for a fetch 177 | fetch_args = { 178 | 'headers': HTTPHeaders({'Content-Type': 'application/json'}) 179 | } 180 | fetch_args.update(self._fetch_args) 181 | fetch_args.update(kwargs) 182 | 183 | if self.session_cookie: 184 | fetch_args['X-CouchDB-WWW-Authenticate': 'Cookie'] 185 | if 'Cookie' in fetch_args: 186 | fetch_args['Cookie'] += '; %s' % self.session_cookie 187 | else: 188 | fetch_args['Cookie'] = self.sesison_cookie 189 | 190 | self._client.fetch(*args, **fetch_args) 191 | 192 | def create(self, name, callback): 193 | if not VALID_DB_NAME.match(name): 194 | # Avoid additional HTTP Query by doing the check here 195 | callback(self._invalid_db_name(name)) 196 | 197 | def _create_callback(response): 198 | if response.code == 201: 199 | callback(Database(self, name)) 200 | elif response.code == 412: 201 | callback( 202 | TrombiErrorResponse( 203 | trombi.errors.PRECONDITION_FAILED, 204 | 'Database already exists: %r' % name 205 | )) 206 | else: 207 | callback(_error_response(response)) 208 | 209 | self._fetch( 210 | '%s/%s' % (self.baseurl, name), 211 | _create_callback, 212 | method='PUT', 213 | body='', 214 | ) 215 | 216 | def get(self, name, callback, create=False): 217 | if not VALID_DB_NAME.match(name): 218 | callback(self._invalid_db_name(name)) 219 | 220 | def _really_callback(response): 221 | if response.code == 200: 222 | callback(Database(self, name)) 223 | elif response.code == 404: 224 | # Database doesn't exist 225 | if create: 226 | self.create(name, callback) 227 | else: 228 | callback(TrombiErrorResponse( 229 | trombi.errors.NOT_FOUND, 230 | 'Database not found: %s' % name 231 | )) 232 | else: 233 | callback(_error_response(response)) 234 | 235 | self._fetch( 236 | '%s/%s' % (self.baseurl, name), 237 | _really_callback, 238 | ) 239 | 240 | def delete(self, name, callback): 241 | def _really_callback(response): 242 | if response.code == 200: 243 | callback(TrombiObject()) 244 | elif response.code == 404: 245 | callback( 246 | TrombiErrorResponse( 247 | trombi.errors.NOT_FOUND, 248 | 'Database does not exist: %r' % name 249 | )) 250 | else: 251 | callback(_error_response(response)) 252 | 253 | self._fetch( 254 | '%s/%s' % (self.baseurl, name), 255 | _really_callback, 256 | method='DELETE', 257 | ) 258 | 259 | def list(self, callback): 260 | def _really_callback(response): 261 | if response.code == 200: 262 | body = response.body.decode('utf-8') 263 | callback(Database(self, x) for x in json.loads(body)) 264 | else: 265 | callback(_error_response(response)) 266 | 267 | self._fetch( 268 | '%s/%s' % (self.baseurl, '_all_dbs'), 269 | _really_callback, 270 | ) 271 | 272 | def add_user(self, name, password, callback, doc=None): 273 | userdb = Database(self, '_users') 274 | 275 | if not doc: 276 | doc = {} 277 | 278 | doc['type'] = 'user' 279 | if 'roles' not in doc: 280 | doc['roles'] = [] 281 | 282 | doc['name'] = name 283 | doc['salt'] = str(uuid.uuid4()) 284 | doc['password_sha'] = sha1(password + doc['salt']).hexdigest() 285 | 286 | if '_id' not in doc: 287 | doc['_id'] = 'org.couchdb.user:%s' % name 288 | 289 | userdb.set(doc, callback) 290 | 291 | def get_user(self, name, callback, attachments=False): 292 | userdb = Database(self, '_users') 293 | 294 | doc_id = name 295 | if not name.startswith('org.couchdb.user:'): 296 | doc_id = 'org.couchdb.user:%s' % name 297 | 298 | userdb.get(doc_id, callback, attachments=attachments) 299 | 300 | def update_user(self, user_doc, callback): 301 | userdb = Database(self, '_users') 302 | userdb.set(user_doc, callback) 303 | 304 | def update_user_password(self, username, password, callback): 305 | def _really_callback(user_doc): 306 | if user_doc.error: 307 | callback(user_doc) 308 | user_doc['password_sha'] = sha1(password + user_doc['salt']).hexdigest() 309 | self.update_user(user_doc, callback) 310 | 311 | self.get_user(username, _really_callback) 312 | 313 | def delete_user(self, user_doc, callback): 314 | userdb = Database(self, '_users') 315 | userdb.delete(user_doc, callback) 316 | 317 | def logout(self, callback): 318 | def _really_callback(response): 319 | if response.code == 200: 320 | self.session_cookie = None 321 | callback(TrombiResult(json.loads(response.body))) 322 | else: 323 | callback(_error_response(response)) 324 | 325 | url = '%s/%s' % (self.baseurl, '_session') 326 | self._client.fetch(url, _really_callback, method='DELETE') 327 | 328 | def login(self, username, password, callback): 329 | def _really_callback(response): 330 | if response.code in (200, 302): 331 | self.session_cookie = response.headers['Set-Cookie'] 332 | response_body = json.loads(response.body) 333 | callback(TrombiResult(response_body)) 334 | else: 335 | callback(_error_response(response)) 336 | 337 | body = urllib.urlencode({'name': username, 'password': password}) 338 | url = '%s/%s' % (self.baseurl, '_session') 339 | 340 | self._client.fetch(url, _really_callback, method='POST', body=body) 341 | 342 | def session(self, callback): 343 | def _really_callback(response): 344 | if response.code == 200: 345 | body = json.loads(response.body) 346 | callback(TrombiResult(body)) 347 | else: 348 | callback(_error_response(response)) 349 | 350 | url = '%s/%s' % (self.baseurl, '_session') 351 | self._client.fetch(url, _really_callback) 352 | 353 | 354 | class Database(TrombiObject): 355 | def __init__(self, server, name): 356 | self.server = server 357 | self._json_encoder = self.server._json_encoder 358 | self.name = name 359 | self.baseurl = '%s/%s' % (self.server.baseurl, self.name) 360 | 361 | def _fetch(self, url, *args, **kwargs): 362 | # Just a convenience wrapper 363 | if 'baseurl' in kwargs: 364 | url = '%s/%s' % (kwargs.pop('baseurl'), url) 365 | else: 366 | url = '%s/%s' % (self.baseurl, url) 367 | return self.server._fetch(url, *args, **kwargs) 368 | 369 | def info(self, callback): 370 | def _really_callback(response): 371 | if response.code == 200: 372 | body = response.body.decode('utf-8') 373 | callback(TrombiDict(json.loads(body))) 374 | else: 375 | callback(_error_response(response)) 376 | 377 | self._fetch('', _really_callback) 378 | 379 | def set(self, *args, **kwargs): 380 | cb = kwargs.pop('callback', None) 381 | if cb: 382 | args += (cb,) 383 | if len(args) == 2: 384 | data, callback = args 385 | doc_id = None 386 | elif len(args) == 3: 387 | doc_id, data, callback = args 388 | else: 389 | raise TypeError( 390 | 'Database.set takes at most 2 non-keyword arguments.') 391 | 392 | if kwargs: 393 | if list(kwargs.keys()) != ['attachments']: 394 | if len(kwargs) > 1: 395 | raise TypeError( 396 | '%s are invalid keyword arguments for this function') %( 397 | (', '.join(kwargs.keys()))) 398 | else: 399 | raise TypeError( 400 | '%s is invalid keyword argument for this function' % ( 401 | list(kwargs.keys())[0])) 402 | 403 | attachments = kwargs['attachments'] 404 | else: 405 | attachments = {} 406 | 407 | if isinstance(data, Document): 408 | doc = data 409 | else: 410 | doc = Document(self, data) 411 | 412 | if doc_id is None and doc.id is not None and doc.rev is not None: 413 | # Update the existing document 414 | doc_id = doc.id 415 | 416 | if doc_id is not None: 417 | url = urlquote(doc_id, safe='') 418 | method = 'PUT' 419 | else: 420 | url = '' 421 | method = 'POST' 422 | 423 | for name, attachment in attachments.items(): 424 | content_type, attachment_data = attachment 425 | if content_type is None: 426 | content_type = 'text/plain' 427 | doc.attachments[name] = { 428 | 'content_type': content_type, 429 | 'data': b64encode(attachment_data).decode('utf-8'), 430 | } 431 | 432 | def _really_callback(response): 433 | try: 434 | # If the connection to the server is malfunctioning, 435 | # ie. the simplehttpclient returns 599 and no body, 436 | # don't set the content as the response.code will not 437 | # be 201 at that point either 438 | if response.body is not None: 439 | content = json.loads(response.body.decode('utf-8')) 440 | except ValueError: 441 | content = response.body 442 | 443 | if response.code == 201: 444 | doc.id = content['id'] 445 | doc.rev = content['rev'] 446 | callback(doc) 447 | else: 448 | callback(_error_response(response)) 449 | 450 | self._fetch( 451 | url, 452 | _really_callback, 453 | method=method, 454 | body=json.dumps(doc.raw(), cls=self._json_encoder), 455 | ) 456 | 457 | def get(self, doc_id, callback, attachments=False): 458 | def _really_callback(response): 459 | if response.code == 200: 460 | data = json.loads(response.body.decode('utf-8')) 461 | doc = Document(self, data) 462 | callback(doc) 463 | elif response.code == 404: 464 | # Document doesn't exist 465 | callback(None) 466 | else: 467 | callback(_error_response(response)) 468 | 469 | doc_id = urlquote(doc_id, safe='') 470 | 471 | kwargs = {} 472 | 473 | if attachments is True: 474 | doc_id += '?attachments=true' 475 | kwargs['headers'] = HTTPHeaders( 476 | {'Content-Type': 'application/json', 477 | 'Accept': 'application/json', 478 | }) 479 | 480 | self._fetch( 481 | doc_id, 482 | _really_callback, 483 | **kwargs 484 | ) 485 | 486 | def get_attachment(self, doc_id, attachment_name, callback): 487 | def _really_callback(response): 488 | if response.code == 200: 489 | callback(response.body) 490 | elif response.code == 404: 491 | # Document or attachment doesn't exist 492 | callback(None) 493 | else: 494 | callback(_error_response(response)) 495 | 496 | doc_id = urlquote(doc_id, safe='') 497 | attachment_name = urlquote(attachment_name, safe='') 498 | 499 | self._fetch( 500 | '%s/%s' % (doc_id, attachment_name), 501 | _really_callback, 502 | ) 503 | 504 | def view(self, design_doc, viewname, callback, **kwargs): 505 | def _really_callback(response): 506 | if response.code == 200: 507 | body = response.body.decode('utf-8') 508 | callback( 509 | ViewResult(json.loads(body), db=self) 510 | ) 511 | else: 512 | callback(_error_response(response)) 513 | 514 | if not design_doc and viewname == '_all_docs': 515 | url = '_all_docs' 516 | else: 517 | url = '_design/%s/_view/%s' % (design_doc, viewname) 518 | 519 | # We need to pop keys before constructing the url to avoid it 520 | # ending up twice in the request, both in the body and as a 521 | # query parameter. 522 | keys = kwargs.pop('keys', None) 523 | 524 | if kwargs: 525 | url = '%s?%s' % (url, _jsonize_params(kwargs)) 526 | 527 | if keys is not None: 528 | self._fetch(url, _really_callback, 529 | method='POST', 530 | body=json.dumps({'keys': keys}) 531 | ) 532 | else: 533 | self._fetch(url, _really_callback) 534 | 535 | def list(self, design_doc, listname, viewname, callback, **kwargs): 536 | def _really_callback(response): 537 | if response.code == 200: 538 | callback(TrombiResult(response.body)) 539 | else: 540 | callback(_error_response(response)) 541 | 542 | url = '_design/%s/_list/%s/%s/' % (design_doc, listname, viewname) 543 | if kwargs: 544 | url = '%s?%s' % (url, _jsonize_params(kwargs)) 545 | 546 | self._fetch(url, _really_callback) 547 | 548 | def temporary_view(self, callback, map_fun, reduce_fun=None, 549 | language='javascript', **kwargs): 550 | def _really_callback(response): 551 | if response.code == 200: 552 | body = response.body.decode('utf-8') 553 | callback( 554 | ViewResult(json.loads(body), db=self) 555 | ) 556 | else: 557 | callback(_error_response(response)) 558 | 559 | url = '_temp_view' 560 | if kwargs: 561 | url = '%s?%s' % (url, _jsonize_params(kwargs)) 562 | 563 | body = {'map': map_fun, 'language': language} 564 | if reduce_fun: 565 | body['reduce'] = reduce_fun 566 | 567 | self._fetch(url, _really_callback, method='POST', 568 | body=json.dumps(body), 569 | headers={'Content-Type': 'application/json'}) 570 | 571 | def delete(self, data, callback): 572 | def _really_callback(response): 573 | try: 574 | json.loads(response.body.decode('utf-8')) 575 | except ValueError: 576 | callback(_error_response(response)) 577 | return 578 | if response.code == 200: 579 | callback(self) 580 | else: 581 | callback(_error_response(response)) 582 | 583 | if isinstance(data, Document): 584 | doc = data 585 | else: 586 | doc = Document(self, data) 587 | 588 | doc_id = urlquote(doc.id, safe='') 589 | self._fetch( 590 | '%s?rev=%s' % (doc_id, doc.rev), 591 | _really_callback, 592 | method='DELETE', 593 | ) 594 | 595 | def bulk_docs(self, data, callback, all_or_nothing=False): 596 | def _really_callback(response): 597 | if response.code == 200 or response.code == 201: 598 | try: 599 | content = json.loads(response.body.decode('utf-8')) 600 | except ValueError: 601 | callback(TrombiErrorResponse(response.code, response.body)) 602 | else: 603 | callback(BulkResult(content)) 604 | else: 605 | callback(_error_response(response)) 606 | 607 | docs = [] 608 | for element in data: 609 | if isinstance(element, Document): 610 | docs.append(element.raw()) 611 | else: 612 | docs.append(element) 613 | 614 | payload = {'docs': docs} 615 | if all_or_nothing is True: 616 | payload['all_or_nothing'] = True 617 | 618 | self._fetch( 619 | '_bulk_docs', 620 | _really_callback, 621 | method='POST', 622 | body=json.dumps(payload), 623 | ) 624 | 625 | def changes(self, callback, timeout=None, feed='normal', **kw): 626 | def _really_callback(response): 627 | log.debug('Changes feed response: %s', response) 628 | if response.code != 200: 629 | callback(_error_response(response)) 630 | return 631 | if feed == 'continuous': 632 | # Feed terminated, call callback with None to indicate 633 | # this, if the mode is continous 634 | callback(None) 635 | else: 636 | body = response.body.decode('utf-8') 637 | callback(TrombiResult(json.loads(body))) 638 | 639 | stream_buffer = [] 640 | 641 | def _stream(text): 642 | stream_buffer.append(text.decode('utf-8')) 643 | chunks = ''.join(stream_buffer).split('\n') 644 | 645 | # The last chunk is either an empty string or an 646 | # incomplete line. Save it for the next round. The [:] 647 | # syntax is used because of variable scoping. 648 | stream_buffer[:] = [chunks.pop()] 649 | 650 | for chunk in chunks: 651 | if not chunk.strip(): 652 | continue 653 | 654 | try: 655 | obj = json.loads(chunk) 656 | except ValueError: 657 | # JSON parsing failed. Apparently we have some 658 | # gibberish on our hands, just discard it. 659 | log.warning('Invalid changes feed line: %s' % chunk) 660 | continue 661 | 662 | # "Escape" the streaming_callback context by invoking 663 | # the handler as an ioloop callback. This makes it 664 | # possible to start new HTTP requests in the handler 665 | # (it is impossible in the streaming_callback 666 | # context). Tornado runs these callbacks in the order 667 | # they were added, so this works correctly. 668 | # 669 | # This also relieves us from handling exceptions in 670 | # the handler. 671 | cb = functools.partial(callback, TrombiDict(obj)) 672 | self.server.io_loop.add_callback(cb) 673 | 674 | couchdb_params = kw 675 | couchdb_params['feed'] = feed 676 | params = dict() 677 | if timeout is not None: 678 | # CouchDB takes timeouts in milliseconds 679 | couchdb_params['timeout'] = timeout * 1000 680 | params['request_timeout'] = timeout + 1 681 | url = '_changes?%s' % urlencode(couchdb_params) 682 | if feed == 'continuous': 683 | params['streaming_callback'] = _stream 684 | 685 | log.debug('Fetching changes from %s with params %s', url, params) 686 | self._fetch(url, _really_callback, **params) 687 | 688 | 689 | class Document(collections.MutableMapping, TrombiObject): 690 | def __init__(self, db, data): 691 | self.db = db 692 | self.data = {} 693 | self.id = None 694 | self.rev = None 695 | self._postponed_attachments = False 696 | self.attachments = {} 697 | 698 | for key, value in data.items(): 699 | if key.startswith('_'): 700 | setattr(self, key[1:], value) 701 | else: 702 | self[key] = value 703 | 704 | def __len__(self): 705 | return len(self.data) 706 | 707 | def __iter__(self): 708 | return iter(self.data) 709 | 710 | def __contains__(self, key): 711 | return key in self.data 712 | 713 | def __getitem__(self, key): 714 | return self.data[key] 715 | 716 | def __setitem__(self, key, value): 717 | if key.startswith('_'): 718 | raise KeyError("Keys starting with '_' are reserved for CouchDB") 719 | self.data[key] = value 720 | 721 | def __delitem__(self, key): 722 | del self.data[key] 723 | 724 | def raw(self): 725 | result = {} 726 | if self.id: 727 | result['_id'] = self.id 728 | if self.rev: 729 | result['_rev'] = self.rev 730 | if self.attachments: 731 | result['_attachments'] = self.attachments 732 | 733 | result.update(self.data) 734 | return result 735 | 736 | def copy(self, new_id, callback): 737 | assert self.rev and self.id 738 | 739 | def _copy_done(response): 740 | if response.code != 201: 741 | callback(_error_response(response)) 742 | return 743 | 744 | content = json.loads(response.body.decode('utf-8')) 745 | doc = Document(self.db, self.data) 746 | doc.attachments = self.attachments.copy() 747 | doc.id = content['id'] 748 | doc.rev = content['rev'] 749 | callback(doc) 750 | 751 | self.db._fetch( 752 | '%s' % urlquote(self.id, safe=''), 753 | _copy_done, 754 | allow_nonstandard_methods=True, 755 | method='COPY', 756 | headers={'Destination': str(new_id)} 757 | ) 758 | 759 | def attach(self, name, data, callback, type='text/plain'): 760 | def _really_callback(response): 761 | if response.code != 201: 762 | callback(_error_response(response)) 763 | return 764 | data = json.loads(response.body.decode('utf-8')) 765 | assert data['id'] == self.id 766 | self.rev = data['rev'] 767 | self.attachments[name] = { 768 | 'content_type': type, 769 | 'length': len(data), 770 | 'stub': True, 771 | } 772 | callback(self) 773 | 774 | headers = {'Content-Type': type, 'Expect': ''} 775 | 776 | self.db._fetch( 777 | '%s/%s?rev=%s' % ( 778 | urlquote(self.id, safe=''), 779 | urlquote(name, safe=''), 780 | self.rev), 781 | _really_callback, 782 | method='PUT', 783 | body=data, 784 | headers=headers, 785 | ) 786 | 787 | def load_attachment(self, name, callback): 788 | def _really_callback(response): 789 | if response.code == 200: 790 | callback(response.body) 791 | else: 792 | callback(_error_response(response)) 793 | 794 | if (hasattr(self, 'attachments') and 795 | name in self.attachments and 796 | not self.attachments[name].get('stub', False)): 797 | data = self.attachments[name]['data'].encode('utf-8') 798 | callback(b64decode(data)) 799 | else: 800 | self.db._fetch( 801 | '%s/%s' % ( 802 | urlquote(self.id, safe=''), 803 | urlquote(name, safe='') 804 | ), 805 | _really_callback, 806 | ) 807 | 808 | def delete_attachment(self, name, callback): 809 | def _really_callback(response): 810 | if response.code != 200: 811 | callback(_error_response(response)) 812 | return 813 | callback(self) 814 | 815 | self.db._fetch( 816 | '%s/%s?rev=%s' % (self.id, name, self.rev), 817 | _really_callback, 818 | method='DELETE', 819 | ) 820 | 821 | 822 | class BulkError(TrombiError): 823 | def __init__(self, data): 824 | self.error_type = data['error'] 825 | self.reason = data.get('reason', None) 826 | self.raw = data 827 | 828 | 829 | class BulkObject(TrombiObject, collections.Mapping): 830 | def __init__(self, data): 831 | self._data = data 832 | 833 | def __len__(self): 834 | return len(self._data) 835 | 836 | def __iter__(self): 837 | return iter(self._data) 838 | 839 | def __contains__(self, key): 840 | return key in self._data 841 | 842 | def __getitem__(self, key): 843 | return self._data[key] 844 | 845 | 846 | class BulkResult(TrombiResult, collections.Sequence): 847 | def __init__(self, result): 848 | self.content = [] 849 | for line in result: 850 | if 'error' in line: 851 | self.content.append(BulkError(line)) 852 | else: 853 | self.content.append(BulkObject(line)) 854 | 855 | def __len__(self): 856 | return len(self.content) 857 | 858 | def __iter__(self): 859 | return iter(self.content) 860 | 861 | def __getitem__(self, key): 862 | return self.content[key] 863 | 864 | 865 | class ViewResult(TrombiObject, collections.Sequence): 866 | def __init__(self, result, db=None): 867 | self.db = db 868 | self.total_rows = result.get('total_rows', len(result['rows'])) 869 | self._rows = result['rows'] 870 | self.offset = result.get('offset', 0) 871 | 872 | def _format_row(self, row): 873 | if 'doc' in row and row['doc']: 874 | row['doc'] = Document(self.db, row['doc']) 875 | return row 876 | 877 | def __len__(self): 878 | return len(self._rows) 879 | 880 | def __iter__(self): 881 | return (self._format_row(x) for x in self._rows) 882 | 883 | def __getitem__(self, key): 884 | return self._format_row(self._rows[key]) 885 | 886 | 887 | class Paginator(TrombiObject): 888 | """ 889 | Provides pseudo pagination of CouchDB documents calculated from 890 | the total_rows and offset of a CouchDB view as well as a user- 891 | defined page limit. 892 | """ 893 | def __init__(self, db, limit=10): 894 | self._db = db 895 | self._limit = limit 896 | self.response = None 897 | self.count = 0 898 | self.start_index = 0 899 | self.end_index = 0 900 | self.num_pages = 0 901 | self.current_page = 0 902 | self.previous_page = 0 903 | self.next_page = 0 904 | self.rows = None 905 | self.has_next = False 906 | self.has_previous = False 907 | self.page_range = None 908 | self.start_doc_id = None 909 | self.end_doc_id = None 910 | 911 | def get_page(self, design_doc, viewname, callback, 912 | key=None, doc_id=None, forward=True, **kw): 913 | """ 914 | On success, callback is called with this Paginator object as an 915 | argument that is fully populated with the page data requested. 916 | 917 | Use forward = True for paging forward, and forward = False for 918 | paging backwargs 919 | 920 | The combination of key/doc_id and forward is crucial. When 921 | requesting to paginate forward the key/doc_id must be the built 922 | from the _last_ document on the current page you are moving forward 923 | from. When paginating backwards, the key/doc_id must be built 924 | from the _first_ document on the current page. 925 | 926 | """ 927 | def _really_callback(response): 928 | if response.error: 929 | # Send the received Database.view error to the callback 930 | callback(response) 931 | return 932 | 933 | if forward: 934 | offset = response.offset 935 | else: 936 | offset = response.total_rows - response.offset - self._limit 937 | 938 | self.response = response 939 | self.count = response.total_rows 940 | self.start_index = offset 941 | self.end_index = response.offset + self._limit - 1 942 | self.num_pages = (self.count / self._limit) + 1 943 | self.current_page = (offset / self._limit) + 1 944 | self.previous_page = self.current_page - 1 945 | self.next_page = self.current_page + 1 946 | self.rows = [row['value'] for row in response] 947 | if not forward: 948 | self.rows.reverse() 949 | self.has_next = (offset + self._limit) < self.count 950 | self.has_previous = (offset - self._limit) >= 0 951 | self.page_range = [p for p in xrange(1, self.num_pages+1)] 952 | try: 953 | self.start_doc_id = self.rows[0]['_id'] 954 | self.end_doc_id = self.rows[-1]['_id'] 955 | except (IndexError, KeyError): 956 | # empty set 957 | self.start_doc_id = None 958 | self.end_doc_id = None 959 | callback(self) 960 | 961 | kwargs = {'limit': self._limit, 962 | 'descending': True} 963 | kwargs.update(kw) 964 | 965 | if 'startkey' not in kwargs: 966 | kwargs['startkey'] = key 967 | 968 | if kwargs['startkey'] and forward and doc_id: 969 | kwargs['start_doc_id'] = doc_id 970 | elif kwargs['startkey'] and not forward: 971 | kwargs['start_doc_id'] = doc_id if doc_id else '' 972 | kwargs['descending'] = False if kwargs['descending'] else True 973 | kwargs['skip'] = 1 974 | 975 | self._db.view(design_doc, viewname, _really_callback, **kwargs) 976 | 977 | 978 | VALID_DB_NAME = re.compile(r'^[a-z][a-z0-9_$()+-/]*$') 979 | -------------------------------------------------------------------------------- /trombi/errors.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2011 Jyrki Pulliainen 2 | # Copyright (c) 2010 Inoi Oy 3 | # 4 | # Permission is hereby granted, free of charge, to any person 5 | # obtaining a copy of this software and associated documentation 6 | # files (the "Software"), to deal in the Software without 7 | # restriction, including without limitation the rights to use, copy, 8 | # modify, merge, publish, distribute, sublicense, and/or sell copies 9 | # of the Software, and to permit persons to whom the Software is 10 | # furnished to do so, subject to the following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be 13 | # included in all copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 17 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS 19 | # BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 20 | # ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 21 | # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | # SOFTWARE. 23 | 24 | # Collection of possible couchdb errors 25 | BAD_REQUEST = 400 26 | CONFLICT = 409 27 | PRECONDITION_FAILED = 412 28 | NOT_FOUND = 404 29 | SERVER_ERROR = 500 30 | 31 | # Non-http errors (or overloaded http 500 errors) 32 | INVALID_DATABASE_NAME = 51 33 | 34 | errormap = { 35 | 409: CONFLICT, 36 | 412: PRECONDITION_FAILED, 37 | 404: NOT_FOUND, 38 | 500: SERVER_ERROR 39 | } 40 | --------------------------------------------------------------------------------