├── .gitignore ├── .pylintrc ├── .travis.yml ├── CHANGES.rst ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.rst ├── aiocouchdb ├── __init__.py ├── authn.py ├── client.py ├── errors.py ├── feeds.py ├── hdrs.py ├── multipart.py ├── tests │ ├── __init__.py │ ├── test_authn.py │ ├── test_client.py │ ├── test_errors.py │ ├── test_feeds.py │ └── utils.py ├── v1 │ ├── __init__.py │ ├── attachment.py │ ├── authdb.py │ ├── config.py │ ├── database.py │ ├── designdoc.py │ ├── document.py │ ├── security.py │ ├── server.py │ ├── session.py │ └── tests │ │ ├── __init__.py │ │ ├── test_attachment.py │ │ ├── test_authdb.py │ │ ├── test_config.py │ │ ├── test_database.py │ │ ├── test_designdoc.py │ │ ├── test_document.py │ │ ├── test_security.py │ │ ├── test_server.py │ │ ├── test_session.py │ │ └── utils.py ├── version.py └── views.py ├── docs ├── Makefile ├── common.rst ├── conf.py ├── index.rst ├── make.bat └── v1 │ └── index.rst └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | # C extensions 4 | *.so 5 | 6 | # Packages 7 | *.egg 8 | *.egg-info 9 | dist 10 | build 11 | eggs 12 | parts 13 | bin 14 | var 15 | sdist 16 | develop-eggs 17 | .installed.cfg 18 | lib 19 | lib64 20 | 21 | # Installer logs 22 | pip-log.txt 23 | 24 | # Unit test / coverage reports 25 | .coverage 26 | .tox 27 | nosetests.xml 28 | cover/ 29 | htmlcov/ 30 | 31 | # Translations 32 | *.mo 33 | 34 | # Mr Developer 35 | .mr.developer.cfg 36 | .project 37 | .pydevproject 38 | 39 | # Sphinx docs 40 | docs/_build 41 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | ignore= 3 | # tests quality need to be fixed one day... 4 | tests, 5 | # pylint fails for multipart.py with 6 | # AttributeError: 'Starred' object has no attribute 'assigned_stmts' 7 | multipart.py 8 | jobs=1 9 | persistent=yes 10 | profile=no 11 | unsafe-load-any-extension=no 12 | 13 | 14 | [MESSAGES CONTROL] 15 | confidence= 16 | disable=W1607,I0020,E1606,W1614,W1616,E1604,I0021,W1630,W1631,W1618,E1608,E1603, 17 | E1602,W1610,W1611,E1607,W1617,E1605,W1615,W1633,W1624,W1623,W1604,W1632, 18 | W1619,W1620,W1626,W1605,W1622,W1621,W1606,W1601,W1627,W1625,W1602,W1629, 19 | W1628,W1609,W1608,W1612,W0704,W1603,W1613,E1601, 20 | # 21 | # These are ok 22 | # 23 | too-few-public-methods, star-args 24 | 25 | 26 | [REPORTS] 27 | comment=no 28 | evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) 29 | files-output=no 30 | output-format=colorized 31 | reports=yes 32 | 33 | 34 | [FORMAT] 35 | expected-line-ending-format= 36 | ignore-long-lines=^\s*(# )??$ 37 | indent-after-paren=4 38 | indent-string=' ' 39 | max-line-length=80 40 | max-module-lines=1000 41 | no-space-check=trailing-comma,dict-separator 42 | single-line-if-stmt=yes 43 | 44 | 45 | [SPELLING] 46 | spelling-dict= 47 | spelling-ignore-words= 48 | spelling-private-dict-file= 49 | spelling-store-unknown-words=no 50 | 51 | 52 | [VARIABLES] 53 | additional-builtins= 54 | callbacks=cb_,_cb 55 | dummy-variables-rgx=_$|dummy 56 | init-import=no 57 | 58 | 59 | [BASIC] 60 | bad-functions= 61 | bad-names=foo,bar,baz,toto,tutu,tata 62 | good-names=i,j,k,ex,Run,_ 63 | include-naming-hint=yes 64 | name-group= 65 | required-attributes= 66 | 67 | class-attribute-rgx=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ 68 | class-attribute-name-hint=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ 69 | function-rgx=[a-z_][a-z0-9_]{2,30}$ 70 | function-name-hint=[a-z_][a-z0-9_]{2,30}$ 71 | const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__))$ 72 | const-name-hint=(([A-Z_][A-Z0-9_]*)|(__.*__))$ 73 | method-rgx=[a-z_][a-z0-9_]{2,30}$ 74 | method-name-hint=[a-z_][a-z0-9_]{2,30}$ 75 | inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$ 76 | inlinevar-name-hint=[A-Za-z_][A-Za-z0-9_]*$ 77 | argument-rgx=[a-z_][a-z0-9_]{2,30}$ 78 | argument-name-hint=[a-z_][a-z0-9_]{2,30}$ 79 | attr-rgx=[a-z_][a-z0-9_]{2,30}$ 80 | attr-name-hint=[a-z_][a-z0-9_]{2,30}$ 81 | module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ 82 | module-name-hint=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ 83 | variable-rgx=[a-z_][a-z0-9_]{2,30}$ 84 | variable-name-hint=[a-z_][a-z0-9_]{2,30}$ 85 | class-rgx=[A-Z_][a-zA-Z0-9]+$ 86 | class-name-hint=[A-Z_][a-zA-Z0-9]+$ 87 | 88 | no-docstring-rgx=__.*__ 89 | docstring-min-length=-1 90 | 91 | 92 | [MISCELLANEOUS] 93 | notes=FIXME,XXX,TODO 94 | 95 | 96 | [SIMILARITIES] 97 | ignore-comments=yes 98 | ignore-docstrings=yes 99 | ignore-imports=no 100 | min-similarity-lines=4 101 | 102 | 103 | [LOGGING] 104 | logging-modules=logging 105 | 106 | 107 | [TYPECHECK] 108 | generated-members= 109 | ignore-mixin-members=yes 110 | ignored-classes= 111 | ignored-modules= 112 | zope=no 113 | 114 | 115 | [CLASSES] 116 | defining-attr-methods=__init__,__new__,setUp 117 | exclude-protected=_asdict,_fields,_replace,_source,_make 118 | ignore-iface-methods= 119 | valid-classmethod-first-arg=cls 120 | valid-metaclass-classmethod-first-arg=mcs 121 | 122 | 123 | [IMPORTS] 124 | deprecated-modules=stringprep,optparse 125 | ext-import-graph= 126 | import-graph= 127 | int-import-graph= 128 | 129 | 130 | [DESIGN] 131 | ignored-argument-names=_.* 132 | max-args=5 133 | max-attributes=7 134 | max-branches=12 135 | max-locals=15 136 | max-parents=7 137 | max-public-methods=20 138 | max-returns=6 139 | max-statements=50 140 | min-public-methods=2 141 | 142 | 143 | [EXCEPTIONS] 144 | overgeneral-exceptions=Exception 145 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - 3.3 4 | - 3.4 5 | 6 | services: 7 | - couchdb 8 | 9 | before_script: 10 | - make dev 11 | 12 | script: 13 | - make check 14 | - make docs 15 | 16 | env: 17 | matrix: 18 | - PYTHONASYNCIODEBUG=0 19 | AIOCOUCHDB_TARGET=mock 20 | - PYTHONASYNCIODEBUG=1 21 | AIOCOUCHDB_TARGET=mock 22 | - PYTHONASYNCIODEBUG=0 23 | AIOCOUCHDB_TARGET=couchdb 24 | - PYTHONASYNCIODEBUG=1 25 | AIOCOUCHDB_TARGET=couchdb 26 | -------------------------------------------------------------------------------- /CHANGES.rst: -------------------------------------------------------------------------------- 1 | 0.9.1 (2016-02-03) 2 | ------------------ 3 | 4 | - Read views and changes feeds line by line, not by chunks. 5 | This fixes #8 and #9 issues. 6 | - Deprecate Python 3.3 support. 0.10 will be 3.4.1+ only. 7 | 8 | 9 | 0.9.0 (2015-10-31) 10 | ------------------ 11 | 12 | - First release in aio-libs organization (: 13 | - Add context managers for response and feeds objects to release connection 14 | when work with them is done 15 | - Use own way to handle JSON responses that doesn't involves chardet usage 16 | - Add HTTPSession object that helps to apply the same auth credentials and 17 | TCP connector for the all further requests made with it 18 | - aiocouchdb now uses own request module which is basically fork of aiohttp one 19 | - AuthProviders API upgraded for better workflow 20 | - Fix _bulk_docs request with new_edit 21 | - Workaround COUCHDB-2295 by calculating multipart request body 22 | - Allow to pass event loop explicitly to every major objects 23 | - Fix parameters for Server.replicate method 24 | - Minor fixes for docstrings 25 | - Quite a lot of changes in Makefile commands for better life 26 | - Minimal requirements for aiohttp raised up to 0.17.4 version 27 | 28 | 0.8.0 (2015-03-20) 29 | ------------------ 30 | 31 | - Source tree was refactored in the way to support multiple major CouchDB 32 | versions as like as the other friendly forks 33 | - Database create and delete methods now return exact the same response as 34 | CouchDB sends back 35 | - Each module now contains __all__ list to normalize their exports 36 | - API classes and Resource now has nicer __repr__ output 37 | - Better error messages format 38 | - Fix function_clause error on attempt to update a document with attachments 39 | by using multipart request 40 | - Document.update doesn't makes document's dict invalid for further requests 41 | after multipart one 42 | - Fixed accidental payload sent with HEAD/GET/DELETE requests which caused 43 | connection close from CouchDB side 44 | - Added integration with Travis CI 45 | - Code cleaned by following pylint and flake8 notices 46 | - Added short tutorial for documentation 47 | - Minor fixes and Makefile improvements 48 | 49 | 0.7.0 (2015-02-18) 50 | ------------------ 51 | 52 | - Greatly improved multipart module, added multipart writer 53 | - Document.update now supports multipart requests to upload 54 | multiple attachments in single request 55 | - Added Proxy Authentication provider 56 | - Minimal requirements for aiohttp raised up to 0.14.0 version 57 | 58 | 0.6.0 (2014-11-12) 59 | ------------------ 60 | 61 | - Adopt test suite to run against real CouchDB instance 62 | - Database, documents and attachments now provides access to their name/id 63 | - Remove redundant longnamed constructors 64 | - Construct Database/Document/Attachment instances through __getitem__ protocol 65 | - Add Document.rev method to get current document`s revision 66 | - Add helpers to work with authentication database (_users) 67 | - Add optional limitation of feeds buffer 68 | - All remove(...) methods are renamed to delete(...) ones 69 | - Add support for config option existence check 70 | - Correctly set members for database security 71 | - Fix requests with Accept-Ranges header against attachments 72 | - Fix views requests when startkey/endkey should be null 73 | - Allow to pass custom query parameters and request headers onto changes feed 74 | request 75 | - Handle correctly HTTP 416 error response 76 | - Minor code fixes and cleanup 77 | 78 | 0.5.0 (2014-09-26) 79 | ------------------ 80 | 81 | - Last checkpoint release. It's in beta now! 82 | - Implements CouchDB Design Documents HTTP API 83 | - Views refactoring and implementation consolidation 84 | 85 | 0.4.0 (2014-09-17) 86 | ------------------ 87 | 88 | - Another checkpoint release 89 | - Implements CouchDB Attachment HTTP API 90 | - Minimal requirements for aiohttp raised up to 0.9.1 version 91 | - Minor fixes for Document API 92 | 93 | 0.3.0 (2014-08-18) 94 | ------------------ 95 | 96 | - Third checkpoint release 97 | - Implements CouchDB Document HTTP API 98 | - Support document`s multipart API (but not doc update due to COUCHDB-2295) 99 | - Minimal requirements for aiohttp raised up to 0.9.0 version 100 | - Better documentation 101 | 102 | 0.2.0 (2014-07-08) 103 | ------------------ 104 | 105 | - Second checkpoint release 106 | - Implements CouchDB Database HTTP API 107 | - Bulk docs accepts generator as an argument and streams request doc by doc 108 | - Views are processed as stream 109 | - Unified output for various changes feed types 110 | - Basic Auth accepts non-ASCII credentials 111 | - Minimal requirements for aiohttp raised up to 0.8.4 version 112 | 113 | 0.1.0 (2014-07-01) 114 | ------------------ 115 | 116 | - Initial checkpoint release 117 | - Implements CouchDB Server HTTP API 118 | - BasicAuth, Cookie, OAuth authentication providers 119 | - Multi-session workflow 120 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (C) 2014-2015 Alexander Shorin 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are 6 | met: 7 | 8 | 1. Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright 12 | notice, this list of conditions and the following disclaimer in the 13 | documentation and/or other materials provided with the distribution. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 16 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 17 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 18 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 19 | HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 20 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 21 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 22 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 23 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 24 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 25 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include CHANGES.rst 3 | include README.rst 4 | include Makefile 5 | graft aiocouchdb 6 | graft docs 7 | prune docs/_build 8 | global-exclude *.pyc 9 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PROJECT=aiocouchdb 2 | PYTHON=`which python3` 3 | PIP=`which pip` 4 | NOSE=`which nosetests` 5 | PYLINT=`which pylint` 6 | FLAKE8=`which flake8` 7 | SPHINX=`which sphinx-build` 8 | 9 | 10 | .PHONY: help 11 | # target: help - Prints this help 12 | help: 13 | @egrep "^# target:" Makefile | sed -e 's/^# target: //g' | sort 14 | 15 | 16 | .PHONY: venv 17 | # target: venv - Setups virtual environment 18 | venv: 19 | ${PYTHON} -m venv venv 20 | @echo "Virtuanenv has been created. Don't forget to run . venv/bin/active" 21 | 22 | 23 | .PHONY: dev 24 | # target: dev - Setups developer environment 25 | dev: 26 | ${PIP} install nose coverage pylint flake8 sphinx 27 | ${PYTHON} setup.py develop 28 | 29 | 30 | .PHONY: install 31 | # target: install - Installs aiocouchdb package 32 | install: 33 | ${PYTHON} setup.py install 34 | 35 | 36 | .PHONY: clean 37 | # target: clean - Removes intermediate and generated files 38 | clean: 39 | rm -rf `find . -name __pycache__` 40 | rm -f `find . -type f -name '*.py[co]' ` 41 | rm -f `find . -type f -name '*.orig' ` 42 | rm -f `find . -type f -name '*.rej' ` 43 | rm -f .coverage 44 | rm -rf coverage 45 | rm -rf build 46 | rm -rf cover 47 | make -C docs clean 48 | python setup.py clean 49 | 50 | 51 | .PHONY: purge 52 | # target: purge - Removes all unversioned files and resets repository 53 | purge: 54 | git reset --hard HEAD 55 | git clean -xdff 56 | 57 | 58 | .PHONY: check 59 | # target: check - Runs test suite against mocked environment 60 | check: flake pylint-errors 61 | ${NOSE} --with-doctest ${PROJECT} 62 | 63 | 64 | .PHONY: check-couchdb 65 | # target: check-couchdb - Runs test suite against real CouchDB instance (AIOCOUCHDB_URL="http://localhost:5984") 66 | check-couchdb: flake 67 | AIOCOUCHDB_URL="http://localhost:5984" AIOCOUCHDB_TARGET="couchdb" \ 68 | ${NOSE} --with-doctest ${PROJECT} 69 | 70 | 71 | .PHONY: distcheck 72 | # target: distcheck - Checks if project is ready to ship 73 | distcheck: distcheck-clean distcheck-33 distcheck-34 74 | 75 | distcheck-clean: 76 | rm -rf distcheck 77 | 78 | distcheck-33: 79 | mkdir -p distcheck 80 | virtualenv --python=python3.3 distcheck/venv-3.3 81 | distcheck/venv-3.3/bin/python setup.py install 82 | distcheck/venv-3.3/bin/python setup.py test 83 | AIOCOUCHDB_URL="http://localhost:5984" AIOCOUCHDB_TARGET="couchdb" \ 84 | distcheck/venv-3.3/bin/python setup.py test 85 | 86 | distcheck-34: 87 | mkdir -p distcheck 88 | python3.4 -m venv distcheck/venv-3.4 89 | distcheck/venv-3.4/bin/python setup.py install 90 | distcheck/venv-3.4/bin/python setup.py test 91 | AIOCOUCHDB_URL="http://localhost:5984" AIOCOUCHDB_TARGET="couchdb" \ 92 | distcheck/venv-3.4/bin/python setup.py test 93 | 94 | flake: 95 | ${FLAKE8} --max-line-length=80 --statistics --exclude=tests --ignore=E501,F403 ${PROJECT} 96 | 97 | 98 | .PHONY: cover 99 | # target: cover - Generates coverage report 100 | cover: 101 | ${NOSE} --with-coverage --cover-html --cover-erase --cover-package=${PROJECT} 102 | 103 | 104 | .PHONY: pylint 105 | # target: pylint - Runs pylint checks 106 | pylint: 107 | ${PYLINT} --rcfile=.pylintrc ${PROJECT} 108 | 109 | 110 | .PHONY: pylint-errors 111 | # target: pylint-errors - Reports about pylint errors 112 | pylint-errors: 113 | ${PYLINT} --rcfile=.pylintrc -E ${PROJECT} 114 | 115 | 116 | .PHONY: docs 117 | # target: docs - Builds Sphinx html docs 118 | docs: 119 | ${SPHINX} -b html -d docs/_build/doctrees docs/ docs/_build/html 120 | 121 | 122 | .PHONY: release 123 | # target: release - Yay new release! 124 | release: ${PROJECT}/version.py 125 | sed -i s/\'dev\'/\'\'/ $< 126 | git commit -m "Release `${PYTHON} $<` version" $< 127 | 128 | 129 | .PHONY: pypi 130 | # target: pypi - Uploads package on PyPI 131 | pypi: 132 | ${PYTHON} setup.py sdist register upload 133 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ========== 2 | aiocouchdb 3 | ========== 4 | 5 | :source: https://github.com/aio-libs/aiocouchdb 6 | :documentation: http://aiocouchdb.readthedocs.org/en/latest/ 7 | :license: BSD 8 | 9 | CouchDB client built on top of `aiohttp`_ and made for `asyncio`_. 10 | 11 | Current status: **beta**. `aiocouchdb` has all CouchDB API implements up to 12 | 1.6.1 release. However, it may lack of some usability and stability bits, but 13 | work is in progress. Feel free to `send pull request`_ or `open issue`_ if 14 | you'd found something that should be fixed. 15 | 16 | Features: 17 | 18 | - Modern CouchDB client for Python 3.3+ based on `aiohttp`_ 19 | - Complete CouchDB API support (JSON and Multipart) up to 1.6.1 version 20 | - Multiuser workflow with Basic Auth, Cookie, Proxy and OAuth support 21 | - Stateless behavior 22 | - Stream-like handling views, changes feeds and bulk docs upload 23 | 24 | Roadmap (not exactly in that order): 25 | 26 | - Cloudant support 27 | - CouchDB 2.0 support 28 | - ElasticSearch CouchDB river support 29 | - GeoCouch support 30 | - Microframework for OS daemons and external handlers 31 | - Native integration with Python Query Server 32 | - Replicator-as-a-Library / Replicator-as-a-Service 33 | - Stateful API 34 | 35 | Requirements 36 | ============ 37 | 38 | - Python 3.3+ 39 | - `aiohttp`_ 40 | - `oauthlib`_ (optional) 41 | 42 | .. _aiohttp: https://github.com/KeepSafe/aiohttp 43 | .. _asyncio: https://docs.python.org/3/library/asyncio.html 44 | .. _oauthlib: https://github.com/idan/oauthlib 45 | 46 | .. _open issue: https://github.com/aio-libs/aiocouchdb/issues 47 | .. _send pull request: https://github.com/aio-libs/aiocouchdb/pulls 48 | -------------------------------------------------------------------------------- /aiocouchdb/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (C) 2014-2015 Alexander Shorin 4 | # All rights reserved. 5 | # 6 | # This software is licensed as described in the file LICENSE, which 7 | # you should have received as part of this distribution. 8 | # 9 | 10 | # flake8: noqa 11 | 12 | from .authn import ( 13 | AuthProvider, 14 | NoAuthProvider, 15 | BasicAuthProvider, 16 | CookieAuthProvider, 17 | OAuthProvider, 18 | ProxyAuthProvider 19 | ) 20 | from .errors import ( 21 | HttpErrorException, 22 | BadRequest, 23 | Unauthorized, 24 | Forbidden, 25 | ResourceNotFound, 26 | MethodNotAllowed, 27 | ResourceConflict, 28 | PreconditionFailed, 29 | RequestedRangeNotSatisfiable, 30 | ServerError 31 | ) 32 | from .v1.attachment import Attachment 33 | from .v1.database import Database 34 | from .v1.document import Document 35 | from .v1.designdoc import DesignDocument 36 | from .v1.server import Server 37 | from .version import __version__, __version_info__ 38 | -------------------------------------------------------------------------------- /aiocouchdb/authn.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (C) 2014-2016 Alexander Shorin 4 | # All rights reserved. 5 | # 6 | # This software is licensed as described in the file LICENSE, which 7 | # you should have received as part of this distribution. 8 | # 9 | 10 | import abc 11 | import asyncio 12 | import base64 13 | import functools 14 | import hashlib 15 | import hmac 16 | import http.cookies 17 | from collections import namedtuple 18 | 19 | from .hdrs import ( 20 | AUTHORIZATION, 21 | COOKIE, 22 | X_AUTH_COUCHDB_ROLES, 23 | X_AUTH_COUCHDB_TOKEN, 24 | X_AUTH_COUCHDB_USERNAME 25 | ) 26 | 27 | 28 | __all__ = ( 29 | 'AuthProvider', 30 | 'NoAuthProvider', 31 | 'BasicAuthProvider', 32 | 'BasicAuthCredentials', 33 | 'CookieAuthProvider', 34 | 'OAuthProvider', 35 | 'OAuthCredentials', 36 | 'ProxyAuthProvider', 37 | 'ProxyAuthCredentials' 38 | ) 39 | 40 | 41 | #: BasicAuth credentials 42 | BasicAuthCredentials = namedtuple('BasicAuthCredentials', [ 43 | 'username', 'password']) 44 | 45 | #: OAuth credentials 46 | OAuthCredentials = namedtuple('OAuthCredentials', [ 47 | 'consumer_key', 'consumer_secret', 'resource_key', 'resource_secret']) 48 | 49 | #: ProxyAuth credentials 50 | ProxyAuthCredentials = namedtuple('ProxyAuthCredentials', [ 51 | 'username', 'roles', 'secret']) 52 | 53 | 54 | class AuthProvider(object, metaclass=abc.ABCMeta): 55 | """Abstract authentication provider class.""" 56 | 57 | @abc.abstractmethod 58 | def reset(self): 59 | """Resets provider instance to default state.""" 60 | raise NotImplementedError # pragma: no cover 61 | 62 | @abc.abstractmethod 63 | def credentials(self): 64 | """Returns authentication credentials if any.""" 65 | raise NotImplementedError # pragma: no cover 66 | 67 | @abc.abstractmethod 68 | def set_credentials(self, *args, **kwargs): 69 | """Sets authentication credentials.""" 70 | raise NotImplementedError # pragma: no cover 71 | 72 | @abc.abstractmethod 73 | def apply(self, url, headers): 74 | """Applies authentication routines on further request. Mostly used 75 | to set right `Authorization` header or cookies to pass the challenge. 76 | 77 | :param str url: Request URL 78 | :param dict headers: Request headers 79 | """ 80 | raise NotImplementedError # pragma: no cover 81 | 82 | @abc.abstractmethod 83 | def update(self, response): 84 | """Updates provider routines from the HTTP response data. 85 | 86 | :param response: :class:`aiocouchdb.client.HttpResponse` instance 87 | """ 88 | raise NotImplementedError # pragma: no cover 89 | 90 | def wrap(self, request_func): 91 | """Wraps request coroutine function to apply the authentication context. 92 | """ 93 | @functools.wraps(request_func) 94 | @asyncio.coroutine 95 | def wrapper(method, url, headers, **kwargs): 96 | self.apply(url, headers) 97 | response = yield from request_func(method, url, 98 | headers=headers, **kwargs) 99 | self.update(response) 100 | return response 101 | return wrapper 102 | 103 | 104 | class NoAuthProvider(AuthProvider): 105 | """Dummy provider to apply no authentication routines.""" 106 | 107 | def reset(self): 108 | pass # pragma: no cover 109 | 110 | def credentials(self): 111 | pass # pragma: no cover 112 | 113 | def set_credentials(self): 114 | pass # pragma: no cover 115 | 116 | def apply(self, url, headers): 117 | pass # pragma: no cover 118 | 119 | def update(self, response): 120 | pass # pragma: no cover 121 | 122 | def wrap(self, request_func): 123 | return request_func 124 | 125 | 126 | class BasicAuthProvider(AuthProvider): 127 | """Provides authentication via BasicAuth method.""" 128 | 129 | _auth_header = None 130 | _credentials = None 131 | 132 | def __init__(self, name=None, password=None): 133 | if name or password: 134 | self.set_credentials(name, password) 135 | 136 | def reset(self): 137 | """Resets provider instance to default state.""" 138 | self._auth_header = None 139 | self._credentials = None 140 | 141 | def credentials(self): 142 | """Returns authentication credentials. 143 | 144 | :rtype: :class:`aiocouchdb.authn.BasicAuthCredentials` 145 | """ 146 | return self._credentials 147 | 148 | def set_credentials(self, name, password): 149 | """Sets authentication credentials. 150 | 151 | :param str name: Username 152 | :param str password: User's password 153 | """ 154 | if name and password: 155 | self._credentials = BasicAuthCredentials(name, password) 156 | elif not name: 157 | raise ValueError("Basic Auth username is missing") 158 | elif not password: 159 | raise ValueError("Basic Auth password is missing") 160 | 161 | def apply(self, url, headers): 162 | """Adds BasicAuth header to ``headers``. 163 | 164 | :param str url: Request URL 165 | :param dict headers: Request headers 166 | """ 167 | if self._auth_header is None: 168 | if self._credentials is None: 169 | raise ValueError('Basic Auth credentials was not specified') 170 | token = base64.b64encode( 171 | ('%s:%s' % self._credentials).encode('utf8')) 172 | self._auth_header = 'Basic %s' % (token.strip().decode('utf8')) 173 | headers[AUTHORIZATION] = self._auth_header 174 | 175 | def update(self, response): 176 | pass # pragma: no cover 177 | 178 | 179 | class CookieAuthProvider(AuthProvider): 180 | """Provides authentication by cookies.""" 181 | 182 | _cookies = None 183 | 184 | def reset(self): 185 | """Resets provider instance to default state.""" 186 | self._cookies = None 187 | 188 | def credentials(self): 189 | # Reserved for future use. 190 | pass # pragma: no cover 191 | 192 | def set_credentials(self, name, password): 193 | # Reserved for future use. 194 | pass # pragma: no cover 195 | 196 | def apply(self, url, headers): 197 | """Adds cookies to provided ``headers``. If ``headers`` already 198 | contains any cookies, they would be merged with instance ones. 199 | 200 | :param str url: Request URL 201 | :param dict headers: Request headers 202 | """ 203 | if self._cookies is None: 204 | return 205 | 206 | cookie = http.cookies.SimpleCookie() 207 | if COOKIE in headers: 208 | cookie.load(headers.get(COOKIE, '')) 209 | del headers[COOKIE] 210 | 211 | for name, value in self._cookies.items(): 212 | if isinstance(value, http.cookies.Morsel): 213 | # use dict method because SimpleCookie class modifies value 214 | dict.__setitem__(cookie, name, value) 215 | else: 216 | cookie[name] = value 217 | 218 | headers[COOKIE] = cookie.output(header='', sep=';').strip() 219 | 220 | def update(self, response): 221 | """Updates cookies from the response. 222 | 223 | :param response: :class:`aiocouchdb.client.HttpResponse` instance 224 | """ 225 | if response.cookies: 226 | self._cookies = response.cookies 227 | 228 | 229 | class OAuthProvider(AuthProvider): 230 | """Provides authentication via OAuth1. Requires ``oauthlib`` package.""" 231 | 232 | _credentials = None 233 | 234 | def __init__(self, *, consumer_key=None, consumer_secret=None, 235 | resource_key=None, resource_secret=None): 236 | from oauthlib import oauth1 # pylint: disable=import-error 237 | self._oauth1 = oauth1 238 | self.set_credentials(consumer_key=consumer_key, 239 | consumer_secret=consumer_secret, 240 | resource_key=resource_key, 241 | resource_secret=resource_secret) 242 | 243 | def reset(self): 244 | """Resets provider instance to default state.""" 245 | self._credentials = None 246 | 247 | def credentials(self): 248 | """Returns OAuth credentials. 249 | 250 | :rtype: :class:`aiocouchdb.authn.OAuthCredentials` 251 | """ 252 | return self._credentials 253 | 254 | def set_credentials(self, *, consumer_key=None, consumer_secret=None, 255 | resource_key=None, resource_secret=None): 256 | """Sets OAuth credentials. Currently, all keyword arguments are 257 | required for successful auth. 258 | 259 | :param str consumer_key: Consumer key (consumer token) 260 | :param str consumer_secret: Consumer secret 261 | :param str resource_key: Resource key (oauth token) 262 | :param str resource_secret: Resource secret (oauth token secret) 263 | """ 264 | creds = (consumer_key, consumer_secret, resource_key, resource_secret) 265 | if not all(creds): 266 | return 267 | self._credentials = OAuthCredentials(*creds) 268 | 269 | def apply(self, url, headers): 270 | """Adds OAuth1 signature to ``headers``. 271 | 272 | :param str url: Request URL 273 | :param dict headers: Request headers 274 | """ 275 | if self._credentials is None: 276 | raise ValueError('OAuth credentials was not specified') 277 | client = self._oauth1.Client( 278 | client_key=self._credentials.consumer_key, 279 | client_secret=self._credentials.consumer_secret, 280 | resource_owner_key=self._credentials.resource_key, 281 | resource_owner_secret=self._credentials.resource_secret, 282 | signature_type=self._oauth1.SIGNATURE_TYPE_AUTH_HEADER) 283 | _, oauth_headers, _ = client.sign(url) 284 | headers[AUTHORIZATION] = oauth_headers['Authorization'] 285 | 286 | def update(self, response): 287 | pass # pragma: no cover 288 | 289 | 290 | class ProxyAuthProvider(AuthProvider): 291 | """Provides CouchDB proxy authentication methods.""" 292 | 293 | _credentials = None 294 | 295 | #: Controls the name of header used to specify CouchDB username 296 | x_auth_username = X_AUTH_COUCHDB_USERNAME 297 | #: Controls the name of header used to specify list of CouchDB user roles 298 | x_auth_roles = X_AUTH_COUCHDB_ROLES 299 | #: Controls the name of header used to provide authentication token 300 | x_auth_token = X_AUTH_COUCHDB_TOKEN 301 | 302 | def __init__(self, username=None, roles=None, secret=None, *, 303 | x_auth_username=None, x_auth_roles=None, x_auth_token=None): 304 | if x_auth_username is not None: 305 | self.x_auth_username = x_auth_username 306 | if x_auth_roles is not None: 307 | self.x_auth_roles = x_auth_roles 308 | if x_auth_token is not None: 309 | self.x_auth_token = x_auth_token 310 | 311 | if username or roles or secret: 312 | self.set_credentials(username, roles, secret) 313 | 314 | def reset(self): 315 | """Resets provider instance to default state.""" 316 | self._credentials = None 317 | 318 | def credentials(self): 319 | """Returns three-element tuple of defined username, roles and secret.""" 320 | return self._credentials 321 | 322 | def set_credentials(self, username, roles=None, secret=None): 323 | """Sets ProxyAuth credentials. 324 | 325 | :param str username: CouchDB username 326 | :param list roles: List of username roles 327 | :param str secret: ProxyAuth secret. Should match the one which defined 328 | on target CouchDB server. 329 | """ 330 | if not username: 331 | raise ValueError('Proxy Auth username should have non-empty value') 332 | self._credentials = ProxyAuthCredentials(username, roles, secret) 333 | 334 | def apply(self, url, headers): 335 | """Adds ProxyAuth credentials to ``headers``. 336 | 337 | :param str url: Request URL 338 | :param dict headers: Request headers 339 | """ 340 | creds = self._credentials 341 | 342 | if creds is None or not creds.username: 343 | raise ValueError('Proxy Auth username is missing') 344 | else: 345 | headers[self.x_auth_username] = creds.username 346 | 347 | if creds.roles is not None: 348 | headers[self.x_auth_roles] = ','.join(creds.roles) 349 | 350 | if creds.secret is not None: 351 | headers[self.x_auth_token] = hmac.new( 352 | creds.secret.encode('utf-8'), 353 | creds.username.encode('utf-8'), 354 | hashlib.sha1).hexdigest() 355 | 356 | def update(self, response): 357 | pass # pragma: no cover 358 | -------------------------------------------------------------------------------- /aiocouchdb/errors.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (C) 2014-2015 Alexander Shorin 4 | # All rights reserved. 5 | # 6 | # This software is licensed as described in the file LICENSE, which 7 | # you should have received as part of this distribution. 8 | # 9 | 10 | """ 11 | Exception hierarchy 12 | ------------------- 13 | 14 | .. code:: 15 | 16 | BaseException 17 | +-- Exception 18 | +-- aiohttp.errors.HttpProcessingError 19 | +-- aiocouchdb.errors.HttpErrorException 20 | +-- aiocouchdb.errors.BadRequest 21 | +-- aiocouchdb.errors.Unauthorized 22 | +-- aiocouchdb.errors.Forbidden 23 | +-- aiocouchdb.errors.ResourceNotFound 24 | +-- aiocouchdb.errors.MethodNotAllowed 25 | +-- aiocouchdb.errors.ResourceConflict 26 | +-- aiocouchdb.errors.PreconditionFailed 27 | +-- aiocouchdb.errors.RequestedRangeNotSatisfiable 28 | +-- aiocouchdb.errors.ServerError 29 | """ 30 | 31 | 32 | import asyncio 33 | import aiohttp.errors 34 | 35 | 36 | __all__ = ( 37 | 'HttpErrorException', 38 | 'BadRequest', 39 | 'Unauthorized', 40 | 'Forbidden', 41 | 'ResourceNotFound', 42 | 'MethodNotAllowed', 43 | 'ResourceConflict', 44 | 'PreconditionFailed', 45 | 'RequestedRangeNotSatisfiable', 46 | 'ServerError', 47 | 'maybe_raise_error' 48 | ) 49 | 50 | 51 | class HttpErrorException(aiohttp.errors.HttpProcessingError): 52 | """Extension of :exc:`aiohttp.errors.HttpErrorException` for CouchDB related 53 | errors.""" 54 | 55 | error = '' 56 | reason = '' 57 | 58 | def __init__(self, error, reason, headers=None): 59 | self.error = error 60 | self.reason = reason 61 | self.headers = headers 62 | 63 | def __str__(self): 64 | return '[{}] {}'.format(self.error or 'unknown_error', self.reason) 65 | 66 | 67 | class BadRequest(HttpErrorException): 68 | """The request could not be understood by the server due to malformed 69 | syntax.""" 70 | 71 | code = 400 72 | 73 | 74 | class Unauthorized(HttpErrorException): 75 | """The request requires user authentication.""" 76 | 77 | code = 401 78 | 79 | 80 | class Forbidden(HttpErrorException): 81 | """The server understood the request, but is refusing to fulfill it.""" 82 | 83 | code = 403 84 | 85 | 86 | class ResourceNotFound(HttpErrorException): 87 | """The server has not found anything matching the Request-URI.""" 88 | 89 | code = 404 90 | 91 | 92 | class MethodNotAllowed(HttpErrorException): 93 | """The method specified in the Request-Line is not allowed for 94 | the resource identified by the Request-URI.""" 95 | 96 | code = 405 97 | 98 | 99 | class ResourceConflict(HttpErrorException): 100 | """The request could not be completed due to a conflict with the current 101 | state of the resource.""" 102 | 103 | code = 409 104 | 105 | 106 | class PreconditionFailed(HttpErrorException): 107 | """The precondition given in one or more of the Request-Header fields 108 | evaluated to false when it was tested on the server.""" 109 | 110 | code = 412 111 | 112 | 113 | class RequestedRangeNotSatisfiable(HttpErrorException): 114 | """The client has asked for a portion of the file, but the server 115 | cannot supply that portion.""" 116 | 117 | code = 416 118 | 119 | 120 | class ServerError(HttpErrorException): 121 | """The server encountered an unexpected condition which prevented it from 122 | fulfilling the request.""" 123 | 124 | code = 500 125 | 126 | 127 | HTTP_ERROR_BY_CODE = { 128 | err.code: err 129 | for err in HttpErrorException.__subclasses__() # pylint: disable=no-member 130 | } 131 | 132 | 133 | @asyncio.coroutine 134 | def maybe_raise_error(resp): 135 | """Raises :exc:`aiohttp.errors.HttpErrorException` exception in case of 136 | ``>=400`` response status code.""" 137 | if resp.status < 400: 138 | return 139 | exc_cls = HTTP_ERROR_BY_CODE[resp.status] 140 | data = yield from resp.json() 141 | if isinstance(data, dict): 142 | error, reason = data.get('error', ''), data.get('reason', '') 143 | exc = exc_cls(error, reason, resp.headers) 144 | else: 145 | exc = exc_cls('', data, resp.headers) 146 | raise exc 147 | -------------------------------------------------------------------------------- /aiocouchdb/feeds.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (C) 2014-2016 Alexander Shorin 4 | # All rights reserved. 5 | # 6 | # This software is licensed as described in the file LICENSE, which 7 | # you should have received as part of this distribution. 8 | # 9 | 10 | import asyncio 11 | import json 12 | 13 | from aiohttp.helpers import parse_mimetype 14 | from .hdrs import CONTENT_TYPE 15 | 16 | 17 | __all__ = ( 18 | 'Feed', 19 | 'JsonFeed', 20 | 'ViewFeed', 21 | 'ChangesFeed', 22 | 'LongPollChangesFeed', 23 | 'ContinuousChangesFeed', 24 | 'EventSourceFeed', 25 | 'EventSourceChangesFeed' 26 | ) 27 | 28 | 29 | class Feed(object): 30 | """Wrapper over :class:`HttpResponse` content to stream continuous response 31 | by emitted chunks.""" 32 | 33 | #: Limits amount of items feed would fetch and keep for further iteration. 34 | buffer_size = 0 35 | _ignore_heartbeats = True 36 | 37 | def __init__(self, resp, *, loop=None, buffer_size=0): 38 | self._active = True 39 | self._exc = None 40 | self._queue = asyncio.Queue(maxsize=buffer_size or self.buffer_size, 41 | loop=loop) 42 | self._resp = resp 43 | 44 | ctype = resp.headers.get(CONTENT_TYPE, '').lower() 45 | *_, params = parse_mimetype(ctype) 46 | self._encoding = params.get('charset', 'utf-8') # pylint: disable=E1101 47 | 48 | asyncio.Task(self._loop(), loop=loop) 49 | 50 | def __enter__(self): 51 | return self 52 | 53 | def __exit__(self, exc_type, exc_val, exc_tb): 54 | self.close(force=True if exc_type else False) 55 | 56 | @asyncio.coroutine 57 | def _loop(self): 58 | try: 59 | while not self._resp.content.at_eof() and self._active: 60 | chunk = yield from self._resp.content.readline() 61 | if not chunk: 62 | continue 63 | if chunk == b'\n' and self._ignore_heartbeats: 64 | continue 65 | yield from self._queue.put(chunk) 66 | except Exception as exc: 67 | self._exc = exc 68 | self.close(True) 69 | else: 70 | self.close() 71 | 72 | @asyncio.coroutine 73 | def next(self): 74 | """Emits the next response chunk or ``None`` is feed is empty. 75 | 76 | :rtype: bytearray 77 | """ 78 | if not self.is_active(): 79 | if self._exc is not None: 80 | raise self._exc from None # pylint: disable=raising-bad-type 81 | return None 82 | chunk = yield from self._queue.get() 83 | if chunk is None: 84 | # in case of race condition, raising an error should have more 85 | # priority then returning stop signal 86 | if self._exc is not None: 87 | raise self._exc from None # pylint: disable=raising-bad-type 88 | return chunk 89 | 90 | def is_active(self): 91 | """Checks if the feed is still able to emit any data. 92 | 93 | :rtype: bool 94 | """ 95 | return self._active or not self._queue.empty() 96 | 97 | def close(self, force=False): 98 | """Closes feed and the related request connection. Closing feed doesnt 99 | means that all 100 | 101 | :param bool force: In case of True, close connection instead of release. 102 | See :meth:`aiohttp.client.ClientResponse.close` for 103 | the details 104 | """ 105 | self._active = False 106 | self._resp.close(force=force) 107 | # put stop signal into queue to break waiting loop on queue.get() 108 | self._queue.put_nowait(None) 109 | 110 | 111 | class JsonFeed(Feed): 112 | """As :class:`Feed`, but for chunked JSON response. Assumes that each 113 | received chunk is valid JSON object and decodes them before emit.""" 114 | 115 | @asyncio.coroutine 116 | def next(self): 117 | """Decodes feed chunk with JSON before emit it. 118 | 119 | :rtype: dict 120 | """ 121 | chunk = yield from super().next() 122 | if chunk is not None: 123 | return json.loads(chunk.decode(self._encoding)) 124 | 125 | 126 | class ViewFeed(Feed): 127 | """Like :class:`JsonFeed`, but uses CouchDB view response specifics.""" 128 | 129 | _total_rows = None 130 | _offset = None 131 | _update_seq = None 132 | 133 | @asyncio.coroutine 134 | def next(self): 135 | """Emits view result row. 136 | 137 | :rtype: dict 138 | """ 139 | chunk = yield from super().next() 140 | if chunk is None: 141 | return chunk 142 | chunk = chunk.decode(self._encoding).strip('\r\n,') 143 | if "total_rows" in chunk: 144 | # couchdb 1.x (and 2.x?) 145 | if chunk.startswith('{'): 146 | chunk += ']}' 147 | # couchbase sync gateway 1.x 148 | elif chunk.endswith('}'): 149 | chunk = '{' + chunk 150 | event = json.loads(chunk) 151 | self._total_rows = event['total_rows'] 152 | self._offset = event.get('offset') 153 | return (yield from self.next()) 154 | elif chunk.startswith(('{"rows"', ']')): 155 | return (yield from self.next()) 156 | elif not chunk: 157 | return (yield from self.next()) 158 | else: 159 | return json.loads(chunk) 160 | 161 | @property 162 | def offset(self): 163 | """Returns view results offset.""" 164 | return self._offset 165 | 166 | @property 167 | def total_rows(self): 168 | """Returns total rows in view.""" 169 | return self._total_rows 170 | 171 | @property 172 | def update_seq(self): 173 | """Returns update sequence for a view.""" 174 | return self._update_seq 175 | 176 | 177 | class EventSourceFeed(Feed): 178 | """Handles `EventSource`_ response following the W3.org spec with single 179 | exception: it expects field `data` to contain valid JSON value. 180 | 181 | .. _EventSource: http://www.w3.org/TR/eventsource/ 182 | """ 183 | 184 | _ignore_heartbeats = False 185 | 186 | @asyncio.coroutine 187 | def next(self): 188 | """Emits decoded EventSource event. 189 | 190 | :rtype: dict 191 | """ 192 | lines = [] 193 | while True: 194 | chunk = (yield from super().next()) 195 | if chunk is None: 196 | if lines: 197 | break 198 | return 199 | if chunk == b'\n': 200 | if lines: 201 | break 202 | continue 203 | chunk = chunk.decode(self._encoding).strip() 204 | lines.append(chunk) 205 | event = {} 206 | data = event['data'] = [] 207 | for line in lines: 208 | if not line: 209 | break 210 | if line.startswith(':'): 211 | # If the line starts with a U+003A COLON character (:) 212 | # Ignore the line. 213 | continue 214 | if ':' not in line: 215 | # Otherwise, the string is not empty but does not contain 216 | # a U+003A COLON character (:) 217 | # Process the field using the steps described below, using 218 | # the whole line as the field name, and the empty string as 219 | # the field value. 220 | field, value = line, '' 221 | else: 222 | # If the line contains a U+003A COLON character (:) 223 | # Collect the characters on the line before the first 224 | # U+003A COLON character (:), and let field be that string. 225 | # 226 | # Collect the characters on the line after the first U+003A 227 | # COLON character (:), and let value be that string. 228 | # If value starts with a U+0020 SPACE character, remove it 229 | # from value. 230 | # 231 | # Process the field using the steps described below, using 232 | # field as the field name and value as the field value. 233 | field, value = line.split(':', 1) 234 | if value.startswith(' '): 235 | value = value[1:] 236 | 237 | if field in ('id', 'event'): 238 | event[field] = value 239 | elif field == 'data': 240 | # If the field name is "data": 241 | # Append the field value to the data buffer, 242 | # then append a single U+000A LINE FEED (LF) character 243 | # to the data buffer. 244 | data.append(value) 245 | data.append('\n') 246 | elif field == 'retry': 247 | # If the field name is "retry": 248 | # If the field value consists of only ASCII digits, 249 | # then interpret the field value as an integer in base ten. 250 | event[field] = int(value) 251 | else: 252 | # Otherwise: The field is ignored. 253 | continue # pragma: no cover 254 | data = ''.join(data).strip() 255 | event['data'] = json.loads(data) if data else None 256 | return event 257 | 258 | 259 | class ChangesFeed(Feed): 260 | """Processes database changes feed.""" 261 | 262 | _last_seq = None 263 | 264 | @asyncio.coroutine 265 | def next(self): 266 | """Emits the next event from changes feed. 267 | 268 | :rtype: dict 269 | """ 270 | chunk = yield from super().next() 271 | if chunk is None: 272 | return chunk 273 | if chunk.startswith((b'{"results"', b'],\n')): 274 | return (yield from self.next()) 275 | if chunk == b',\r\n': 276 | return (yield from self.next()) 277 | if chunk.startswith(b'"last_seq":'): 278 | chunk = b'{' + chunk 279 | try: 280 | event = json.loads(chunk.strip(b',\r\n').decode(self._encoding)) 281 | except: 282 | print('>>>', chunk) 283 | raise 284 | if 'last_seq' in event: 285 | self._last_seq = event['last_seq'] 286 | return (yield from self.next()) 287 | self._last_seq = event['seq'] 288 | return event 289 | 290 | @property 291 | def last_seq(self): 292 | """Returns last emitted sequence number. 293 | 294 | :rtype: int 295 | """ 296 | return self._last_seq 297 | 298 | 299 | class LongPollChangesFeed(ChangesFeed): 300 | """Processes long polling database changes feed.""" 301 | 302 | 303 | class ContinuousChangesFeed(ChangesFeed, JsonFeed): 304 | """Processes continuous database changes feed.""" 305 | 306 | @asyncio.coroutine 307 | def next(self): 308 | """Emits the next event from changes feed. 309 | 310 | :rtype: dict 311 | """ 312 | event = yield from JsonFeed.next(self) 313 | if event is None: 314 | return None 315 | if 'last_seq' in event: 316 | self._last_seq = event['last_seq'] 317 | return (yield from self.next()) 318 | self._last_seq = event['seq'] 319 | return event 320 | 321 | 322 | class EventSourceChangesFeed(ChangesFeed, EventSourceFeed): 323 | """Process event source database changes feed. 324 | Similar to :class:`EventSourceFeed`, but includes specifics for changes feed 325 | and emits events in the same format as others :class:`ChangesFeed` does. 326 | """ 327 | 328 | @asyncio.coroutine 329 | def next(self): 330 | """Emits the next event from changes feed. 331 | 332 | :rtype: dict 333 | """ 334 | event = (yield from EventSourceFeed.next(self)) 335 | if event is None: 336 | return event 337 | if event.get('event') == 'heartbeat': 338 | return (yield from self.next()) 339 | if 'id' in event: 340 | self._last_seq = int(event['id']) 341 | return event['data'] 342 | -------------------------------------------------------------------------------- /aiocouchdb/hdrs.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (C) 2015-2016 Alexander Shorin 4 | # All rights reserved. 5 | # 6 | # This software is licensed as described in the file LICENSE, which 7 | # you should have received as part of this distribution. 8 | # 9 | 10 | # flake8: noqa 11 | 12 | from aiohttp.hdrs import * 13 | from aiohttp.multidict import upstr 14 | 15 | #: Defines CouchDB Proxy Auth username 16 | X_AUTH_COUCHDB_USERNAME = upstr('X-Auth-CouchDB-UserName') 17 | #: Defines CouchDB Proxy Auth list of roles separated by a comma 18 | X_AUTH_COUCHDB_ROLES = upstr('X-Auth-CouchDB-Roles') 19 | #: Defines CouchDB Proxy Auth token 20 | X_AUTH_COUCHDB_TOKEN = upstr('X-Auth-CouchDB-Token') 21 | -------------------------------------------------------------------------------- /aiocouchdb/multipart.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (C) 2015 Alexander Shorin 4 | # All rights reserved. 5 | # 6 | # This software is licensed as described in the file LICENSE, which 7 | # you should have received as part of this distribution. 8 | # 9 | 10 | # flake8: noqa 11 | 12 | from aiohttp.multipart import ( 13 | MultipartReader, 14 | MultipartWriter as _MultipartWriter, 15 | BodyPartReader, 16 | BodyPartWriter as _BodyPartWriter, 17 | ) 18 | from aiocouchdb.hdrs import ( 19 | CONTENT_ENCODING, 20 | CONTENT_LENGTH, 21 | CONTENT_TRANSFER_ENCODING, 22 | ) 23 | 24 | 25 | class BodyPartWriter(_BodyPartWriter): 26 | 27 | def calc_content_length(self): 28 | has_encoding = ( 29 | CONTENT_ENCODING in self.headers 30 | and self.headers[CONTENT_ENCODING] != 'identity' 31 | or CONTENT_TRANSFER_ENCODING in self.headers 32 | ) 33 | if has_encoding: 34 | raise ValueError('Cannot calculate content length') 35 | 36 | if CONTENT_LENGTH not in self.headers: 37 | raise ValueError('No content length') 38 | 39 | total = 0 40 | for keyvalue in self.headers.items(): 41 | total += sum(map(lambda i: len(i.encode('latin1')), keyvalue)) 42 | total += 4 # key-value delimiter and \r\n 43 | total += 4 # delimiter of headers from body 44 | 45 | total += int(self.headers[CONTENT_LENGTH]) 46 | 47 | return total 48 | 49 | 50 | class MultipartWriter(_MultipartWriter): 51 | 52 | part_writer_cls = BodyPartWriter 53 | 54 | def calc_content_length(self): 55 | total = 0 56 | len_boundary = len(self.boundary) 57 | for part in self.parts: 58 | total += len_boundary + 4 # -- and \r\n 59 | total += part.calc_content_length() 60 | total += len_boundary + 6 # -- and --\r\n 61 | return total 62 | -------------------------------------------------------------------------------- /aiocouchdb/tests/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (C) 2014 Alexander Shorin 4 | # All rights reserved. 5 | # 6 | # This software is licensed as described in the file LICENSE, which 7 | # you should have received as part of this distribution. 8 | # 9 | -------------------------------------------------------------------------------- /aiocouchdb/tests/test_authn.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (C) 2014-2015 Alexander Shorin 4 | # All rights reserved. 5 | # 6 | # This software is licensed as described in the file LICENSE, which 7 | # you should have received as part of this distribution. 8 | # 9 | 10 | import http.cookies 11 | import unittest 12 | import unittest.mock as mock 13 | 14 | import aiocouchdb.authn 15 | import aiocouchdb.client 16 | from aiocouchdb.hdrs import ( 17 | AUTHORIZATION, 18 | COOKIE, 19 | X_AUTH_COUCHDB_USERNAME, 20 | X_AUTH_COUCHDB_ROLES, 21 | X_AUTH_COUCHDB_TOKEN 22 | ) 23 | 24 | URL = 'http://localhost:5984' 25 | 26 | 27 | class BasicAuthProviderTestCase(unittest.TestCase): 28 | 29 | def setUp(self): 30 | self.auth = aiocouchdb.authn.BasicAuthProvider() 31 | 32 | def test_set_credentials(self): 33 | self.auth.set_credentials('foo', 'bar') 34 | self.assertIsInstance(self.auth.credentials(), 35 | aiocouchdb.authn.BasicAuthCredentials) 36 | self.assertEqual(('foo', 'bar'), self.auth.credentials()) 37 | 38 | def test_missed_username(self): 39 | self.assertRaises(ValueError, self.auth.set_credentials, '', 'password') 40 | 41 | def test_missed_password(self): 42 | self.assertRaises(ValueError, self.auth.set_credentials, 'name', '') 43 | 44 | def test_require_credentials_to_set_auth_header(self): 45 | self.assertRaises(ValueError, self.auth.apply, URL, {}) 46 | 47 | def test_set_auth_header(self): 48 | self.auth.set_credentials('foo', 'bar') 49 | headers = {} 50 | self.auth.apply(URL, headers) 51 | self.assertIn(AUTHORIZATION, headers) 52 | self.assertTrue(headers[AUTHORIZATION].startswith('Basic')) 53 | 54 | def test_set_auth_header_utf8(self): 55 | self.auth.set_credentials('foo', 'бар') 56 | headers = {} 57 | self.auth.apply(URL, headers) 58 | self.assertIn(AUTHORIZATION, headers) 59 | self.assertTrue(headers[AUTHORIZATION].startswith('Basic')) 60 | 61 | def test_reset_credentials(self): 62 | self.auth.set_credentials('foo', 'bar') 63 | self.auth.reset() 64 | self.assertIsNone(self.auth.credentials()) 65 | 66 | def test_cache_auth_header(self): 67 | self.auth.set_credentials('foo', 'bar') 68 | self.auth.apply(URL, {}) 69 | self.auth._credentials = None 70 | 71 | headers = {} 72 | self.auth.apply(URL, headers) 73 | self.assertIn(AUTHORIZATION, headers) 74 | 75 | 76 | class CookieAuthProviderTestCase(unittest.TestCase): 77 | 78 | def setUp(self): 79 | self.auth = aiocouchdb.authn.CookieAuthProvider() 80 | self.resp = mock.Mock(spec=aiocouchdb.client.HttpResponse) 81 | self.resp.cookies = http.cookies.SimpleCookie({'AuthSession': 'secret'}) 82 | 83 | def test_update_cookies_from_response(self): 84 | self.assertIsNone(self.auth._cookies) 85 | self.auth.update(self.resp) 86 | self.assertIs(self.auth._cookies, self.resp.cookies) 87 | 88 | def test_dont_erase_cookies(self): 89 | self.assertIsNone(self.auth._cookies) 90 | self.auth.update(self.resp) 91 | self.assertIs(self.auth._cookies, self.resp.cookies) 92 | 93 | # another response 94 | resp = mock.Mock(spec=aiocouchdb.client.HttpResponse) 95 | resp.cookies = http.cookies.SimpleCookie() 96 | self.auth.update(resp) 97 | self.assertIs(self.auth._cookies, self.resp.cookies) 98 | 99 | def test_reset(self): 100 | self.auth.update(self.resp) 101 | self.auth.reset() 102 | self.assertIsNone(self.auth._cookies) 103 | 104 | def test_set_no_cookies(self): 105 | headers = {} 106 | self.auth.apply(URL, headers) 107 | self.assertNotIn(COOKIE, headers) 108 | 109 | def test_set_cookies(self): 110 | self.auth.update(self.resp) 111 | headers = {} 112 | self.auth.apply(URL, headers) 113 | self.assertIn(COOKIE, headers) 114 | 115 | def test_merge_cookies_on_apply(self): 116 | self.auth.update(self.resp) 117 | headers = {COOKIE: 'AuthSession=s3kr1t'} 118 | self.auth.apply(URL, headers) 119 | self.assertIn(COOKIE, headers) 120 | self.assertEqual('AuthSession=secret', headers[COOKIE]) 121 | 122 | 123 | class OAuthProviderTestCase(unittest.TestCase): 124 | 125 | def setUp(self): 126 | try: 127 | self.auth = aiocouchdb.authn.OAuthProvider() 128 | except ImportError as exc: 129 | raise unittest.SkipTest(exc) 130 | 131 | def test_set_credentials(self): 132 | self.assertIsNone(self.auth.credentials()) 133 | self.auth.set_credentials(consumer_key='foo', consumer_secret='bar', 134 | resource_key='baz', resource_secret='boo') 135 | self.assertIsInstance(self.auth.credentials(), 136 | aiocouchdb.authn.OAuthCredentials) 137 | self.assertEqual(('foo', 'bar', 'baz', 'boo'), self.auth.credentials()) 138 | 139 | def test_require_credentials_to_set_oauth_header(self): 140 | self.assertRaises(ValueError, self.auth.apply, URL, {}) 141 | 142 | def test_set_oauth_header(self): 143 | self.auth.set_credentials(consumer_key='foo', consumer_secret='bar', 144 | resource_key='baz', resource_secret='boo') 145 | headers = {} 146 | self.auth.apply(URL, headers) 147 | self.assertIn(AUTHORIZATION, headers) 148 | self.assertTrue(headers[AUTHORIZATION].startswith('OAuth')) 149 | 150 | def test_reset_credentials(self): 151 | self.auth.set_credentials(consumer_key='foo', consumer_secret='bar', 152 | resource_key='baz', resource_secret='boo') 153 | self.auth.reset() 154 | self.assertIsNone(self.auth.credentials()) 155 | 156 | 157 | class ProxyAuthProviderTestCase(unittest.TestCase): 158 | 159 | def setUp(self): 160 | self.auth = aiocouchdb.authn.ProxyAuthProvider() 161 | 162 | def test_set_credentials(self): 163 | self.auth.set_credentials('username') 164 | self.assertIsInstance(self.auth.credentials(), 165 | aiocouchdb.authn.ProxyAuthCredentials) 166 | self.assertEqual(('username', None, None), self.auth.credentials()) 167 | 168 | self.auth.set_credentials('username', ['foo', 'bar'], 's3cr1t') 169 | self.assertEqual(('username', ['foo', 'bar'], 's3cr1t'), 170 | self.auth.credentials()) 171 | 172 | def test_empty_username(self): 173 | self.assertRaises(ValueError, self.auth.set_credentials, '') 174 | 175 | def test_require_credentials_to_set_auth_header(self): 176 | self.assertRaises(ValueError, self.auth.apply, URL, {}) 177 | 178 | def test_set_username_header(self): 179 | self.auth.set_credentials('username') 180 | headers = {} 181 | self.auth.apply(URL, headers) 182 | self.assertIn(X_AUTH_COUCHDB_USERNAME, headers) 183 | self.assertEqual(headers[X_AUTH_COUCHDB_USERNAME], 'username') 184 | 185 | def test_set_roles_header(self): 186 | self.auth.set_credentials('username', roles=['foo', 'bar']) 187 | headers = {} 188 | self.auth.apply(URL, headers) 189 | self.assertIn(X_AUTH_COUCHDB_USERNAME, headers) 190 | self.assertEqual(headers[X_AUTH_COUCHDB_USERNAME], 'username') 191 | self.assertIn(X_AUTH_COUCHDB_ROLES, headers) 192 | self.assertEqual(headers[X_AUTH_COUCHDB_ROLES], 'foo,bar') 193 | 194 | def test_set_token_header(self): 195 | self.auth.set_credentials('username', secret='s3cr1t') 196 | headers = {} 197 | self.auth.apply(URL, headers) 198 | self.assertIn(X_AUTH_COUCHDB_USERNAME, headers) 199 | self.assertEqual(headers[X_AUTH_COUCHDB_USERNAME], 'username') 200 | self.assertIn(X_AUTH_COUCHDB_TOKEN, headers) 201 | self.assertEqual(headers[X_AUTH_COUCHDB_TOKEN], 202 | '56c8cdc87a39677c2c334c244dc78108e74b55d2') 203 | 204 | def test_reset_credentials(self): 205 | self.auth.set_credentials('username') 206 | self.auth.reset() 207 | self.assertIsNone(self.auth.credentials()) 208 | 209 | def test_custom_username_header(self): 210 | self.auth.x_auth_username = 'X-Foo' 211 | self.auth.set_credentials('username') 212 | headers = {} 213 | self.auth.apply(URL, headers) 214 | self.assertNotIn(X_AUTH_COUCHDB_USERNAME, headers) 215 | self.assertIn('X-Foo', headers) 216 | self.assertEqual(headers['X-Foo'], 'username') 217 | 218 | def test_custom_roles_header(self): 219 | self.auth.x_auth_roles = 'X-Foo' 220 | self.auth.set_credentials('username', roles=['foo', 'bar']) 221 | headers = {} 222 | self.auth.apply(URL, headers) 223 | self.assertNotIn(X_AUTH_COUCHDB_ROLES, headers) 224 | self.assertIn('X-Foo', headers) 225 | self.assertEqual(headers['X-Foo'], 'foo,bar') 226 | 227 | def test_custom_token_header(self): 228 | self.auth.x_auth_token = 'X-Foo' 229 | self.auth.set_credentials('username', secret='s3cr1t') 230 | headers = {} 231 | self.auth.apply(URL, headers) 232 | self.assertNotIn(X_AUTH_COUCHDB_TOKEN, headers) 233 | self.assertIn('X-Foo', headers) 234 | self.assertEqual(headers['X-Foo'], 235 | '56c8cdc87a39677c2c334c244dc78108e74b55d2') 236 | -------------------------------------------------------------------------------- /aiocouchdb/tests/test_client.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (C) 2014 Alexander Shorin 4 | # All rights reserved. 5 | # 6 | # This software is licensed as described in the file LICENSE, which 7 | # you should have received as part of this distribution. 8 | # 9 | 10 | import io 11 | import types 12 | import unittest.mock as mock 13 | 14 | import aiocouchdb.authn 15 | import aiocouchdb.client 16 | 17 | from . import utils 18 | 19 | 20 | class ResourceTestCase(utils.TestCase): 21 | 22 | _test_target = 'mock' 23 | 24 | def test_head_request(self): 25 | res = aiocouchdb.client.Resource(self.url) 26 | yield from res.head() 27 | self.assert_request_called_with('HEAD') 28 | 29 | def test_get_request(self): 30 | res = aiocouchdb.client.Resource(self.url) 31 | yield from res.get() 32 | self.assert_request_called_with('GET') 33 | 34 | def test_post_request(self): 35 | res = aiocouchdb.client.Resource(self.url) 36 | yield from res.post() 37 | self.assert_request_called_with('POST') 38 | 39 | def test_put_request(self): 40 | res = aiocouchdb.client.Resource(self.url) 41 | yield from res.put() 42 | self.assert_request_called_with('PUT') 43 | 44 | def test_delete_request(self): 45 | res = aiocouchdb.client.Resource(self.url) 46 | yield from res.delete() 47 | self.assert_request_called_with('DELETE') 48 | 49 | def test_copy_request(self): 50 | res = aiocouchdb.client.Resource(self.url) 51 | yield from res.copy() 52 | self.assert_request_called_with('COPY') 53 | 54 | def test_options_request(self): 55 | res = aiocouchdb.client.Resource(self.url) 56 | yield from res.options() 57 | self.assert_request_called_with('OPTIONS') 58 | 59 | def test_to_str(self): 60 | res = aiocouchdb.client.Resource(self.url) 61 | self.assertEqual( 62 | '' 63 | ''.format(hex(id(res))), 64 | str(res)) 65 | 66 | def test_on_call(self): 67 | res = aiocouchdb.client.Resource(self.url) 68 | new_res = res('foo', 'bar/baz') 69 | self.assertIsNot(res, new_res) 70 | self.assertEqual('http://localhost:5984/foo/bar%2Fbaz', new_res.url) 71 | 72 | def test_empty_call(self): 73 | res = aiocouchdb.client.Resource(self.url) 74 | new_res = res() 75 | self.assertIsNot(res, new_res) 76 | self.assertEqual('http://localhost:5984', new_res.url) 77 | 78 | def test_request_with_path(self): 79 | res = aiocouchdb.client.Resource(self.url) 80 | yield from res.request('get', 'foo/bar') 81 | self.assert_request_called_with('get', 'foo/bar') 82 | 83 | def test_dont_sign_request_none_auth(self): 84 | res = aiocouchdb.client.Resource(self.url) 85 | res.apply_auth = mock.Mock() 86 | yield from res.request('get') 87 | self.assertFalse(res.apply_auth.called) 88 | 89 | def test_dont_update_none_auth(self): 90 | res = aiocouchdb.client.Resource(self.url) 91 | res.update_auth = mock.Mock() 92 | yield from res.request('get') 93 | self.assertFalse(res.update_auth.called) 94 | 95 | def test_override_request_class(self): 96 | class Thing(object): 97 | pass 98 | res = aiocouchdb.client.Resource(self.url) 99 | yield from res.request('get', request_class=Thing) 100 | self.assert_request_called_with('get', request_class=Thing) 101 | 102 | def test_override_response_class(self): 103 | class Thing(object): 104 | pass 105 | res = aiocouchdb.client.Resource(self.url) 106 | yield from res.request('get', response_class=Thing) 107 | self.assert_request_called_with('get', response_class=Thing) 108 | 109 | 110 | class HttpRequestTestCase(utils.TestCase): 111 | 112 | _test_target = 'mock' 113 | 114 | def test_encode_json_body(self): 115 | req = aiocouchdb.client.HttpRequest('post', self.url, 116 | data={'foo': 'bar'}) 117 | self.assertEqual(b'{"foo": "bar"}', req.body) 118 | 119 | def test_correct_encode_boolean_params(self): 120 | req = aiocouchdb.client.HttpRequest('get', self.url, 121 | params={'foo': True}) 122 | self.assertEqual('/?foo=true', req.path) 123 | 124 | req = aiocouchdb.client.HttpRequest('get', self.url, 125 | params={'bar': False}) 126 | self.assertEqual('/?bar=false', req.path) 127 | 128 | def test_encode_chunked_json_body(self): 129 | req = aiocouchdb.client.HttpRequest( 130 | 'post', self.url, data=('{"foo": "bar"}' for _ in [0])) 131 | self.assertIsInstance(req.body, types.GeneratorType) 132 | 133 | def test_encode_readable_object(self): 134 | req = aiocouchdb.client.HttpRequest( 135 | 'post', self.url, data=io.BytesIO(b'foobarbaz')) 136 | self.assertIsInstance(req.body, io.IOBase) 137 | 138 | 139 | class HttpResponseTestCase(utils.TestCase): 140 | 141 | _test_target = 'mock' 142 | 143 | def test_read_body(self): 144 | with self.response(data=b'{"couchdb": "Welcome!"}') as resp: 145 | result = yield from resp.read() 146 | self.assertEqual(b'{"couchdb": "Welcome!"}', result) 147 | 148 | def test_decode_json_body(self): 149 | with self.response(data=b'{"couchdb": "Welcome!"}') as resp: 150 | result = yield from resp.json() 151 | self.assertEqual({'couchdb': 'Welcome!'}, result) 152 | 153 | def test_decode_json_from_empty_body(self): 154 | with self.response(data=b'') as resp: 155 | result = yield from resp.json() 156 | self.assertEqual(None, result) 157 | -------------------------------------------------------------------------------- /aiocouchdb/tests/test_errors.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (C) 2014-2015 Alexander Shorin 4 | # All rights reserved. 5 | # 6 | # This software is licensed as described in the file LICENSE, which 7 | # you should have received as part of this distribution. 8 | # 9 | 10 | import aiohttp 11 | 12 | import aiocouchdb.client 13 | import aiocouchdb.errors 14 | 15 | from . import utils 16 | 17 | 18 | class HttpErrorsTestCase(utils.TestCase): 19 | 20 | def setUp(self): 21 | super().setUp() 22 | self.resp = self.prepare_response( 23 | status=500, 24 | data=b'{"error": "test", "reason": "passed"}') 25 | 26 | def test_should_not_raise_error_on_success_response(self): 27 | self.resp.status = 200 28 | yield from aiocouchdb.errors.maybe_raise_error(self.resp) 29 | 30 | def test_raise_aiohttp_exception(self): 31 | with self.assertRaises(aiohttp.errors.HttpProcessingError): 32 | yield from aiocouchdb.errors.maybe_raise_error(self.resp) 33 | 34 | def test_decode_common_error_response(self): 35 | try: 36 | yield from aiocouchdb.errors.maybe_raise_error(self.resp) 37 | except aiocouchdb.errors.HttpErrorException as exc: 38 | self.assertEqual('test', exc.error) 39 | self.assertEqual('passed', exc.reason) 40 | else: 41 | assert False, 'exception expected' 42 | 43 | def test_exception_holds_response_headers(self): 44 | self.resp.headers['X-Foo'] = 'bar' 45 | try: 46 | yield from aiocouchdb.errors.maybe_raise_error(self.resp) 47 | except aiocouchdb.errors.HttpErrorException as exc: 48 | self.assertEqual('bar', exc.headers.get('X-Foo')) 49 | else: 50 | assert False, 'exception expected' 51 | 52 | def test_exc_to_str(self): 53 | try: 54 | yield from aiocouchdb.errors.maybe_raise_error(self.resp) 55 | except aiocouchdb.errors.HttpErrorException as exc: 56 | self.assertEqual('[test] passed', str(exc)) 57 | else: 58 | assert False, 'exception expected' 59 | -------------------------------------------------------------------------------- /aiocouchdb/v1/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (C) 2015 Alexander Shorin 4 | # All rights reserved. 5 | # 6 | # This software is licensed as described in the file LICENSE, which 7 | # you should have received as part of this distribution. 8 | # 9 | 10 | # flake8: noqa 11 | 12 | from .attachment import Attachment 13 | from .authdb import AuthDatabase, UserDocument 14 | from .config import ServerConfig 15 | from .database import Database 16 | from .document import Document 17 | from .designdoc import DesignDocument 18 | from .server import Server 19 | from .session import Session 20 | from .security import DatabaseSecurity 21 | -------------------------------------------------------------------------------- /aiocouchdb/v1/attachment.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (C) 2014-2016 Alexander Shorin 4 | # All rights reserved. 5 | # 6 | # This software is licensed as described in the file LICENSE, which 7 | # you should have received as part of this distribution. 8 | # 9 | 10 | import asyncio 11 | import base64 12 | from io import RawIOBase 13 | 14 | from aiocouchdb.client import Resource 15 | from aiocouchdb.hdrs import ( 16 | ACCEPT_RANGES, 17 | CONTENT_ENCODING, 18 | CONTENT_TYPE, 19 | IF_NONE_MATCH, 20 | RANGE 21 | ) 22 | 23 | 24 | __all__ = ( 25 | 'Attachment', 26 | ) 27 | 28 | 29 | class Attachment(object): 30 | """Implementation of :ref:`CouchDB Attachment API `.""" 31 | 32 | def __init__(self, url_or_resource, *, name=None, loop=None): 33 | if isinstance(url_or_resource, str): 34 | url_or_resource = Resource(url_or_resource, loop=loop) 35 | self.resource = url_or_resource 36 | self._name = name 37 | 38 | def __repr__(self): 39 | return '<{}.{}({}) object at {}>'.format( 40 | self.__module__, 41 | self.__class__.__qualname__, # pylint: disable=no-member 42 | self.resource.url, 43 | hex(id(self))) 44 | 45 | @property 46 | def name(self): 47 | """Returns attachment name specified in class constructor.""" 48 | return self._name 49 | 50 | @asyncio.coroutine 51 | def exists(self, rev=None, *, auth=None): 52 | """Checks if `attachment exists`_. Assumes success on receiving response 53 | with `200 OK` status. 54 | 55 | :param str rev: Document revision 56 | :param auth: :class:`aiocouchdb.authn.AuthProvider` instance 57 | 58 | :rtype: bool 59 | 60 | .. _attachment exists: http://docs.couchdb.org/en/latest/api/document/attachments.html#head--db-docid-attname 61 | """ 62 | params = {} 63 | if rev is not None: 64 | params['rev'] = rev 65 | resp = yield from self.resource.head(auth=auth, params=params) 66 | yield from resp.release() 67 | return resp.status == 200 68 | 69 | @asyncio.coroutine 70 | def modified(self, digest, *, auth=None): 71 | """Checks if `attachment was modified`_ by known MD5 digest. 72 | 73 | :param bytes digest: Attachment MD5 digest. Optionally, 74 | may be passed in base64 encoding form 75 | :param auth: :class:`aiocouchdb.authn.AuthProvider` instance 76 | 77 | :rtype: bool 78 | 79 | .. _attachment was modified: http://docs.couchdb.org/en/latest/api/document/attachments.html#head--db-docid-attname 80 | """ 81 | if isinstance(digest, bytes): 82 | if len(digest) != 16: 83 | raise ValueError('MD5 digest has 16 bytes') 84 | digest = base64.b64encode(digest).decode() 85 | elif isinstance(digest, str): 86 | if not (len(digest) == 24 and digest.endswith('==')): 87 | raise ValueError('invalid base64 encoded MD5 digest') 88 | else: 89 | raise TypeError('invalid `digest` type {}, bytes or str expected' 90 | ''.format(type(digest))) 91 | qdigest = '"%s"' % digest 92 | resp = yield from self.resource.head(auth=auth, 93 | headers={IF_NONE_MATCH: qdigest}) 94 | yield from resp.maybe_raise_error() 95 | yield from resp.release() 96 | return resp.status != 304 97 | 98 | @asyncio.coroutine 99 | def accepts_range(self, rev=None, *, auth=None): 100 | """Returns ``True`` if attachments accepts bytes range requests. 101 | 102 | :param str rev: Document revision 103 | :param auth: :class:`aiocouchdb.authn.AuthProvider` instance 104 | 105 | :rtype: bool 106 | """ 107 | params = {} 108 | if rev is not None: 109 | params['rev'] = rev 110 | resp = yield from self.resource.head(auth=auth, params=params) 111 | yield from resp.release() 112 | return resp.headers.get(ACCEPT_RANGES) == 'bytes' 113 | 114 | @asyncio.coroutine 115 | def get(self, rev=None, *, auth=None, range=None): 116 | """`Returns an attachment`_ reader object. 117 | 118 | :param str rev: Document revision 119 | :param auth: :class:`aiocouchdb.authn.AuthProvider` instance 120 | 121 | :param slice range: Bytes range. Could be :func:`slice` 122 | or two-element iterable object like :class:`list` 123 | etc or just :func:`int` 124 | 125 | :rtype: :class:`~aiocouchdb.v1.attachments.AttachmentReader` 126 | 127 | .. _Returns an attachment: http://docs.couchdb.org/en/latest/api/document/attachments.html#get--db-docid-attname 128 | """ 129 | headers = {} 130 | params = {} 131 | if rev is not None: 132 | params['rev'] = rev 133 | 134 | if range is not None: 135 | if isinstance(range, slice): 136 | start, stop = range.start, range.stop 137 | elif isinstance(range, int): 138 | start, stop = 0, range 139 | else: 140 | start, stop = range 141 | headers[RANGE] = 'bytes={}-{}'.format(start or 0, stop) 142 | resp = yield from self.resource.get(auth=auth, 143 | headers=headers, 144 | params=params) 145 | yield from resp.maybe_raise_error() 146 | return AttachmentReader(resp) 147 | 148 | @asyncio.coroutine 149 | def update(self, fileobj, *, 150 | auth=None, 151 | content_encoding=None, 152 | content_type='application/octet-stream', 153 | rev=None): 154 | """`Attaches a file`_ to document. 155 | 156 | :param file fileobj: File object, should be readable 157 | 158 | :param auth: :class:`aiocouchdb.authn.AuthProvider` instance 159 | :param str content_encoding: Content encoding: ``gzip`` or ``identity`` 160 | :param str content_type: Attachment :mimetype:`Content-Type` header 161 | :param str rev: Document revision 162 | 163 | :rtype: dict 164 | 165 | .. _Attaches a file: http://docs.couchdb.org/en/latest/api/document/attachments.html#put--db-docid-attname 166 | """ 167 | assert hasattr(fileobj, 'read') 168 | 169 | params = {} 170 | if rev is not None: 171 | params['rev'] = rev 172 | 173 | headers = { 174 | CONTENT_TYPE: content_type 175 | } 176 | if content_encoding is not None: 177 | headers[CONTENT_ENCODING] = content_encoding 178 | 179 | resp = yield from self.resource.put(auth=auth, 180 | data=fileobj, 181 | headers=headers, 182 | params=params) 183 | yield from resp.maybe_raise_error() 184 | return (yield from resp.json()) 185 | 186 | @asyncio.coroutine 187 | def delete(self, rev, *, auth=None): 188 | """`Deletes an attachment`_. 189 | 190 | :param str rev: Document revision 191 | :param auth: :class:`aiocouchdb.authn.AuthProvider` instance 192 | 193 | :rtype: dict 194 | 195 | .. _Deletes an attachment: http://docs.couchdb.org/en/latest/api/document/attachments.html#delete--db-docid-attname 196 | """ 197 | resp = yield from self.resource.delete(auth=auth, 198 | params={'rev': rev}) 199 | yield from resp.maybe_raise_error() 200 | return (yield from resp.json()) 201 | 202 | 203 | class AttachmentReader(RawIOBase): 204 | """Attachment reader implements :class:`io.RawIOBase` interface 205 | with the exception that all I/O bound methods are coroutines.""" 206 | 207 | def __init__(self, resp): 208 | super().__init__() 209 | self._resp = resp 210 | 211 | def close(self): 212 | """Closes attachment reader and underlying connection. 213 | 214 | This method has no effect if the attachment is already closed. 215 | """ 216 | if not self.closed: 217 | self._resp.close() 218 | 219 | @property 220 | def closed(self): 221 | """Return a bool indicating whether object is closed.""" 222 | return self._resp.content.at_eof() 223 | 224 | def readable(self): 225 | """Return a bool indicating whether object was opened for reading.""" 226 | return True 227 | 228 | @asyncio.coroutine 229 | def read(self, size=-1): 230 | """Read and return up to n bytes, where `size` is an :func:`int`. 231 | 232 | Returns an empty bytes object on EOF, or None if the object is 233 | set not to block and has no data to read. 234 | """ 235 | return (yield from self._resp.content.read(size)) 236 | 237 | @asyncio.coroutine 238 | def readall(self, size=8192): 239 | """Read until EOF, using multiple :meth:`read` call.""" 240 | acc = bytearray() 241 | while not self.closed: 242 | acc.extend((yield from self.read(size))) 243 | return acc 244 | 245 | @asyncio.coroutine 246 | def readline(self): 247 | """Read and return a line of bytes from the stream. 248 | 249 | If limit is specified, at most limit bytes will be read. 250 | Limit should be an :func:`int`. 251 | 252 | The line terminator is always ``b'\\n'`` for binary files; for text 253 | files, the newlines argument to open can be used to select the line 254 | terminator(s) recognized. 255 | """ 256 | return (yield from self._resp.content.readline()) 257 | 258 | @asyncio.coroutine 259 | def readlines(self, hint=None): 260 | """Return a list of lines from the stream. 261 | 262 | `hint` can be specified to control the number of lines read: no more 263 | lines will be read if the total size (in bytes/characters) of all 264 | lines so far exceeds `hint`. 265 | """ 266 | if hint is None or hint <= 0: 267 | acc = [] 268 | while not self.closed: 269 | line = yield from self.readline() 270 | if line: 271 | acc.append(line) 272 | return acc 273 | read = 0 274 | acc = [] 275 | while not self.closed: 276 | line = yield from self.readline() 277 | if not line: 278 | continue 279 | acc.append(line) 280 | read += len(line) 281 | if read >= hint: 282 | break 283 | return acc 284 | -------------------------------------------------------------------------------- /aiocouchdb/v1/authdb.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (C) 2014-2015 Alexander Shorin 4 | # All rights reserved. 5 | # 6 | # This software is licensed as described in the file LICENSE, which 7 | # you should have received as part of this distribution. 8 | # 9 | 10 | import asyncio 11 | 12 | from .database import Database 13 | from .document import Document 14 | 15 | 16 | __all__ = ( 17 | 'AuthDatabase', 18 | 'UserDocument', 19 | ) 20 | 21 | 22 | class UserDocument(Document): 23 | """Represents user document for the :class:`authentication database 24 | `.""" 25 | 26 | doc_prefix = 'org.couchdb.user:' 27 | 28 | def __init__(self, *args, **kwargs): 29 | super().__init__(*args, **kwargs) 30 | if self._docid is None: 31 | raise ValueError('docid must be specified for User documents.') 32 | 33 | def __repr__(self): 34 | return '<{}.{}({}) object at {}>'.format( 35 | self.__module__, 36 | self.__class__.__qualname__, # pylint: disable=no-member 37 | self.resource.url, 38 | hex(id(self))) 39 | 40 | @property 41 | def name(self): 42 | """Returns username.""" 43 | return self.id.split(self.doc_prefix, 1)[-1] 44 | 45 | @asyncio.coroutine 46 | def register(self, password, *, auth=None, **additional_data): 47 | """Helper method over :meth:`aiocouchdb.v1.document.Document.update` 48 | to change a user password. 49 | 50 | :param str password: User's password 51 | :param auth: :class:`aiocouchdb.authn.AuthProvider` instance 52 | 53 | :rtype: dict 54 | """ 55 | data = { 56 | '_id': self.id, 57 | 'name': self.name, 58 | 'password': password, 59 | 'roles': [], 60 | 'type': 'user' 61 | } 62 | data.update(additional_data) 63 | return (yield from self.update(data, auth=auth)) 64 | 65 | @asyncio.coroutine 66 | def update_password(self, password, *, auth=None): 67 | """Helper method over :meth:`aiocouchdb.v1.document.Document.update` 68 | to change a user password. 69 | 70 | :param str password: New password 71 | :param auth: :class:`aiocouchdb.authn.AuthProvider` instance 72 | 73 | :rtype: dict 74 | """ 75 | data = yield from self.get(auth=auth) 76 | data['password'] = password 77 | return (yield from self.update(data, auth=auth)) 78 | 79 | 80 | class AuthDatabase(Database): 81 | """Represents system authentication database. 82 | Used via :attr:`aiocouchdb.v1.server.Server.authdb`.""" 83 | 84 | document_class = UserDocument 85 | 86 | def __getitem__(self, docid): 87 | if docid.startswith('_design/'): 88 | resource = self.resource(*docid.split('/', 1)) 89 | return self.design_document_class(resource, docid=docid) 90 | elif docid.startswith(self.document_class.doc_prefix): 91 | return self.document_class(self.resource(docid), docid=docid) 92 | else: 93 | docid = self.document_class.doc_prefix + docid 94 | return self.document_class(self.resource(docid), docid=docid) 95 | 96 | def __repr__(self): 97 | return '<{}.{}({}) object at {}>'.format( 98 | self.__module__, 99 | self.__class__.__qualname__, # pylint: disable=no-member 100 | self.resource.url, 101 | hex(id(self))) 102 | -------------------------------------------------------------------------------- /aiocouchdb/v1/config.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (C) 2015 Alexander Shorin 4 | # All rights reserved. 5 | # 6 | # This software is licensed as described in the file LICENSE, which 7 | # you should have received as part of this distribution. 8 | # 9 | 10 | import asyncio 11 | 12 | 13 | __all__ = ( 14 | 'ServerConfig', 15 | ) 16 | 17 | 18 | class ServerConfig(object): 19 | """Implements :ref:`/_config/* ` API. Should be used via 20 | :attr:`server.config ` property.""" 21 | 22 | def __init__(self, resource): 23 | self.resource = resource('_config') 24 | 25 | def __repr__(self): 26 | return '<{}.{}({}) object at {}>'.format( 27 | self.__module__, 28 | self.__class__.__qualname__, # pylint: disable=no-member 29 | self.resource.url, 30 | hex(id(self))) 31 | 32 | @asyncio.coroutine 33 | def exists(self, section, key, *, auth=None): 34 | """Checks if :ref:`configuration option ` 35 | exists. 36 | 37 | :param str section: Section name 38 | :param str key: Option name 39 | :param auth: :class:`aiocouchdb.authn.AuthProvider` instance 40 | 41 | :rtype: bool 42 | """ 43 | resp = yield from self.resource(section, key).head(auth=auth) 44 | yield from resp.read() 45 | return resp.status == 200 46 | 47 | @asyncio.coroutine 48 | def get(self, section=None, key=None, *, auth=None): 49 | """Returns :ref:`server configuration `. Depending on 50 | specified arguments returns: 51 | 52 | - :ref:`Complete configuration ` if ``section`` and ``key`` 53 | are ``None`` 54 | 55 | - :ref:`Section options ` if ``section`` 56 | was specified 57 | 58 | - :ref:`Option value ` if both ``section`` 59 | and ``key`` were specified 60 | 61 | :param str section: Section name (`optional`) 62 | :param str key: Option name (`optional`) 63 | :param auth: :class:`aiocouchdb.authn.AuthProvider` instance 64 | 65 | :rtype: dict or str 66 | """ 67 | path = [] 68 | if section is not None: 69 | path.append(section) 70 | if key is not None: 71 | assert isinstance(section, str) 72 | path.append(key) 73 | resp = yield from self.resource(*path).get(auth=auth) 74 | yield from resp.maybe_raise_error() 75 | return (yield from resp.json()) 76 | 77 | @asyncio.coroutine 78 | def update(self, section, key, value, *, auth=None): 79 | """Updates specific :ref:`configuration option ` 80 | value and returns the old one back. 81 | 82 | :param str section: Configuration section name 83 | :param str key: Option name 84 | :param str value: New option value 85 | :param auth: :class:`aiocouchdb.authn.AuthProvider` instance 86 | 87 | :rtype: str 88 | """ 89 | resp = yield from self.resource(section).put(key, auth=auth, data=value) 90 | yield from resp.maybe_raise_error() 91 | return (yield from resp.json()) 92 | 93 | @asyncio.coroutine 94 | def delete(self, section, key, *, auth=None): 95 | """Deletes specific :ref:`configuration option ` 96 | and returns it value back. 97 | 98 | :param string section: Configuration section name 99 | :param string key: Option name 100 | :param auth: :class:`aiocouchdb.authn.AuthProvider` instance 101 | 102 | :rtype: str 103 | """ 104 | resp = yield from self.resource(section).delete(key, auth=auth) 105 | yield from resp.maybe_raise_error() 106 | return (yield from resp.json()) 107 | -------------------------------------------------------------------------------- /aiocouchdb/v1/designdoc.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (C) 2014-2015 Alexander Shorin 4 | # All rights reserved. 5 | # 6 | # This software is licensed as described in the file LICENSE, which 7 | # you should have received as part of this distribution. 8 | # 9 | 10 | import asyncio 11 | 12 | from aiocouchdb.client import Resource 13 | from aiocouchdb.views import View 14 | 15 | from .document import Document 16 | 17 | 18 | __all__ = ( 19 | 'DesignDocument', 20 | ) 21 | 22 | 23 | class DesignDocument(object): 24 | """Implementation of :ref:`CouchDB Design Document API `.""" 25 | 26 | #: Default :class:`~aiocouchdb.v1.document.Document` instance class. 27 | document_class = Document 28 | #: :class:`Views requesting helper ` 29 | view_class = View 30 | 31 | def __init__(self, url_or_resource, *, 32 | docid=None, 33 | document_class=None, 34 | loop=None, 35 | view_class=None): 36 | if document_class is not None: 37 | self.document_class = document_class 38 | if isinstance(url_or_resource, str): 39 | url_or_resource = Resource(url_or_resource, loop=loop) 40 | if view_class is not None: 41 | self.view_class = view_class 42 | self.resource = url_or_resource 43 | self._document = self.document_class(self.resource, docid=docid) 44 | 45 | def __getitem__(self, attname): 46 | return self._document[attname] 47 | 48 | def __repr__(self): 49 | return '<{}.{}({}) object at {}>'.format( 50 | self.__module__, 51 | self.__class__.__qualname__, # pylint: disable=no-member 52 | self.resource.url, 53 | hex(id(self))) 54 | 55 | @property 56 | def id(self): 57 | """Returns a document id specified in class constructor.""" 58 | return self.doc.id 59 | 60 | @property 61 | def name(self): 62 | """Returns a document id specified in class constructor.""" 63 | docid = self.doc.id 64 | if docid is not None and '/' in docid: 65 | return docid.split('/', 1)[1] 66 | return None 67 | 68 | @property 69 | def doc(self): 70 | """Returns 71 | :class:`~aiocouchdb.v1.designdoc.DesignDocument.document_class` 72 | instance to operate with design document as with regular CouchDB 73 | document. 74 | 75 | :rtype: :class:`~aiocouchdb.v1.document.Document` 76 | """ 77 | return self._document 78 | 79 | @asyncio.coroutine 80 | def info(self, *, auth=None): 81 | """:ref:`Returns view index information `. 82 | 83 | :param auth: :class:`aiocouchdb.authn.AuthProvider` instance 84 | 85 | :rtype: dict 86 | """ 87 | resp = yield from self.resource.get('_info', auth=auth) 88 | yield from resp.maybe_raise_error() 89 | return (yield from resp.json()) 90 | 91 | @asyncio.coroutine 92 | def list(self, 93 | list_name, 94 | view_name=None, 95 | *keys, 96 | auth=None, 97 | headers=None, 98 | data=None, 99 | params=None, 100 | format=None, 101 | att_encoding_info=None, 102 | attachments=None, 103 | conflicts=None, 104 | descending=None, 105 | endkey=..., 106 | endkey_docid=None, 107 | group=None, 108 | group_level=None, 109 | include_docs=None, 110 | inclusive_end=None, 111 | limit=None, 112 | reduce=None, 113 | skip=None, 114 | stale=None, 115 | startkey=..., 116 | startkey_docid=None, 117 | update_seq=None): 118 | """Calls a :ref:`list function ` and returns a raw 119 | response object. 120 | 121 | :param str list_name: List function name 122 | :param str view_name: View function name 123 | 124 | :param auth: :class:`aiocouchdb.authn.AuthProvider` instance 125 | :param dict headers: Additional request headers 126 | :param data: Request payload 127 | :param dict params: Additional request query parameters 128 | :param str format: List function output format 129 | 130 | For other parameters see 131 | :meth:`aiocouchdb.v1.designdoc.DesignDocument.view` method docstring. 132 | 133 | :rtype: :class:`~aiocouchdb.client.HttpResponse` 134 | """ 135 | assert headers is None or isinstance(headers, dict) 136 | assert params is None or isinstance(params, dict) 137 | assert data is None or isinstance(data, dict) 138 | 139 | view_params = locals() 140 | for key in ('self', 'list_name', 'view_name', 'auth', 141 | 'headers', 'data', 'params'): 142 | view_params.pop(key) 143 | 144 | view_params, data = self.view_class.handle_keys_param(view_params, data) 145 | view_params = self.view_class.prepare_params(view_params) 146 | 147 | if params is None: 148 | params = view_params 149 | else: 150 | params.update(view_params) 151 | 152 | method = 'GET' if data is None else 'POST' 153 | 154 | path = ['_list', list_name] 155 | if view_name: 156 | path.extend(view_name.split('/', 1)) 157 | resp = yield from self.resource(*path).request(method, 158 | auth=auth, 159 | data=data, 160 | params=params, 161 | headers=headers) 162 | return resp 163 | 164 | @asyncio.coroutine 165 | def rewrite(self, *path, 166 | auth=None, method=None, headers=None, data=None, params=None): 167 | """Requests :ref:`rewrite ` resource and returns a 168 | raw response object. 169 | 170 | :param str path: Request path by segments 171 | :param auth: :class:`aiocouchdb.authn.AuthProvider` instance 172 | :param str method: HTTP request method 173 | :param dict headers: Additional request headers 174 | :param data: Request payload 175 | :param dict params: Additional request query parameters 176 | 177 | :rtype: :class:`~aiocouchdb.client.HttpResponse` 178 | """ 179 | if method is None: 180 | method = 'GET' if data is None else 'POST' 181 | 182 | resp = yield from self.resource('_rewrite', *path).request( 183 | method, auth=auth, data=data, params=params, headers=headers) 184 | return resp 185 | 186 | @asyncio.coroutine 187 | def show(self, show_name, docid=None, *, 188 | auth=None, method=None, headers=None, data=None, params=None, 189 | format=None): 190 | """Calls a :ref:`show function ` and returns a raw 191 | response object. 192 | 193 | :param str show_name: Show function name 194 | :param str docid: Document ID 195 | 196 | :param auth: :class:`aiocouchdb.authn.AuthProvider` instance 197 | :param str method: HTTP request method 198 | :param dict headers: Additional request headers 199 | :param data: Request payload 200 | :param dict params: Additional request query parameters 201 | :param str format: Show function output format 202 | 203 | :rtype: :class:`~aiocouchdb.client.HttpResponse` 204 | """ 205 | assert headers is None or isinstance(headers, dict) 206 | assert params is None or isinstance(params, dict) 207 | 208 | if method is None: 209 | method = 'GET' if data is None else 'POST' 210 | 211 | if format is not None: 212 | if params is None: 213 | params = {} 214 | assert 'format' not in params 215 | params['format'] = format 216 | 217 | path = ['_show', show_name] 218 | if docid is not None: 219 | path.append(docid) 220 | resp = yield from self.resource(*path).request(method, 221 | auth=auth, 222 | data=data, 223 | params=params, 224 | headers=headers) 225 | return resp 226 | 227 | @asyncio.coroutine 228 | def update(self, update_name, docid=None, *, 229 | auth=None, method=None, headers=None, data=None, params=None): 230 | """Calls a :ref:`show function ` and returns a raw 231 | response object. 232 | 233 | :param str update_name: Update function name 234 | :param str docid: Document ID 235 | 236 | :param auth: :class:`aiocouchdb.authn.AuthProvider` instance 237 | :param str method: HTTP request method 238 | :param dict headers: Additional request headers 239 | :param data: Request payload 240 | :param dict params: Additional request query parameters 241 | 242 | :rtype: :class:`~aiocouchdb.client.HttpResponse` 243 | """ 244 | assert headers is None or isinstance(headers, dict) 245 | assert params is None or isinstance(params, dict) 246 | 247 | if method is None: 248 | method = 'POST' if docid is None else 'PUT' 249 | 250 | path = ['_update', update_name] 251 | if docid is not None: 252 | path.append(docid) 253 | resp = yield from self.resource(*path).request(method, 254 | auth=auth, 255 | data=data, 256 | params=params, 257 | headers=headers) 258 | return resp 259 | 260 | @asyncio.coroutine 261 | def view(self, 262 | view_name, 263 | *keys, 264 | auth=None, 265 | feed_buffer_size=None, 266 | att_encoding_info=None, 267 | attachments=None, 268 | conflicts=None, 269 | descending=None, 270 | endkey=..., 271 | endkey_docid=None, 272 | group=None, 273 | group_level=None, 274 | include_docs=None, 275 | inclusive_end=None, 276 | limit=None, 277 | reduce=None, 278 | skip=None, 279 | stale=None, 280 | startkey=..., 281 | startkey_docid=None, 282 | update_seq=None): 283 | """Queries a :ref:`stored view ` by the name with 284 | the specified parameters. 285 | 286 | :param str view_name: Name of view stored in the related design document 287 | :param str keys: List of view index keys to fetch. This method is smart 288 | enough to use `GET` or `POST` request depending on 289 | amount of ``keys`` 290 | 291 | :param auth: :class:`aiocouchdb.authn.AuthProvider` instance 292 | :param int feed_buffer_size: Internal buffer size for fetched feed items 293 | 294 | :param bool att_encoding_info: Includes encoding information in an 295 | attachment stubs 296 | :param bool attachments: Includes attachments content into documents. 297 | **Warning**: use with caution! 298 | :param bool conflicts: Includes conflicts information into documents 299 | :param bool descending: Return rows in descending by key order 300 | :param endkey: Stop fetching rows when the specified key is reached 301 | :param str endkey_docid: Stop fetching rows when the specified 302 | document ID is reached 303 | :param bool group: Reduces the view result grouping by unique keys 304 | :param int group_level: Reduces the view result grouping the keys 305 | with defined level 306 | :param str include_docs: Include document body for each row 307 | :param bool inclusive_end: When ``False``, doesn't includes ``endkey`` 308 | in returned rows 309 | :param int limit: Limits the number of the returned rows by 310 | the specified number 311 | :param bool reduce: Defines is the reduce function needs to be applied 312 | or not 313 | :param int skip: Skips specified number of rows before starting 314 | to return the actual result 315 | :param str stale: Allow to fetch the rows from a stale view, without 316 | triggering index update. Supported values: ``ok`` 317 | and ``update_after`` 318 | :param startkey: Return rows starting with the specified key 319 | :param str startkey_docid: Return rows starting with the specified 320 | document ID 321 | :param bool update_seq: Include an ``update_seq`` value into view 322 | results header 323 | 324 | :rtype: :class:`aiocouchdb.feeds.ViewFeed` 325 | """ 326 | params = locals() 327 | for key in ('self', 'auth', 'feed_buffer_size', 'view_name'): 328 | params.pop(key) 329 | 330 | view = self.view_class(self.resource('_view', view_name)) 331 | return (yield from view.request(auth=auth, 332 | feed_buffer_size=feed_buffer_size, 333 | params=params)) 334 | -------------------------------------------------------------------------------- /aiocouchdb/v1/security.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (C) 2015 Alexander Shorin 4 | # All rights reserved. 5 | # 6 | # This software is licensed as described in the file LICENSE, which 7 | # you should have received as part of this distribution. 8 | # 9 | 10 | import asyncio 11 | 12 | 13 | __all__ = ( 14 | 'DatabaseSecurity', 15 | ) 16 | 17 | 18 | class DatabaseSecurity(object): 19 | """Provides set of methods to work with :ref:`database security API 20 | `. Should be used via :attr:`database.security 21 | ` property.""" 22 | 23 | def __init__(self, resource): 24 | self.resource = resource('_security') 25 | 26 | def __repr__(self): 27 | return '<{}.{}({}) object at {}>'.format( 28 | self.__module__, 29 | self.__class__.__qualname__, # pylint: disable=no-member 30 | self.resource.url, 31 | hex(id(self))) 32 | 33 | @asyncio.coroutine 34 | def get(self, *, auth=None): 35 | """`Returns database security object`_. 36 | 37 | :param auth: :class:`aiocouchdb.authn.AuthProvider` instance 38 | 39 | :rtype: dict 40 | 41 | .. _Returns database security object: http://docs.couchdb.org/en/latest/api/database/security.html#get--db-_security 42 | """ 43 | resp = yield from self.resource.get(auth=auth) 44 | yield from resp.maybe_raise_error() 45 | secobj = (yield from resp.json()) 46 | if not secobj: 47 | secobj = { 48 | 'admins': { 49 | 'names': [], 50 | 'roles': [] 51 | }, 52 | 'members': { 53 | 'names': [], 54 | 'roles': [] 55 | } 56 | } 57 | return secobj 58 | 59 | @asyncio.coroutine 60 | def update(self, *, auth=None, admins=None, members=None, merge=False): 61 | """`Updates database security object`_. 62 | 63 | :param auth: :class:`aiocouchdb.authn.AuthProvider` instance 64 | :param dict admins: Mapping of administrators users/roles 65 | :param dict members: Mapping of members users/roles 66 | :param bool merge: Merges admins/members mappings with existed ones when 67 | is ``True``, otherwise replaces them with the given 68 | 69 | :rtype: dict 70 | 71 | .. _Updates database security object: http://docs.couchdb.org/en/latest/api/database/security.html#put--db-_security 72 | """ 73 | secobj = yield from self.get(auth=auth) 74 | for role, section in [('admins', admins), ('members', members)]: 75 | if section is None: 76 | continue 77 | if merge: 78 | for key, group in section.items(): 79 | items = secobj[role][key] 80 | for item in group: 81 | if item in items: 82 | continue 83 | items.append(item) 84 | else: 85 | secobj[role].update(section) 86 | resp = yield from self.resource.put(auth=auth, data=secobj) 87 | yield from resp.maybe_raise_error() 88 | return (yield from resp.json()) 89 | 90 | def update_admins(self, *, auth=None, names=None, roles=None, merge=False): 91 | """Helper for :meth:`~aiocouchdb.v1.database.Security.update` method to 92 | update only database administrators leaving members as is. 93 | 94 | :param auth: :class:`aiocouchdb.authn.AuthProvider` instance 95 | :param list names: List of user names 96 | :param list roles: List of role names 97 | :param bool merge: Merges user/role lists with existed ones when 98 | is ``True``, otherwise replaces them with the given 99 | 100 | :rtype: dict 101 | """ 102 | admins = { 103 | 'names': [] if names is None else names, 104 | 'roles': [] if roles is None else roles 105 | } 106 | return self.update(auth=auth, admins=admins, merge=merge) 107 | 108 | def update_members(self, *, auth=None, names=None, roles=None, merge=False): 109 | """Helper for :meth:`~aiocouchdb.v1.database.Security.update` method to 110 | update only database members leaving administrators as is. 111 | 112 | :param auth: :class:`aiocouchdb.authn.AuthProvider` instance 113 | :param list names: List of user names 114 | :param list roles: List of role names 115 | :param bool merge: Merges user/role lists with existed ones when 116 | is ``True``, otherwise replaces them with the given 117 | 118 | :rtype: dict 119 | """ 120 | members = { 121 | 'names': [] if names is None else names, 122 | 'roles': [] if roles is None else roles 123 | } 124 | return self.update(auth=auth, members=members, merge=merge) 125 | -------------------------------------------------------------------------------- /aiocouchdb/v1/server.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (C) 2014-2015 Alexander Shorin 4 | # All rights reserved. 5 | # 6 | # This software is licensed as described in the file LICENSE, which 7 | # you should have received as part of this distribution. 8 | # 9 | 10 | import asyncio 11 | 12 | from aiocouchdb.client import Resource 13 | from aiocouchdb.feeds import EventSourceFeed, JsonFeed 14 | 15 | from .authdb import AuthDatabase 16 | from .config import ServerConfig 17 | from .database import Database 18 | from .session import Session 19 | 20 | 21 | __all__ = ( 22 | 'Server', 23 | ) 24 | 25 | 26 | class Server(object): 27 | """Implementation of :ref:`CouchDB Server API `.""" 28 | 29 | #: Default :class:`~aiocouchdb.v1.database.Database` instance class 30 | database_class = Database 31 | 32 | #: Authentication database name 33 | authdb_name = '_users' 34 | 35 | #: Authentication database class 36 | authdb_class = AuthDatabase 37 | 38 | #: Default :class:`~aiocouchdb.v1.config.ServerConfig` instance class 39 | config_class = ServerConfig 40 | 41 | #: Default :class:`~aiocouchdb.v1.session.Session` instance class 42 | session_class = Session 43 | 44 | def __init__(self, url_or_resource='http://localhost:5984', *, 45 | authdb_class=None, 46 | authdb_name=None, 47 | config_class=None, 48 | database_class=None, 49 | loop=None, 50 | session_class=None): 51 | if authdb_class is not None: 52 | self.authdb_class = authdb_class 53 | if authdb_name is not None: 54 | self.authdb_name = authdb_name 55 | if config_class is not None: 56 | self.config_class = config_class 57 | if database_class is not None: 58 | self.database_class = database_class 59 | if session_class is not None: 60 | self.session_class = session_class 61 | if isinstance(url_or_resource, str): 62 | url_or_resource = Resource(url_or_resource, loop=loop) 63 | self.resource = url_or_resource 64 | self._authdb = self.authdb_class(self.resource(self.authdb_name), 65 | dbname=self.authdb_name) 66 | self._config = self.config_class(self.resource) 67 | self._session = self.session_class(self.resource) 68 | 69 | def __getitem__(self, dbname): 70 | return self.database_class(self.resource(dbname), dbname=dbname) 71 | 72 | def __repr__(self): 73 | return '<{}.{}({}) object at {}>'.format( 74 | self.__module__, 75 | self.__class__.__qualname__, # pylint: disable=no-member 76 | self.resource.url, 77 | hex(id(self))) 78 | 79 | @property 80 | def authdb(self): 81 | """Proxy to the :class:`authentication database 82 | ` instance.""" 83 | return self._authdb 84 | 85 | @asyncio.coroutine 86 | def db(self, dbname, *, auth=None): 87 | """Returns :class:`~aiocouchdb.v1.database.Database` instance against 88 | specified database name. 89 | 90 | If database isn't accessible for provided auth credentials, this method 91 | raises :exc:`aiocouchdb.errors.HttpErrorException` with the related 92 | response status code. 93 | 94 | :param str dbname: Database name 95 | :param auth: :class:`aiocouchdb.authn.AuthProvider` instance 96 | 97 | :rtype: :attr:`aiocouchdb.v1.server.Server.database_class` 98 | """ 99 | db = self[dbname] 100 | resp = yield from db.resource.head(auth=auth) 101 | if resp.status != 404: 102 | yield from resp.maybe_raise_error() 103 | yield from resp.release() 104 | return db 105 | 106 | @asyncio.coroutine 107 | def info(self, *, auth=None): 108 | """Returns server :ref:`meta information and welcome message 109 | `. 110 | 111 | :param auth: :class:`aiocouchdb.authn.AuthProvider` instance 112 | 113 | :rtype: dict 114 | """ 115 | resp = yield from self.resource.get(auth=auth) 116 | yield from resp.maybe_raise_error() 117 | return (yield from resp.json()) 118 | 119 | @asyncio.coroutine 120 | def active_tasks(self, *, auth=None): 121 | """Returns list of :ref:`active tasks ` 122 | which runs on server. 123 | 124 | :param auth: :class:`aiocouchdb.authn.AuthProvider` instance 125 | 126 | :rtype: list 127 | """ 128 | resp = yield from self.resource.get('_active_tasks', auth=auth) 129 | yield from resp.maybe_raise_error() 130 | return (yield from resp.json()) 131 | 132 | @asyncio.coroutine 133 | def all_dbs(self, *, auth=None): 134 | """Returns list of available :ref:`databases ` 135 | on server. 136 | 137 | :param auth: :class:`aiocouchdb.authn.AuthProvider` instance 138 | 139 | :rtype: list 140 | """ 141 | resp = yield from self.resource.get('_all_dbs', auth=auth) 142 | yield from resp.maybe_raise_error() 143 | return (yield from resp.json()) 144 | 145 | @property 146 | def config(self): 147 | """Proxy to the related :class:`~aiocouchdb.v1.server.config_class` 148 | instance.""" 149 | return self._config 150 | 151 | @asyncio.coroutine 152 | def db_updates(self, *, 153 | auth=None, 154 | feed_buffer_size=None, 155 | feed=None, 156 | timeout=None, 157 | heartbeat=None): 158 | """Emits :ref:`databases events ` for 159 | the related server instance. 160 | 161 | :param auth: :class:`aiocouchdb.authn.AuthProvider` instance 162 | :param int feed_buffer_size: Internal buffer size for fetched feed items 163 | 164 | :param str feed: Feed type 165 | :param int timeout: Timeout in milliseconds 166 | :param bool heartbeat: Whenever use heartbeats to keep connection alive 167 | 168 | Depending on feed type returns: 169 | 170 | - :class:`dict` - for default or ``longpoll`` feed 171 | - :class:`aiocouchdb.feeds.JsonFeed` - for ``continuous`` feed 172 | - :class:`aiocouchdb.feeds.EventSourceFeed` - for ``eventsource`` feed 173 | """ 174 | params = {} 175 | if feed is not None: 176 | params['feed'] = feed 177 | if timeout is not None: 178 | params['timeout'] = timeout 179 | if heartbeat is not None: 180 | params['heartbeat'] = heartbeat 181 | resp = yield from self.resource.get('_db_updates', 182 | auth=auth, params=params) 183 | yield from resp.maybe_raise_error() 184 | if feed == 'continuous': 185 | return JsonFeed(resp, buffer_size=feed_buffer_size) 186 | elif feed == 'eventsource': 187 | return EventSourceFeed(resp, buffer_size=feed_buffer_size) 188 | else: 189 | return (yield from resp.json()) 190 | 191 | @asyncio.coroutine 192 | def log(self, *, bytes=None, offset=None, auth=None): 193 | """Returns a chunk of data from the tail of :ref:`CouchDB's log 194 | ` file. 195 | 196 | :param int bytes: Bytes to return 197 | :param int offset: Offset in bytes where the log tail should be started 198 | :param auth: :class:`aiocouchdb.authn.AuthProvider` instance 199 | 200 | :rtype: str 201 | """ 202 | params = {} 203 | if bytes is not None: 204 | params['bytes'] = bytes 205 | if offset is not None: 206 | params['offset'] = offset 207 | resp = yield from self.resource.get('_log', auth=auth, params=params) 208 | yield from resp.maybe_raise_error() 209 | return (yield from resp.read()).decode('utf-8') 210 | 211 | @asyncio.coroutine 212 | def replicate(self, source, target, *, 213 | auth=None, 214 | cancel=None, 215 | continuous=None, 216 | create_target=None, 217 | doc_ids=None, 218 | filter=None, 219 | headers=None, 220 | proxy=None, 221 | query_params=None, 222 | since_seq=None, 223 | checkpoint_interval=None, 224 | connection_timeout=None, 225 | http_connections=None, 226 | retries_per_request=None, 227 | socket_options=None, 228 | use_checkpoints=None, 229 | worker_batch_size=None, 230 | worker_processes=None): 231 | """:ref:`Runs a replication ` from ``source`` 232 | to ``target``. 233 | 234 | :param str source: Source database name or URL 235 | :param str target: Target database name or URL 236 | 237 | :param auth: :class:`aiocouchdb.authn.AuthProvider` instance 238 | (don't confuse with ``authobj`` which belongs to 239 | replication options) 240 | 241 | :param bool cancel: Cancels active replication 242 | :param bool continuous: Runs continuous replication 243 | :param bool create_target: Creates target database if it not exists 244 | :param list doc_ids: List of specific document ids to replicate 245 | :param str filter: Filter function name 246 | :param str proxy: Proxy server URL 247 | :param dict query_params: Custom query parameters for filter function 248 | :param since_seq: Start replication from specified sequence number 249 | 250 | :param int checkpoint_interval: Tweaks `checkpoint_interval`_ option 251 | :param int connection_timeout: Tweaks `connection_timeout`_ option 252 | :param int http_connections: Tweaks `http_connections`_ option 253 | :param int retries_per_request: Tweaks `retries_per_request`_ option 254 | :param str socket_options: Tweaks `socket_options`_ option 255 | :param bool use_checkpoints: Tweaks `use_checkpoints`_ option 256 | :param int worker_batch_size: Tweaks `worker_batch_size`_ option 257 | :param int worker_processes: Tweaks `worker_processes`_ option 258 | 259 | :rtype: dict 260 | 261 | .. _checkpoint_interval: http://docs.couchdb.org/en/latest/config/replicator.html#replicator/checkpoint_interval 262 | .. _connection_timeout: http://docs.couchdb.org/en/latest/config/replicator.html#replicator/connection_timeout 263 | .. _http_connections: http://docs.couchdb.org/en/latest/config/replicator.html#replicator/http_connections 264 | .. _retries_per_request: http://docs.couchdb.org/en/latest/config/replicator.html#replicator/retries_per_request 265 | .. _socket_options: http://docs.couchdb.org/en/latest/config/replicator.html#replicator/socket_options 266 | .. _use_checkpoints: http://docs.couchdb.org/en/latest/config/replicator.html#replicator/use_checkpoints 267 | .. _worker_batch_size: http://docs.couchdb.org/en/latest/config/replicator.html#replicator/worker_batch_size 268 | .. _worker_processes: http://docs.couchdb.org/en/latest/config/replicator.html#replicator/worker_processes 269 | 270 | """ 271 | params = dict((key, value) 272 | for key, value in locals().items() 273 | if (key not in {'self', 'source', 'target', 'auth'} and 274 | value is not None)) 275 | 276 | doc = {'source': source, 'target': target} 277 | doc.update(params) 278 | 279 | resp = yield from self.resource.post('_replicate', auth=auth, data=doc) 280 | yield from resp.maybe_raise_error() 281 | return (yield from resp.json()) 282 | 283 | @asyncio.coroutine 284 | def restart(self, *, auth=None): 285 | """:ref:`Restarts ` server instance. 286 | 287 | :param auth: :class:`aiocouchdb.authn.AuthProvider` instance 288 | 289 | :rtype: dict 290 | """ 291 | resp = yield from self.resource.post('_restart', auth=auth) 292 | yield from resp.maybe_raise_error() 293 | return (yield from resp.json()) 294 | 295 | @property 296 | def session(self): 297 | """Proxy to the related 298 | :class:`~aiocouchdb.v1.server.Server.session_class` instance.""" 299 | return self._session 300 | 301 | @asyncio.coroutine 302 | def stats(self, metric=None, *, auth=None, flush=None, range=None): 303 | """Returns :ref:`server statistics `. 304 | 305 | :param str metric: Metrics name in format ``group/name``. For instance, 306 | ``httpd/requests``. If omitted, all metrics 307 | will be returned 308 | :param bool flush: If ``True``, collects samples right for this request 309 | :param int range: `Sampling range`_ 310 | :param auth: :class:`aiocouchdb.authn.AuthProvider` instance 311 | 312 | :rtype: dict 313 | 314 | .. _Sampling range: http://docs.couchdb.org/en/latest/config/misc.html#stats/samples 315 | """ 316 | path = ['_stats'] 317 | params = {} 318 | if metric is not None: 319 | if '/' in metric: 320 | path.extend(metric.split('/', 1)) 321 | else: 322 | raise ValueError('invalid metric name. try "httpd/requests"') 323 | if flush is not None: 324 | params['flush'] = flush 325 | if range is not None: 326 | params['range'] = range 327 | resource = self.resource(*path) 328 | resp = yield from resource.get(auth=auth, params=params) 329 | yield from resp.maybe_raise_error() 330 | return (yield from resp.json()) 331 | 332 | @asyncio.coroutine 333 | def uuids(self, *, auth=None, count=None): 334 | """Returns :ref:`UUIDs ` generated on server. 335 | 336 | :param int count: Amount of UUIDs to generate 337 | :param auth: :class:`aiocouchdb.authn.AuthProvider` instance 338 | 339 | :rtype: list 340 | """ 341 | params = {} 342 | if count is not None: 343 | params['count'] = count 344 | resp = yield from self.resource.get('_uuids', auth=auth, params=params) 345 | yield from resp.maybe_raise_error() 346 | return (yield from resp.json())['uuids'] 347 | -------------------------------------------------------------------------------- /aiocouchdb/v1/session.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (C) 2015 Alexander Shorin 4 | # All rights reserved. 5 | # 6 | # This software is licensed as described in the file LICENSE, which 7 | # you should have received as part of this distribution. 8 | # 9 | 10 | import asyncio 11 | 12 | from aiocouchdb.authn import CookieAuthProvider 13 | 14 | 15 | __all__ = ( 16 | 'Session', 17 | ) 18 | 19 | 20 | class Session(object): 21 | """Implements :ref:`/_session ` API. Should be used 22 | via :attr:`server.session ` property. 23 | """ 24 | 25 | cookie_auth_provider_class = CookieAuthProvider 26 | 27 | def __init__(self, resource): 28 | self.resource = resource('_session') 29 | 30 | def __repr__(self): 31 | return '<{}.{}({}) object at {}>'.format( 32 | self.__module__, 33 | self.__class__.__qualname__, # pylint: disable=no-member 34 | self.resource.url, 35 | hex(id(self))) 36 | 37 | @asyncio.coroutine 38 | def open(self, name, password): 39 | """Opens session for cookie auth provider and returns the auth provider 40 | back for usage in further requests. 41 | 42 | :param str name: Username 43 | :param str password: User's password 44 | 45 | :rtype: :class:`aiocouchdb.authn.CookieAuthProvider` 46 | """ 47 | auth = self.cookie_auth_provider_class() 48 | doc = {'name': name, 'password': password} 49 | resp = yield from self.resource.post(auth=auth, data=doc) 50 | yield from resp.maybe_raise_error() 51 | yield from resp.release() 52 | return auth 53 | 54 | @asyncio.coroutine 55 | def info(self, *, auth=None): 56 | """Returns information about authenticated user. 57 | Usable for any :class:`~aiocouchdb.authn.AuthProvider`. 58 | 59 | :rtype: dict 60 | """ 61 | resp = yield from self.resource.get(auth=auth) 62 | yield from resp.maybe_raise_error() 63 | return (yield from resp.json()) 64 | 65 | @asyncio.coroutine 66 | def close(self, *, auth=None): 67 | """Closes active cookie session. 68 | Uses for :class:`aiocouchdb.authn.CookieAuthProvider`.""" 69 | resp = yield from self.resource.delete(auth=auth) 70 | yield from resp.maybe_raise_error() 71 | return (yield from resp.json()) 72 | -------------------------------------------------------------------------------- /aiocouchdb/v1/tests/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (C) 2015 Alexander Shorin 4 | # All rights reserved. 5 | # 6 | # This software is licensed as described in the file LICENSE, which 7 | # you should have received as part of this distribution. 8 | # 9 | -------------------------------------------------------------------------------- /aiocouchdb/v1/tests/test_attachment.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (C) 2014-2015 Alexander Shorin 4 | # All rights reserved. 5 | # 6 | # This software is licensed as described in the file LICENSE, which 7 | # you should have received as part of this distribution. 8 | # 9 | 10 | import base64 11 | import hashlib 12 | import io 13 | 14 | import aiocouchdb.client 15 | import aiocouchdb.v1.attachment 16 | import aiocouchdb.v1.document 17 | 18 | from . import utils 19 | 20 | 21 | class AttachmentTestCase(utils.AttachmentTestCase): 22 | 23 | def request_path(self, att=None, *parts): 24 | attname = att.name if att is not None else self.attbin.name 25 | return [self.db.name, self.doc.id, attname] + list(parts) 26 | 27 | def test_init_with_url(self): 28 | self.assertIsInstance(self.attbin.resource, aiocouchdb.client.Resource) 29 | 30 | def test_init_with_resource(self): 31 | res = aiocouchdb.client.Resource(self.url_att) 32 | att = aiocouchdb.v1.attachment.Attachment(res) 33 | self.assertIsInstance(att.resource, aiocouchdb.client.Resource) 34 | self.assertEqual(self.url_att, att.resource.url) 35 | 36 | def test_init_with_name(self): 37 | res = aiocouchdb.client.Resource(self.url_att) 38 | att = aiocouchdb.v1.attachment.Attachment(res, name='foo.txt') 39 | self.assertEqual(att.name, 'foo.txt') 40 | 41 | def test_init_with_name_from_doc(self): 42 | att = yield from self.doc.att('bar.txt') 43 | self.assertEqual(att.name, 'bar.txt') 44 | 45 | def test_exists(self): 46 | result = yield from self.attbin.exists() 47 | self.assert_request_called_with('HEAD', *self.request_path()) 48 | self.assertTrue(result) 49 | 50 | def test_exists_rev(self): 51 | result = yield from self.attbin.exists(self.rev) 52 | self.assert_request_called_with('HEAD', *self.request_path(), 53 | params={'rev': self.rev}) 54 | self.assertTrue(result) 55 | 56 | @utils.with_fixed_admin_party('root', 'relax') 57 | def test_exists_forbidden(self, root): 58 | with self.response(): 59 | yield from self.db.security.update_members(names=['foo', 'bar'], 60 | auth=root) 61 | with self.response(status=403): 62 | result = yield from self.attbin.exists() 63 | self.assert_request_called_with('HEAD', *self.request_path()) 64 | self.assertFalse(result) 65 | 66 | def test_exists_not_found(self): 67 | with self.response(status=404): 68 | attname = utils.uuid() 69 | result = yield from self.doc[attname].exists() 70 | self.assert_request_called_with( 71 | 'HEAD', self.db.name, self.doc.id, attname) 72 | self.assertFalse(result) 73 | 74 | def test_modified(self): 75 | digest = hashlib.md5(utils.uuid().encode()).digest() 76 | reqdigest = '"{}"'.format(base64.b64encode(digest).decode()) 77 | result = yield from self.attbin.modified(digest) 78 | self.assert_request_called_with('HEAD', *self.request_path(), 79 | headers={'IF-NONE-MATCH': reqdigest}) 80 | self.assertTrue(result) 81 | 82 | def test_not_modified(self): 83 | digest = hashlib.md5(b'Time to relax!').digest() 84 | reqdigest = '"Ehemn5lWOgCMUJ/c1x0bcg=="' 85 | 86 | with self.response(status=304): 87 | result = yield from self.attbin.modified(digest) 88 | self.assert_request_called_with( 89 | 'HEAD', *self.request_path(), 90 | headers={'IF-NONE-MATCH': reqdigest}) 91 | self.assertFalse(result) 92 | 93 | def test_modified_with_base64_digest(self): 94 | digest = base64.b64encode(hashlib.md5(b'foo').digest()).decode() 95 | reqdigest = '"rL0Y20zC+Fzt72VPzMSk2A=="' 96 | result = yield from self.attbin.modified(digest) 97 | self.assert_request_called_with('HEAD', *self.request_path(), 98 | headers={'IF-NONE-MATCH': reqdigest}) 99 | self.assertTrue(result) 100 | 101 | def test_modified_invalid_digest(self): 102 | with self.assertRaises(TypeError): 103 | yield from self.attbin.modified({}) 104 | 105 | with self.assertRaises(ValueError): 106 | yield from self.attbin.modified(b'foo') 107 | 108 | with self.assertRaises(ValueError): 109 | yield from self.attbin.modified('bar') 110 | 111 | def test_accepts_range(self): 112 | with self.response(headers={'ACCEPT-RANGES': 'bytes'}): 113 | result = yield from self.attbin.accepts_range() 114 | self.assert_request_called_with('HEAD', *self.request_path()) 115 | self.assertTrue(result) 116 | 117 | def test_accepts_range_not(self): 118 | result = yield from self.atttxt.accepts_range() 119 | self.assert_request_called_with('HEAD', *self.request_path(self.atttxt)) 120 | self.assertFalse(result) 121 | 122 | def test_accepts_range_with_rev(self): 123 | result = yield from self.atttxt.accepts_range(rev=self.rev) 124 | self.assert_request_called_with('HEAD', *self.request_path(self.atttxt), 125 | params={'rev': self.rev}) 126 | self.assertFalse(result) 127 | 128 | def test_get(self): 129 | result = yield from self.attbin.get() 130 | self.assert_request_called_with('GET', *self.request_path()) 131 | self.assertIsInstance(result, aiocouchdb.v1.attachment.AttachmentReader) 132 | 133 | def test_get_rev(self): 134 | result = yield from self.attbin.get(self.rev) 135 | self.assert_request_called_with('GET', *self.request_path(), 136 | params={'rev': self.rev}) 137 | self.assertIsInstance(result, aiocouchdb.v1.attachment.AttachmentReader) 138 | 139 | def test_get_range(self): 140 | yield from self.attbin.get(range=slice(12, 24)) 141 | self.assert_request_called_with('GET', *self.request_path(), 142 | headers={'RANGE': 'bytes=12-24'}) 143 | 144 | def test_get_range_from_start(self): 145 | yield from self.attbin.get(range=slice(42)) 146 | self.assert_request_called_with('GET', *self.request_path(), 147 | headers={'RANGE': 'bytes=0-42'}) 148 | 149 | def test_get_range_iterable(self): 150 | yield from self.attbin.get(range=[11, 22]) 151 | self.assert_request_called_with('GET', *self.request_path(), 152 | headers={'RANGE': 'bytes=11-22'}) 153 | 154 | def test_get_range_int(self): 155 | yield from self.attbin.get(range=42) 156 | self.assert_request_called_with('GET', *self.request_path(), 157 | headers={'RANGE': 'bytes=0-42'}) 158 | 159 | def test_get_bad_range(self): 160 | with self.response(status=416): 161 | with self.assertRaises(aiocouchdb.RequestedRangeNotSatisfiable): 162 | yield from self.attbin.get(range=slice(1024, 8192)) 163 | 164 | self.assert_request_called_with('GET', *self.request_path(), 165 | headers={'RANGE': 'bytes=1024-8192'}) 166 | 167 | def test_update(self): 168 | yield from self.attbin.update(io.BytesIO(b''), rev=self.rev) 169 | self.assert_request_called_with( 170 | 'PUT', *self.request_path(), 171 | data=Ellipsis, 172 | headers={'CONTENT-TYPE': 'application/octet-stream'}, 173 | params={'rev': self.rev}) 174 | 175 | def test_update_ctype(self): 176 | yield from self.attbin.update(io.BytesIO(b''), 177 | content_type='foo/bar', 178 | rev=self.rev) 179 | self.assert_request_called_with( 180 | 'PUT', *self.request_path(), 181 | data=Ellipsis, 182 | headers={'CONTENT-TYPE': 'foo/bar'}, 183 | params={'rev': self.rev}) 184 | 185 | def test_update_with_encoding(self): 186 | yield from self.attbin.update(io.BytesIO(b''), 187 | content_encoding='gzip', 188 | rev=self.rev) 189 | self.assert_request_called_with( 190 | 'PUT', *self.request_path(), 191 | data=Ellipsis, 192 | headers={'CONTENT-TYPE': 'application/octet-stream', 193 | 'CONTENT-ENCODING': 'gzip'}, 194 | params={'rev': self.rev}) 195 | 196 | def test_delete(self): 197 | yield from self.attbin.delete(self.rev) 198 | self.assert_request_called_with('DELETE', *self.request_path(), 199 | params={'rev': self.rev}) 200 | 201 | 202 | class AttachmentReaderTestCase(utils.TestCase): 203 | 204 | _test_target = 'mock' 205 | 206 | def setUp(self): 207 | super().setUp() 208 | self.att = aiocouchdb.v1.attachment.AttachmentReader(self.request) 209 | 210 | def test_close(self): 211 | self.request.content.at_eof.return_value = False 212 | self.att.close() 213 | self.assertTrue(self.request.close.called) 214 | 215 | def test_closed(self): 216 | _ = self.att.closed 217 | self.assertTrue(self.request.content.at_eof.called) 218 | 219 | def test_close_when_closed(self): 220 | self.request.content.at_eof.return_value = True 221 | self.att.close() 222 | self.assertFalse(self.request.close.called) 223 | 224 | def test_readable(self): 225 | self.assertTrue(self.att.readable()) 226 | 227 | def test_writable(self): 228 | self.assertFalse(self.att.writable()) 229 | 230 | def test_seekable(self): 231 | self.assertFalse(self.att.seekable()) 232 | 233 | def test_read(self): 234 | yield from self.att.read() 235 | self.request.content.read.assert_called_once_with(-1) 236 | 237 | def test_read_some(self): 238 | yield from self.att.read(10) 239 | self.request.content.read.assert_called_once_with(10) 240 | 241 | def test_readall(self): 242 | with self.response(data=[b'...', b'---']) as resp: 243 | self.att._resp = resp 244 | res = yield from self.att.readall() 245 | 246 | resp.content.read.assert_called_with(8192) 247 | self.assertEqual(resp.content.read.call_count, 3) 248 | self.assertIsInstance(res, bytearray) 249 | 250 | def test_readline(self): 251 | yield from self.att.readline() 252 | self.request.content.readline.assert_called_once_with() 253 | 254 | def test_readlines(self): 255 | with self.response(data=[b'...', b'---']) as resp: 256 | resp.content.readline = resp.content.read 257 | self.att._resp = resp 258 | res = yield from self.att.readlines() 259 | 260 | self.assertTrue(resp.content.readline.called) 261 | self.assertEqual(resp.content.read.call_count, 3) 262 | self.assertEqual(res, [b'...', b'---']) 263 | 264 | def test_readlines_hint(self): 265 | with self.response(data=[b'...', b'---']) as resp: 266 | resp.content.readline = resp.content.read 267 | self.att._resp = resp 268 | res = yield from self.att.readlines(2) 269 | 270 | self.assertTrue(resp.content.readline.called) 271 | self.assertEqual(resp.content.read.call_count, 1) 272 | self.assertEqual(res, [b'...']) 273 | 274 | def test_readlines_hint_more(self): 275 | with self.response(data=[b'...', b'---']) as resp: 276 | resp.content.readline = resp.content.read 277 | self.att._resp = resp 278 | res = yield from self.att.readlines(42) 279 | 280 | self.assertTrue(resp.content.readline.called) 281 | self.assertEqual(resp.content.read.call_count, 3) 282 | self.assertEqual(res, [b'...', b'---']) 283 | -------------------------------------------------------------------------------- /aiocouchdb/v1/tests/test_authdb.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (C) 2014-2015 Alexander Shorin 4 | # All rights reserved. 5 | # 6 | # This software is licensed as described in the file LICENSE, which 7 | # you should have received as part of this distribution. 8 | # 9 | 10 | import asyncio 11 | import json 12 | 13 | import aiocouchdb.v1.authdb 14 | 15 | from aiocouchdb.client import urljoin 16 | from . import utils 17 | 18 | 19 | class AuthDatabaseTestCase(utils.ServerTestCase): 20 | 21 | def setUp(self): 22 | super().setUp() 23 | self.url_db = urljoin(self.url, '_users') 24 | self.db = aiocouchdb.v1.authdb.AuthDatabase(self.url_db) 25 | 26 | def test_get_doc_with_prefix(self): 27 | doc = self.db['test'] 28 | self.assertEqual(doc.id, self.db.document_class.doc_prefix + 'test') 29 | 30 | doc = self.db[self.db.document_class.doc_prefix + 'test'] 31 | self.assertEqual(doc.id, self.db.document_class.doc_prefix + 'test') 32 | 33 | 34 | class UserDocumentTestCase(utils.ServerTestCase): 35 | 36 | def setUp(self): 37 | super().setUp() 38 | self.username = utils.uuid() 39 | docid = aiocouchdb.v1.authdb.UserDocument.doc_prefix + self.username 40 | self.url_doc = urljoin(self.url, '_users', docid) 41 | self.doc = aiocouchdb.v1.authdb.UserDocument(self.url_doc, docid=docid) 42 | 43 | def tearDown(self): 44 | self.loop.run_until_complete(self.teardown_document()) 45 | super().tearDown() 46 | 47 | @asyncio.coroutine 48 | def setup_document(self, password, **kwargs): 49 | data = { 50 | '_id': self.doc.id, 51 | 'name': self.doc.name, 52 | 'password': password, 53 | 'roles': [], 54 | 'type': 'user' 55 | } 56 | data.update(kwargs) 57 | with self.response(data=b'{}'): 58 | resp = yield from self.doc.register(password, **kwargs) 59 | self.assert_request_called_with('PUT', '_users', self.doc.id, data=data) 60 | self.assertIsInstance(resp, dict) 61 | return resp 62 | 63 | @asyncio.coroutine 64 | def teardown_document(self): 65 | if not (yield from self.doc.exists()): 66 | return 67 | with self.response(headers={'ETAG': '"1-ABC"'}): 68 | rev = yield from self.doc.rev() 69 | yield from self.doc.delete(rev) 70 | 71 | def test_require_docid(self): 72 | with self.assertRaises(ValueError): 73 | aiocouchdb.v1.authdb.UserDocument(self.url_doc) 74 | 75 | def test_username(self): 76 | self.assertEqual(self.doc.name, self.username) 77 | 78 | def test_register(self): 79 | yield from self.setup_document('s3cr1t') 80 | 81 | def test_register_with_additional_data(self): 82 | yield from self.setup_document('s3cr1t', email='user@example.com') 83 | 84 | def test_change_password(self): 85 | yield from self.setup_document('s3cr1t') 86 | with self.response(data=b'{}'): 87 | doc = yield from self.doc.get() 88 | data = json.dumps(doc).encode() 89 | 90 | with self.response(data=data): 91 | yield from self.doc.update_password('n3ws3cr1t') 92 | doc['password'] = 'n3ws3cr1t' 93 | self.assert_request_called_with( 94 | 'PUT', '_users', self.doc.id, 95 | data=doc) 96 | -------------------------------------------------------------------------------- /aiocouchdb/v1/tests/test_config.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (C) 2015 Alexander Shorin 4 | # All rights reserved. 5 | # 6 | # This software is licensed as described in the file LICENSE, which 7 | # you should have received as part of this distribution. 8 | # 9 | 10 | from . import utils 11 | 12 | 13 | class ServerConfigTestCase(utils.ServerTestCase): 14 | 15 | def test_config(self): 16 | yield from self.server.config.get() 17 | self.assert_request_called_with('GET', '_config') 18 | 19 | def test_config_get_section(self): 20 | yield from self.server.config.get('couchdb') 21 | self.assert_request_called_with('GET', '_config', 'couchdb') 22 | 23 | def test_config_get_option(self): 24 | yield from self.server.config.get('couchdb', 'uuid') 25 | self.assert_request_called_with('GET', '_config', 'couchdb', 'uuid') 26 | 27 | @utils.modify_server('aiocouchdb', 'test', 'relax') 28 | def test_config_set_option(self): 29 | with self.response(data=b'"relax!"'): 30 | result = yield from self.server.config.update( 31 | 'aiocouchdb', 'test', 'passed') 32 | self.assert_request_called_with( 33 | 'PUT', '_config', 'aiocouchdb', 'test', data='passed') 34 | self.assertIsInstance(result, str) 35 | 36 | @utils.modify_server('aiocouchdb', 'test', 'passed') 37 | def test_config_del_option(self): 38 | with self.response(data=b'"passed"'): 39 | result = yield from self.server.config.delete('aiocouchdb', 'test') 40 | self.assert_request_called_with('DELETE', 41 | '_config', 'aiocouchdb', 'test') 42 | self.assertIsInstance(result, str) 43 | 44 | def test_config_option_exists(self): 45 | with self.response(status=200): 46 | result = yield from self.server.config.exists('couchdb', 'uuid') 47 | self.assert_request_called_with('HEAD', '_config', 48 | 'couchdb', 'uuid') 49 | self.assertTrue(result) 50 | 51 | def test_config_option_not_exists(self): 52 | with self.response(status=404): 53 | result = yield from self.server.config.exists('foo', 'bar') 54 | self.assert_request_called_with('HEAD', '_config', 'foo', 'bar') 55 | self.assertFalse(result) 56 | -------------------------------------------------------------------------------- /aiocouchdb/v1/tests/test_designdoc.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (C) 2014-2015 Alexander Shorin 4 | # All rights reserved. 5 | # 6 | # This software is licensed as described in the file LICENSE, which 7 | # you should have received as part of this distribution. 8 | # 9 | 10 | import json 11 | 12 | import aiocouchdb.client 13 | import aiocouchdb.feeds 14 | import aiocouchdb.v1.database 15 | import aiocouchdb.v1.document 16 | import aiocouchdb.v1.designdoc 17 | 18 | from aiocouchdb.client import urljoin 19 | from . import utils 20 | 21 | 22 | class DesignDocTestCase(utils.DesignDocumentTestCase): 23 | 24 | def request_path(self, *parts): 25 | return [self.db.name] + self.ddoc.id.split('/') + list(parts) 26 | 27 | def test_init_with_url(self): 28 | self.assertIsInstance(self.ddoc.resource, aiocouchdb.client.Resource) 29 | 30 | def test_init_with_resource(self): 31 | res = aiocouchdb.client.Resource(self.url_ddoc) 32 | ddoc = aiocouchdb.v1.designdoc.DesignDocument(res) 33 | self.assertIsInstance(ddoc.resource, aiocouchdb.client.Resource) 34 | self.assertEqual(self.url_ddoc, ddoc.resource.url) 35 | 36 | def test_init_with_id(self): 37 | res = aiocouchdb.client.Resource(self.url_ddoc) 38 | ddoc = aiocouchdb.v1.designdoc.DesignDocument(res, docid='foo') 39 | self.assertEqual(ddoc.id, 'foo') 40 | 41 | def test_init_with_id_from_database(self): 42 | db = aiocouchdb.v1.database.Database(urljoin(self.url, 'dbname'), 43 | dbname='dbname') 44 | ddoc = yield from db.ddoc('foo') 45 | self.assertEqual(ddoc.id, '_design/foo') 46 | 47 | def test_get_item_returns_attachment(self): 48 | att = self.ddoc['attname'] 49 | with self.assertRaises(AssertionError): 50 | self.assert_request_called_with( 51 | 'HEAD', *self.request_path('attname')) 52 | self.assertIsInstance(att, self.ddoc.document_class.attachment_class) 53 | 54 | def test_ddoc_name(self): 55 | res = aiocouchdb.client.Resource(self.url_ddoc) 56 | ddoc = aiocouchdb.v1.designdoc.DesignDocument(res, docid='_design/bar') 57 | self.assertEqual(ddoc.name, 'bar') 58 | 59 | def test_ddoc_bad_name_because_of_bad_id(self): 60 | res = aiocouchdb.client.Resource(self.url_ddoc) 61 | ddoc = aiocouchdb.v1.designdoc.DesignDocument(res, docid='bar') 62 | self.assertEqual(ddoc.name, None) 63 | 64 | def test_access_to_document_api(self): 65 | self.assertIsInstance(self.ddoc.doc, aiocouchdb.v1.document.Document) 66 | 67 | def test_access_to_custom_document_api(self): 68 | class CustomDoc(object): 69 | def __init__(self, resource, **kwargs): 70 | pass 71 | ddoc = aiocouchdb.v1.designdoc.DesignDocument( 72 | '', 73 | document_class=CustomDoc) 74 | self.assertIsInstance(ddoc.doc, CustomDoc) 75 | 76 | def test_info(self): 77 | with self.response(data=b'{}'): 78 | result = yield from self.ddoc.info() 79 | self.assert_request_called_with('GET', *self.request_path('_info')) 80 | self.assertIsInstance(result, dict) 81 | 82 | def test_view(self): 83 | with (yield from self.ddoc.view('viewname')) as view: 84 | self.assert_request_called_with( 85 | 'GET', *self.request_path('_view', 'viewname')) 86 | self.assertIsInstance(view, aiocouchdb.feeds.ViewFeed) 87 | 88 | def test_view_key(self): 89 | with (yield from self.ddoc.view('viewname', 'foo')) as view: 90 | self.assert_request_called_with( 91 | 'GET', *self.request_path('_view', 'viewname'), 92 | params={'key': '"foo"'}) 93 | self.assertIsInstance(view, aiocouchdb.feeds.ViewFeed) 94 | 95 | def test_view_keys(self): 96 | with (yield from self.ddoc.view('viewname', 'foo', 'bar')) as view: 97 | self.assert_request_called_with( 98 | 'POST', *self.request_path('_view', 'viewname'), 99 | data={'keys': ('foo', 'bar')}) 100 | self.assertIsInstance(view, aiocouchdb.feeds.ViewFeed) 101 | 102 | def test_view_startkey_none(self): 103 | with (yield from self.ddoc.view('viewname', startkey=None)): 104 | self.assert_request_called_with( 105 | 'GET', *self.request_path('_view', 'viewname'), 106 | params={'startkey': 'null'}) 107 | 108 | def test_view_endkey_none(self): 109 | with (yield from self.ddoc.view('viewname', endkey=None)): 110 | self.assert_request_called_with( 111 | 'GET', *self.request_path('_view', 'viewname'), 112 | params={'endkey': 'null'}) 113 | 114 | @utils.run_for('mock') 115 | def test_view_params(self): 116 | all_params = { 117 | 'att_encoding_info': False, 118 | 'attachments': False, 119 | 'conflicts': True, 120 | 'descending': True, 121 | 'endkey': 'foo', 122 | 'endkey_docid': 'foo_id', 123 | 'group': False, 124 | 'group_level': 10, 125 | 'include_docs': True, 126 | 'inclusive_end': False, 127 | 'limit': 10, 128 | 'reduce': True, 129 | 'skip': 20, 130 | 'stale': 'ok', 131 | 'startkey': 'bar', 132 | 'startkey_docid': 'bar_id', 133 | 'update_seq': True 134 | } 135 | 136 | for key, value in all_params.items(): 137 | result = yield from self.ddoc.view('viewname', **{key: value}) 138 | if key in ('endkey', 'startkey'): 139 | value = json.dumps(value) 140 | self.assert_request_called_with( 141 | 'GET', *self.request_path('_view', 'viewname'), 142 | params={key: value}) 143 | self.assertIsInstance(result, aiocouchdb.feeds.ViewFeed) 144 | 145 | def test_list(self): 146 | with (yield from self.ddoc.list('listname')) as resp: 147 | self.assert_request_called_with( 148 | 'GET', *self.request_path('_list', 'listname')) 149 | self.assertIsInstance(resp, aiocouchdb.client.HttpResponse) 150 | 151 | def test_list_view(self): 152 | with (yield from self.ddoc.list('listname', 'viewname')) as resp: 153 | self.assert_request_called_with( 154 | 'GET', *self.request_path('_list', 'listname', 'viewname')) 155 | self.assertIsInstance(resp, aiocouchdb.client.HttpResponse) 156 | 157 | def test_list_view_ddoc(self): 158 | with (yield from self.ddoc.list('listname', 'ddoc/view')) as resp: 159 | self.assert_request_called_with( 160 | 'GET', *self.request_path('_list', 'listname', 'ddoc', 'view')) 161 | self.assertIsInstance(resp, aiocouchdb.client.HttpResponse) 162 | 163 | @utils.run_for('mock') 164 | def test_list_params(self): 165 | all_params = { 166 | 'att_encoding_info': False, 167 | 'attachments': False, 168 | 'conflicts': True, 169 | 'descending': True, 170 | 'endkey': 'foo', 171 | 'endkey_docid': 'foo_id', 172 | 'format': 'json', 173 | 'group': False, 174 | 'group_level': 10, 175 | 'include_docs': True, 176 | 'inclusive_end': False, 177 | 'limit': 10, 178 | 'reduce': True, 179 | 'skip': 20, 180 | 'stale': 'ok', 181 | 'startkey': 'bar', 182 | 'startkey_docid': 'bar_id', 183 | 'update_seq': True 184 | } 185 | 186 | for key, value in all_params.items(): 187 | result = yield from self.ddoc.list('listname', 'viewname', 188 | **{key: value}) 189 | if key in ('endkey', 'startkey'): 190 | value = json.dumps(value) 191 | self.assert_request_called_with( 192 | 'GET', *self.request_path('_list', 'listname', 'viewname'), 193 | params={key: value}) 194 | self.assertIsInstance(result, aiocouchdb.client.HttpResponse) 195 | 196 | def test_list_custom_headers(self): 197 | with (yield from self.ddoc.list('listname', headers={'Foo': '1'})): 198 | self.assert_request_called_with( 199 | 'GET', *self.request_path('_list', 'listname'), 200 | headers={'Foo': '1'}) 201 | 202 | def test_list_custom_params(self): 203 | with (yield from self.ddoc.list('listname', params={'foo': '1'})): 204 | self.assert_request_called_with( 205 | 'GET', *self.request_path('_list', 'listname'), 206 | params={'foo': '1'}) 207 | 208 | def test_list_key(self): 209 | with (yield from self.ddoc.list('listname', 'viewname', 'foo')): 210 | self.assert_request_called_with( 211 | 'GET', *self.request_path('_list', 'listname', 'viewname'), 212 | params={'key': '"foo"'}) 213 | 214 | def test_list_keys(self): 215 | with (yield from self.ddoc.list('listname', 'viewname', 'foo', 'bar')): 216 | self.assert_request_called_with( 217 | 'POST', *self.request_path('_list', 'listname', 'viewname'), 218 | data={'keys': ('foo', 'bar')}) 219 | 220 | def test_show(self): 221 | with (yield from self.ddoc.show('time')) as resp: 222 | self.assert_request_called_with( 223 | 'GET', *self.request_path('_show', 'time')) 224 | self.assertIsInstance(resp, aiocouchdb.client.HttpResponse) 225 | 226 | def test_show_docid(self): 227 | with (yield from self.ddoc.show('time', 'docid')) as resp: 228 | self.assert_request_called_with( 229 | 'GET', *self.request_path('_show', 'time', 'docid')) 230 | self.assertIsInstance(resp, aiocouchdb.client.HttpResponse) 231 | 232 | def test_show_custom_method(self): 233 | with (yield from self.ddoc.show('time', method='HEAD')): 234 | self.assert_request_called_with( 235 | 'HEAD', *self.request_path('_show', 'time')) 236 | 237 | def test_show_custom_headers(self): 238 | with (yield from self.ddoc.show('time', headers={'foo': 'bar'})): 239 | self.assert_request_called_with( 240 | 'GET', *self.request_path('_show', 'time'), 241 | headers={'foo': 'bar'}) 242 | 243 | def test_show_custom_data(self): 244 | with (yield from self.ddoc.show('time', data={'foo': 'bar'})): 245 | self.assert_request_called_with( 246 | 'POST', *self.request_path('_show', 'time'), 247 | data={'foo': 'bar'}) 248 | 249 | def test_show_custom_params(self): 250 | with (yield from self.ddoc.show('time', params={'foo': 'bar'})): 251 | self.assert_request_called_with( 252 | 'GET', *self.request_path('_show', 'time'), 253 | params={'foo': 'bar'}) 254 | 255 | def test_show_format(self): 256 | with (yield from self.ddoc.show('time', format='xml')): 257 | self.assert_request_called_with( 258 | 'GET', *self.request_path('_show', 'time'), 259 | params={'format': 'xml'}) 260 | 261 | def test_update(self): 262 | with (yield from self.ddoc.update('fun')) as resp: 263 | self.assert_request_called_with( 264 | 'POST', *self.request_path('_update', 'fun')) 265 | self.assertIsInstance(resp, aiocouchdb.client.HttpResponse) 266 | 267 | def test_update_docid(self): 268 | with (yield from self.ddoc.update('fun', 'docid')) as resp: 269 | self.assert_request_called_with( 270 | 'PUT', *self.request_path('_update', 'fun', 'docid')) 271 | self.assertIsInstance(resp, aiocouchdb.client.HttpResponse) 272 | 273 | def test_update_custom_method(self): 274 | with (yield from self.ddoc.update('fun', method='HEAD')): 275 | self.assert_request_called_with( 276 | 'HEAD', *self.request_path('_update', 'fun')) 277 | 278 | def test_update_custom_headers(self): 279 | with (yield from self.ddoc.update('fun', headers={'foo': 'bar'})): 280 | self.assert_request_called_with( 281 | 'POST', *self.request_path('_update', 'fun'), 282 | headers={'foo': 'bar'}) 283 | 284 | def test_update_custom_data(self): 285 | with (yield from self.ddoc.update('fun', data={'foo': 'bar'})): 286 | self.assert_request_called_with( 287 | 'POST', *self.request_path('_update', 'fun'), 288 | data={'foo': 'bar'}) 289 | 290 | def test_update_custom_params(self): 291 | with (yield from self.ddoc.update('fun', params={'foo': 'bar'})): 292 | self.assert_request_called_with( 293 | 'POST', *self.request_path('_update', 'fun'), 294 | params={'foo': 'bar'}) 295 | 296 | def test_rewrite(self): 297 | with (yield from self.ddoc.rewrite('rewrite', 'me')) as resp: 298 | self.assert_request_called_with( 299 | 'GET', *self.request_path('_rewrite', 'rewrite', 'me')) 300 | self.assertIsInstance(resp, aiocouchdb.client.HttpResponse) 301 | 302 | def test_rewrite_custom_method(self): 303 | with (yield from self.ddoc.rewrite('path', method='HEAD')): 304 | self.assert_request_called_with( 305 | 'HEAD', *self.request_path('_rewrite', 'path')) 306 | 307 | def test_rewrite_custom_headers(self): 308 | with (yield from self.ddoc.rewrite('path', headers={'foo': '42'})): 309 | self.assert_request_called_with( 310 | 'GET', *self.request_path('_rewrite', 'path'), 311 | headers={'foo': '42'}) 312 | 313 | def test_rewrite_custom_data(self): 314 | with (yield from self.ddoc.rewrite('path', data={'foo': 'bar'})): 315 | self.assert_request_called_with( 316 | 'POST', *self.request_path('_rewrite', 'path'), 317 | data={'foo': 'bar'}) 318 | 319 | def test_rewrite_custom_params(self): 320 | with (yield from self.ddoc.rewrite('path', params={'foo': 'bar'})): 321 | self.assert_request_called_with( 322 | 'GET', *self.request_path('_rewrite', 'path'), 323 | params={'foo': 'bar'}) 324 | -------------------------------------------------------------------------------- /aiocouchdb/v1/tests/test_security.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (C) 2015 Alexander Shorin 4 | # All rights reserved. 5 | # 6 | # This software is licensed as described in the file LICENSE, which 7 | # you should have received as part of this distribution. 8 | # 9 | 10 | 11 | from . import utils 12 | 13 | 14 | class DatabaseSecurityTestCase(utils.DatabaseTestCase): 15 | 16 | def test_security_get(self): 17 | data = { 18 | 'admins': { 19 | 'names': [], 20 | 'roles': [] 21 | }, 22 | 'members': { 23 | 'names': [], 24 | 'roles': [] 25 | } 26 | } 27 | 28 | result = yield from self.db.security.get() 29 | self.assert_request_called_with('GET', self.db.name, '_security') 30 | self.assertEqual(data, result) 31 | 32 | def test_security_update(self): 33 | data = { 34 | 'admins': { 35 | 'names': ['foo'], 36 | 'roles': [] 37 | }, 38 | 'members': { 39 | 'names': [], 40 | 'roles': ['bar', 'baz'] 41 | } 42 | } 43 | 44 | yield from self.db.security.update(admins={'names': ['foo']}, 45 | members={'roles': ['bar', 'baz']}) 46 | self.assert_request_called_with('PUT', self.db.name, '_security', 47 | data=data) 48 | 49 | def test_security_update_merge(self): 50 | yield from self.db.security.update( 51 | admins={"names": ["foo"], "roles": []}, 52 | members={"names": [], "roles": ["bar", "baz"]}) 53 | 54 | with self.response(data=b'''{ 55 | "admins": { 56 | "names": ["foo"], 57 | "roles": [] 58 | }, 59 | "members": { 60 | "names": [], 61 | "roles": ["bar", "baz"] 62 | } 63 | }'''): 64 | yield from self.db.security.update(admins={'roles': ['zoo']}, 65 | members={'names': ['boo']}, 66 | merge=True) 67 | data = { 68 | 'admins': { 69 | 'names': ['foo'], 70 | 'roles': ['zoo'] 71 | }, 72 | 'members': { 73 | 'names': ['boo'], 74 | 'roles': ['bar', 'baz'] 75 | } 76 | } 77 | self.assert_request_called_with('PUT', self.db.name, '_security', 78 | data=data) 79 | 80 | def test_security_update_merge_duplicate(self): 81 | yield from self.db.security.update( 82 | admins={"names": ["foo"], "roles": []}, 83 | members={"names": [], "roles": ["bar", "baz"]}) 84 | 85 | with self.response(data=b'''{ 86 | "admins": { 87 | "names": ["foo"], 88 | "roles": [] 89 | }, 90 | "members": { 91 | "names": [], 92 | "roles": ["bar", "baz"] 93 | } 94 | }'''): 95 | yield from self.db.security.update(admins={'names': ['foo', 'bar']}, 96 | merge=True) 97 | data = { 98 | 'admins': { 99 | 'names': ['foo', 'bar'], 100 | 'roles': [] 101 | }, 102 | 'members': { 103 | 'names': [], 104 | 'roles': ['bar', 'baz'] 105 | } 106 | } 107 | self.assert_request_called_with('PUT', self.db.name, '_security', 108 | data=data) 109 | 110 | def test_security_update_empty_admins(self): 111 | with self.response(data=b'{}'): 112 | yield from self.db.security.update_admins() 113 | data = { 114 | 'admins': { 115 | 'names': [], 116 | 'roles': [] 117 | }, 118 | 'members': { 119 | 'names': [], 120 | 'roles': [] 121 | } 122 | } 123 | self.assert_request_called_with('PUT', self.db.name, '_security', 124 | data=data) 125 | 126 | def test_security_update_some_admins(self): 127 | with self.response(data=b'{}'): 128 | yield from self.db.security.update_admins(names=['foo'], 129 | roles=['bar', 'baz']) 130 | data = { 131 | 'admins': { 132 | 'names': ['foo'], 133 | 'roles': ['bar', 'baz'] 134 | }, 135 | 'members': { 136 | 'names': [], 137 | 'roles': [] 138 | } 139 | } 140 | self.assert_request_called_with('PUT', self.db.name, '_security', 141 | data=data) 142 | 143 | def test_security_update_empty_members(self): 144 | with self.response(data=b'{}'): 145 | yield from self.db.security.update_members() 146 | data = { 147 | 'admins': { 148 | 'names': [], 149 | 'roles': [] 150 | }, 151 | 'members': { 152 | 'names': [], 153 | 'roles': [] 154 | } 155 | } 156 | self.assert_request_called_with('PUT', self.db.name, '_security', 157 | data=data) 158 | 159 | def test_security_update_some_members(self): 160 | with self.response(data=b'{}'): 161 | yield from self.db.security.update_members(names=['foo'], 162 | roles=['bar', 'baz']) 163 | data = { 164 | 'admins': { 165 | 'names': [], 166 | 'roles': [] 167 | }, 168 | 'members': { 169 | 'names': ['foo'], 170 | 'roles': ['bar', 'baz'] 171 | } 172 | } 173 | self.assert_request_called_with('PUT', self.db.name, '_security', 174 | data=data) 175 | -------------------------------------------------------------------------------- /aiocouchdb/v1/tests/test_server.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (C) 2014-2015 Alexander Shorin 4 | # All rights reserved. 5 | # 6 | # This software is licensed as described in the file LICENSE, which 7 | # you should have received as part of this distribution. 8 | # 9 | 10 | import asyncio 11 | 12 | import aiocouchdb.client 13 | import aiocouchdb.feeds 14 | import aiocouchdb.v1.config 15 | import aiocouchdb.v1.server 16 | import aiocouchdb.v1.session 17 | 18 | from . import utils 19 | 20 | 21 | class ServerTestCase(utils.ServerTestCase): 22 | 23 | def test_init_with_url(self): 24 | self.assertIsInstance(self.server.resource, aiocouchdb.client.Resource) 25 | 26 | def test_init_with_resource(self): 27 | res = aiocouchdb.client.Resource(self.url) 28 | server = aiocouchdb.v1.server.Server(res) 29 | self.assertIsInstance(server.resource, aiocouchdb.client.Resource) 30 | self.assertEqual(self.url, self.server.resource.url) 31 | 32 | def test_info(self): 33 | with self.response(data=b'{}'): 34 | result = yield from self.server.info() 35 | self.assert_request_called_with('GET') 36 | self.assertIsInstance(result, dict) 37 | 38 | def test_active_tasks(self): 39 | with self.response(data=b'[]'): 40 | result = yield from self.server.active_tasks() 41 | self.assert_request_called_with('GET', '_active_tasks') 42 | self.assertIsInstance(result, list) 43 | 44 | def test_all_dbs(self): 45 | with self.response(data=b'[]'): 46 | result = yield from self.server.all_dbs() 47 | self.assert_request_called_with('GET', '_all_dbs') 48 | self.assertIsInstance(result, list) 49 | 50 | def test_authdb(self): 51 | db = self.server.authdb 52 | self.assertFalse(self.request.called) 53 | self.assertIsInstance(db, self.server.authdb_class) 54 | 55 | def test_authdb_custom_class(self): 56 | class CustomDatabase(object): 57 | def __init__(self, thing, **kwargs): 58 | self.resource = thing 59 | 60 | server = aiocouchdb.v1.server.Server(authdb_class=CustomDatabase) 61 | db = server.authdb 62 | self.assertFalse(self.request.called) 63 | self.assertIsInstance(db, server.authdb_class) 64 | 65 | def test_authdb_name(self): 66 | self.assertEqual(self.server.authdb.name, '_users') 67 | 68 | server = aiocouchdb.v1.server.Server(authdb_name='_authdb') 69 | self.assertEqual(server.authdb.name, '_authdb') 70 | 71 | def test_config(self): 72 | self.assertIsInstance(self.server.config, 73 | aiocouchdb.v1.config.ServerConfig) 74 | 75 | def test_custom_config(self): 76 | class CustomConfig(object): 77 | def __init__(self, thing): 78 | self.resource = thing 79 | server = aiocouchdb.v1.server.Server(config_class=CustomConfig) 80 | self.assertIsInstance(server.config, CustomConfig) 81 | 82 | def test_database(self): 83 | result = yield from self.server.db('db') 84 | self.assert_request_called_with('HEAD', 'db') 85 | self.assertIsInstance(result, self.server.database_class) 86 | 87 | def test_database_custom_class(self): 88 | class CustomDatabase(object): 89 | def __init__(self, thing, **kwargs): 90 | self.resource = thing 91 | 92 | server = aiocouchdb.v1.server.Server(self.url, 93 | database_class=CustomDatabase) 94 | 95 | result = yield from server.db('db') 96 | self.assert_request_called_with('HEAD', 'db') 97 | self.assertIsInstance(result, CustomDatabase) 98 | self.assertIsInstance(result.resource, aiocouchdb.client.Resource) 99 | 100 | def test_database_get_item(self): 101 | db = self.server['db'] 102 | with self.assertRaises(AssertionError): 103 | self.assert_request_called_with('HEAD', 'db') 104 | self.assertIsInstance(db, self.server.database_class) 105 | 106 | def trigger_db_update(self, db): 107 | @asyncio.coroutine 108 | def task(): 109 | yield from asyncio.sleep(0.1) 110 | yield from db[utils.uuid()].update({}) 111 | asyncio.Task(task()) 112 | 113 | @utils.using_database() 114 | def test_db_updates(self, db): 115 | self.trigger_db_update(db) 116 | 117 | with self.response(data=('{"db_name": "%s"}' % db.name).encode()): 118 | event = yield from self.server.db_updates() 119 | self.assert_request_called_with('GET', '_db_updates') 120 | self.assertIsInstance(event, dict) 121 | self.assertEqual(event['db_name'], db.name, event) 122 | 123 | @utils.using_database() 124 | def test_db_updates_feed_continuous(self, db): 125 | self.trigger_db_update(db) 126 | 127 | with self.response(data=('{"db_name": "%s"}' % db.name).encode()): 128 | feed = yield from self.server.db_updates(feed='continuous', 129 | timeout=1000, 130 | heartbeat=False) 131 | self.assert_request_called_with('GET', '_db_updates', 132 | params={'feed': 'continuous', 133 | 'timeout': 1000, 134 | 'heartbeat': False}) 135 | 136 | self.assertIsInstance(feed, aiocouchdb.feeds.JsonFeed) 137 | while True: 138 | event = yield from feed.next() 139 | if event is None: 140 | break 141 | self.assertEqual(event['db_name'], db.name, event) 142 | 143 | @utils.using_database() 144 | def test_db_updates_feed_eventsource(self, db): 145 | self.trigger_db_update(db) 146 | 147 | with self.response(data=('data: {"db_name": "%s"}' % db.name).encode()): 148 | feed = yield from self.server.db_updates(feed='eventsource', 149 | timeout=1000, 150 | heartbeat=False) 151 | self.assert_request_called_with('GET', '_db_updates', 152 | params={'feed': 'eventsource', 153 | 'timeout': 1000, 154 | 'heartbeat': False}) 155 | 156 | self.assertIsInstance(feed, aiocouchdb.feeds.EventSourceFeed) 157 | while True: 158 | event = yield from feed.next() 159 | if event is None: 160 | break 161 | self.assertEqual(event['data']['db_name'], db.name, event) 162 | 163 | def test_log(self): 164 | result = yield from self.server.log() 165 | self.assert_request_called_with('GET', '_log') 166 | self.assertIsInstance(result, str) 167 | 168 | @utils.using_database('source') 169 | @utils.using_database('target') 170 | def test_replicate(self, source, target): 171 | with self.response(data=b'[]'): 172 | yield from utils.populate_database(source, 10) 173 | 174 | with self.response(data=b'{"history": [{"docs_written": 10}]}'): 175 | info = yield from self.server.replicate(source.name, target.name) 176 | self.assert_request_called_with( 177 | 'POST', '_replicate', data={'source': source.name, 178 | 'target': target.name}) 179 | self.assertEqual(info['history'][0]['docs_written'], 10) 180 | 181 | @utils.run_for('mock') 182 | def test_replicate_kwargs(self): 183 | all_kwargs = { 184 | 'cancel': True, 185 | 'continuous': True, 186 | 'create_target': False, 187 | 'doc_ids': ['foo', 'bar', 'baz'], 188 | 'filter': '_design/filter', 189 | 'proxy': 'http://localhost:8080', 190 | 'query_params': {'test': 'passed'}, 191 | 'since_seq': 0, 192 | 'checkpoint_interval': 5000, 193 | 'connection_timeout': 60000, 194 | 'http_connections': 10, 195 | 'retries_per_request': 10, 196 | 'socket_options': '[]', 197 | 'use_checkpoints': True, 198 | 'worker_batch_size': 200, 199 | 'worker_processes': 4 200 | } 201 | 202 | for key, value in all_kwargs.items(): 203 | yield from self.server.replicate('source', 'target', 204 | **{key: value}) 205 | data = {'source': 'source', 'target': 'target', key: value} 206 | self.assert_request_called_with('POST', '_replicate', data=data) 207 | 208 | @utils.run_for('mock') 209 | def test_restart(self): 210 | yield from self.server.restart() 211 | self.assert_request_called_with('POST', '_restart') 212 | 213 | def test_session(self): 214 | self.assertIsInstance(self.server.session, 215 | aiocouchdb.v1.session.Session) 216 | 217 | def test_custom_session(self): 218 | class CustomSession(object): 219 | def __init__(self, thing): 220 | self.resource = thing 221 | server = aiocouchdb.v1.server.Server(session_class=CustomSession) 222 | self.assertIsInstance(server.session, CustomSession) 223 | 224 | def test_stats(self): 225 | yield from self.server.stats() 226 | self.assert_request_called_with('GET', '_stats') 227 | 228 | def test_stats_flush(self): 229 | yield from self.server.stats(flush=True) 230 | self.assert_request_called_with('GET', '_stats', params={'flush': True}) 231 | 232 | def test_stats_range(self): 233 | yield from self.server.stats(range=60) 234 | self.assert_request_called_with('GET', '_stats', params={'range': 60}) 235 | 236 | def test_stats_single_metric(self): 237 | yield from self.server.stats('httpd/requests') 238 | self.assert_request_called_with('GET', '_stats', 'httpd', 'requests') 239 | 240 | def test_stats_invalid_metric(self): 241 | with self.assertRaises(ValueError): 242 | yield from self.server.stats('httpd') 243 | 244 | def test_uuids(self): 245 | with self.response(data=b'{"uuids": ["..."]}'): 246 | result = yield from self.server.uuids() 247 | self.assert_request_called_with('GET', '_uuids') 248 | self.assertIsInstance(result, list) 249 | self.assertEqual(len(result), 1) 250 | 251 | def test_uuids_count(self): 252 | with self.response(data=b'{"uuids": ["...", "..."]}'): 253 | result = yield from self.server.uuids(count=2) 254 | self.assert_request_called_with('GET', '_uuids', 255 | params={'count': 2}) 256 | self.assertIsInstance(result, list) 257 | self.assertEqual(len(result), 2) 258 | -------------------------------------------------------------------------------- /aiocouchdb/v1/tests/test_session.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (C) 2015 Alexander Shorin 4 | # All rights reserved. 5 | # 6 | # This software is licensed as described in the file LICENSE, which 7 | # you should have received as part of this distribution. 8 | # 9 | 10 | import aiocouchdb.authn 11 | 12 | from . import utils 13 | 14 | 15 | class SessionTestCase(utils.ServerTestCase): 16 | 17 | @utils.with_fixed_admin_party('root', 'relax') 18 | def test_open_session(self, root): 19 | with self.response(data=b'{"ok": true}', 20 | cookies={'AuthSession': 's3cr1t'}): 21 | auth = yield from self.server.session.open('root', 'relax') 22 | self.assert_request_called_with('POST', '_session', 23 | data={'name': 'root', 24 | 'password': 'relax'}) 25 | self.assertIsInstance(auth, aiocouchdb.authn.CookieAuthProvider) 26 | self.assertIn('AuthSession', auth._cookies) 27 | 28 | def test_session_info(self): 29 | with self.response(data=b'{}'): 30 | result = yield from self.server.session.info() 31 | self.assert_request_called_with('GET', '_session') 32 | self.assertIsInstance(result, dict) 33 | 34 | def test_close_session(self): 35 | with self.response(data=b'{"ok": true}'): 36 | result = yield from self.server.session.close() 37 | self.assert_request_called_with('DELETE', '_session') 38 | self.assertIsInstance(result, dict) 39 | 40 | -------------------------------------------------------------------------------- /aiocouchdb/v1/tests/utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (C) 2015 Alexander Shorin 4 | # All rights reserved. 5 | # 6 | # This software is licensed as described in the file LICENSE, which 7 | # you should have received as part of this distribution. 8 | # 9 | 10 | from aiocouchdb.tests import utils 11 | from aiocouchdb.tests.utils import ( 12 | modify_server, 13 | populate_database, 14 | run_for, 15 | skip_for, 16 | using_database, 17 | uuid, 18 | with_fixed_admin_party, 19 | TestCase 20 | ) 21 | 22 | from .. import attachment 23 | from .. import database 24 | from .. import designdoc 25 | from .. import document 26 | from .. import server 27 | 28 | 29 | class ServerTestCase(utils.ServerTestCase): 30 | server_class = server.Server 31 | 32 | 33 | class DatabaseTestCase(ServerTestCase, utils.DatabaseTestCase): 34 | database_class = database.Database 35 | 36 | 37 | class DocumentTestCase(DatabaseTestCase, utils.DocumentTestCase): 38 | document_class = document.Document 39 | 40 | 41 | class DesignDocumentTestCase(DatabaseTestCase, utils.DesignDocumentTestCase): 42 | designdoc_class = designdoc.DesignDocument 43 | 44 | 45 | class AttachmentTestCase(DocumentTestCase, utils.AttachmentTestCase): 46 | attachment_class = attachment.Attachment 47 | -------------------------------------------------------------------------------- /aiocouchdb/version.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (C) 2014-2016 Alexander Shorin 4 | # All rights reserved. 5 | # 6 | # This software is licensed as described in the file LICENSE, which 7 | # you should have received as part of this distribution. 8 | # 9 | 10 | __version_info__ = (0, 10, 0, 'dev', 0) 11 | __version__ = '%(version)s%(tag)s%(build)s' % { 12 | 'version': '.'.join(map(str, __version_info__[:3])), 13 | 'tag': '-' + __version_info__[3] if __version_info__[3] else '', 14 | 'build': '.' + str(__version_info__[4]) if __version_info__[4] else '' 15 | } 16 | 17 | 18 | if __name__ == '__main__': 19 | import sys 20 | sys.stdout.write(__version__) 21 | sys.stdout.flush() 22 | -------------------------------------------------------------------------------- /aiocouchdb/views.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (C) 2014-2016 Alexander Shorin 4 | # All rights reserved. 5 | # 6 | # This software is licensed as described in the file LICENSE, which 7 | # you should have received as part of this distribution. 8 | # 9 | 10 | import asyncio 11 | import json 12 | from .feeds import ViewFeed 13 | 14 | 15 | __all__ = ( 16 | 'View', 17 | ) 18 | 19 | 20 | class View(object): 21 | """Views requesting helper.""" 22 | 23 | def __init__(self, resource): 24 | self.resource = resource 25 | 26 | @asyncio.coroutine 27 | def request(self, *, 28 | auth=None, 29 | feed_buffer_size=None, 30 | data=None, 31 | params=None): 32 | """Requests a view associated with the owned resource. 33 | 34 | :param auth: :class:`aiocouchdb.authn.AuthProvider` instance 35 | :param int feed_buffer_size: Internal buffer size for fetched feed items 36 | :param dict data: View request payload 37 | :param dict params: View request query parameters 38 | 39 | :rtype: :class:`aiocouchdb.feeds.ViewFeed` 40 | """ 41 | if params is not None: 42 | params, data = self.handle_keys_param(params, data) 43 | params = self.prepare_params(params) 44 | 45 | if data: 46 | request = self.resource.post 47 | else: 48 | request = self.resource.get 49 | 50 | resp = yield from request(auth=auth, data=data, params=params) 51 | yield from resp.maybe_raise_error() 52 | return ViewFeed(resp, buffer_size=feed_buffer_size) 53 | 54 | @staticmethod 55 | def prepare_params(params): 56 | json_params = {'key', 'keys', 'startkey', 'endkey'} 57 | result = {} 58 | for key, value in params.items(): 59 | if key in json_params: 60 | if value is Ellipsis: 61 | continue 62 | value = json.dumps(value) 63 | elif value is None: 64 | continue 65 | result[key] = value 66 | return result 67 | 68 | @staticmethod 69 | def handle_keys_param(params, data): 70 | keys = params.pop('keys', ()) 71 | if keys is None or keys is Ellipsis: 72 | return params, data 73 | assert not isinstance(keys, (bytes, str)) 74 | 75 | if len(keys) >= 2: 76 | if data is None: 77 | data = {'keys': keys} 78 | elif isinstance(data, dict): 79 | data['keys'] = keys 80 | else: 81 | params['keys'] = keys 82 | elif keys: 83 | assert params.get('key') is None 84 | params['key'] = keys[0] 85 | 86 | return params, data 87 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = -W 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 36 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 38 | @echo " text to make text files" 39 | @echo " man to make manual pages" 40 | @echo " texinfo to make Texinfo files" 41 | @echo " info to make Texinfo files and run them through makeinfo" 42 | @echo " gettext to make PO message catalogs" 43 | @echo " changes to make an overview of all changed/added/deprecated items" 44 | @echo " xml to make Docutils-native XML files" 45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 46 | @echo " linkcheck to check all external links for integrity" 47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 48 | 49 | clean: 50 | rm -rf $(BUILDDIR)/* 51 | 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | dirhtml: 58 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 59 | @echo 60 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 61 | 62 | singlehtml: 63 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 64 | @echo 65 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 66 | 67 | pickle: 68 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 69 | @echo 70 | @echo "Build finished; now you can process the pickle files." 71 | 72 | json: 73 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 74 | @echo 75 | @echo "Build finished; now you can process the JSON files." 76 | 77 | htmlhelp: 78 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 79 | @echo 80 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 81 | ".hhp project file in $(BUILDDIR)/htmlhelp." 82 | 83 | qthelp: 84 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 85 | @echo 86 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 87 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 88 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/aiocouchdb.qhcp" 89 | @echo "To view the help file:" 90 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/aiocouchdb.qhc" 91 | 92 | devhelp: 93 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 94 | @echo 95 | @echo "Build finished." 96 | @echo "To view the help file:" 97 | @echo "# mkdir -p $$HOME/.local/share/devhelp/aiocouchdb" 98 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/aiocouchdb" 99 | @echo "# devhelp" 100 | 101 | epub: 102 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 103 | @echo 104 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 105 | 106 | latex: 107 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 108 | @echo 109 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 110 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 111 | "(use \`make latexpdf' here to do that automatically)." 112 | 113 | latexpdf: 114 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 115 | @echo "Running LaTeX files through pdflatex..." 116 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 117 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 118 | 119 | latexpdfja: 120 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 121 | @echo "Running LaTeX files through platex and dvipdfmx..." 122 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 123 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 124 | 125 | text: 126 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 127 | @echo 128 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 129 | 130 | man: 131 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 132 | @echo 133 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 134 | 135 | texinfo: 136 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 137 | @echo 138 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 139 | @echo "Run \`make' in that directory to run these through makeinfo" \ 140 | "(use \`make info' here to do that automatically)." 141 | 142 | info: 143 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 144 | @echo "Running Texinfo files through makeinfo..." 145 | make -C $(BUILDDIR)/texinfo info 146 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 147 | 148 | gettext: 149 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 150 | @echo 151 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 152 | 153 | changes: 154 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 155 | @echo 156 | @echo "The overview file is in $(BUILDDIR)/changes." 157 | 158 | linkcheck: 159 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 160 | @echo 161 | @echo "Link check complete; look for any errors in the above output " \ 162 | "or in $(BUILDDIR)/linkcheck/output.txt." 163 | 164 | doctest: 165 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 166 | @echo "Testing of doctests in the sources finished, look at the " \ 167 | "results in $(BUILDDIR)/doctest/output.txt." 168 | 169 | xml: 170 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 171 | @echo 172 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 173 | 174 | pseudoxml: 175 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 176 | @echo 177 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 178 | -------------------------------------------------------------------------------- /docs/common.rst: -------------------------------------------------------------------------------- 1 | =================== 2 | Common Base Objects 3 | =================== 4 | 5 | Client 6 | ====== 7 | 8 | .. automodule:: aiocouchdb.client 9 | :members: 10 | 11 | Authentication Providers 12 | ======================== 13 | 14 | .. automodule:: aiocouchdb.authn 15 | :members: 16 | 17 | Feeds 18 | ===== 19 | 20 | .. automodule:: aiocouchdb.feeds 21 | :members: 22 | 23 | Views 24 | ===== 25 | 26 | .. automodule:: aiocouchdb.views 27 | :members: 28 | 29 | Errors 30 | ====== 31 | 32 | .. automodule:: aiocouchdb.errors 33 | :members: 34 | 35 | Headers 36 | ======= 37 | 38 | .. automodule:: aiocouchdb.hdrs 39 | :members: 40 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # aiocouchdb documentation build configuration file, created by 5 | # sphinx-quickstart on Fri Jun 27 20:44:37 2014. 6 | # 7 | # This file is execfile()d with the current directory set to its 8 | # containing dir. 9 | # 10 | # Note that not all possible configuration values are present in this 11 | # autogenerated file. 12 | # 13 | # All configuration values have a default; values that are commented out 14 | # serve to show the default. 15 | 16 | import sys 17 | import os 18 | 19 | sys.path.append(os.path.join(os.path.dirname(__file__), '..')) 20 | 21 | import aiocouchdb 22 | 23 | 24 | # If extensions (or modules to document with autodoc) are in another directory, 25 | # add these directories to sys.path here. If the directory is relative to the 26 | # documentation root, use os.path.abspath to make it absolute, like shown here. 27 | #sys.path.insert(0, os.path.abspath('.')) 28 | 29 | # -- General configuration ------------------------------------------------ 30 | 31 | # If your documentation needs a minimal Sphinx version, state it here. 32 | #needs_sphinx = '1.0' 33 | 34 | # Add any Sphinx extension module names here, as strings. They can be 35 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 36 | # ones. 37 | extensions = [ 38 | 'sphinx.ext.autodoc', 39 | 'sphinx.ext.doctest', 40 | 'sphinx.ext.intersphinx', 41 | 'sphinx.ext.coverage', 42 | 'sphinx.ext.viewcode', 43 | ] 44 | 45 | # Add any paths that contain templates here, relative to this directory. 46 | templates_path = ['_templates'] 47 | 48 | # The suffix of source filenames. 49 | source_suffix = '.rst' 50 | 51 | # The encoding of source files. 52 | #source_encoding = 'utf-8-sig' 53 | 54 | # The master toctree document. 55 | master_doc = 'index' 56 | 57 | # General information about the project. 58 | project = 'aiocouchdb' 59 | copyright = '2014, Alexander Shorin' 60 | 61 | # The version info for the project you're documenting, acts as replacement for 62 | # |version| and |release|, also used in various other places throughout the 63 | # built documents. 64 | # 65 | # The short X.Y version. 66 | version = aiocouchdb.__version__.split('-')[0] 67 | # The full version, including alpha/beta/rc tags. 68 | release = aiocouchdb.__version__ 69 | 70 | # The language for content autogenerated by Sphinx. Refer to documentation 71 | # for a list of supported languages. 72 | #language = None 73 | 74 | # There are two options for replacing |today|: either, you set today to some 75 | # non-false value, then it is used: 76 | #today = '' 77 | # Else, today_fmt is used as the format for a strftime call. 78 | #today_fmt = '%B %d, %Y' 79 | 80 | # List of patterns, relative to source directory, that match files and 81 | # directories to ignore when looking for source files. 82 | exclude_patterns = ['_build'] 83 | 84 | # The reST default role (used for this markup: `text`) to use for all 85 | # documents. 86 | #default_role = None 87 | 88 | # If true, '()' will be appended to :func: etc. cross-reference text. 89 | #add_function_parentheses = True 90 | 91 | # If true, the current module name will be prepended to all description 92 | # unit titles (such as .. function::). 93 | #add_module_names = True 94 | 95 | # If true, sectionauthor and moduleauthor directives will be shown in the 96 | # output. They are ignored by default. 97 | #show_authors = False 98 | 99 | # The name of the Pygments (syntax highlighting) style to use. 100 | pygments_style = 'sphinx' 101 | 102 | # A list of ignored prefixes for module index sorting. 103 | #modindex_common_prefix = [] 104 | 105 | # If true, keep warnings as "system message" paragraphs in the built documents. 106 | #keep_warnings = False 107 | 108 | 109 | # -- Options for HTML output ---------------------------------------------- 110 | 111 | # The theme to use for HTML and HTML Help pages. See the documentation for 112 | # a list of builtin themes. 113 | html_theme = 'haiku' 114 | 115 | # Theme options are theme-specific and customize the look and feel of a theme 116 | # further. For a list of options available for each theme, see the 117 | # documentation. 118 | #html_theme_options = {} 119 | 120 | # Add any paths that contain custom themes here, relative to this directory. 121 | #html_theme_path = [] 122 | 123 | # The name for this set of Sphinx documents. If None, it defaults to 124 | # " v documentation". 125 | #html_title = None 126 | 127 | # A shorter title for the navigation bar. Default is the same as html_title. 128 | #html_short_title = None 129 | 130 | # The name of an image file (relative to this directory) to place at the top 131 | # of the sidebar. 132 | #html_logo = None 133 | 134 | # The name of an image file (within the static path) to use as favicon of the 135 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 136 | # pixels large. 137 | #html_favicon = None 138 | 139 | # Add any paths that contain custom static files (such as style sheets) here, 140 | # relative to this directory. They are copied after the builtin static files, 141 | # so a file named "default.css" will overwrite the builtin "default.css". 142 | html_static_path = ['_static'] 143 | 144 | # Add any extra paths that contain custom files (such as robots.txt or 145 | # .htaccess) here, relative to this directory. These files are copied 146 | # directly to the root of the documentation. 147 | #html_extra_path = [] 148 | 149 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 150 | # using the given strftime format. 151 | #html_last_updated_fmt = '%b %d, %Y' 152 | 153 | # If true, SmartyPants will be used to convert quotes and dashes to 154 | # typographically correct entities. 155 | #html_use_smartypants = True 156 | 157 | # Custom sidebar templates, maps document names to template names. 158 | #html_sidebars = {} 159 | 160 | # Additional templates that should be rendered to pages, maps page names to 161 | # template names. 162 | #html_additional_pages = {} 163 | 164 | # If false, no module index is generated. 165 | #html_domain_indices = True 166 | 167 | # If false, no index is generated. 168 | #html_use_index = True 169 | 170 | # If true, the index is split into individual pages for each letter. 171 | #html_split_index = False 172 | 173 | # If true, links to the reST sources are added to the pages. 174 | #html_show_sourcelink = True 175 | 176 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 177 | #html_show_sphinx = True 178 | 179 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 180 | #html_show_copyright = True 181 | 182 | # If true, an OpenSearch description file will be output, and all pages will 183 | # contain a tag referring to it. The value of this option must be the 184 | # base URL from which the finished HTML is served. 185 | #html_use_opensearch = '' 186 | 187 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 188 | #html_file_suffix = None 189 | 190 | # Output file base name for HTML help builder. 191 | htmlhelp_basename = 'aiocouchdbdoc' 192 | 193 | 194 | # -- Options for LaTeX output --------------------------------------------- 195 | 196 | latex_elements = { 197 | # The paper size ('letterpaper' or 'a4paper'). 198 | #'papersize': 'letterpaper', 199 | 200 | # The font size ('10pt', '11pt' or '12pt'). 201 | #'pointsize': '10pt', 202 | 203 | # Additional stuff for the LaTeX preamble. 204 | #'preamble': '', 205 | } 206 | 207 | # Grouping the document tree into LaTeX files. List of tuples 208 | # (source start file, target name, title, 209 | # author, documentclass [howto, manual, or own class]). 210 | latex_documents = [ 211 | ('index', 'aiocouchdb.tex', 'aiocouchdb Documentation', 212 | 'Alexander Shorin', 'manual'), 213 | ] 214 | 215 | # The name of an image file (relative to this directory) to place at the top of 216 | # the title page. 217 | #latex_logo = None 218 | 219 | # For "manual" documents, if this is true, then toplevel headings are parts, 220 | # not chapters. 221 | #latex_use_parts = False 222 | 223 | # If true, show page references after internal links. 224 | #latex_show_pagerefs = False 225 | 226 | # If true, show URL addresses after external links. 227 | #latex_show_urls = False 228 | 229 | # Documents to append as an appendix to all manuals. 230 | #latex_appendices = [] 231 | 232 | # If false, no module index is generated. 233 | #latex_domain_indices = True 234 | 235 | 236 | # -- Options for manual page output --------------------------------------- 237 | 238 | # One entry per manual page. List of tuples 239 | # (source start file, name, description, authors, manual section). 240 | man_pages = [ 241 | ('index', 'aiocouchdb', 'aiocouchdb Documentation', 242 | ['Alexander Shorin'], 1) 243 | ] 244 | 245 | # If true, show URL addresses after external links. 246 | #man_show_urls = False 247 | 248 | 249 | # -- Options for Texinfo output ------------------------------------------- 250 | 251 | # Grouping the document tree into Texinfo files. List of tuples 252 | # (source start file, target name, title, author, 253 | # dir menu entry, description, category) 254 | texinfo_documents = [ 255 | ('index', 'aiocouchdb', 'aiocouchdb Documentation', 256 | 'Alexander Shorin', 'aiocouchdb', 'One line description of project.', 257 | 'Miscellaneous'), 258 | ] 259 | 260 | # Documents to append as an appendix to all manuals. 261 | #texinfo_appendices = [] 262 | 263 | # If false, no module index is generated. 264 | #texinfo_domain_indices = True 265 | 266 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 267 | #texinfo_show_urls = 'footnote' 268 | 269 | # If true, do not generate a @detailmenu in the "Top" node's menu. 270 | #texinfo_no_detailmenu = False 271 | 272 | 273 | # Example configuration for intersphinx: refer to the Python standard library. 274 | intersphinx_mapping = { 275 | 'http://docs.python.org/3': None, 276 | 'http://docs.couchdb.org/en/latest': None, 277 | 'http://aiohttp.readthedocs.org/en/latest': None 278 | } 279 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Welcome to aiocouchdb's documentation! 2 | ************************************** 3 | 4 | :source: https://github.com/aio-libs/aiocouchdb 5 | :documentation: http://aiocouchdb.readthedocs.org/en/latest/ 6 | :license: BSD 7 | 8 | .. toctree:: 9 | v1/index 10 | common 11 | 12 | asyncio 101 13 | =========== 14 | 15 | Using this library assumes knowledge of the ``asyncio`` module of the Python 3 standard library. 16 | 17 | In particular, this means rolling and managing your event loop. To avoid 18 | repetition and because implementations will vary depending on your needs, the 19 | examples below do not include such wrapper code. If needed, read 20 | `asyncio's documentation `_ and 21 | read a few articles on the subject. Then, here is an example of the simplest 22 | ``asyncio`` context you may build, applied to simply using 23 | :meth:`Server.info() `. 24 | 25 | .. code:: python 26 | 27 | import asyncio 28 | import aiocouchdb 29 | 30 | server = aiocouchdb.Server() 31 | 32 | @asyncio.coroutine 33 | def get_info(): 34 | inf = yield from server.info() 35 | print(inf) 36 | 37 | asyncio.get_event_loop().run_until_complete(get_info()) 38 | 39 | 40 | Getting started 41 | =============== 42 | 43 | .. contents:: 44 | 45 | If you'd some background experience with `couchdb-python`_ client, you'll find 46 | `aiocouchdb` API a bit familiar. That project is my lovely too, but suddenly 47 | it's completely synchronous. 48 | 49 | At first, you need to create instance of :class:`~aiocouchdb.v1.server.Server` 50 | object which interacts with `CouchDB Server API`_: 51 | 52 | .. code:: python 53 | 54 | >>> import aiocouchdb 55 | >>> server = aiocouchdb.Server() 56 | >>> server 57 | 58 | 59 | As like as `couchdb-python`_ Server instance has ``resource`` attribute that 60 | acts very familiar: 61 | 62 | .. code:: python 63 | 64 | >>> server.resource 65 | 66 | >>> server.resource('db', 'doc1') 67 | 68 | 69 | With the only exception that it's a coroutine: 70 | 71 | .. note:: 72 | Python doesn't supports ``yield from`` in shell, so examples below are a bit 73 | out of a real, but be sure - that's how they works in real. 74 | 75 | .. code:: python 76 | 77 | >>> resp = yield from server.resource.get() 78 | 79 | 80 | >>> yield from resp.json() 81 | {'couchdb': 'Welcome!', 82 | 'vendor': {'version': '1.6.1', 'name': 'The Apache Software Foundation'}, 83 | 'uuid': '0510c29b75ae33fd3975eb505db2dd12', 84 | 'version': '1.6.1'} 85 | 86 | The :class:`~aiocouchdb.client.Resource` object provides a tiny wrapper over 87 | :func:`aiohttp.client.request()` function so you can use it in case of raw API 88 | access. 89 | 90 | But, libraries are made to hide all the implementation details and make work 91 | with API nice and easy one and `aiocouchdb` isn't an exception. 92 | The example above is actually what the :meth:`Server.info() 93 | ` method does: 94 | 95 | .. code:: python 96 | 97 | >>> yield from server.info() 98 | {'couchdb': 'Welcome!', 99 | 'vendor': {'version': '1.6.1', 'name': 'The Apache Software Foundation'}, 100 | 'uuid': '0510c29b75ae33fd3975eb505db2dd12', 101 | 'version': '1.6.1'} 102 | 103 | Most of :class:`~aiocouchdb.v1.server.Server` and not only methods are named 104 | similar to the real HTTP API endpoints: 105 | 106 | .. code:: python 107 | 108 | >>> yield from server.all_dbs() 109 | ['_replicator', '_users', 'db'] 110 | >>> yield from server.active_tasks() 111 | [{'database': 'db', 112 | 'pid': '<0.10209.20>', 113 | 'changes_done': 0, 114 | 'progress': 0, 115 | 'started_on': 1425805499, 116 | 'total_changes': 1430818, 117 | 'type': 'database_compaction', 118 | 'updated_on': 1425805499}] 119 | 120 | With a few exceptions like 121 | :attr:`Server.session ` or 122 | :attr:`Server.config ` which has complex 123 | use-case behind and are operated by other objects. 124 | 125 | Speaking about :attr:`aiocouchdb.v1.server.Server.session`, `aiocouchdb` 126 | supports `multiuser workflow` where you pass session object as an argument 127 | on resource request. 128 | 129 | .. code:: python 130 | 131 | >>> admin = yield from server.session.open('admin', 's3cr1t') 132 | >>> user = yield from server.session.open('user', 'pass') 133 | 134 | Here we just opened two session for different users. Their usage is pretty 135 | trivial - just pass them as ``auth`` keyword parameter to every API function 136 | call: 137 | 138 | .. code:: python 139 | 140 | >>> yield from server.active_tasks(auth=admin) 141 | [{'database': 'db', 142 | 'pid': '<0.10209.20>', 143 | 'changes_done': 50413, 144 | 'progress': 3, 145 | 'started_on': 1425805499, 146 | 'total_changes': 1430818, 147 | 'type': 'database_compaction', 148 | 'updated_on': 1425806018}] 149 | >>> yield from server.active_tasks(auth=user) 150 | Traceback: 151 | ... 152 | Unauthorized: [forbidden] You are not a server admin. 153 | 154 | Another important moment that `aiocouchdb` raises exception on HTTP errors. 155 | By using :class:`~aiocouchdb.client.Resource` object you'll receive raw response and may build custom 156 | logic on processing such errors: to raise an exception or to not. 157 | 158 | With using :meth:`Server.session.open() ` 159 | you implicitly creates :class:`~aiocouchdb.authn.CookieAuthProvider` which hold 160 | received from CouchDB cookie with authentication token. 161 | `aiocouchdb` also provides the way to authorize via `Basic Auth`, `OAuth` 162 | (`oauthlib`_ required) and others. Their usage is also pretty trivial: 163 | 164 | .. code:: python 165 | 166 | >>> admin = aiocouchdb.BasicAuthProvider('admin', 's3cr1t') 167 | >>> yield from server.active_tasks(auth=admin) 168 | [{'database': 'db', 169 | 'pid': '<0.10209.20>', 170 | 'changes_done': 50413, 171 | 'progress': 3, 172 | 'started_on': 1425805499, 173 | 'total_changes': 1430818, 174 | 'type': 'database_compaction', 175 | 'updated_on': 1425806018}] 176 | 177 | Working with databases 178 | ====================== 179 | 180 | To create a database object which will interact with `CouchDB Database API`_ 181 | you have three ways to go: 182 | 183 | 1. Using direct object instance creation: 184 | 185 | .. code:: python 186 | 187 | >>> aiocouchdb.Database('http://localhost:5984/db') 188 | 189 | 190 | 2. Using ``__getitem__`` protocol similar to `couchdb-python`_: 191 | 192 | .. code:: python 193 | 194 | >>> server['db'] 195 | 196 | 197 | 3. Using :meth:`Server.db() ` method: 198 | 199 | .. code:: python 200 | 201 | >>> yield from server.db('db') 202 | 203 | 204 | What's their difference? First method is useful when you don't have access to a 205 | :class:`~aiocouchdb.v1.server.Server` instance, but knows database URL. 206 | Second one returns instantly a :class:`~aiocouchdb.v1.database.Database` 207 | instance for the name you specified. 208 | 209 | But the third one is smarter: it verifies that database by name you'd specified 210 | is accessible for you and if it's not - raises an exception: 211 | 212 | .. code:: python 213 | 214 | >>> yield from server.db('_users') 215 | Traceback: 216 | ... 217 | Unauthorized: [forbidden] You are not a server admin. 218 | >>> yield from server.db('_foo') 219 | Traceback: 220 | ... 221 | BadRequest: [illegal_database_name] Name: '_foo'. Only lowercase characters (a-z), digits (0-9), and any of the characters _, $, (, ), +, -, and / are allowed. Must begin with a letter. 222 | 223 | This costs you an additional HTTP request, but gives the insurance that the 224 | following methods calls will not fail by unrelated reasons. 225 | 226 | This method doesn't raises an exception if database doesn't exists to allow 227 | you create it: 228 | 229 | .. code:: python 230 | 231 | >>> db = yield from server.db('newdb') 232 | >>> yield from db.exists() 233 | False 234 | >>> yield from db.create() 235 | True 236 | >>> yield from db.exists() 237 | True 238 | 239 | Iterating over documents 240 | ------------------------ 241 | 242 | In `couchdb-python`_ you might done it with in the following way: 243 | 244 | .. code:: python 245 | 246 | >>> for docid in db: 247 | ... do_something(db[docid]) 248 | 249 | Or: 250 | 251 | .. code:: python 252 | 253 | >>> for row in db.view('_all_docs'): 254 | ... do_something(db[row['id']]) 255 | 256 | `aiocouchdb` does that quite differently: 257 | 258 | .. code:: python 259 | 260 | >>> res = yield from db.all_docs() 261 | >>> while True: 262 | ... rec = yield from res.next() 263 | ... if rec is None: 264 | ... break 265 | ... do_something(rec['id']) 266 | 267 | What's going on here? 268 | 269 | #. You requesting `/db/_all_docs` endpoint explicitly and may pass all his query 270 | parameters as you need; 271 | 272 | #. On :meth:`Database.all_docs() ` 273 | call returns not a list of view results, but a special instance of 274 | :class:`~aiocouchdb.feeds.ViewFeed` object which fetches results one by one 275 | in background into internal buffer without loading whole result into memory 276 | in single shot. You can control this buffer size with `feed_buffer_size` 277 | keyword argument; 278 | 279 | #. When all the records are processed it emits None which signs on empty feed 280 | and the loop breaking out; 281 | 282 | `aiocouchdb` tries never load large streams, but process them in iterative way. 283 | This may looks ugly for small data sets, but when you deal with the large ones 284 | it'll save you a lot of resources. 285 | 286 | The same loop pattern in used to process :meth:`Database.changes() 287 | ` as well. 288 | 289 | Working with documents 290 | ====================== 291 | 292 | To work with a document you need get :class:`~aiocouchdb.v1.document.Document` 293 | instance first - :class:`~aiocouchdb.v1.database.Database` doesn't knows 294 | anything about `CouchDB Document API`_. The way to do this is the same as for 295 | database: 296 | 297 | .. code:: python 298 | 299 | >>> aiocouchdb.Document('http://localhost:5984/db/doc1') 300 | 301 | >>> server['db']['doc1'] 302 | 303 | >>> doc = yield from db.doc('doc1') 304 | 305 | 306 | Their difference is the same as for :class:`~aiocouchdb.v1.database.Database` 307 | mentioned above. 308 | 309 | .. code:: python 310 | 311 | >>> yield from doc.exists() 312 | False 313 | >>> meta = yield from doc.update({'hello': 'CouchDB'}) 314 | >>> meta 315 | {'ok': True, 'rev': '1-7c6fb984afda7e07d030cce000dc5965', 'id': 'doc1'} 316 | >>> yield from doc.exists() 317 | True 318 | >>> meta = yield from doc.update({'hello': 'CouchDB'}, rev=meta['rev']) 319 | >>> meta 320 | {'ok': True, 'rev': '2-c5298951d02b03f3d6273ad5854ea729', 'id': 'doc1'} 321 | >>> yield from doc.get() 322 | {'hello': 'CouchDB', 323 | '_id': 'doc1', 324 | '_rev': '2-c5298951d02b03f3d6273ad5854ea729'} 325 | >>> yield from doc.delete('2-c5298951d02b03f3d6273ad5854ea729') 326 | {'ok': True, 'rev': '3-cfa05c76fb4a0557605d6a8b1a765055', 'id': 'doc1'} 327 | >>> yield from doc.exists() 328 | False 329 | 330 | Pretty simple, right? 331 | 332 | What's next? 333 | ============ 334 | 335 | There are a lot of things are left untold. Checkout :ref:`CouchDB 1x ` API 336 | for more. Happy hacking! 337 | 338 | Changes 339 | ======= 340 | 341 | .. include:: ../CHANGES.rst 342 | 343 | License 344 | ======= 345 | 346 | .. literalinclude:: ../LICENSE 347 | 348 | 349 | .. _aiohttp: https://github.com/KeepSafe/aiohttp 350 | .. _asyncio: https://docs.python.org/3/library/asyncio.html 351 | .. _couchdb-python: https://github.com/djc/couchdb-python 352 | .. _oauthlib: https://github.com/idan/oauthlib 353 | 354 | .. _CouchDB Database API: http://docs.couchdb.org/en/latest/api/database/index.html 355 | .. _CouchDB Document API: http://docs.couchdb.org/en/latest/api/document/index.html 356 | .. _CouchDB Server API: http://docs.couchdb.org/en/latest/api/server/index.html 357 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=_build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% -W . 10 | set I18NSPHINXOPTS=%SPHINXOPTS% . 11 | if NOT "%PAPER%" == "" ( 12 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 13 | set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% 14 | ) 15 | 16 | if "%1" == "" goto help 17 | 18 | if "%1" == "help" ( 19 | :help 20 | echo.Please use `make ^` where ^ is one of 21 | echo. html to make standalone HTML files 22 | echo. dirhtml to make HTML files named index.html in directories 23 | echo. singlehtml to make a single large HTML file 24 | echo. pickle to make pickle files 25 | echo. json to make JSON files 26 | echo. htmlhelp to make HTML files and a HTML help project 27 | echo. qthelp to make HTML files and a qthelp project 28 | echo. devhelp to make HTML files and a Devhelp project 29 | echo. epub to make an epub 30 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 31 | echo. text to make text files 32 | echo. man to make manual pages 33 | echo. texinfo to make Texinfo files 34 | echo. gettext to make PO message catalogs 35 | echo. changes to make an overview over all changed/added/deprecated items 36 | echo. xml to make Docutils-native XML files 37 | echo. pseudoxml to make pseudoxml-XML files for display purposes 38 | echo. linkcheck to check all external links for integrity 39 | echo. doctest to run all doctests embedded in the documentation if enabled 40 | goto end 41 | ) 42 | 43 | if "%1" == "clean" ( 44 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 45 | del /q /s %BUILDDIR%\* 46 | goto end 47 | ) 48 | 49 | 50 | %SPHINXBUILD% 2> nul 51 | if errorlevel 9009 ( 52 | echo. 53 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 54 | echo.installed, then set the SPHINXBUILD environment variable to point 55 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 56 | echo.may add the Sphinx directory to PATH. 57 | echo. 58 | echo.If you don't have Sphinx installed, grab it from 59 | echo.http://sphinx-doc.org/ 60 | exit /b 1 61 | ) 62 | 63 | if "%1" == "html" ( 64 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 65 | if errorlevel 1 exit /b 1 66 | echo. 67 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 68 | goto end 69 | ) 70 | 71 | if "%1" == "dirhtml" ( 72 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 73 | if errorlevel 1 exit /b 1 74 | echo. 75 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 76 | goto end 77 | ) 78 | 79 | if "%1" == "singlehtml" ( 80 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 81 | if errorlevel 1 exit /b 1 82 | echo. 83 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 84 | goto end 85 | ) 86 | 87 | if "%1" == "pickle" ( 88 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 89 | if errorlevel 1 exit /b 1 90 | echo. 91 | echo.Build finished; now you can process the pickle files. 92 | goto end 93 | ) 94 | 95 | if "%1" == "json" ( 96 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 97 | if errorlevel 1 exit /b 1 98 | echo. 99 | echo.Build finished; now you can process the JSON files. 100 | goto end 101 | ) 102 | 103 | if "%1" == "htmlhelp" ( 104 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 105 | if errorlevel 1 exit /b 1 106 | echo. 107 | echo.Build finished; now you can run HTML Help Workshop with the ^ 108 | .hhp project file in %BUILDDIR%/htmlhelp. 109 | goto end 110 | ) 111 | 112 | if "%1" == "qthelp" ( 113 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 114 | if errorlevel 1 exit /b 1 115 | echo. 116 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 117 | .qhcp project file in %BUILDDIR%/qthelp, like this: 118 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\aiocouchdb.qhcp 119 | echo.To view the help file: 120 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\aiocouchdb.ghc 121 | goto end 122 | ) 123 | 124 | if "%1" == "devhelp" ( 125 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 126 | if errorlevel 1 exit /b 1 127 | echo. 128 | echo.Build finished. 129 | goto end 130 | ) 131 | 132 | if "%1" == "epub" ( 133 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 134 | if errorlevel 1 exit /b 1 135 | echo. 136 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 137 | goto end 138 | ) 139 | 140 | if "%1" == "latex" ( 141 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 142 | if errorlevel 1 exit /b 1 143 | echo. 144 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 145 | goto end 146 | ) 147 | 148 | if "%1" == "latexpdf" ( 149 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 150 | cd %BUILDDIR%/latex 151 | make all-pdf 152 | cd %BUILDDIR%/.. 153 | echo. 154 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 155 | goto end 156 | ) 157 | 158 | if "%1" == "latexpdfja" ( 159 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 160 | cd %BUILDDIR%/latex 161 | make all-pdf-ja 162 | cd %BUILDDIR%/.. 163 | echo. 164 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 165 | goto end 166 | ) 167 | 168 | if "%1" == "text" ( 169 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 170 | if errorlevel 1 exit /b 1 171 | echo. 172 | echo.Build finished. The text files are in %BUILDDIR%/text. 173 | goto end 174 | ) 175 | 176 | if "%1" == "man" ( 177 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 178 | if errorlevel 1 exit /b 1 179 | echo. 180 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 181 | goto end 182 | ) 183 | 184 | if "%1" == "texinfo" ( 185 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo 186 | if errorlevel 1 exit /b 1 187 | echo. 188 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. 189 | goto end 190 | ) 191 | 192 | if "%1" == "gettext" ( 193 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale 194 | if errorlevel 1 exit /b 1 195 | echo. 196 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale. 197 | goto end 198 | ) 199 | 200 | if "%1" == "changes" ( 201 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 202 | if errorlevel 1 exit /b 1 203 | echo. 204 | echo.The overview file is in %BUILDDIR%/changes. 205 | goto end 206 | ) 207 | 208 | if "%1" == "linkcheck" ( 209 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 210 | if errorlevel 1 exit /b 1 211 | echo. 212 | echo.Link check complete; look for any errors in the above output ^ 213 | or in %BUILDDIR%/linkcheck/output.txt. 214 | goto end 215 | ) 216 | 217 | if "%1" == "doctest" ( 218 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 219 | if errorlevel 1 exit /b 1 220 | echo. 221 | echo.Testing of doctests in the sources finished, look at the ^ 222 | results in %BUILDDIR%/doctest/output.txt. 223 | goto end 224 | ) 225 | 226 | if "%1" == "xml" ( 227 | %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml 228 | if errorlevel 1 exit /b 1 229 | echo. 230 | echo.Build finished. The XML files are in %BUILDDIR%/xml. 231 | goto end 232 | ) 233 | 234 | if "%1" == "pseudoxml" ( 235 | %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml 236 | if errorlevel 1 exit /b 1 237 | echo. 238 | echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. 239 | goto end 240 | ) 241 | 242 | :end 243 | -------------------------------------------------------------------------------- /docs/v1/index.rst: -------------------------------------------------------------------------------- 1 | .. _v1: 2 | 3 | =============== 4 | CouchDB 1.x API 5 | =============== 6 | 7 | Server 8 | ====== 9 | 10 | .. autoclass:: aiocouchdb.v1.server.Server 11 | :members: 12 | 13 | Configuration 14 | ------------- 15 | 16 | .. autoclass:: aiocouchdb.v1.config.ServerConfig 17 | :members: 18 | 19 | Session 20 | ------- 21 | 22 | .. autoclass:: aiocouchdb.v1.session.Session 23 | :members: 24 | 25 | Database 26 | ======== 27 | 28 | .. autoclass:: aiocouchdb.v1.database.Database 29 | :members: 30 | 31 | .. autoclass:: aiocouchdb.v1.authdb.AuthDatabase 32 | :members: 33 | 34 | Security 35 | -------- 36 | 37 | .. autoclass:: aiocouchdb.v1.security.DatabaseSecurity 38 | :members: 39 | 40 | Document 41 | ======== 42 | 43 | .. autoclass:: aiocouchdb.v1.document.Document 44 | :members: 45 | 46 | .. autoclass:: aiocouchdb.v1.authdb.UserDocument 47 | :members: 48 | 49 | .. autoclass:: aiocouchdb.v1.document.DocAttachmentsMultipartReader 50 | :members: 51 | 52 | .. autoclass:: aiocouchdb.v1.document.OpenRevsMultipartReader 53 | :members: 54 | 55 | Design Document 56 | =============== 57 | 58 | .. autoclass:: aiocouchdb.v1.designdoc.DesignDocument 59 | :members: 60 | 61 | Attachment 62 | ========== 63 | 64 | .. autoclass:: aiocouchdb.v1.attachment.Attachment 65 | :members: 66 | 67 | .. autoclass:: aiocouchdb.v1.attachment.AttachmentReader 68 | :members: 69 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (C) 2014-2015 Alexander Shorin 4 | # All rights reserved. 5 | # 6 | # This software is licensed as described in the file LICENSE, which 7 | # you should have received as part of this distribution. 8 | # 9 | 10 | import imp 11 | import os 12 | import sys 13 | from os.path import join 14 | from setuptools import setup, find_packages 15 | 16 | setup_dir = os.path.dirname(__file__) 17 | mod = imp.load_module( 18 | 'version', *imp.find_module('version', [join(setup_dir, 'aiocouchdb')])) 19 | 20 | if sys.version_info < (3, 3): 21 | raise RuntimeError('aiocouchdb requires Python 3.3+') 22 | 23 | long_description = ''.join([ 24 | open(join(setup_dir, 'README.rst')).read().strip(), 25 | ''' 26 | 27 | Changes 28 | ======= 29 | 30 | ''', 31 | open(join(setup_dir, 'CHANGES.rst')).read().strip() 32 | ]) 33 | 34 | 35 | setup( 36 | name='aiocouchdb', 37 | version=mod.__version__, 38 | license='BSD', 39 | url='https://github.com/kxepal/aiocouchdb', 40 | 41 | description='CouchDB client built on top of aiohttp (asyncio)', 42 | long_description=long_description, 43 | 44 | author='Alexander Shorin', 45 | author_email='kxepal@gmail.com', 46 | 47 | classifiers=[ 48 | 'Development Status :: 4 - Beta', 49 | 'Intended Audience :: Developers', 50 | 'License :: OSI Approved :: BSD License', 51 | 'Operating System :: OS Independent', 52 | 'Programming Language :: Python', 53 | 'Programming Language :: Python :: 3', 54 | 'Programming Language :: Python :: 3.3', 55 | 'Programming Language :: Python :: 3.4', 56 | 'Topic :: Database :: Front-Ends', 57 | 'Topic :: Software Development :: Libraries :: Python Modules' 58 | ], 59 | 60 | packages=find_packages(), 61 | test_suite='nose.collector', 62 | zip_safe=False, 63 | 64 | install_requires=[ 65 | 'aiohttp==0.17.4' 66 | ], 67 | extras_require={ 68 | 'oauth': [ 69 | 'oauthlib==1.0.3' 70 | ], 71 | 'docs': [ 72 | 'sphinx==1.3.1' 73 | ] 74 | }, 75 | tests_require=[ 76 | 'flake8==2.4.1', 77 | 'nose==1.3.7' 78 | ] 79 | ) 80 | --------------------------------------------------------------------------------