├── pycouchdb ├── py.typed ├── __init__.py ├── exceptions.py ├── feedreader.py ├── types.py ├── utils.py ├── resource.py └── client.py ├── .python-version ├── docs ├── .gitignore ├── source │ ├── api.rst │ ├── install.rst │ ├── index.rst │ ├── quickstart.rst │ └── conf.py └── Makefile ├── .bumpversion.cfg ├── .gitignore ├── test ├── integration │ ├── README.md │ ├── conftest.py │ ├── test_error_scenarios.py │ └── test_integration.py ├── test_exceptions.py ├── conftest.py ├── test_feedreader.py ├── test_utils.py ├── test_resource.py └── test_server.py ├── LICENSE ├── README.md ├── CONTRIBUTING.md ├── .github └── workflows │ └── main.yml ├── CHANGES.rst └── pyproject.toml /pycouchdb/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.9 2 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | -------------------------------------------------------------------------------- /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 1.14.2 3 | 4 | [bumpversion:file:pycouchdb/__init__.py] 5 | 6 | [bumpversion:file:pyproject.toml] 7 | -------------------------------------------------------------------------------- /pycouchdb/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | __author__ = "Andrey Antukh" 4 | __license__ = "BSD" 5 | __version__ = "1.16.0" 6 | __maintainer__ = "Rinat Sabitov" 7 | __email__ = "rinat.sabitov@gmail.com" 8 | __status__ = "Development" 9 | 10 | 11 | from .client import Server # noqa: F401 12 | -------------------------------------------------------------------------------- /docs/source/api.rst: -------------------------------------------------------------------------------- 1 | .. _api: 2 | 3 | Developer Interface 4 | =================== 5 | 6 | This part of documentation covers a main developer interface. All py-couchdb functionality 7 | can be accessed by these classes: 8 | 9 | Server 10 | ------ 11 | 12 | .. autoclass:: pycouchdb.client.Server 13 | :members: 14 | 15 | 16 | Database 17 | -------- 18 | 19 | .. autoclass:: pycouchdb.client.Database 20 | :members: 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | *.py[cod] 3 | *.so 4 | .Python 5 | env/ 6 | build/ 7 | develop-eggs/ 8 | dist/ 9 | downloads/ 10 | eggs/ 11 | .eggs/ 12 | lib/ 13 | lib64/ 14 | parts/ 15 | sdist/ 16 | var/ 17 | *.egg-info/ 18 | .installed.cfg 19 | *.egg 20 | *.manifest 21 | *.spec 22 | pip-log.txt 23 | pip-delete-this-directory.txt 24 | htmlcov/ 25 | .tox/ 26 | .coverage 27 | .coverage.* 28 | .cache 29 | nosetests.xml 30 | coverage.xml 31 | *,cover 32 | *.log 33 | docs/_build/ 34 | target/ 35 | 36 | 37 | *.iml 38 | .idea/ 39 | *.ipr 40 | *.iws 41 | 42 | .env/ 43 | -------------------------------------------------------------------------------- /docs/source/install.rst: -------------------------------------------------------------------------------- 1 | .. _install: 2 | 3 | Installation 4 | ============ 5 | 6 | This part of the documentation covers the installation of ``py-couchdb``. 7 | 8 | 9 | Distribute & Pip 10 | ---------------- 11 | 12 | Installing ``py-couchdb`` is simple with `pip `_:: 13 | 14 | $ pip install pycouchdb 15 | 16 | 17 | Cheeseshop Mirror 18 | ----------------- 19 | 20 | If the Cheeseshop is down, you can also install Requests from one of the 21 | mirrors. `Crate.io `_ is one of them:: 22 | 23 | $ pip install -i http://simple.crate.io/ pycouchdb 24 | 25 | 26 | Get the Code 27 | ------------ 28 | 29 | ``py-couchdb`` is actively developed on GitHub, where the code is 30 | `always available `_. 31 | 32 | You can either clone the public repository:: 33 | 34 | git clone git://github.com/histrio/py-couchdb.git 35 | 36 | Once you have a copy of the source, you can embed it in your Python package, 37 | or install it into your site-packages easily:: 38 | 39 | $ python setup.py install 40 | -------------------------------------------------------------------------------- /pycouchdb/exceptions.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from typing import Final 4 | 5 | 6 | class Error(Exception): 7 | """Base exception class for all pycouchdb errors.""" 8 | pass 9 | 10 | 11 | class UnexpectedError(Error): 12 | """Raised when an unexpected error occurs.""" 13 | pass 14 | 15 | 16 | class FeedReaderExited(Error): 17 | """Raised when a feed reader exits unexpectedly.""" 18 | pass 19 | 20 | 21 | class ApiError(Error): 22 | """Base class for API-related errors.""" 23 | pass 24 | 25 | 26 | class GenericError(ApiError): 27 | """Raised for generic API errors.""" 28 | pass 29 | 30 | 31 | class Conflict(ApiError): 32 | """Raised when a conflict occurs (e.g., document revision conflict).""" 33 | pass 34 | 35 | 36 | class NotFound(ApiError): 37 | """Raised when a resource is not found.""" 38 | pass 39 | 40 | 41 | class BadRequest(ApiError): 42 | """Raised when a bad request is made.""" 43 | pass 44 | 45 | 46 | class AuthenticationFailed(ApiError): 47 | """Raised when authentication fails.""" 48 | pass 49 | -------------------------------------------------------------------------------- /test/integration/README.md: -------------------------------------------------------------------------------- 1 | # Integration Tests 2 | 3 | This directory contains integration tests for pycouchdb that require a running CouchDB instance. 4 | 5 | ## Requirements 6 | 7 | - CouchDB server running on `http://admin:password@localhost:5984/` 8 | - The server should be accessible and have admin credentials configured 9 | 10 | ## Running Integration Tests 11 | 12 | ### Using pytest: 13 | ```bash 14 | # Run integration tests 15 | pytest test/integration/ -m integration 16 | 17 | # Run with CouchDB requirement check 18 | pytest test/integration/ -m "integration and requires_couchdb" 19 | ``` 20 | 21 | ## Test Structure 22 | 23 | - `test_integration.py`: Main integration test file containing tests that interact with a real CouchDB instance 24 | - `conftest.py`: Integration-specific fixtures and configuration 25 | 26 | ## Test Markers 27 | 28 | All tests in this directory are automatically marked with: 29 | - `integration`: Identifies these as integration tests 30 | - `requires_couchdb`: Indicates these tests need a running CouchDB instance 31 | 32 | ## Fixtures 33 | 34 | - `server`: Provides a clean Server instance with test database cleanup 35 | - `db`: Creates a temporary database for each test 36 | - `rec`: Sets up test records with design documents 37 | - `rec_with_attachment`: Creates test records with attachments 38 | - `view`: Sets up views for testing 39 | - `view_duplicate_keys`: Creates views with duplicate keys for pagination testing -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2024 Andrey Antukh 2 | 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions 7 | are met: 8 | 1. Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | 2. Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in the 12 | documentation and/or other materials provided with the distribution. 13 | 3. The name of the author may not be used to endorse or promote products 14 | derived from this software without specific prior written permission. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR 17 | IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES 18 | OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. 19 | IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, 20 | INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT 21 | NOT 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 OF 25 | THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | .. py-couchdb documentation master file, created by 2 | sphinx-quickstart on Wed Jan 16 19:57:28 2013. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | ========== 7 | py-couchdb 8 | ========== 9 | 10 | Release v\ |version|. 11 | 12 | py-couchdb is a :ref:`BSD Licensed`, modern pure `Python`_ `CouchDB`_ client. 13 | 14 | Currently there are several libraries for Python to connect to CouchDB. **Why one more?** It's very simple. 15 | All seem to be not maintained, all libraries use standard Python libraries for http requests, and are not compatible with Python3. 16 | 17 | Advantages of py-couchdb 18 | ======================== 19 | 20 | - Uses `requests`_ for http requests (much faster than the standard library) 21 | - CouchDB 2.x and CouchDB 3.x compatible 22 | - Also compatible with pypy. 23 | 24 | .. _python: http://python.org 25 | .. _couchdb: http://couchdb.apache.org/ 26 | .. _requests: http://docs.python-requests.org/en/latest/ 27 | 28 | 29 | Example: 30 | 31 | .. code-block:: python 32 | 33 | >>> import pycouchdb 34 | >>> server = pycouchdb.Server("http://admin:admin@localhost:5984/") 35 | >>> server.info()['version'] 36 | '1.2.1' 37 | 38 | User guide 39 | ========== 40 | 41 | This part of the documentation gives a simple introduction on py-couchdb usage. 42 | 43 | .. toctree:: 44 | :maxdepth: 2 45 | 46 | install.rst 47 | quickstart.rst 48 | api.rst 49 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # py-couchdb 2 | 3 | [![CI](https://github.com/histrio/py-couchdb/actions/workflows/main.yml/badge.svg?branch=master)](https://github.com/histrio/py-couchdb/actions/workflows/main.yml) 4 | ![PyPI](https://img.shields.io/pypi/v/pycouchdb) 5 | ![PyPI - Downloads](https://img.shields.io/pypi/dm/pycouchdb) 6 | [![codecov](https://codecov.io/github/histrio/py-couchdb/graph/badge.svg?token=eXN16KQiEq)](https://codecov.io/github/histrio/py-couchdb) 7 | [![Documentation Status](https://readthedocs.org/projects/pycouchdb/badge/?version=latest)](https://pycouchdb.readthedocs.io/en/latest/?badge=latest) 8 | 9 | 10 | 11 | Modern pure python [CouchDB](https://couchdb.apache.org/) Client. 12 | 13 | Currently there are several libraries in python to connect to couchdb. **Why one more?** 14 | It's very simple. 15 | 16 | All seems not be maintained, all libraries used standard Python libraries for http requests. 17 | 18 | 19 | 20 | ## Advantages of py-couchdb 21 | 22 | - Use [requests](http://docs.python-requests.org/en/latest/) for http requests (much faster than the standard library) 23 | - CouchDB 2.x and CouchDB 3.x compatible 24 | - Also compatible with pypy. 25 | 26 | 27 | Example: 28 | 29 | ```python 30 | >>> import pycouchdb 31 | >>> server = pycouchdb.Server("http://admin:admin@localhost:5984/") 32 | >>> server.info()['version'] 33 | '1.2.1' 34 | ``` 35 | 36 | 37 | ## Installation 38 | 39 | To install py-couchdb, simply: 40 | 41 | ```bash 42 | pip install pycouchdb 43 | ``` 44 | 45 | ## Documentation 46 | 47 | Documentation is available at http://pycouchdb.readthedocs.org. 48 | 49 | 50 | ## Test 51 | 52 | To test py-couchdb, simply run: 53 | 54 | ``` bash 55 | pytest -v --doctest-modules --cov pycouchdb 56 | ``` 57 | -------------------------------------------------------------------------------- /pycouchdb/feedreader.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from typing import Any, Dict, Callable, Optional, TYPE_CHECKING 4 | 5 | if TYPE_CHECKING: 6 | from .client import Database 7 | 8 | # Type aliases for message callback functions 9 | MessageCallback = Callable[[Dict[str, Any]], None] 10 | MessageCallbackWithDb = Callable[..., None] # Uses *args, **kwargs for flexibility 11 | 12 | 13 | class BaseFeedReader: 14 | """ 15 | Base interface class for changes feed reader. 16 | """ 17 | 18 | def __call__(self, db: Any) -> "BaseFeedReader": 19 | self.db = db 20 | return self 21 | 22 | def on_message(self, message: Dict[str, Any]) -> None: 23 | """ 24 | Callback method that is called when change 25 | message is received from couchdb. 26 | 27 | :param message: change object 28 | :returns: None 29 | """ 30 | raise NotImplementedError() 31 | 32 | def on_close(self) -> None: 33 | """ 34 | Callback method that is received when connection 35 | is closed with a server. By default, does nothing. 36 | """ 37 | pass 38 | 39 | def on_heartbeat(self) -> None: 40 | """ 41 | Callback method invoked when a hearbeat (empty line) is received 42 | from the _changes stream. Override this to purge the reader's internal 43 | buffers (if any) if it waited too long without receiving anything. 44 | """ 45 | pass 46 | 47 | 48 | class SimpleFeedReader(BaseFeedReader): 49 | """ 50 | Simple feed reader that encapsule any callable in 51 | a valid feed reader interface. 52 | """ 53 | 54 | def __init__(self, db: Any = None, callback: Optional[MessageCallbackWithDb] = None) -> None: 55 | if db is not None: 56 | self.db = db 57 | if callback is not None: 58 | self.callback = callback 59 | 60 | def __call__(self, db: Any, callback: Optional[MessageCallbackWithDb] = None) -> "SimpleFeedReader": 61 | self.db = db 62 | if callback is not None: 63 | self.callback = callback 64 | return self 65 | 66 | def on_message(self, message: Dict[str, Any]) -> None: 67 | if hasattr(self, 'callback') and self.callback is not None: 68 | try: 69 | self.callback(message, db=self.db) 70 | except TypeError: 71 | # Fallback for callbacks that don't accept db parameter 72 | self.callback(message) 73 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to py-couchdb 2 | We love your input! We want to make contributing to this project as easy and transparent as possible, whether it's: 3 | 4 | - Reporting a bug 5 | - Discussing the current state of the code 6 | - Submitting a fix 7 | - Proposing new features 8 | - Becoming a maintainer 9 | 10 | ## We Develop with Github 11 | We use github to host code, to track issues and feature requests, as well as accept pull requests. 12 | 13 | ## We Use [Github Flow](https://guides.github.com/introduction/flow/index.html), So All Code Changes Happen Through Pull Requests 14 | Pull requests are the best way to propose changes to the codebase (we use [Github Flow](https://guides.github.com/introduction/flow/index.html)). We actively welcome your pull requests: 15 | 16 | 1. Fork the repo and create your branch from `master`. 17 | 2. If you've added code that should be tested, add tests. 18 | 3. If you've changed APIs, update the documentation. 19 | 4. Ensure the test suite passes. 20 | 5. Make sure your code lints. 21 | 6. Issue that pull request! 22 | 23 | ## Any contributions you make will be under the MIT Software License 24 | In short, when you submit code changes, your submissions are understood to be under the same [3-Clause BSD License](https://opensource.org/licenses/BSD-3-Clause) that covers the project. Feel free to contact the maintainers if that's a concern. 25 | 26 | ## Report bugs using Github's [issues](https://github.com/histrio/py-couchdb/issues) 27 | We use GitHub issues to track public bugs. Report a bug by [opening a new issue](); it's that easy! 28 | 29 | ## Write bug reports with detail, background, and sample code 30 | 31 | **Great Bug Reports** tend to have: 32 | 33 | - A quick summary and/or background 34 | - Steps to reproduce 35 | - Be specific! 36 | - Give sample code if you can. 37 | - What you expected would happen 38 | - What actually happens 39 | - Notes (possibly including why you think this might be happening, or stuff you tried that didn't work) 40 | 41 | People *love* thorough bug reports. I'm not even kidding. 42 | 43 | ## Use a Consistent Coding Style 44 | I'm borrowing these from [Google Python Style Guide](https://google.github.io/styleguide/pyguide.html) 45 | 46 | ## License 47 | By contributing, you agree that your contributions will be licensed under its 3-Clause BSD License. 48 | 49 | 50 | ## References 51 | This document was adapted from the open-source contribution guidelines for [Transcriptase Contributing Guideline](https://gist.github.com/briandk/3d2e8b3ec8daf5a27a62) 52 | 53 | -------------------------------------------------------------------------------- /pycouchdb/types.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from typing import Union, Dict, List, Any, Optional, TypedDict, Iterator, Iterable, Callable, Protocol, Tuple 4 | from typing_extensions import Final 5 | 6 | # JSON type alias for all valid JSON values 7 | Json = Union[Dict[str, Any], List[Any], str, int, float, bool, None] 8 | 9 | # Document type - represents a CouchDB document 10 | Document = Dict[str, Any] 11 | 12 | # Row type for view results 13 | class Row(TypedDict, total=False): 14 | id: str 15 | key: Any 16 | value: Any 17 | doc: Optional[Document] 18 | 19 | # Bulk operation result item 20 | class BulkItem(TypedDict, total=False): 21 | id: str 22 | rev: str 23 | ok: bool 24 | error: str 25 | reason: str 26 | 27 | # Server info response 28 | class ServerInfo(TypedDict, total=False): 29 | couchdb: str 30 | version: str 31 | git_sha: str 32 | uuid: str 33 | features: List[str] 34 | vendor: Dict[str, str] 35 | 36 | # Database info response 37 | class DatabaseInfo(TypedDict, total=False): 38 | db_name: str 39 | doc_count: int 40 | doc_del_count: int 41 | update_seq: str 42 | purge_seq: int 43 | compact_running: bool 44 | disk_size: int 45 | data_size: int 46 | instance_start_time: str 47 | disk_format_version: int 48 | committed_update_seq: int 49 | 50 | # Changes feed result 51 | class ChangeResult(TypedDict, total=False): 52 | seq: str 53 | id: str 54 | changes: List[Dict[str, str]] 55 | deleted: bool 56 | doc: Optional[Document] 57 | 58 | # View query result 59 | class ViewResult(TypedDict, total=False): 60 | total_rows: int 61 | offset: int 62 | rows: List[Row] 63 | 64 | # HTTP client protocol for dependency injection 65 | class HTTPClient(Protocol): 66 | def get(self, url: str, **kwargs: Any) -> Any: ... 67 | def post(self, url: str, **kwargs: Any) -> Any: ... 68 | def put(self, url: str, **kwargs: Any) -> Any: ... 69 | def delete(self, url: str, **kwargs: Any) -> Any: ... 70 | def head(self, url: str, **kwargs: Any) -> Any: ... 71 | 72 | # Feed reader protocol 73 | class FeedReader(Protocol): 74 | def on_message(self, message: Dict[str, Any]) -> None: ... 75 | def on_close(self) -> None: ... 76 | def on_heartbeat(self) -> None: ... 77 | 78 | # Type aliases for common patterns 79 | Credentials = Tuple[str, str] 80 | AuthMethod = str 81 | ViewName = str 82 | DocId = str 83 | Rev = str 84 | 85 | # Constants 86 | DEFAULT_BASE_URL: Final[str] = "http://localhost:5984/" 87 | DEFAULT_AUTH_METHOD: Final[str] = "basic" 88 | -------------------------------------------------------------------------------- /test/test_exceptions.py: -------------------------------------------------------------------------------- 1 | """ 2 | Unit tests for pycouchdb.exceptions module. 3 | """ 4 | 5 | import pytest 6 | from pycouchdb import exceptions 7 | 8 | 9 | class TestExceptions: 10 | """Test exception classes and their inheritance.""" 11 | 12 | def test_error_base_class(self): 13 | """Test that Error is the base exception class.""" 14 | assert issubclass(exceptions.Error, Exception) 15 | 16 | with pytest.raises(exceptions.Error): 17 | raise exceptions.Error("test error") 18 | 19 | def test_unexpected_error(self): 20 | """Test UnexpectedError exception.""" 21 | assert issubclass(exceptions.UnexpectedError, exceptions.Error) 22 | 23 | with pytest.raises(exceptions.UnexpectedError): 24 | raise exceptions.UnexpectedError("unexpected error") 25 | 26 | def test_feed_reader_exited(self): 27 | """Test FeedReaderExited exception.""" 28 | assert issubclass(exceptions.FeedReaderExited, exceptions.Error) 29 | 30 | with pytest.raises(exceptions.FeedReaderExited): 31 | raise exceptions.FeedReaderExited() 32 | 33 | def test_api_error(self): 34 | """Test ApiError exception.""" 35 | assert issubclass(exceptions.ApiError, exceptions.Error) 36 | 37 | with pytest.raises(exceptions.ApiError): 38 | raise exceptions.ApiError("api error") 39 | 40 | def test_generic_error(self): 41 | """Test GenericError exception.""" 42 | assert issubclass(exceptions.GenericError, exceptions.ApiError) 43 | 44 | with pytest.raises(exceptions.GenericError): 45 | raise exceptions.GenericError("generic error") 46 | 47 | def test_conflict_error(self): 48 | """Test Conflict exception.""" 49 | assert issubclass(exceptions.Conflict, exceptions.ApiError) 50 | 51 | with pytest.raises(exceptions.Conflict): 52 | raise exceptions.Conflict("conflict error") 53 | 54 | def test_not_found_error(self): 55 | """Test NotFound exception.""" 56 | assert issubclass(exceptions.NotFound, exceptions.ApiError) 57 | 58 | with pytest.raises(exceptions.NotFound): 59 | raise exceptions.NotFound("not found error") 60 | 61 | def test_bad_request_error(self): 62 | """Test BadRequest exception.""" 63 | assert issubclass(exceptions.BadRequest, exceptions.ApiError) 64 | 65 | with pytest.raises(exceptions.BadRequest): 66 | raise exceptions.BadRequest("bad request error") 67 | 68 | def test_authentication_failed_error(self): 69 | """Test AuthenticationFailed exception.""" 70 | assert issubclass(exceptions.AuthenticationFailed, exceptions.ApiError) 71 | 72 | with pytest.raises(exceptions.AuthenticationFailed): 73 | raise exceptions.AuthenticationFailed("auth failed error") 74 | 75 | def test_exception_message_preservation(self): 76 | """Test that exception messages are preserved.""" 77 | message = "Custom error message" 78 | 79 | with pytest.raises(exceptions.Error, match=message): 80 | raise exceptions.Error(message) 81 | 82 | with pytest.raises(exceptions.Conflict, match=message): 83 | raise exceptions.Conflict(message) -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | workflow_dispatch: 9 | 10 | jobs: 11 | unit-tests: 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] 16 | 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v4 20 | - uses: actions/setup-python@v4 21 | with: 22 | python-version: ${{ matrix.python-version }} 23 | - name: Install uv 24 | uses: astral-sh/setup-uv@v3 25 | with: 26 | version: "latest" 27 | - run: uv sync --extra dev 28 | - name: Run unit tests 29 | run: uv run pytest -v -m unit 30 | 31 | type-check: 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] 36 | 37 | runs-on: ubuntu-latest 38 | steps: 39 | - uses: actions/checkout@v4 40 | - uses: actions/setup-python@v4 41 | with: 42 | python-version: ${{ matrix.python-version }} 43 | - name: Install uv 44 | uses: astral-sh/setup-uv@v3 45 | with: 46 | version: "latest" 47 | - run: uv sync --extra dev 48 | - name: Run mypy type checking 49 | run: uv run mypy pycouchdb 50 | 51 | coverage: 52 | runs-on: ubuntu-latest 53 | needs: unit-tests 54 | steps: 55 | - uses: actions/checkout@v4 56 | - uses: actions/setup-python@v4 57 | with: 58 | python-version: "3.11" 59 | - name: Install uv 60 | uses: astral-sh/setup-uv@v3 61 | with: 62 | version: "latest" 63 | - run: uv sync --extra dev 64 | - name: Run unit tests with coverage 65 | run: uv run pytest -v -m unit --cov=pycouchdb --cov-report=xml --cov-report=html --cov-report=term-missing 66 | - name: Upload coverage to Codecov 67 | uses: codecov/codecov-action@v3 68 | with: 69 | file: ./coverage.xml 70 | flags: unittests 71 | name: codecov-umbrella 72 | fail_ci_if_error: false 73 | token: ${{ secrets.CODECOV_TOKEN }} 74 | 75 | integration-tests: 76 | strategy: 77 | fail-fast: false 78 | max-parallel: 3 79 | matrix: 80 | python-version: ["3.9", "3.11", "3.13"] 81 | couchdb-version: ["2.3", "3.2", "3.3", "3.4", "3.5", "latest"] 82 | 83 | runs-on: ubuntu-latest 84 | steps: 85 | - uses: actions/checkout@v4 86 | - uses: actions/setup-python@v4 87 | with: 88 | python-version: ${{ matrix.python-version }} 89 | - name: Install uv 90 | uses: astral-sh/setup-uv@v3 91 | with: 92 | version: "latest" 93 | - name: Set up CouchDB 94 | run: | 95 | docker run --name couchdb-test-${{ matrix.couchdb-version }} \ 96 | -p 5984:5984 \ 97 | -e COUCHDB_USER=admin \ 98 | -e COUCHDB_PASSWORD=password \ 99 | -d couchdb:${{ matrix.couchdb-version }} 100 | # Wait for CouchDB to be ready 101 | sleep 10 102 | # Test connection 103 | curl -f http://admin:password@localhost:5984/ || exit 1 104 | - run: uv sync --extra dev 105 | - name: Run integration tests 106 | run: uv run pytest -v -m integration test/integration/ 107 | -------------------------------------------------------------------------------- /CHANGES.rst: -------------------------------------------------------------------------------- 1 | ========= 2 | Changelog 3 | ========= 4 | 5 | Version 1.14 6 | ------------ 7 | 8 | Date: 2015-11-03 9 | 10 | Technical release: fixed improper package building bug 11 | 12 | Version 1.13 13 | ------------ 14 | 15 | Date: 2015-08-27 16 | 17 | - Subscribing to changes feed of the whole CouchDB server. (thanks to @kravietz) 18 | - Removed default "since" parameter from `_listen_feed` method. (by @krisb78) 19 | - Disabled slash quoting for url parts. (thanks to @internaut) 20 | 21 | 22 | Version 1.12 23 | ------------ 24 | 25 | Date: 2015-03-02 26 | 27 | - Backward incompatibility: `all` method changes its semantics, the wrapper now 28 | receives a complete result item and it is the responsibility of wrapper 29 | to handle it. (thanks to @krisb78) 30 | - Bug fix in __contains__ method of Database class (thanks to @beezz) 31 | - Improved performance on changes_feed (thanks to @krisb78) 32 | 33 | 34 | Version 1.11 35 | ------------ 36 | 37 | Date: 2015-02-05 38 | 39 | - Remove default "since" parameter from `changes_feed` method. (by @krisb78) 40 | 41 | 42 | Version 1.10 43 | ------------ 44 | 45 | Date: 2015-01-29 46 | 47 | - Proper handling heartbeats on changes feed (by @krisb78) 48 | - Add batch option to save method (by @kravietz) 49 | - Fix unicode related problems on python 2.7 (by @bmihelac) 50 | 51 | 52 | Version 1.9 53 | ----------- 54 | 55 | Date: 2014-08-23 56 | 57 | - Add support for `include_docs='false'` 58 | 59 | Version 1.8 60 | ----------- 61 | 62 | Date: 2014-07-17 63 | 64 | - Fix revisions api (Thanks to @GuoJing) 65 | - Speed up JSON decoding (by David Kendal) 66 | 67 | 68 | Version 1.7 69 | ----------- 70 | 71 | Date: 2013-12-13 72 | 73 | - Fix encoding problems on retrieve data (thanks to Jonas Hagen) 74 | 75 | Version 1.6 76 | ----------- 77 | 78 | Date: 2013-06-29 79 | 80 | - Fixed some wrong behavior with use of a simple copy instead of deepcopy. 81 | - Change some default parameters from mutable objects to more pythoninc 82 | way using immutable types. 83 | http://pythonconquerstheuniverse.wordpress.com/category/python-gotchas/ 84 | - Fixed wrong usage of get_attachmen (now uses properly a rev parameter) 85 | 86 | 87 | Version 1.5 88 | ----------- 89 | 90 | Date: 2013-06-16 91 | 92 | - Improved error management. 93 | - Improved exception hierarchy. 94 | - Fix a lot of inconsistents on method calls. 95 | - Add a simple way to obtain attachment as stream instead of 96 | load all content in memory. 97 | - Add a simple way to subscribe to couchdb changes stream. 98 | 99 | Thanks to: 100 | 101 | - Kristoffer Berdal (@flexd) for hard work on error management improvement. 102 | - @Dedalus2000 for test and report a lot of issues and some ideas. 103 | 104 | 105 | Version 1.4 106 | ----------- 107 | 108 | Date: 2013-05-11 109 | 110 | - Fixed invalid url parsing on use session (thanks to KodjoSuprem) 111 | - Fixed invalid encode method call (thanks to Kristoffer Berdal) 112 | - Switch to basic auth method as default auth method. 113 | - Add new method "one" for more clean way to obtain a first value 114 | for a query a view (thanks to Kristoffer Berdal for the initial idea) 115 | - Add flat and as_list parameters to query, one and all methods. 116 | 117 | 118 | Version 1.3 119 | ----------- 120 | 121 | Date: 2013-04-04 122 | 123 | - Added replication methods thanks to Bruno Bord (@brunobord) 124 | 125 | 126 | Version 1.2 127 | ----------- 128 | 129 | Date: 2013-01-27 130 | 131 | - All methods that can receive document, do not modify id (inmutable api?) 132 | - Add delete_bulk methods. 133 | - A lot of fixes and improvements on tests. 134 | 135 | 136 | Version 1.1 137 | ----------- 138 | 139 | Date: 2013-01-27 140 | 141 | - Add python view server (imported from https://github.com/lilydjwg/couchdb-python3 with some changes). 142 | - Now compatible with pypy. 143 | 144 | 145 | Version 1.0 146 | ----------- 147 | 148 | - Initial version. 149 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "pycouchdb" 3 | version = "1.16.0" 4 | description = "Modern pure python CouchDB Client." 5 | authors = [ 6 | {name = "Andrey Antukh", email = "niwi@niwi.be"} 7 | ] 8 | maintainers = [ 9 | {name = "Rinat Sabitov", email = "rinat.sabitov@gmail.com"} 10 | ] 11 | license = {text = "BSD-3-Clause"} 12 | readme = "README.md" 13 | requires-python = ">=3.9" 14 | keywords = ["couchdb", "database"] 15 | classifiers = [ 16 | "Topic :: Utilities", 17 | "Topic :: Software Development :: Libraries :: Python Modules" 18 | ] 19 | dependencies = [ 20 | "requests>=2.32.5", 21 | "chardet>=5.2.0" 22 | ] 23 | 24 | [project.urls] 25 | Homepage = "https://github.com/histrio/py-couchdb" 26 | Documentation = "https://pycouchdb.readthedocs.io/en/latest/" 27 | Repository = "https://github.com/histrio/py-couchdb" 28 | 29 | [project.optional-dependencies] 30 | dev = [ 31 | # Core testing framework 32 | "pytest>=8.0.0", 33 | "pytest-cov>=5.0.0", 34 | "pytest-html>=4.0.0", 35 | "pytest-xdist>=3.0.0", 36 | "pytest-mock>=3.10.0", 37 | "pytest-timeout>=2.1.0", 38 | 39 | # HTTP mocking 40 | "responses>=0.25.0", 41 | "requests-mock>=1.11.0", 42 | 43 | # Code quality tools 44 | "flake8>=7.0.0", 45 | "mypy>=1.0.0", 46 | "bandit>=1.7.0", 47 | "black>=23.0.0", 48 | "isort>=5.12.0", 49 | 50 | # Test utilities 51 | "factory-boy>=3.3.0", 52 | "faker>=19.0.0", 53 | "freezegun>=1.2.0", 54 | 55 | # Coverage reporting 56 | "coverage>=7.0.0", 57 | 58 | # Performance testing 59 | "pytest-benchmark>=4.0.0", 60 | 61 | # Documentation testing 62 | "pytest-doctestplus>=0.13.0" 63 | ] 64 | 65 | [tool.pytest.ini_options] 66 | # Pytest configuration for pycouchdb tests 67 | 68 | # Test discovery 69 | testpaths = ["test"] 70 | python_files = ["test_*.py"] 71 | python_classes = ["Test*"] 72 | python_functions = ["test_*"] 73 | 74 | # Output options 75 | addopts = [ 76 | "--verbose", 77 | "--tb=short", 78 | "--strict-markers", 79 | "--disable-warnings", 80 | "--color=yes", 81 | "--durations=10" 82 | ] 83 | 84 | # Markers 85 | markers = [ 86 | "unit: Unit tests that don't require external dependencies", 87 | "integration: Integration tests that may require external services", 88 | "slow: Tests that take a long time to run", 89 | "requires_couchdb: Tests that require a running CouchDB instance", 90 | "network: Tests that require network access" 91 | ] 92 | 93 | # Minimum version 94 | minversion = "6.0" 95 | 96 | # Test timeout (in seconds) 97 | timeout = 300 98 | 99 | # Coverage options (if pytest-cov is installed) 100 | # addopts = ["--cov=pycouchdb", "--cov-report=html", "--cov-report=term-missing"] 101 | 102 | # Logging 103 | log_cli = true 104 | log_cli_level = "INFO" 105 | log_cli_format = "%(asctime)s [%(levelname)8s] %(name)s: %(message)s" 106 | log_cli_date_format = "%Y-%m-%d %H:%M:%S" 107 | 108 | # Warnings 109 | filterwarnings = [ 110 | "ignore::DeprecationWarning", 111 | "ignore::PendingDeprecationWarning", 112 | "ignore::UserWarning:requests.*" 113 | ] 114 | 115 | # Test paths 116 | norecursedirs = [ 117 | ".git", 118 | ".tox", 119 | "dist", 120 | "build", 121 | "*.egg", 122 | ".eggs", 123 | "__pycache__", 124 | ".pytest_cache", 125 | ".coverage", 126 | "htmlcov", 127 | ".venv", 128 | "venv", 129 | "env" 130 | ] 131 | 132 | [build-system] 133 | requires = ["hatchling"] 134 | build-backend = "hatchling.build" 135 | 136 | [dependency-groups] 137 | dev = [ 138 | "mypy>=1.17.1", 139 | "types-requests>=2.32.4.20250809", 140 | ] 141 | 142 | [tool.mypy] 143 | python_version = "3.9" 144 | strict = true 145 | warn_unused_ignores = true 146 | warn_return_any = false 147 | warn_redundant_casts = true 148 | show_error_codes = true 149 | show_column_numbers = true 150 | show_error_context = true 151 | pretty = true 152 | 153 | # Allow Any only in internal places with explanatory comments 154 | disallow_any_unimported = true 155 | disallow_any_expr = false 156 | disallow_any_decorated = false 157 | disallow_any_explicit = false 158 | disallow_any_generics = false 159 | 160 | # Additional strict checks 161 | disallow_untyped_defs = false 162 | disallow_incomplete_defs = true 163 | check_untyped_defs = false 164 | disallow_untyped_decorators = true 165 | no_implicit_optional = true 166 | warn_no_return = true 167 | warn_unreachable = true 168 | strict_equality = true 169 | 170 | # Import handling 171 | ignore_missing_imports = false 172 | follow_imports = "normal" 173 | follow_imports_for_stubs = true 174 | 175 | # Per-module options 176 | [[tool.mypy.overrides]] 177 | module = "tests.*" 178 | disallow_untyped_defs = false 179 | disallow_incomplete_defs = false 180 | 181 | [[tool.mypy.overrides]] 182 | module = "test.*" 183 | disallow_untyped_defs = false 184 | disallow_incomplete_defs = false 185 | 186 | [[tool.mypy.overrides]] 187 | module = "test.integration.*" 188 | disallow_untyped_defs = false 189 | -------------------------------------------------------------------------------- /test/conftest.py: -------------------------------------------------------------------------------- 1 | """ 2 | Pytest configuration and shared fixtures for pycouchdb unit tests. 3 | """ 4 | 5 | import pytest 6 | import os 7 | from unittest.mock import Mock, patch 8 | import pycouchdb.client as client 9 | 10 | 11 | @pytest.fixture 12 | def mock_server(): 13 | """Create a mock Server instance for testing.""" 14 | with patch('pycouchdb.client.Resource') as mock_resource_class: 15 | mock_resource = Mock() 16 | mock_resource_class.return_value = mock_resource 17 | server = client.Server("http://localhost:5984/") 18 | yield server, mock_resource 19 | 20 | 21 | @pytest.fixture 22 | def mock_database(): 23 | """Create a mock Database instance for testing.""" 24 | mock_resource = Mock() 25 | db = client.Database(mock_resource, "testdb") 26 | yield db, mock_resource 27 | 28 | 29 | @pytest.fixture 30 | def sample_document(): 31 | """Sample document for testing.""" 32 | return { 33 | "_id": "doc123", 34 | "_rev": "1-abc", 35 | "name": "Test Document", 36 | "value": 42, 37 | "nested": { 38 | "key": "value" 39 | } 40 | } 41 | 42 | 43 | @pytest.fixture 44 | def sample_documents(): 45 | """Sample documents for bulk testing.""" 46 | return [ 47 | {"_id": "doc1", "name": "Document 1", "value": 1}, 48 | {"_id": "doc2", "name": "Document 2", "value": 2}, 49 | {"_id": "doc3", "name": "Document 3", "value": 3} 50 | ] 51 | 52 | 53 | @pytest.fixture 54 | def sample_attachment(): 55 | """Sample attachment content for testing.""" 56 | return b"Hello, World! This is a test attachment." 57 | 58 | 59 | @pytest.fixture 60 | def mock_couchdb_response(): 61 | """Mock CouchDB response for testing.""" 62 | response = Mock() 63 | response.status_code = 200 64 | response.json.return_value = {"ok": True} 65 | response.headers = {"etag": '"1-abc"'} 66 | response.content = b"test content" 67 | response.iter_lines.return_value = [] 68 | response.iter_content.return_value = [] 69 | return response 70 | 71 | 72 | @pytest.fixture 73 | def mock_couchdb_error_response(): 74 | """Mock CouchDB error response for testing.""" 75 | response = Mock() 76 | response.status_code = 404 77 | response.json.return_value = {"error": "not_found", "reason": "Document not found"} 78 | return response 79 | 80 | 81 | @pytest.fixture 82 | def mock_feed_reader(): 83 | """Mock feed reader for testing changes feed.""" 84 | class MockFeedReader: 85 | def __init__(self): 86 | self.messages = [] 87 | self.heartbeats = 0 88 | self.closed = False 89 | 90 | def on_message(self, message): 91 | self.messages.append(message) 92 | 93 | def on_heartbeat(self): 94 | self.heartbeats += 1 95 | 96 | def on_close(self): 97 | self.closed = True 98 | 99 | return MockFeedReader() 100 | 101 | 102 | @pytest.fixture(autouse=True) 103 | def reset_environment(): 104 | """Reset environment variables before each test.""" 105 | # Store original environment 106 | original_env = os.environ.copy() 107 | 108 | yield 109 | 110 | # Restore original environment 111 | os.environ.clear() 112 | os.environ.update(original_env) 113 | 114 | 115 | @pytest.fixture 116 | def temp_database_name(): 117 | """Generate a temporary database name for testing.""" 118 | import uuid 119 | return f"test_db_{uuid.uuid4().hex[:8]}" 120 | 121 | 122 | # Test markers 123 | def pytest_configure(config): 124 | """Configure pytest markers.""" 125 | config.addinivalue_line( 126 | "markers", "unit: mark test as a unit test" 127 | ) 128 | config.addinivalue_line( 129 | "markers", "integration: mark test as an integration test" 130 | ) 131 | config.addinivalue_line( 132 | "markers", "slow: mark test as slow running" 133 | ) 134 | config.addinivalue_line( 135 | "markers", "requires_couchdb: mark test as requiring a running CouchDB instance" 136 | ) 137 | 138 | 139 | # Test collection hooks 140 | def pytest_collection_modifyitems(config, items): 141 | """Modify test collection to add markers based on test names.""" 142 | for item in items: 143 | # Add unit marker to unit tests (all tests in main test directory) 144 | if "test/" in item.nodeid and "integration" not in item.nodeid: 145 | item.add_marker(pytest.mark.unit) 146 | 147 | # Add slow marker to tests that might be slow 148 | if any(keyword in item.name for keyword in ["bulk", "feed", "stream", "pagination"]): 149 | item.add_marker(pytest.mark.slow) 150 | 151 | 152 | # Test reporting 153 | def pytest_html_report_title(report): 154 | """Set the title of the HTML report.""" 155 | report.title = "pycouchdb Test Report" 156 | 157 | 158 | def pytest_html_results_summary(prefix, summary, postfix): 159 | """Customize the HTML report summary.""" 160 | prefix.extend([f"

pycouchdb Test Suite

"]) -------------------------------------------------------------------------------- /pycouchdb/utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import json 4 | from urllib.parse import unquote as _unquote 5 | from urllib.parse import urlunsplit, urlsplit 6 | from functools import reduce 7 | from typing import Union, Optional, Tuple, List, Dict, Any 8 | from typing_extensions import Final 9 | 10 | string_type = str 11 | bytes_type = bytes 12 | 13 | URLSPLITTER: Final[str] = '/' 14 | 15 | 16 | json_encoder = json.JSONEncoder() 17 | 18 | 19 | def extract_credentials(url: str) -> Tuple[str, Optional[Tuple[str, str]]]: 20 | """ 21 | Extract authentication (user name and password) credentials from the 22 | given URL. 23 | 24 | >>> extract_credentials('http://localhost:5984/_config/') 25 | ('http://localhost:5984/_config/', None) 26 | >>> extract_credentials('http://joe:secret@localhost:5984/_config/') 27 | ('http://localhost:5984/_config/', ('joe', 'secret')) 28 | >>> extract_credentials('http://joe%40example.com:secret@' 29 | ... 'localhost:5984/_config/') 30 | ('http://localhost:5984/_config/', ('joe@example.com', 'secret')) 31 | """ 32 | parts = urlsplit(url) 33 | netloc = parts[1] 34 | if '@' in netloc: 35 | creds, netloc = netloc.split('@') 36 | cred_parts = creds.split(':') 37 | if len(cred_parts) >= 2: 38 | # Handle passwords containing colons by joining all parts after the first one 39 | username = _unquote(cred_parts[0]) 40 | password = _unquote(':'.join(cred_parts[1:])) 41 | credentials: Optional[Tuple[str, str]] = (username, password) 42 | else: 43 | credentials = None 44 | parts_list = list(parts) 45 | parts_list[1] = netloc 46 | new_parts = tuple(parts_list) 47 | else: 48 | credentials = None 49 | new_parts = parts 50 | return urlunsplit(new_parts), credentials 51 | 52 | 53 | def _join(head: str, tail: str) -> str: 54 | parts = [head.rstrip(URLSPLITTER), tail.lstrip(URLSPLITTER)] 55 | return URLSPLITTER.join(parts) 56 | 57 | 58 | def urljoin(base: str, *path: str) -> str: 59 | """ 60 | Assemble a uri based on a base, any number of path segments, and query 61 | string parameters. 62 | 63 | >>> urljoin('http://example.org', '_all_dbs') 64 | 'http://example.org/_all_dbs' 65 | 66 | A trailing slash on the uri base is handled gracefully: 67 | 68 | >>> urljoin('http://example.org/', '_all_dbs') 69 | 'http://example.org/_all_dbs' 70 | 71 | And multiple positional arguments become path parts: 72 | 73 | >>> urljoin('http://example.org/', 'foo', 'bar') 74 | 'http://example.org/foo/bar' 75 | 76 | >>> urljoin('http://example.org/', 'foo/bar') 77 | 'http://example.org/foo/bar' 78 | 79 | >>> urljoin('http://example.org/', 'foo', '/bar/') 80 | 'http://example.org/foo/bar/' 81 | 82 | >>> urljoin('http://example.com', 'org.couchdb.user:username') 83 | 'http://example.com/org.couchdb.user:username' 84 | """ 85 | return reduce(_join, path, base) 86 | 87 | 88 | def as_json(response: Any) -> Optional[Union[Dict[str, Any], List[Any], str]]: 89 | if "application/json" in response.headers['content-type']: 90 | response_src = response.content.decode('utf-8') 91 | if response.content != b'': 92 | return json.loads(response_src) 93 | else: 94 | return response_src 95 | return None 96 | 97 | 98 | def _path_from_name(name: str, type: str) -> List[str]: 99 | """ 100 | Expand a 'design/foo' style name to its full path as a list of 101 | segments. 102 | 103 | >>> _path_from_name("_design/test", '_view') 104 | ['_design', 'test'] 105 | >>> _path_from_name("design/test", '_view') 106 | ['_design', 'design', '_view', 'test'] 107 | """ 108 | if name.startswith('_'): 109 | return name.split('/') 110 | design, name = name.split('/', 1) 111 | return ['_design', design, type, name] 112 | 113 | 114 | def encode_view_options(options: Dict[str, Any]) -> Dict[str, Any]: 115 | """ 116 | Encode any items in the options dict that are sent as a JSON string to a 117 | view/list function. 118 | 119 | >>> opts = {'key': 'foo', "notkey":"bar"} 120 | >>> res = encode_view_options(opts) 121 | >>> res["key"], res["notkey"] 122 | ('"foo"', 'bar') 123 | 124 | >>> opts = {'startkey': 'foo', "endkey":"bar"} 125 | >>> res = encode_view_options(opts) 126 | >>> res['startkey'], res['endkey'] 127 | ('"foo"', '"bar"') 128 | """ 129 | retval = {} 130 | 131 | for name, value in options.items(): 132 | if name in ('key', 'startkey', 'endkey'): 133 | value = json_encoder.encode(value) 134 | retval[name] = value 135 | return retval 136 | 137 | 138 | def force_bytes(data: Union[str, bytes], encoding: str = "utf-8") -> bytes: 139 | if isinstance(data, string_type): 140 | data = data.encode(encoding) 141 | return data 142 | 143 | 144 | def force_text(data: Union[str, bytes], encoding: str = "utf-8") -> str: 145 | if isinstance(data, bytes_type): 146 | data = data.decode(encoding) 147 | return data 148 | -------------------------------------------------------------------------------- /pycouchdb/resource.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import json 4 | import requests 5 | from typing import Optional, Tuple, Any, Dict, List, Union 6 | 7 | from . import utils 8 | from . import exceptions 9 | from .types import Credentials, AuthMethod 10 | 11 | # Type aliases for cleaner HTTP method signatures 12 | HttpPath = Optional[Union[str, List[str]]] 13 | HttpParams = Optional[Dict[str, Any]] 14 | HttpHeaders = Optional[Dict[str, str]] 15 | HttpResponse = Tuple[requests.Response, Optional[Any]] 16 | 17 | 18 | class Resource: 19 | def __init__(self, base_url: str, full_commit: bool = True, session: Optional[requests.Session] = None, 20 | credentials: Optional[Credentials] = None, authmethod: AuthMethod = "session", verify: bool = False) -> None: 21 | 22 | self.base_url = base_url 23 | # self.verify = verify 24 | 25 | if not session: 26 | self.session = requests.session() 27 | 28 | self.session.headers.update({"accept": "application/json", 29 | "content-type": "application/json"}) 30 | self._authenticate(credentials, authmethod) 31 | 32 | if not full_commit: 33 | self.session.headers.update({'X-Couch-Full-Commit': 'false'}) 34 | else: 35 | self.session = session 36 | self.session.verify = verify 37 | 38 | def _authenticate(self, credentials: Optional[Credentials], method: AuthMethod) -> None: 39 | if not credentials: 40 | return 41 | 42 | if method == "session": 43 | data_dict = {"name": credentials[0], "password": credentials[1]} 44 | data = utils.force_bytes(json.dumps(data_dict)) 45 | 46 | post_url = utils.urljoin(self.base_url, "_session") 47 | r = self.session.post(post_url, data=data) 48 | if r.status_code != 200: 49 | raise exceptions.AuthenticationFailed() 50 | 51 | elif method == "basic": 52 | self.session.auth = credentials 53 | 54 | else: 55 | raise RuntimeError("Invalid authentication method") 56 | 57 | def __call__(self, *path: str) -> "Resource": 58 | base_url = utils.urljoin(self.base_url, *path) 59 | return self.__class__(base_url, session=self.session) 60 | 61 | def _check_result(self, response: requests.Response, result: Optional[Any]) -> None: 62 | try: 63 | error = result.get('error', None) if result else None 64 | reason = result.get('reason', None) if result else None 65 | except AttributeError: 66 | error = None 67 | reason = '' 68 | 69 | # This is here because couchdb can return http 201 70 | # but containing a list of conflict errors 71 | if error == 'conflict' or error == "file_exists": 72 | raise exceptions.Conflict(reason or "Conflict") 73 | 74 | if response.status_code > 205: 75 | if response.status_code == 404 or error == 'not_found': 76 | raise exceptions.NotFound(reason or 'Not found') 77 | elif error == 'bad_request': 78 | raise exceptions.BadRequest(reason or "Bad request") 79 | raise exceptions.GenericError(result) 80 | 81 | def request(self, method: str, path: HttpPath = None, params: HttpParams = None, 82 | data: Optional[Any] = None, headers: HttpHeaders = None, stream: bool = False, **kwargs: Any) -> HttpResponse: 83 | 84 | if headers is None: 85 | headers = {} 86 | 87 | headers.setdefault('Accept', 'application/json') 88 | 89 | if path: 90 | if not isinstance(path, (list, tuple)): 91 | path = [path] 92 | url = utils.urljoin(self.base_url, *path) 93 | else: 94 | url = self.base_url 95 | 96 | response = self.session.request(method, url, stream=stream, 97 | data=data, params=params, 98 | headers=headers, **kwargs) 99 | # Ignore result validation if 100 | # request is with stream mode 101 | 102 | if stream and response.status_code < 400: 103 | result = None 104 | self._check_result(response, result) 105 | else: 106 | result = utils.as_json(response) 107 | 108 | if result is None: 109 | return response, result 110 | 111 | if isinstance(result, list): 112 | for res in result: 113 | self._check_result(response, res) 114 | else: 115 | self._check_result(response, result) 116 | 117 | return response, result 118 | 119 | def get(self, path: HttpPath = None, **kwargs: Any) -> HttpResponse: 120 | return self.request("GET", path, **kwargs) 121 | 122 | def put(self, path: HttpPath = None, **kwargs: Any) -> HttpResponse: 123 | return self.request("PUT", path, **kwargs) 124 | 125 | def post(self, path: HttpPath = None, **kwargs: Any) -> HttpResponse: 126 | return self.request("POST", path, **kwargs) 127 | 128 | def delete(self, path: HttpPath = None, **kwargs: Any) -> HttpResponse: 129 | return self.request("DELETE", path, **kwargs) 130 | 131 | def head(self, path: HttpPath = None, **kwargs: Any) -> HttpResponse: 132 | return self.request("HEAD", path, **kwargs) 133 | -------------------------------------------------------------------------------- /test/integration/conftest.py: -------------------------------------------------------------------------------- 1 | """ 2 | Integration test configuration and fixtures for pycouchdb tests. 3 | 4 | This module provides fixtures and configuration specifically for integration tests 5 | that require a running CouchDB instance. 6 | """ 7 | 8 | import pytest 9 | import os 10 | import time 11 | import requests 12 | from pycouchdb import Server 13 | 14 | 15 | # CouchDB connection settings 16 | SERVER_URL = 'http://admin:password@localhost:5984/' 17 | 18 | 19 | def is_couchdb_running(): 20 | """Check if CouchDB is running and accessible.""" 21 | try: 22 | response = requests.get(SERVER_URL, timeout=5) 23 | return response.status_code == 200 24 | except (requests.exceptions.RequestException, requests.exceptions.Timeout): 25 | return False 26 | 27 | 28 | @pytest.fixture(scope="session") 29 | def couchdb_available(): 30 | """Check if CouchDB is available for integration tests.""" 31 | if not is_couchdb_running(): 32 | pytest.skip("CouchDB is not running or not accessible. " 33 | "Please start CouchDB and ensure it's accessible at " 34 | f"{SERVER_URL}") 35 | 36 | 37 | @pytest.fixture 38 | def server(couchdb_available): 39 | """Create a clean Server instance for integration tests.""" 40 | server = Server(SERVER_URL) 41 | 42 | # Clean up existing test databases 43 | for db_name in list(server): 44 | if db_name.startswith('pycouchdb-testing-'): 45 | try: 46 | server.delete(db_name) 47 | except Exception: 48 | pass # Ignore errors during cleanup 49 | 50 | # Ensure _users database exists 51 | if "_users" not in server: 52 | server.create("_users") 53 | 54 | yield server 55 | 56 | # Cleanup after test 57 | for db_name in list(server): 58 | if db_name.startswith('pycouchdb-testing-'): 59 | try: 60 | server.delete(db_name) 61 | except Exception: 62 | pass # Ignore errors during cleanup 63 | 64 | 65 | @pytest.fixture 66 | def db(server, request): 67 | """Create a temporary database for testing.""" 68 | db_name = 'pycouchdb-testing-' + request.node.name 69 | yield server.create(db_name) 70 | try: 71 | server.delete(db_name) 72 | except Exception: 73 | pass # Ignore cleanup errors 74 | 75 | 76 | @pytest.fixture 77 | def rec(db): 78 | """Create test records with a design document.""" 79 | querydoc = { 80 | "_id": "_design/testing", 81 | "views": { 82 | "names": { 83 | "map": "function(doc) { emit(doc.name, 1); }", 84 | "reduce": "function(keys, values) { return sum(values); }", 85 | } 86 | } 87 | } 88 | db.save(querydoc) 89 | db.save_bulk([ 90 | {"_id": "kk1", "name": "Andrey"}, 91 | {"_id": "kk2", "name": "Pepe"}, 92 | {"_id": "kk3", "name": "Alex"}, 93 | ]) 94 | yield 95 | try: 96 | db.delete("_design/testing") 97 | except Exception: 98 | pass # Ignore cleanup errors 99 | 100 | 101 | @pytest.fixture 102 | def rec_with_attachment(db, rec, tmpdir): 103 | """Create test records with an attachment.""" 104 | doc = db.get("kk1") 105 | att = tmpdir.join('sample.txt') 106 | att.write(b"Hello") 107 | with open(str(att)) as f: 108 | doc = db.put_attachment(doc, f, "sample.txt") 109 | yield doc 110 | 111 | 112 | @pytest.fixture 113 | def view(db): 114 | """Create a view for testing.""" 115 | querydoc = { 116 | "_id": "_design/testing", 117 | "views": { 118 | "names": { 119 | "map": "function(doc) { emit(doc.name, 1); }", 120 | } 121 | } 122 | } 123 | db.save(querydoc) 124 | db.save_bulk([ 125 | {"_id": "kk1", "name": "Florian"}, 126 | {"_id": "kk2", "name": "Raphael"}, 127 | {"_id": "kk3", "name": "Jaideep"}, 128 | {"_id": "kk4", "name": "Andrew"}, 129 | {"_id": "kk5", "name": "Pepe"}, 130 | {"_id": "kk6", "name": "Alex"}, 131 | ]) 132 | yield 133 | try: 134 | db.delete("_design/testing") 135 | except Exception: 136 | pass # Ignore cleanup errors 137 | 138 | 139 | @pytest.fixture 140 | def view_duplicate_keys(db): 141 | """Create a view with duplicate keys for testing.""" 142 | querydoc = { 143 | "_id": "_design/testing", 144 | "views": { 145 | "names": { 146 | "map": "function(doc) { emit(doc.name, 1); }", 147 | } 148 | } 149 | } 150 | db.save(querydoc) 151 | db.save_bulk([ 152 | {"_id": "kk1", "name": "Andrew"}, 153 | {"_id": "kk2", "name": "Andrew"}, 154 | {"_id": "kk3", "name": "Andrew"}, 155 | {"_id": "kk4", "name": "Andrew"}, 156 | {"_id": "kk5", "name": "Andrew"}, 157 | {"_id": "kk6", "name": "Andrew"}, 158 | ]) 159 | yield 160 | try: 161 | db.delete("_design/testing") 162 | except Exception: 163 | pass # Ignore cleanup errors 164 | 165 | 166 | # Mark all tests in this directory as integration tests 167 | def pytest_collection_modifyitems(config, items): 168 | """Mark all tests in this directory as integration tests.""" 169 | for item in items: 170 | item.add_marker(pytest.mark.integration) 171 | item.add_marker(pytest.mark.requires_couchdb) -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = build 9 | 10 | # Internal variables. 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source 14 | # the i18n builder cannot share the environment and doctrees with the others 15 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source 16 | 17 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 18 | 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 " latexpdf to make LaTeX files and run them through pdflatex" 32 | @echo " text to make text files" 33 | @echo " man to make manual pages" 34 | @echo " texinfo to make Texinfo files" 35 | @echo " info to make Texinfo files and run them through makeinfo" 36 | @echo " gettext to make PO message catalogs" 37 | @echo " changes to make an overview of all changed/added/deprecated items" 38 | @echo " linkcheck to check all external links for integrity" 39 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 40 | 41 | clean: 42 | -rm -rf $(BUILDDIR)/* 43 | 44 | html: 45 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 46 | @echo 47 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 48 | 49 | dirhtml: 50 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 51 | @echo 52 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 53 | 54 | singlehtml: 55 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 56 | @echo 57 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 58 | 59 | pickle: 60 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 61 | @echo 62 | @echo "Build finished; now you can process the pickle files." 63 | 64 | json: 65 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 66 | @echo 67 | @echo "Build finished; now you can process the JSON files." 68 | 69 | htmlhelp: 70 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 71 | @echo 72 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 73 | ".hhp project file in $(BUILDDIR)/htmlhelp." 74 | 75 | qthelp: 76 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 77 | @echo 78 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 79 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 80 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/py-couchdb.qhcp" 81 | @echo "To view the help file:" 82 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/py-couchdb.qhc" 83 | 84 | devhelp: 85 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 86 | @echo 87 | @echo "Build finished." 88 | @echo "To view the help file:" 89 | @echo "# mkdir -p $$HOME/.local/share/devhelp/py-couchdb" 90 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/py-couchdb" 91 | @echo "# devhelp" 92 | 93 | epub: 94 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 95 | @echo 96 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 97 | 98 | latex: 99 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 100 | @echo 101 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 102 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 103 | "(use \`make latexpdf' here to do that automatically)." 104 | 105 | latexpdf: 106 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 107 | @echo "Running LaTeX files through pdflatex..." 108 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 109 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 110 | 111 | text: 112 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 113 | @echo 114 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 115 | 116 | man: 117 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 118 | @echo 119 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 120 | 121 | texinfo: 122 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 123 | @echo 124 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 125 | @echo "Run \`make' in that directory to run these through makeinfo" \ 126 | "(use \`make info' here to do that automatically)." 127 | 128 | info: 129 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 130 | @echo "Running Texinfo files through makeinfo..." 131 | make -C $(BUILDDIR)/texinfo info 132 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 133 | 134 | gettext: 135 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 136 | @echo 137 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 138 | 139 | changes: 140 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 141 | @echo 142 | @echo "The overview file is in $(BUILDDIR)/changes." 143 | 144 | linkcheck: 145 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 146 | @echo 147 | @echo "Link check complete; look for any errors in the above output " \ 148 | "or in $(BUILDDIR)/linkcheck/output.txt." 149 | 150 | doctest: 151 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 152 | @echo "Testing of doctests in the sources finished, look at the " \ 153 | "results in $(BUILDDIR)/doctest/output.txt." 154 | -------------------------------------------------------------------------------- /docs/source/quickstart.rst: -------------------------------------------------------------------------------- 1 | .. _quickstart: 2 | 3 | Quickstart 4 | ========== 5 | 6 | This page gives a good introduction in how to get started with py-couchdb. This assumes you already have it 7 | installed. If you do not, head over to the :ref:`Installation ` section. 8 | 9 | 10 | Connect to a server 11 | -------------------- 12 | 13 | Connect to a couchdb server is very simple. Begin by importing ``pycouchdb`` module and instance 14 | a server class: 15 | 16 | .. code-block:: python 17 | 18 | >>> import pycouchdb 19 | >>> server = pycouchdb.Server() 20 | 21 | 22 | Authentication 23 | -------------- 24 | 25 | By default, py-couchdb connects to a ``http://localhost:5984/`` but if your couchdb requieres 26 | authentication, you can pass ``http://username:password@localhost:5984/`` to server constructor: 27 | 28 | .. code-block:: python 29 | 30 | >>> server = pycouchdb.Server("http://username:password@localhost:5984/") 31 | 32 | py-couchdb have two methods for authentication: with session or basic auth. By default, "basic" method 33 | is used but if you like, can specify the method on create a server instance: 34 | 35 | .. code-block:: python 36 | 37 | >>> server = pycouchdb.Server("http://username:password@localhost:5984/", 38 | authmethod="session") 39 | 40 | Create, obtain and delete a database 41 | ------------------------------------- 42 | 43 | CouchDB can contains multiple databases. For access to one, this is a simple example: 44 | 45 | .. code-block:: python 46 | 47 | >>> db = server.database("foo") 48 | >>> db 49 | 50 | 51 | 52 | Can create one new db: 53 | 54 | .. code-block:: python 55 | 56 | >>> server.create("foo2") 57 | 58 | 59 | And can remove a database: 60 | 61 | .. code-block:: python 62 | 63 | >>> server.delete("foo2") 64 | 65 | 66 | If you intent remove not existent database, `NotFound` exception is raised. For 67 | more information see :ref:`Exceptions API `. 68 | 69 | .. code-block:: python 70 | 71 | >>> server.delete("foo") 72 | Traceback (most recent call last): 73 | File "", line 1, in 74 | File "./pycouchdb/client.py", line 42, in delete 75 | raise NotFound("database {0} not found".format(name)) 76 | pycouchdb.exceptions.NotFound: database foo not found 77 | 78 | 79 | Create, obtain and delete a document 80 | ------------------------------------ 81 | 82 | The simplest way for get a document is using its ``id``. 83 | 84 | .. code-block:: python 85 | 86 | >>> db = server.database("foo") 87 | >>> doc = db.get("b1536a237d8d14f3bfde54b41b036d8a") 88 | >>> doc 89 | {'_rev': '1-d62e11770282e4fcc5f6311dae6c80ee', 'name': 'Bar', 90 | '_id': 'b1536a237d8d14f3bfde54b41b036d8a'} 91 | 92 | 93 | You can create an own document: 94 | 95 | .. code-block:: python 96 | 97 | >>> doc = db.save({"name": "FOO"}) 98 | >>> doc 99 | {'_rev': '1-6a1be826ddbd67649df8aa1e0bf12da1', 100 | '_id': 'ef9e608db6434dd39ab3dc4cf35d22b7', 'name': 'FOO'} 101 | 102 | 103 | Delete a document: 104 | 105 | .. code-block:: python 106 | 107 | >>> db.delete("ef9e608db6434dd39ab3dc4cf35d22b7") 108 | >>> "ef9e608db6434dd39ab3dc4cf35d22b7" not in db 109 | True 110 | 111 | 112 | Querying a database 113 | ------------------- 114 | 115 | With couchDB you can make two types of queries: temporary or view. This is a simple way to make 116 | a temporary query: 117 | 118 | .. code-block:: python 119 | 120 | >>> map_func = "function(doc) { emit(doc.name, 1); }" 121 | >>> db.temporary_query(map_func) 122 | 123 | >>> list(db.temporary_query(map_func)) 124 | [{'value': 1, 'id': '8b588fa0a3b74a299c6d958467994b9a', 'key': 'Fooo'}] 125 | 126 | 127 | And this is a way to make a query using predefined views: 128 | 129 | .. code-block:: python 130 | 131 | >>> _doc = { 132 | ... "_id": "_design/testing", 133 | ... "views": { 134 | ... "names": { 135 | ... "map": "function(doc) { emit(doc.name, 1); }", 136 | ... "reduce": "function(k, v) { return sum(v); }", 137 | ... } 138 | ... } 139 | ...} 140 | >>> doc = db.save(_doc) 141 | >>> list(db.query("testing/names", group='true')) 142 | [{'value': 1, 'key': 'Fooo'}] 143 | 144 | 145 | In order to make query with Python see :ref:`Views ` on how to configure 146 | CouchDB. And this is a way to make a query using predefined views with Python: 147 | 148 | .. code-block:: python 149 | 150 | >>> _doc = { 151 | ... "_id": "_design/testing", 152 | ... "language": "python3", 153 | ... "views": { 154 | ... "names": { 155 | ... "map": "def fun(doc): yield doc.name, 1", 156 | ... "reduce": "def fun(k, v): return sum(v)", 157 | ... } 158 | ... } 159 | ...} 160 | >>> doc = db.save(_doc) 161 | >>> list(db.query("testing/names", group='true', language='python3')) 162 | [{'value': 1, 'key': 'Fooo'}] 163 | 164 | 165 | Subscribe to a changes stream feed 166 | ---------------------------------- 167 | 168 | CouchDB exposes a fantastic stream API for push change notifications, 169 | and with **pycouchdb** you can subscribe to these changes in a very 170 | simple way: 171 | 172 | .. code-block:: python 173 | 174 | >>> def feed_reader(message, db): 175 | ... print(message) 176 | ... 177 | >>> db.changes_feed(feed_reader) 178 | 179 | ``changes_feed`` blocks until a stream is closed or :py:class:`~pycouchdb.exceptions.FeedReaderExited` 180 | is raised inside of reader function. 181 | 182 | Also, you can make reader as class. This have some advantage, because it exposes often useful 183 | close callback. 184 | 185 | Example: 186 | 187 | .. code-block:: python 188 | 189 | >>> from pycouchdb.feedreader import BaseFeedReader 190 | >>> from pycouchdb.exceptions import FeedReaderExited 191 | >>> 192 | >>> class MyReader(BaseFeedReader): 193 | ... def on_message(self, message): 194 | ... # self.db is a current Database instance 195 | ... # process message 196 | ... raise FeedReaderExited() 197 | ... 198 | ... def on_close(self): 199 | ... # This is executed after a exception 200 | ... # is raised on on_message method 201 | ... print("Feed reader end") 202 | ... 203 | >>> db.changes_feed(MyReader()) 204 | -------------------------------------------------------------------------------- /test/test_feedreader.py: -------------------------------------------------------------------------------- 1 | """ 2 | Unit tests for pycouchdb.feedreader module. 3 | """ 4 | 5 | import pytest 6 | from pycouchdb import feedreader, exceptions 7 | 8 | 9 | class TestBaseFeedReader: 10 | """Test BaseFeedReader class.""" 11 | 12 | def test_base_feed_reader_initialization(self): 13 | """Test BaseFeedReader initialization.""" 14 | reader = feedreader.BaseFeedReader() 15 | assert not hasattr(reader, 'db') 16 | 17 | def test_base_feed_reader_call(self): 18 | """Test BaseFeedReader __call__ method.""" 19 | reader = feedreader.BaseFeedReader() 20 | mock_db = "mock_database" 21 | 22 | result = reader(mock_db) 23 | 24 | assert result is reader 25 | assert reader.db == mock_db 26 | 27 | def test_base_feed_reader_on_message_not_implemented(self): 28 | """Test that on_message raises NotImplementedError.""" 29 | reader = feedreader.BaseFeedReader() 30 | reader.db = "mock_db" 31 | 32 | with pytest.raises(NotImplementedError): 33 | reader.on_message({"test": "message"}) 34 | 35 | def test_base_feed_reader_on_close_default(self): 36 | """Test that on_close does nothing by default.""" 37 | reader = feedreader.BaseFeedReader() 38 | reader.db = "mock_db" 39 | 40 | # Should not raise any exception 41 | reader.on_close() 42 | 43 | def test_base_feed_reader_on_heartbeat_default(self): 44 | """Test that on_heartbeat does nothing by default.""" 45 | reader = feedreader.BaseFeedReader() 46 | reader.db = "mock_db" 47 | 48 | # Should not raise any exception 49 | reader.on_heartbeat() 50 | 51 | 52 | class TestSimpleFeedReader: 53 | """Test SimpleFeedReader class.""" 54 | 55 | def test_simple_feed_reader_initialization(self): 56 | """Test SimpleFeedReader initialization.""" 57 | reader = feedreader.SimpleFeedReader() 58 | assert not hasattr(reader, 'db') 59 | assert not hasattr(reader, 'callback') 60 | 61 | def test_simple_feed_reader_call(self): 62 | """Test SimpleFeedReader __call__ method.""" 63 | reader = feedreader.SimpleFeedReader() 64 | mock_db = "mock_database" 65 | mock_callback = lambda msg, db: None 66 | 67 | result = reader(mock_db, mock_callback) 68 | 69 | assert result is reader 70 | assert reader.db == mock_db 71 | assert reader.callback is mock_callback 72 | 73 | def test_simple_feed_reader_on_message(self): 74 | """Test SimpleFeedReader on_message method.""" 75 | reader = feedreader.SimpleFeedReader() 76 | mock_db = "mock_database" 77 | messages_received = [] 78 | 79 | def mock_callback(message, db): 80 | messages_received.append((message, db)) 81 | 82 | reader(mock_db, mock_callback) 83 | 84 | test_message = {"id": "test_doc", "seq": 1} 85 | reader.on_message(test_message) 86 | 87 | assert len(messages_received) == 1 88 | assert messages_received[0] == (test_message, mock_db) 89 | 90 | def test_simple_feed_reader_on_message_multiple_calls(self): 91 | """Test SimpleFeedReader on_message with multiple calls.""" 92 | reader = feedreader.SimpleFeedReader() 93 | mock_db = "mock_database" 94 | messages_received = [] 95 | 96 | def mock_callback(message, db): 97 | messages_received.append(message) 98 | 99 | reader(mock_db, mock_callback) 100 | 101 | # Send multiple messages 102 | for i in range(3): 103 | test_message = {"id": f"test_doc_{i}", "seq": i} 104 | reader.on_message(test_message) 105 | 106 | assert len(messages_received) == 3 107 | assert messages_received[0]["id"] == "test_doc_0" 108 | assert messages_received[1]["id"] == "test_doc_1" 109 | assert messages_received[2]["id"] == "test_doc_2" 110 | 111 | def test_simple_feed_reader_inheritance(self): 112 | """Test that SimpleFeedReader inherits from BaseFeedReader.""" 113 | reader = feedreader.SimpleFeedReader() 114 | assert isinstance(reader, feedreader.BaseFeedReader) 115 | 116 | def test_simple_feed_reader_on_close_inherited(self): 117 | """Test that SimpleFeedReader inherits on_close from BaseFeedReader.""" 118 | reader = feedreader.SimpleFeedReader() 119 | mock_db = "mock_database" 120 | reader(mock_db, lambda msg, db: None) 121 | 122 | # Should not raise any exception (inherited from BaseFeedReader) 123 | reader.on_close() 124 | 125 | def test_simple_feed_reader_on_heartbeat_inherited(self): 126 | """Test that SimpleFeedReader inherits on_heartbeat from BaseFeedReader.""" 127 | reader = feedreader.SimpleFeedReader() 128 | mock_db = "mock_database" 129 | reader(mock_db, lambda msg, db: None) 130 | 131 | # Should not raise any exception (inherited from BaseFeedReader) 132 | reader.on_heartbeat() 133 | 134 | 135 | class TestFeedReaderIntegration: 136 | """Test feed reader integration scenarios.""" 137 | 138 | def test_custom_feed_reader_implementation(self): 139 | """Test custom feed reader implementation.""" 140 | class CustomFeedReader(feedreader.BaseFeedReader): 141 | def __init__(self): 142 | super().__init__() 143 | self.messages = [] 144 | self.heartbeats = 0 145 | self.closed = False 146 | 147 | def on_message(self, message): 148 | self.messages.append(message) 149 | 150 | def on_heartbeat(self): 151 | self.heartbeats += 1 152 | 153 | def on_close(self): 154 | self.closed = True 155 | 156 | reader = CustomFeedReader() 157 | mock_db = "mock_database" 158 | reader(mock_db) 159 | 160 | # Test message handling 161 | test_message = {"id": "test", "seq": 1} 162 | reader.on_message(test_message) 163 | assert len(reader.messages) == 1 164 | assert reader.messages[0] == test_message 165 | 166 | # Test heartbeat handling 167 | reader.on_heartbeat() 168 | assert reader.heartbeats == 1 169 | 170 | # Test close handling 171 | reader.on_close() 172 | assert reader.closed is True 173 | 174 | def test_simple_feed_reader_with_exception_handling(self): 175 | """Test SimpleFeedReader with callback that raises exceptions.""" 176 | reader = feedreader.SimpleFeedReader() 177 | mock_db = "mock_database" 178 | 179 | def failing_callback(message, db): 180 | raise ValueError("Callback failed") 181 | 182 | reader(mock_db, failing_callback) 183 | 184 | # Should propagate the exception 185 | with pytest.raises(ValueError, match="Callback failed"): 186 | reader.on_message({"test": "message"}) -------------------------------------------------------------------------------- /test/test_utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | Unit tests for pycouchdb.utils module. 3 | """ 4 | 5 | import json 6 | import pytest 7 | from pycouchdb import utils 8 | 9 | 10 | class TestUtils: 11 | """Test utility functions.""" 12 | 13 | def test_extract_credentials_no_auth(self): 14 | """Test extracting credentials from URL without authentication.""" 15 | url = 'http://localhost:5984/_config/' 16 | clean_url, credentials = utils.extract_credentials(url) 17 | 18 | assert clean_url == 'http://localhost:5984/_config/' 19 | assert credentials is None 20 | 21 | def test_extract_credentials_basic_auth(self): 22 | """Test extracting credentials from URL with basic authentication.""" 23 | url = 'http://joe:secret@localhost:5984/_config/' 24 | clean_url, credentials = utils.extract_credentials(url) 25 | 26 | assert clean_url == 'http://localhost:5984/_config/' 27 | assert credentials == ('joe', 'secret') 28 | 29 | def test_extract_credentials_encoded_auth(self): 30 | """Test extracting credentials from URL with encoded authentication.""" 31 | url = 'http://joe%40example.com:secret@localhost:5984/_config/' 32 | clean_url, credentials = utils.extract_credentials(url) 33 | 34 | assert clean_url == 'http://localhost:5984/_config/' 35 | assert credentials == ('joe@example.com', 'secret') 36 | 37 | def test_extract_credentials_password_with_colon(self): 38 | """Test extracting credentials from URL with password containing colons.""" 39 | url = 'http://user:pass:word@localhost:5984/_config/' 40 | clean_url, credentials = utils.extract_credentials(url) 41 | 42 | assert clean_url == 'http://localhost:5984/_config/' 43 | assert credentials == ('user', 'pass:word') 44 | 45 | def test_extract_credentials_password_with_multiple_colons(self): 46 | """Test extracting credentials from URL with password containing multiple colons.""" 47 | url = 'http://user:pass:word:with:colons@localhost:5984/_config/' 48 | clean_url, credentials = utils.extract_credentials(url) 49 | 50 | assert clean_url == 'http://localhost:5984/_config/' 51 | assert credentials == ('user', 'pass:word:with:colons') 52 | 53 | def test_extract_credentials_encoded_password_with_colon(self): 54 | """Test extracting credentials from URL with encoded password containing colons.""" 55 | url = 'http://user:pass%3Aword@localhost:5984/_config/' 56 | clean_url, credentials = utils.extract_credentials(url) 57 | 58 | assert clean_url == 'http://localhost:5984/_config/' 59 | assert credentials == ('user', 'pass:word') 60 | 61 | def test_extract_credentials_invalid_format(self): 62 | """Test extracting credentials from URL with invalid credential format.""" 63 | url = 'http://user@localhost:5984/_config/' 64 | clean_url, credentials = utils.extract_credentials(url) 65 | 66 | assert clean_url == 'http://localhost:5984/_config/' 67 | assert credentials is None 68 | 69 | def test_urljoin_simple(self): 70 | """Test simple URL joining.""" 71 | result = utils.urljoin('http://localhost:5984/', 'test') 72 | assert result == 'http://localhost:5984/test' 73 | 74 | def test_urljoin_multiple_paths(self): 75 | """Test URL joining with multiple path segments.""" 76 | result = utils.urljoin('http://localhost:5984/', 'db', 'doc', 'id') 77 | assert result == 'http://localhost:5984/db/doc/id' 78 | 79 | def test_urljoin_with_trailing_slash(self): 80 | """Test URL joining with trailing slash in base URL.""" 81 | result = utils.urljoin('http://localhost:5984/', '/test') 82 | assert result == 'http://localhost:5984/test' 83 | 84 | def test_urljoin_with_leading_slash(self): 85 | """Test URL joining with leading slash in path.""" 86 | result = utils.urljoin('http://localhost:5984', '/test') 87 | assert result == 'http://localhost:5984/test' 88 | 89 | def test_urljoin_empty_paths(self): 90 | """Test URL joining with empty path segments.""" 91 | result = utils.urljoin('http://localhost:5984/', '', 'test') 92 | assert result == 'http://localhost:5984/test' 93 | 94 | def test_force_bytes_string(self): 95 | """Test force_bytes with string input.""" 96 | result = utils.force_bytes('hello world') 97 | assert result == b'hello world' 98 | assert isinstance(result, bytes) 99 | 100 | def test_force_bytes_already_bytes(self): 101 | """Test force_bytes with bytes input.""" 102 | input_bytes = b'hello world' 103 | result = utils.force_bytes(input_bytes) 104 | assert result == input_bytes 105 | assert result is input_bytes 106 | 107 | def test_force_text_bytes(self): 108 | """Test force_text with bytes input.""" 109 | result = utils.force_text(b'hello world') 110 | assert result == 'hello world' 111 | assert isinstance(result, str) 112 | 113 | def test_force_text_already_string(self): 114 | """Test force_text with string input.""" 115 | input_str = 'hello world' 116 | result = utils.force_text(input_str) 117 | assert result == input_str 118 | assert result is input_str 119 | 120 | def test_as_json_valid_json(self): 121 | """Test as_json with valid JSON response.""" 122 | class MockResponse: 123 | def __init__(self): 124 | self.headers = {'content-type': 'application/json'} 125 | self.content = b'{"key": "value"}' 126 | 127 | response = MockResponse() 128 | result = utils.as_json(response) 129 | assert result == {'key': 'value'} 130 | 131 | def test_as_json_invalid_json(self): 132 | """Test as_json with invalid JSON response.""" 133 | class MockResponse: 134 | def __init__(self): 135 | self.headers = {'content-type': 'application/json'} 136 | self.content = b'invalid json' 137 | 138 | response = MockResponse() 139 | # The function doesn't handle JSON decode errors, so it raises an exception 140 | with pytest.raises(json.JSONDecodeError): # json.loads raises JSONDecodeError for invalid JSON 141 | utils.as_json(response) 142 | 143 | def test_encode_view_options(self): 144 | """Test encoding view options.""" 145 | options = { 146 | 'key': 'value', 147 | 'startkey': 'start', 148 | 'endkey': 'end', 149 | 'limit': 10, 150 | 'skip': 5, 151 | 'descending': True, 152 | 'include_docs': True 153 | } 154 | 155 | result = utils.encode_view_options(options) 156 | 157 | assert result['key'] == '"value"' 158 | assert result['startkey'] == '"start"' 159 | assert result['endkey'] == '"end"' 160 | assert result['limit'] == 10 161 | assert result['skip'] == 5 162 | assert result['descending'] == True # Boolean values are not converted 163 | assert result['include_docs'] == True 164 | 165 | def test_encode_view_options_with_list(self): 166 | """Test encoding view options with list values.""" 167 | options = { 168 | 'keys': ['key1', 'key2', 'key3'] 169 | } 170 | 171 | result = utils.encode_view_options(options) 172 | assert result['keys'] == ['key1', 'key2', 'key3'] # 'keys' is not in the special list, so it's not converted 173 | 174 | def test_encode_view_options_with_dict(self): 175 | """Test encoding view options with dict values.""" 176 | options = { 177 | 'key': {'nested': 'value'} 178 | } 179 | 180 | result = utils.encode_view_options(options) 181 | assert result['key'] == '{"nested": "value"}' 182 | 183 | def test_encode_view_options_boolean_values(self): 184 | """Test encoding view options with boolean values.""" 185 | options = { 186 | 'descending': False, 187 | 'include_docs': True, 188 | 'reduce': False 189 | } 190 | 191 | result = utils.encode_view_options(options) 192 | assert result['descending'] == False # Boolean values are not converted to strings 193 | assert result['include_docs'] == True 194 | assert result['reduce'] == False 195 | 196 | def test_encode_view_options_numeric_values(self): 197 | """Test encoding view options with numeric values.""" 198 | options = { 199 | 'limit': 100, 200 | 'skip': 0, 201 | 'group_level': 2 202 | } 203 | 204 | result = utils.encode_view_options(options) 205 | assert result['limit'] == 100 206 | assert result['skip'] == 0 207 | assert result['group_level'] == 2 -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # py-couchdb documentation build configuration file, created by 4 | # sphinx-quickstart on Wed Jan 16 19:57:28 2013. 5 | # 6 | # This file is execfile()d with the current directory set to its containing dir. 7 | # 8 | # Note that not all possible configuration values are present in this 9 | # autogenerated file. 10 | # 11 | # All configuration values have a default; values that are commented out 12 | # serve to show the default. 13 | 14 | import sys, os 15 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.abspath(__file__), "..", "..", ".."))) 16 | 17 | 18 | # If extensions (or modules to document with autodoc) are in another directory, 19 | # add these directories to sys.path here. If the directory is relative to the 20 | # documentation root, use os.path.abspath to make it absolute, like shown here. 21 | #sys.path.insert(0, os.path.abspath('.')) 22 | 23 | # -- General configuration ----------------------------------------------------- 24 | 25 | # If your documentation needs a minimal Sphinx version, state it here. 26 | #needs_sphinx = '1.0' 27 | 28 | # Add any Sphinx extension module names here, as strings. They can be extensions 29 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 30 | extensions = ['sphinx.ext.autodoc'] 31 | 32 | # Add any paths that contain templates here, relative to this directory. 33 | templates_path = ['_templates'] 34 | 35 | # The suffix of source filenames. 36 | source_suffix = '.rst' 37 | 38 | # The encoding of source files. 39 | #source_encoding = 'utf-8-sig' 40 | 41 | # The master toctree document. 42 | master_doc = 'index' 43 | 44 | # General information about the project. 45 | project = u'py-couchdb' 46 | copyright = u'2024, Andrey Antukh' 47 | 48 | # The version info for the project you're documenting, acts as replacement for 49 | # |version| and |release|, also used in various other places throughout the 50 | # built documents. 51 | # 52 | # The short X.Y version. 53 | version = '1.16' 54 | # The full version, including alpha/beta/rc tags. 55 | release = '1.16' 56 | 57 | # The language for content autogenerated by Sphinx. Refer to documentation 58 | # for a list of supported languages. 59 | #language = None 60 | 61 | # There are two options for replacing |today|: either, you set today to some 62 | # non-false value, then it is used: 63 | #today = '' 64 | # Else, today_fmt is used as the format for a strftime call. 65 | #today_fmt = '%B %d, %Y' 66 | 67 | # List of patterns, relative to source directory, that match files and 68 | # directories to ignore when looking for source files. 69 | exclude_patterns = [] 70 | 71 | # The reST default role (used for this markup: `text`) to use for all documents. 72 | #default_role = None 73 | 74 | # If true, '()' will be appended to :func: etc. cross-reference text. 75 | #add_function_parentheses = True 76 | 77 | # If true, the current module name will be prepended to all description 78 | # unit titles (such as .. function::). 79 | #add_module_names = True 80 | 81 | # If true, sectionauthor and moduleauthor directives will be shown in the 82 | # output. They are ignored by default. 83 | #show_authors = False 84 | 85 | # The name of the Pygments (syntax highlighting) style to use. 86 | pygments_style = 'sphinx' 87 | 88 | # A list of ignored prefixes for module index sorting. 89 | #modindex_common_prefix = [] 90 | 91 | 92 | # -- Options for HTML output --------------------------------------------------- 93 | 94 | # The theme to use for HTML and HTML Help pages. See the documentation for 95 | # a list of builtin themes. 96 | #sys.path.append(os.path.abspath('_themes')) 97 | 98 | #html_theme_path = ['_themes'] 99 | #html_theme = 'kr' 100 | 101 | # Theme options are theme-specific and customize the look and feel of a theme 102 | # further. For a list of options available for each theme, see the 103 | # documentation. 104 | #html_theme_options = {} 105 | 106 | # Add any paths that contain custom themes here, relative to this directory. 107 | #html_theme_path = [] 108 | 109 | # The name for this set of Sphinx documents. If None, it defaults to 110 | # " v documentation". 111 | #html_title = None 112 | 113 | # A shorter title for the navigation bar. Default is the same as html_title. 114 | #html_short_title = None 115 | 116 | # The name of an image file (relative to this directory) to place at the top 117 | # of the sidebar. 118 | #html_logo = None 119 | 120 | # The name of an image file (within the static path) to use as favicon of the 121 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 122 | # pixels large. 123 | #html_favicon = None 124 | 125 | # Add any paths that contain custom static files (such as style sheets) here, 126 | # relative to this directory. They are copied after the builtin static files, 127 | # so a file named "default.css" will overwrite the builtin "default.css". 128 | html_static_path = ['_static'] 129 | 130 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 131 | # using the given strftime format. 132 | #html_last_updated_fmt = '%b %d, %Y' 133 | 134 | # If true, SmartyPants will be used to convert quotes and dashes to 135 | # typographically correct entities. 136 | #html_use_smartypants = True 137 | 138 | # Custom sidebar templates, maps document names to template names. 139 | #html_sidebars = {} 140 | 141 | # Additional templates that should be rendered to pages, maps page names to 142 | # template names. 143 | #html_additional_pages = {} 144 | 145 | # If false, no module index is generated. 146 | #html_domain_indices = True 147 | 148 | # If false, no index is generated. 149 | #html_use_index = True 150 | 151 | # If true, the index is split into individual pages for each letter. 152 | #html_split_index = False 153 | 154 | # If true, links to the reST sources are added to the pages. 155 | #html_show_sourcelink = True 156 | 157 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 158 | #html_show_sphinx = True 159 | 160 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 161 | #html_show_copyright = True 162 | 163 | # If true, an OpenSearch description file will be output, and all pages will 164 | # contain a tag referring to it. The value of this option must be the 165 | # base URL from which the finished HTML is served. 166 | #html_use_opensearch = '' 167 | 168 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 169 | #html_file_suffix = None 170 | 171 | # Output file base name for HTML help builder. 172 | htmlhelp_basename = 'py-couchdbdoc' 173 | 174 | 175 | # -- Options for LaTeX output -------------------------------------------------- 176 | 177 | latex_elements = { 178 | # The paper size ('letterpaper' or 'a4paper'). 179 | #'papersize': 'letterpaper', 180 | 181 | # The font size ('10pt', '11pt' or '12pt'). 182 | #'pointsize': '10pt', 183 | 184 | # Additional stuff for the LaTeX preamble. 185 | #'preamble': '', 186 | } 187 | 188 | # Grouping the document tree into LaTeX files. List of tuples 189 | # (source start file, target name, title, author, documentclass [howto/manual]). 190 | latex_documents = [ 191 | ('index', 'py-couchdb.tex', u'py-couchdb Documentation', 192 | u'Andrey Antukh', 'manual'), 193 | ] 194 | 195 | # The name of an image file (relative to this directory) to place at the top of 196 | # the title page. 197 | #latex_logo = None 198 | 199 | # For "manual" documents, if this is true, then toplevel headings are parts, 200 | # not chapters. 201 | #latex_use_parts = False 202 | 203 | # If true, show page references after internal links. 204 | #latex_show_pagerefs = False 205 | 206 | # If true, show URL addresses after external links. 207 | #latex_show_urls = False 208 | 209 | # Documents to append as an appendix to all manuals. 210 | #latex_appendices = [] 211 | 212 | # If false, no module index is generated. 213 | #latex_domain_indices = True 214 | 215 | 216 | # -- Options for manual page output -------------------------------------------- 217 | 218 | # One entry per manual page. List of tuples 219 | # (source start file, name, description, authors, manual section). 220 | man_pages = [ 221 | ('index', 'py-couchdb', u'py-couchdb Documentation', 222 | [u'Andrey Antukh'], 1) 223 | ] 224 | 225 | # If true, show URL addresses after external links. 226 | #man_show_urls = False 227 | 228 | 229 | # -- Options for Texinfo output ------------------------------------------------ 230 | 231 | # Grouping the document tree into Texinfo files. List of tuples 232 | # (source start file, target name, title, author, 233 | # dir menu entry, description, category) 234 | texinfo_documents = [ 235 | ('index', 'py-couchdb', u'py-couchdb Documentation', 236 | u'Andrey Antukh', 'py-couchdb', 'One line description of project.', 237 | 'Miscellaneous'), 238 | ] 239 | 240 | # Documents to append as an appendix to all manuals. 241 | #texinfo_appendices = [] 242 | 243 | # If false, no module index is generated. 244 | #texinfo_domain_indices = True 245 | 246 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 247 | #texinfo_show_urls = 'footnote' 248 | -------------------------------------------------------------------------------- /test/integration/test_error_scenarios.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Integration tests for error scenarios using a simulated bad-behaving CouchDB. 5 | 6 | This module tests pycouchdb library behavior under various error conditions 7 | by using a test server that can simulate different failure modes. 8 | """ 9 | 10 | import pytest 11 | import pycouchdb 12 | import threading 13 | import time 14 | import json 15 | from http.server import HTTPServer, BaseHTTPRequestHandler 16 | from urllib.parse import urlparse, parse_qs 17 | import socketserver 18 | 19 | 20 | class BadCouchDBHandler(BaseHTTPRequestHandler): 21 | """HTTP handler that simulates various CouchDB error conditions.""" 22 | 23 | def __init__(self, *args, error_scenario=None, **kwargs): 24 | self.error_scenario = error_scenario 25 | super().__init__(*args, **kwargs) 26 | 27 | def do_GET(self): 28 | """Handle GET requests with various error scenarios.""" 29 | if self.error_scenario == 'timeout': 30 | time.sleep(10) # Simulate timeout 31 | elif self.error_scenario == 'server_error': 32 | self.send_response(500) 33 | self.send_header('Content-Type', 'application/json') 34 | self.end_headers() 35 | self.wfile.write(json.dumps({"error": "Internal Server Error"}).encode()) 36 | elif self.error_scenario == 'malformed_json': 37 | self.send_response(200) 38 | self.send_header('Content-Type', 'application/json') 39 | self.end_headers() 40 | self.wfile.write(b'{"invalid": json}') # Malformed JSON 41 | elif self.error_scenario == 'connection_refused': 42 | # This would be handled at the connection level, not HTTP level 43 | self.send_response(200) 44 | self.send_header('Content-Type', 'application/json') 45 | self.end_headers() 46 | self.wfile.write(json.dumps({"couchdb": "Welcome"}).encode()) 47 | else: 48 | # Normal response 49 | self.send_response(200) 50 | self.send_header('Content-Type', 'application/json') 51 | self.end_headers() 52 | self.wfile.write(json.dumps({"couchdb": "Welcome", "version": "3.2.0"}).encode()) 53 | 54 | def do_POST(self): 55 | """Handle POST requests.""" 56 | if self.error_scenario == 'timeout': 57 | time.sleep(10) 58 | elif self.error_scenario == 'server_error': 59 | self.send_response(500) 60 | self.send_header('Content-Type', 'application/json') 61 | self.end_headers() 62 | self.wfile.write(json.dumps({"error": "Internal Server Error"}).encode()) 63 | else: 64 | self.send_response(200) 65 | self.send_header('Content-Type', 'application/json') 66 | self.end_headers() 67 | self.wfile.write(json.dumps({"ok": True}).encode()) 68 | 69 | def do_PUT(self): 70 | """Handle PUT requests.""" 71 | if self.error_scenario == 'timeout': 72 | time.sleep(10) 73 | elif self.error_scenario == 'server_error': 74 | self.send_response(500) 75 | self.send_header('Content-Type', 'application/json') 76 | self.end_headers() 77 | self.wfile.write(json.dumps({"error": "Internal Server Error"}).encode()) 78 | else: 79 | self.send_response(200) 80 | self.send_header('Content-Type', 'application/json') 81 | self.end_headers() 82 | self.wfile.write(json.dumps({"ok": True}).encode()) 83 | 84 | def do_DELETE(self): 85 | """Handle DELETE requests.""" 86 | if self.error_scenario == 'timeout': 87 | time.sleep(10) 88 | elif self.error_scenario == 'server_error': 89 | self.send_response(500) 90 | self.send_header('Content-Type', 'application/json') 91 | self.end_headers() 92 | self.wfile.write(json.dumps({"error": "Internal Server Error"}).encode()) 93 | else: 94 | self.send_response(200) 95 | self.send_header('Content-Type', 'application/json') 96 | self.end_headers() 97 | self.wfile.write(json.dumps({"ok": True}).encode()) 98 | 99 | def do_HEAD(self): 100 | """Handle HEAD requests.""" 101 | if self.error_scenario == 'timeout': 102 | time.sleep(10) 103 | elif self.error_scenario == 'server_error': 104 | self.send_response(500) 105 | self.send_header('Content-Type', 'application/json') 106 | self.end_headers() 107 | else: 108 | self.send_response(200) 109 | self.send_header('Content-Type', 'application/json') 110 | self.end_headers() 111 | 112 | def log_message(self, format, *args): 113 | """Suppress log messages during testing.""" 114 | pass 115 | 116 | 117 | class BadCouchDBServer: 118 | """Test server that can simulate various CouchDB error conditions.""" 119 | 120 | def __init__(self, port=0, error_scenario=None): 121 | self.port = port 122 | self.error_scenario = error_scenario 123 | self.server = None 124 | self.thread = None 125 | 126 | def start(self): 127 | """Start the test server.""" 128 | def handler(*args, **kwargs): 129 | return BadCouchDBHandler(*args, error_scenario=self.error_scenario, **kwargs) 130 | 131 | self.server = HTTPServer(('localhost', self.port), handler) 132 | self.port = self.server.server_address[1] 133 | self.thread = threading.Thread(target=self.server.serve_forever) 134 | self.thread.daemon = True 135 | self.thread.start() 136 | time.sleep(0.1) # Give server time to start 137 | 138 | def stop(self): 139 | """Stop the test server.""" 140 | if self.server: 141 | self.server.shutdown() 142 | self.server.server_close() 143 | if self.thread: 144 | self.thread.join(timeout=1) 145 | 146 | @property 147 | def url(self): 148 | """Get the server URL.""" 149 | return f'http://localhost:{self.port}/' 150 | 151 | 152 | @pytest.fixture 153 | def bad_couchdb_server(): 154 | """Fixture for bad behaving CouchDB server.""" 155 | server = None 156 | try: 157 | yield server 158 | finally: 159 | if server: 160 | server.stop() 161 | 162 | 163 | @pytest.fixture 164 | def timeout_server(): 165 | """Fixture for timeout scenario.""" 166 | server = BadCouchDBServer(error_scenario='timeout') 167 | server.start() 168 | try: 169 | yield server 170 | finally: 171 | server.stop() 172 | 173 | 174 | @pytest.fixture 175 | def server_error_server(): 176 | """Fixture for server error scenario.""" 177 | server = BadCouchDBServer(error_scenario='server_error') 178 | server.start() 179 | try: 180 | yield server 181 | finally: 182 | server.stop() 183 | 184 | 185 | @pytest.fixture 186 | def malformed_json_server(): 187 | """Fixture for malformed JSON scenario.""" 188 | server = BadCouchDBServer(error_scenario='malformed_json') 189 | server.start() 190 | try: 191 | yield server 192 | finally: 193 | server.stop() 194 | 195 | 196 | # Error Scenario Tests 197 | def test_timeout_handling(timeout_server): 198 | """Test pycouchdb library behavior with timeout scenarios.""" 199 | server = pycouchdb.Server(timeout_server.url) 200 | 201 | with pytest.raises(Exception): 202 | server.info() 203 | 204 | 205 | def test_server_error_handling(server_error_server): 206 | """Test pycouchdb library behavior with server errors.""" 207 | server = pycouchdb.Server(server_error_server.url) 208 | 209 | with pytest.raises(Exception): 210 | server.info() 211 | 212 | 213 | def test_malformed_json_handling(malformed_json_server): 214 | """Test pycouchdb library behavior with malformed JSON responses.""" 215 | server = pycouchdb.Server(malformed_json_server.url) 216 | 217 | with pytest.raises(Exception): 218 | server.info() 219 | 220 | 221 | def test_connection_refused_handling(): 222 | """Test pycouchdb library behavior when connection is refused.""" 223 | server = pycouchdb.Server('http://localhost:99999/') 224 | 225 | with pytest.raises(Exception): 226 | server.info() 227 | 228 | 229 | def test_authentication_failure_handling(): 230 | """Test pycouchdb library behavior with authentication failures.""" 231 | server = pycouchdb.Server('http://invalid:credentials@localhost:5984/') 232 | 233 | with pytest.raises(Exception): 234 | server.info() 235 | 236 | 237 | def test_ssl_verification_failure_handling(): 238 | """Test pycouchdb library behavior with SSL verification failures.""" 239 | try: 240 | server = pycouchdb.Server('https://self-signed.badssl.com/', verify=True) 241 | server.info() 242 | pytest.fail("Expected SSL verification to fail") 243 | except Exception: 244 | pass 245 | 246 | 247 | def test_network_unreachable_handling(): 248 | """Test pycouchdb library behavior when network is unreachable.""" 249 | server = pycouchdb.Server('http://192.0.2.1:5984/') 250 | 251 | with pytest.raises(Exception): 252 | server.info() 253 | 254 | 255 | def test_invalid_url_handling(): 256 | """Test pycouchdb library behavior with invalid URLs.""" 257 | invalid_urls = [ 258 | 'http://localhost:not-a-port/', 259 | 'http://nonexistent-host-12345:5984/', 260 | ] 261 | 262 | for url in invalid_urls: 263 | server = pycouchdb.Server(url) 264 | assert server is not None 265 | 266 | with pytest.raises(Exception): 267 | server.info() 268 | 269 | 270 | def test_large_response_handling(): 271 | """Test pycouchdb library behavior with very large responses.""" 272 | server = pycouchdb.Server('http://admin:password@localhost:5984/') 273 | 274 | try: 275 | db = server.create('large_response_test') 276 | 277 | docs = [{'index': i, 'data': 'x' * 1000} for i in range(1000)] 278 | db.save_bulk(docs) 279 | 280 | all_docs = list(db.all()) 281 | assert len(all_docs) >= 1000 282 | 283 | except Exception as e: 284 | pytest.skip(f"Large response test skipped: {e}") 285 | finally: 286 | try: 287 | server.delete('large_response_test') 288 | except: 289 | pass 290 | 291 | 292 | def test_concurrent_error_handling(): 293 | """Test pycouchdb library behavior under concurrent error conditions.""" 294 | server = pycouchdb.Server('http://admin:password@localhost:5984/') 295 | errors = [] 296 | 297 | def make_request(): 298 | try: 299 | server.info() 300 | except Exception as e: 301 | errors.append(e) 302 | 303 | threads = [] 304 | for i in range(5): 305 | thread = threading.Thread(target=make_request) 306 | threads.append(thread) 307 | thread.start() 308 | 309 | for thread in threads: 310 | thread.join() 311 | 312 | assert len(errors) == 0 313 | 314 | 315 | def test_database_operations_under_error_conditions(): 316 | """Test database operations under various error conditions.""" 317 | server = pycouchdb.Server('http://admin:password@localhost:5984/') 318 | 319 | try: 320 | # Test database operations that might fail 321 | db = server.create('error_test_db') 322 | 323 | # Test saving document 324 | doc = db.save({'_id': 'test_doc', 'data': 'test'}) 325 | assert doc['_id'] == 'test_doc' 326 | 327 | # Test getting document 328 | retrieved_doc = db.get('test_doc') 329 | assert retrieved_doc['data'] == 'test' 330 | 331 | # Test deleting document 332 | db.delete('test_doc') 333 | 334 | # Test that document is gone 335 | with pytest.raises(pycouchdb.exceptions.NotFound): 336 | db.get('test_doc') 337 | 338 | except Exception as e: 339 | pytest.skip(f"Database operations test skipped: {e}") 340 | finally: 341 | try: 342 | server.delete('error_test_db') 343 | except: 344 | pass 345 | 346 | 347 | def test_bulk_operations_error_handling(): 348 | """Test bulk operations error handling.""" 349 | server = pycouchdb.Server('http://admin:password@localhost:5984/') 350 | 351 | try: 352 | db = server.create('bulk_error_test') 353 | 354 | # Test bulk save 355 | docs = [{'index': i, 'data': f'bulk_{i}'} for i in range(10)] 356 | saved_docs = db.save_bulk(docs) 357 | assert len(saved_docs) == 10 358 | 359 | # Test bulk delete 360 | deleted_docs = db.delete_bulk(saved_docs) 361 | assert len(deleted_docs) == 10 362 | 363 | except Exception as e: 364 | pytest.skip(f"Bulk operations test skipped: {e}") 365 | finally: 366 | try: 367 | server.delete('bulk_error_test') 368 | except: 369 | pass 370 | 371 | 372 | def test_attachment_operations_error_handling(): 373 | """Test attachment operations error handling.""" 374 | server = pycouchdb.Server('http://admin:password@localhost:5984/') 375 | 376 | try: 377 | db = server.create('attachment_error_test') 378 | 379 | # Create document 380 | doc = db.save({'_id': 'attachment_test', 'type': 'test'}) 381 | 382 | # Test attachment operations 383 | import io 384 | content = b'test attachment content' 385 | content_stream = io.BytesIO(content) 386 | 387 | # Put attachment 388 | doc_with_attachment = db.put_attachment(doc, content_stream, 'test.txt') 389 | assert '_attachments' in doc_with_attachment 390 | 391 | # Get attachment 392 | retrieved_content = db.get_attachment(doc_with_attachment, 'test.txt') 393 | assert retrieved_content == content 394 | 395 | # Delete attachment 396 | doc_without_attachment = db.delete_attachment(doc_with_attachment, 'test.txt') 397 | assert '_attachments' not in doc_without_attachment 398 | 399 | except Exception as e: 400 | pytest.skip(f"Attachment operations test skipped: {e}") 401 | finally: 402 | try: 403 | server.delete('attachment_error_test') 404 | except: 405 | pass -------------------------------------------------------------------------------- /test/test_resource.py: -------------------------------------------------------------------------------- 1 | """ 2 | Unit tests for pycouchdb.resource module. 3 | """ 4 | 5 | import pytest 6 | import json 7 | from unittest.mock import Mock, patch, MagicMock 8 | from pycouchdb import resource, exceptions 9 | 10 | 11 | class TestResource: 12 | """Test Resource class.""" 13 | 14 | def test_resource_initialization_without_session(self): 15 | """Test Resource initialization without existing session.""" 16 | with patch('pycouchdb.resource.requests.session') as mock_session: 17 | mock_session_instance = Mock() 18 | mock_session.return_value = mock_session_instance 19 | 20 | res = resource.Resource("http://localhost:5984/") 21 | 22 | assert res.base_url == "http://localhost:5984/" 23 | assert res.session == mock_session_instance 24 | assert res.session.verify is False 25 | mock_session_instance.headers.update.assert_called_with({ 26 | "accept": "application/json", 27 | "content-type": "application/json" 28 | }) 29 | 30 | def test_resource_initialization_with_session(self): 31 | """Test Resource initialization with existing session.""" 32 | mock_session = Mock() 33 | mock_session.verify = True 34 | 35 | res = resource.Resource("http://localhost:5984/", session=mock_session, verify=True) 36 | 37 | assert res.base_url == "http://localhost:5984/" 38 | assert res.session == mock_session 39 | assert res.session.verify is True 40 | 41 | def test_resource_initialization_with_credentials_basic(self): 42 | """Test Resource initialization with basic auth credentials.""" 43 | with patch('pycouchdb.resource.requests.session') as mock_session: 44 | mock_session_instance = Mock() 45 | mock_session.return_value = mock_session_instance 46 | 47 | credentials = ("user", "password") 48 | res = resource.Resource("http://localhost:5984/", 49 | credentials=credentials, 50 | authmethod="basic") 51 | 52 | assert res.session.auth == credentials 53 | 54 | def test_resource_initialization_with_credentials_session(self): 55 | """Test Resource initialization with session auth credentials.""" 56 | with patch('pycouchdb.resource.requests.session') as mock_session: 57 | mock_session_instance = Mock() 58 | mock_session.return_value = mock_session_instance 59 | 60 | # Mock successful authentication 61 | mock_response = Mock() 62 | mock_response.status_code = 200 63 | mock_response.headers = {'content-type': 'application/json'} 64 | mock_session_instance.post.return_value = mock_response 65 | 66 | credentials = ("user", "password") 67 | res = resource.Resource("http://localhost:5984/", 68 | credentials=credentials, 69 | authmethod="session") 70 | 71 | # Verify session authentication was called 72 | mock_session_instance.post.assert_called_once() 73 | call_args = mock_session_instance.post.call_args 74 | assert "_session" in call_args[0][0] 75 | 76 | def test_resource_initialization_with_credentials_session_failure(self): 77 | """Test Resource initialization with failed session auth.""" 78 | with patch('pycouchdb.resource.requests.session') as mock_session: 79 | mock_session_instance = Mock() 80 | mock_session.return_value = mock_session_instance 81 | 82 | # Mock failed authentication 83 | mock_response = Mock() 84 | mock_response.status_code = 401 85 | mock_session_instance.post.return_value = mock_response 86 | 87 | credentials = ("user", "password") 88 | 89 | with pytest.raises(exceptions.AuthenticationFailed): 90 | resource.Resource("http://localhost:5984/", 91 | credentials=credentials, 92 | authmethod="session") 93 | 94 | def test_resource_initialization_invalid_auth_method(self): 95 | """Test Resource initialization with invalid auth method.""" 96 | with patch('pycouchdb.resource.requests.session') as mock_session: 97 | mock_session_instance = Mock() 98 | mock_session.return_value = mock_session_instance 99 | 100 | credentials = ("user", "password") 101 | 102 | with pytest.raises(RuntimeError, match="Invalid authentication method"): 103 | resource.Resource("http://localhost:5984/", 104 | credentials=credentials, 105 | authmethod="invalid") 106 | 107 | def test_resource_initialization_full_commit_false(self): 108 | """Test Resource initialization with full_commit=False.""" 109 | with patch('pycouchdb.resource.requests.session') as mock_session: 110 | mock_session_instance = Mock() 111 | mock_session.return_value = mock_session_instance 112 | 113 | res = resource.Resource("http://localhost:5984/", full_commit=False) 114 | 115 | # Verify full commit header was set 116 | calls = mock_session_instance.headers.update.call_args_list 117 | assert any('X-Couch-Full-Commit' in str(call) for call in calls) 118 | 119 | def test_resource_call(self): 120 | """Test Resource __call__ method.""" 121 | with patch('pycouchdb.resource.requests.session') as mock_session: 122 | mock_session_instance = Mock() 123 | mock_session.return_value = mock_session_instance 124 | 125 | res = resource.Resource("http://localhost:5984/") 126 | new_res = res("db", "doc") 127 | 128 | assert isinstance(new_res, resource.Resource) 129 | assert new_res.base_url == "http://localhost:5984/db/doc" 130 | assert new_res.session == mock_session_instance 131 | 132 | def test_resource_request_get(self): 133 | """Test Resource request method with GET.""" 134 | with patch('pycouchdb.resource.requests.session') as mock_session: 135 | mock_session_instance = Mock() 136 | mock_session.return_value = mock_session_instance 137 | 138 | # Mock successful response 139 | mock_response = Mock() 140 | mock_response.status_code = 200 141 | mock_response.headers = {'content-type': 'application/json'} 142 | mock_response.content = b'{"result": "success"}' 143 | mock_response.json.return_value = {"result": "success"} 144 | mock_session_instance.request.return_value = mock_response 145 | 146 | res = resource.Resource("http://localhost:5984/") 147 | response, result = res.request("GET", "test") 148 | 149 | assert response == mock_response 150 | assert result == {"result": "success"} 151 | mock_session_instance.request.assert_called_once() 152 | 153 | def test_resource_request_with_stream(self): 154 | """Test Resource request method with stream=True.""" 155 | with patch('pycouchdb.resource.requests.session') as mock_session: 156 | mock_session_instance = Mock() 157 | mock_session.return_value = mock_session_instance 158 | 159 | # Mock successful response 160 | mock_response = Mock() 161 | mock_response.status_code = 200 162 | mock_response.headers = {'content-type': 'application/json'} 163 | mock_session_instance.request.return_value = mock_response 164 | 165 | res = resource.Resource("http://localhost:5984/") 166 | response, result = res.request("GET", "test", stream=True) 167 | 168 | assert response == mock_response 169 | assert result is None # Should be None for stream requests 170 | 171 | def test_resource_request_with_conflict_error(self): 172 | """Test Resource request method with conflict error.""" 173 | with patch('pycouchdb.resource.requests.session') as mock_session: 174 | mock_session_instance = Mock() 175 | mock_session.return_value = mock_session_instance 176 | 177 | # Mock conflict response 178 | mock_response = Mock() 179 | mock_response.status_code = 409 180 | mock_response.headers = {'content-type': 'application/json'} 181 | mock_response.content = b'{"error": "conflict", "reason": "Document conflict"}' 182 | mock_response.json.return_value = {"error": "conflict", "reason": "Document conflict"} 183 | mock_session_instance.request.return_value = mock_response 184 | 185 | res = resource.Resource("http://localhost:5984/") 186 | 187 | with pytest.raises(exceptions.Conflict, match="Document conflict"): 188 | res.request("PUT", "test") 189 | 190 | def test_resource_request_with_not_found_error(self): 191 | """Test Resource request method with not found error.""" 192 | with patch('pycouchdb.resource.requests.session') as mock_session: 193 | mock_session_instance = Mock() 194 | mock_session.return_value = mock_session_instance 195 | 196 | # Mock not found response 197 | mock_response = Mock() 198 | mock_response.status_code = 404 199 | mock_response.headers = {'content-type': 'application/json'} 200 | mock_response.content = b'{"error": "not_found", "reason": "Document not found"}' 201 | mock_response.json.return_value = {"error": "not_found", "reason": "Document not found"} 202 | mock_session_instance.request.return_value = mock_response 203 | 204 | res = resource.Resource("http://localhost:5984/") 205 | 206 | with pytest.raises(exceptions.NotFound, match="Document not found"): 207 | res.request("GET", "test") 208 | 209 | def test_resource_request_with_bad_request_error(self): 210 | """Test Resource request method with bad request error.""" 211 | with patch('pycouchdb.resource.requests.session') as mock_session: 212 | mock_session_instance = Mock() 213 | mock_session.return_value = mock_session_instance 214 | 215 | # Mock bad request response 216 | mock_response = Mock() 217 | mock_response.status_code = 400 218 | mock_response.headers = {'content-type': 'application/json'} 219 | mock_response.content = b'{"error": "bad_request", "reason": "Invalid request"}' 220 | mock_response.json.return_value = {"error": "bad_request", "reason": "Invalid request"} 221 | mock_session_instance.request.return_value = mock_response 222 | 223 | res = resource.Resource("http://localhost:5984/") 224 | 225 | with pytest.raises(exceptions.BadRequest, match="Invalid request"): 226 | res.request("POST", "test") 227 | 228 | def test_resource_request_with_generic_error(self): 229 | """Test Resource request method with generic error.""" 230 | with patch('pycouchdb.resource.requests.session') as mock_session: 231 | mock_session_instance = Mock() 232 | mock_session.return_value = mock_session_instance 233 | 234 | # Mock generic error response 235 | mock_response = Mock() 236 | mock_response.status_code = 500 237 | mock_response.headers = {'content-type': 'application/json'} 238 | mock_response.content = b'{"error": "unknown", "reason": "Server error"}' 239 | mock_response.json.return_value = {"error": "unknown", "reason": "Server error"} 240 | mock_session_instance.request.return_value = mock_response 241 | 242 | res = resource.Resource("http://localhost:5984/") 243 | 244 | with pytest.raises(exceptions.GenericError): 245 | res.request("GET", "test") 246 | 247 | def test_resource_request_with_list_result(self): 248 | """Test Resource request method with list result containing errors.""" 249 | with patch('pycouchdb.resource.requests.session') as mock_session: 250 | mock_session_instance = Mock() 251 | mock_session.return_value = mock_session_instance 252 | 253 | # Mock response with list containing errors 254 | mock_response = Mock() 255 | mock_response.status_code = 200 256 | mock_response.headers = {'content-type': 'application/json'} 257 | mock_response.content = b'[{"id": "doc1", "rev": "1-abc"}, {"error": "conflict", "reason": "Document conflict"}]' 258 | mock_response.json.return_value = [ 259 | {"id": "doc1", "rev": "1-abc"}, 260 | {"error": "conflict", "reason": "Document conflict"} 261 | ] 262 | mock_session_instance.request.return_value = mock_response 263 | 264 | res = resource.Resource("http://localhost:5984/") 265 | 266 | with pytest.raises(exceptions.Conflict, match="Document conflict"): 267 | res.request("POST", "test") 268 | 269 | def test_resource_http_methods(self): 270 | """Test Resource HTTP method shortcuts.""" 271 | with patch('pycouchdb.resource.requests.session') as mock_session: 272 | mock_session_instance = Mock() 273 | mock_session.return_value = mock_session_instance 274 | 275 | # Mock successful response 276 | mock_response = Mock() 277 | mock_response.status_code = 200 278 | mock_response.headers = {'content-type': 'application/json'} 279 | mock_response.content = b'{"result": "success"}' 280 | mock_response.json.return_value = {"result": "success"} 281 | mock_session_instance.request.return_value = mock_response 282 | 283 | res = resource.Resource("http://localhost:5984/") 284 | 285 | # Test GET 286 | res.get("test") 287 | mock_session_instance.request.assert_called_with("GET", "http://localhost:5984/test", 288 | stream=False, data=None, params=None, 289 | headers={'Accept': 'application/json'}) 290 | 291 | # Test PUT 292 | res.put("test", data='{"test": "data"}') 293 | mock_session_instance.request.assert_called_with("PUT", "http://localhost:5984/test", 294 | stream=False, data='{"test": "data"}', 295 | params=None, headers={'Accept': 'application/json'}) 296 | 297 | # Test POST 298 | res.post("test", data='{"test": "data"}') 299 | mock_session_instance.request.assert_called_with("POST", "http://localhost:5984/test", 300 | stream=False, data='{"test": "data"}', 301 | params=None, headers={'Accept': 'application/json'}) 302 | 303 | # Test DELETE 304 | res.delete("test") 305 | mock_session_instance.request.assert_called_with("DELETE", "http://localhost:5984/test", 306 | stream=False, data=None, params=None, 307 | headers={'Accept': 'application/json'}) 308 | 309 | # Test HEAD 310 | res.head("test") 311 | mock_session_instance.request.assert_called_with("HEAD", "http://localhost:5984/test", 312 | stream=False, data=None, params=None, 313 | headers={'Accept': 'application/json'}) 314 | 315 | def test_resource_request_with_custom_headers(self): 316 | """Test Resource request method with custom headers.""" 317 | with patch('pycouchdb.resource.requests.session') as mock_session: 318 | mock_session_instance = Mock() 319 | mock_session.return_value = mock_session_instance 320 | 321 | # Mock successful response 322 | mock_response = Mock() 323 | mock_response.status_code = 200 324 | mock_response.headers = {'content-type': 'application/json'} 325 | mock_response.content = b'{"result": "success"}' 326 | mock_response.json.return_value = {"result": "success"} 327 | mock_session_instance.request.return_value = mock_response 328 | 329 | res = resource.Resource("http://localhost:5984/") 330 | custom_headers = {"Custom-Header": "value"} 331 | 332 | res.request("GET", "test", headers=custom_headers) 333 | 334 | expected_headers = {"Accept": "application/json", "Custom-Header": "value"} 335 | mock_session_instance.request.assert_called_with("GET", "http://localhost:5984/test", 336 | stream=False, data=None, params=None, 337 | headers=expected_headers) 338 | 339 | def test_resource_request_with_params(self): 340 | """Test Resource request method with parameters.""" 341 | with patch('pycouchdb.resource.requests.session') as mock_session: 342 | mock_session_instance = Mock() 343 | mock_session.return_value = mock_session_instance 344 | 345 | # Mock successful response 346 | mock_response = Mock() 347 | mock_response.status_code = 200 348 | mock_response.headers = {'content-type': 'application/json'} 349 | mock_response.content = b'{"result": "success"}' 350 | mock_response.json.return_value = {"result": "success"} 351 | mock_session_instance.request.return_value = mock_response 352 | 353 | res = resource.Resource("http://localhost:5984/") 354 | params = {"limit": 10, "skip": 5} 355 | 356 | res.request("GET", "test", params=params) 357 | 358 | mock_session_instance.request.assert_called_with("GET", "http://localhost:5984/test", 359 | stream=False, data=None, params=params, 360 | headers={'Accept': 'application/json'}) -------------------------------------------------------------------------------- /test/test_server.py: -------------------------------------------------------------------------------- 1 | """ 2 | Unit tests for pycouchdb.client.Server class. 3 | """ 4 | 5 | import pytest 6 | import json 7 | from unittest.mock import Mock, patch, MagicMock 8 | from pycouchdb import client, exceptions 9 | 10 | 11 | class TestServer: 12 | """Test Server class.""" 13 | 14 | def test_server_initialization_default(self): 15 | """Test Server initialization with default parameters.""" 16 | with patch('pycouchdb.client.Resource') as mock_resource: 17 | server = client.Server() 18 | 19 | mock_resource.assert_called_once_with( 20 | 'http://localhost:5984/', 21 | True, # full_commit as positional argument 22 | credentials=None, 23 | authmethod="basic", 24 | verify=False 25 | ) 26 | 27 | def test_server_initialization_custom_url(self): 28 | """Test Server initialization with custom URL.""" 29 | with patch('pycouchdb.client.Resource') as mock_resource: 30 | server = client.Server("http://example.com:5984/") 31 | 32 | mock_resource.assert_called_once_with( 33 | 'http://example.com:5984/', 34 | True, # full_commit as positional argument 35 | credentials=None, 36 | authmethod="basic", 37 | verify=False 38 | ) 39 | 40 | def test_server_initialization_with_credentials(self): 41 | """Test Server initialization with credentials in URL.""" 42 | with patch('pycouchdb.client.Resource') as mock_resource: 43 | with patch('pycouchdb.client.utils.extract_credentials', return_value=("http://example.com:5984/", ("user", "pass"))): 44 | server = client.Server("http://user:pass@example.com:5984/") 45 | 46 | mock_resource.assert_called_once_with( 47 | 'http://example.com:5984/', 48 | True, # full_commit as positional argument 49 | credentials=("user", "pass"), 50 | authmethod="basic", 51 | verify=False 52 | ) 53 | 54 | def test_server_initialization_with_verify(self): 55 | """Test Server initialization with SSL verification.""" 56 | with patch('pycouchdb.client.Resource') as mock_resource: 57 | server = client.Server("http://example.com:5984/", verify=True) 58 | 59 | mock_resource.assert_called_once_with( 60 | 'http://example.com:5984/', 61 | True, # full_commit as positional argument 62 | credentials=None, 63 | authmethod="basic", 64 | verify=True 65 | ) 66 | 67 | def test_server_repr(self): 68 | """Test Server __repr__ method.""" 69 | with patch('pycouchdb.client.Resource'): 70 | server = client.Server("http://example.com:5984/") 71 | repr_str = repr(server) 72 | assert "CouchDB Server" in repr_str 73 | assert "http://example.com:5984/" in repr_str 74 | 75 | def test_server_info(self): 76 | """Test Server info method.""" 77 | with patch('pycouchdb.client.Resource') as mock_resource_class: 78 | mock_resource = Mock() 79 | mock_resource_class.return_value = mock_resource 80 | 81 | # Mock successful response 82 | mock_response = Mock() 83 | mock_response.status_code = 200 84 | mock_response.json.return_value = {"version": "3.2.0", "couchdb": "Welcome"} 85 | mock_resource.get.return_value = (mock_response, {"version": "3.2.0", "couchdb": "Welcome"}) 86 | 87 | server = client.Server() 88 | result = server.info() 89 | 90 | assert result == {"version": "3.2.0", "couchdb": "Welcome"} 91 | mock_resource.get.assert_called_once_with() 92 | 93 | def test_server_version(self): 94 | """Test Server version method.""" 95 | with patch('pycouchdb.client.Resource') as mock_resource_class: 96 | mock_resource = Mock() 97 | mock_resource_class.return_value = mock_resource 98 | 99 | # Mock successful response 100 | mock_response = Mock() 101 | mock_response.status_code = 200 102 | mock_response.json.return_value = {"version": "3.2.0"} 103 | mock_resource.get.return_value = (mock_response, {"version": "3.2.0"}) 104 | 105 | server = client.Server() 106 | result = server.version() 107 | 108 | assert result == "3.2.0" 109 | mock_resource.get.assert_called_once_with() 110 | 111 | def test_server_contains_true(self): 112 | """Test Server __contains__ method when database exists.""" 113 | with patch('pycouchdb.client.Resource') as mock_resource_class: 114 | mock_resource = Mock() 115 | mock_resource_class.return_value = mock_resource 116 | 117 | # Mock successful HEAD response 118 | mock_response = Mock() 119 | mock_response.status_code = 200 120 | mock_resource.head.return_value = (mock_response, None) 121 | 122 | server = client.Server() 123 | result = "testdb" in server 124 | 125 | assert result is True 126 | mock_resource.head.assert_called_once_with("testdb") 127 | 128 | def test_server_contains_false(self): 129 | """Test Server __contains__ method when database doesn't exist.""" 130 | with patch('pycouchdb.client.Resource') as mock_resource_class: 131 | mock_resource = Mock() 132 | mock_resource_class.return_value = mock_resource 133 | 134 | # Mock 404 response 135 | mock_resource.head.side_effect = exceptions.NotFound() 136 | 137 | server = client.Server() 138 | result = "testdb" in server 139 | 140 | assert result is False 141 | 142 | def test_server_iter(self): 143 | """Test Server __iter__ method.""" 144 | with patch('pycouchdb.client.Resource') as mock_resource_class: 145 | mock_resource = Mock() 146 | mock_resource_class.return_value = mock_resource 147 | 148 | # Mock successful response 149 | mock_response = Mock() 150 | mock_response.status_code = 200 151 | mock_response.json.return_value = ["db1", "db2", "db3"] 152 | mock_resource.get.return_value = (mock_response, ["db1", "db2", "db3"]) 153 | 154 | server = client.Server() 155 | result = list(server) 156 | 157 | assert result == ["db1", "db2", "db3"] 158 | # The method calls get('_all_dbs') once 159 | mock_resource.get.assert_called_with("_all_dbs") 160 | 161 | def test_server_len(self): 162 | """Test Server __len__ method.""" 163 | with patch('pycouchdb.client.Resource') as mock_resource_class: 164 | mock_resource = Mock() 165 | mock_resource_class.return_value = mock_resource 166 | 167 | # Mock successful response 168 | mock_response = Mock() 169 | mock_response.status_code = 200 170 | mock_response.json.return_value = ["db1", "db2", "db3"] 171 | mock_resource.get.return_value = (mock_response, ["db1", "db2", "db3"]) 172 | 173 | server = client.Server() 174 | result = len(server) 175 | 176 | assert result == 3 177 | mock_resource.get.assert_called_once_with("_all_dbs") 178 | 179 | def test_server_create_success(self): 180 | """Test Server create method success.""" 181 | with patch('pycouchdb.client.Resource') as mock_resource_class: 182 | mock_resource = Mock() 183 | mock_resource_class.return_value = mock_resource 184 | 185 | # Mock successful PUT response 186 | mock_put_response = Mock() 187 | mock_put_response.status_code = 201 188 | mock_put_response.json.return_value = {"ok": True} 189 | 190 | # Mock successful HEAD response 191 | mock_head_response = Mock() 192 | mock_head_response.status_code = 200 193 | 194 | mock_resource.put.return_value = (mock_put_response, {"ok": True}) 195 | mock_resource.head.return_value = (mock_head_response, None) 196 | 197 | server = client.Server() 198 | result = server.create("testdb") 199 | 200 | assert isinstance(result, client.Database) 201 | assert result.name == "testdb" 202 | mock_resource.put.assert_called_once_with("testdb") 203 | mock_resource.head.assert_called_once_with("testdb") 204 | 205 | def test_server_create_conflict(self): 206 | """Test Server create method with conflict.""" 207 | with patch('pycouchdb.client.Resource') as mock_resource_class: 208 | mock_resource = Mock() 209 | mock_resource_class.return_value = mock_resource 210 | 211 | # Mock conflict response 212 | mock_resource.put.side_effect = exceptions.Conflict("Database already exists") 213 | 214 | server = client.Server() 215 | 216 | with pytest.raises(exceptions.Conflict, match="Database already exists"): 217 | server.create("testdb") 218 | 219 | def test_server_delete_success(self): 220 | """Test Server delete method success.""" 221 | with patch('pycouchdb.client.Resource') as mock_resource_class: 222 | mock_resource = Mock() 223 | mock_resource_class.return_value = mock_resource 224 | 225 | # Mock successful DELETE response 226 | mock_response = Mock() 227 | mock_response.status_code = 200 228 | mock_response.json.return_value = {"ok": True} 229 | mock_resource.delete.return_value = (mock_response, {"ok": True}) 230 | 231 | server = client.Server() 232 | result = server.delete("testdb") 233 | 234 | assert result is None # delete method doesn't return anything 235 | mock_resource.delete.assert_called_once_with("testdb") 236 | 237 | def test_server_delete_not_found(self): 238 | """Test Server delete method with not found.""" 239 | with patch('pycouchdb.client.Resource') as mock_resource_class: 240 | mock_resource = Mock() 241 | mock_resource_class.return_value = mock_resource 242 | 243 | # Mock not found response 244 | mock_resource.delete.side_effect = exceptions.NotFound("Database not found") 245 | 246 | server = client.Server() 247 | 248 | with pytest.raises(exceptions.NotFound, match="Database not found"): 249 | server.delete("testdb") 250 | 251 | def test_server_database(self): 252 | """Test Server database method.""" 253 | with patch('pycouchdb.client.Resource') as mock_resource_class: 254 | mock_resource = Mock() 255 | mock_resource_class.return_value = mock_resource 256 | 257 | # Mock successful HEAD response 258 | mock_response = Mock() 259 | mock_response.status_code = 200 260 | mock_resource.head.return_value = (mock_response, None) 261 | 262 | # Mock the resource(name) call that creates the database resource 263 | mock_db_resource = Mock() 264 | mock_resource.return_value = mock_db_resource 265 | 266 | server = client.Server() 267 | result = server.database("testdb") 268 | 269 | assert isinstance(result, client.Database) 270 | assert result.name == "testdb" 271 | assert result.resource == mock_db_resource 272 | mock_resource.head.assert_called_once_with("testdb") 273 | mock_resource.assert_called_with("testdb") 274 | 275 | def test_server_replicate_success(self): 276 | """Test Server replicate method success.""" 277 | with patch('pycouchdb.client.Resource') as mock_resource_class: 278 | mock_resource = Mock() 279 | mock_resource_class.return_value = mock_resource 280 | 281 | # Mock successful POST response 282 | mock_response = Mock() 283 | mock_response.status_code = 200 284 | mock_response.json.return_value = {"ok": True, "session_id": "abc123"} 285 | mock_resource.post.return_value = (mock_response, {"ok": True, "session_id": "abc123"}) 286 | 287 | server = client.Server() 288 | result = server.replicate("http://source:5984/source_db", 289 | "http://target:5984/target_db") 290 | 291 | assert result == {"ok": True, "session_id": "abc123"} 292 | mock_resource.post.assert_called_once_with("_replicate", 293 | data=b'{"source": "http://source:5984/source_db", "target": "http://target:5984/target_db"}') 294 | 295 | def test_server_replicate_with_options(self): 296 | """Test Server replicate method with additional options.""" 297 | with patch('pycouchdb.client.Resource') as mock_resource_class: 298 | mock_resource = Mock() 299 | mock_resource_class.return_value = mock_resource 300 | 301 | # Mock successful POST response 302 | mock_response = Mock() 303 | mock_response.status_code = 200 304 | mock_response.json.return_value = {"ok": True} 305 | mock_resource.post.return_value = (mock_response, {"ok": True}) 306 | 307 | server = client.Server() 308 | result = server.replicate("http://source:5984/source_db", 309 | "http://target:5984/target_db", 310 | create_target=True, 311 | continuous=True) 312 | 313 | expected_data = { 314 | "source": "http://source:5984/source_db", 315 | "target": "http://target:5984/target_db", 316 | "create_target": True, 317 | "continuous": True 318 | } 319 | mock_resource.post.assert_called_once_with("_replicate", 320 | data=json.dumps(expected_data).encode()) 321 | 322 | def test_server_changes_feed(self): 323 | """Test Server changes_feed method.""" 324 | with patch('pycouchdb.client.Resource') as mock_resource_class: 325 | mock_resource = Mock() 326 | mock_resource_class.return_value = mock_resource 327 | 328 | # Mock successful POST response with stream 329 | mock_response = Mock() 330 | mock_response.status_code = 200 331 | mock_response.iter_lines.return_value = [ 332 | b'{"seq": 1, "id": "doc1"}', 333 | b'{"seq": 2, "id": "doc2"}', 334 | b'' # Empty line (heartbeat) 335 | ] 336 | mock_resource.post.return_value = (mock_response, None) 337 | 338 | server = client.Server() 339 | messages_received = [] 340 | 341 | def mock_feed_reader(message, db): 342 | messages_received.append(message) 343 | if len(messages_received) >= 2: 344 | raise exceptions.FeedReaderExited() 345 | 346 | with patch('pycouchdb.client._listen_feed') as mock_listen: 347 | server.changes_feed(mock_feed_reader) 348 | mock_listen.assert_called_once() 349 | 350 | def test_server_changes_feed_with_options(self): 351 | """Test Server changes_feed method with options.""" 352 | with patch('pycouchdb.client.Resource') as mock_resource_class: 353 | mock_resource = Mock() 354 | mock_resource_class.return_value = mock_resource 355 | 356 | server = client.Server() 357 | 358 | def mock_feed_reader(message, db): 359 | pass 360 | 361 | with patch('pycouchdb.client._listen_feed') as mock_listen: 362 | server.changes_feed(mock_feed_reader, 363 | feed="longpoll", 364 | since=100, 365 | limit=50) 366 | 367 | mock_listen.assert_called_once() 368 | call_args = mock_listen.call_args 369 | assert call_args[1]['feed'] == "longpoll" 370 | assert call_args[1]['since'] == 100 371 | assert call_args[1]['limit'] == 50 372 | 373 | 374 | class TestServerHelperFunctions: 375 | """Test Server helper functions.""" 376 | 377 | def test_id_to_path_regular_id(self): 378 | """Test _id_to_path with regular document ID.""" 379 | result = client._id_to_path("doc123") 380 | assert result == ["doc123"] 381 | 382 | def test_id_to_path_design_doc(self): 383 | """Test _id_to_path with design document ID.""" 384 | result = client._id_to_path("_design/test") 385 | assert result == ["_design", "test"] 386 | 387 | def test_id_to_path_other_system_doc(self): 388 | """Test _id_to_path with other system document ID.""" 389 | result = client._id_to_path("_local/doc123") 390 | assert result == ["_local", "doc123"] 391 | 392 | def test_listen_feed_with_callable(self): 393 | """Test _listen_feed with callable feed reader.""" 394 | mock_object = Mock() 395 | mock_resource = Mock() 396 | mock_object.resource.return_value = mock_resource 397 | 398 | # Mock successful POST response with stream 399 | mock_response = Mock() 400 | mock_response.status_code = 200 401 | mock_response.iter_lines.return_value = [ 402 | b'', # Empty line (heartbeat) first 403 | b'{"seq": 1, "id": "doc1"}' 404 | ] 405 | mock_resource.post.return_value = (mock_response, None) 406 | 407 | messages_received = [] 408 | 409 | def mock_feed_reader(message, db): 410 | messages_received.append(message) 411 | raise exceptions.FeedReaderExited() 412 | 413 | with patch('pycouchdb.client.feedreader.SimpleFeedReader') as mock_reader_class: 414 | mock_reader = Mock() 415 | mock_reader_class.return_value.return_value = mock_reader 416 | mock_reader.on_heartbeat = Mock() 417 | mock_reader.on_close = Mock() 418 | 419 | # Set up the on_message to call the actual callback 420 | def on_message_side_effect(message): 421 | mock_feed_reader(message, None) 422 | mock_reader.on_message.side_effect = on_message_side_effect 423 | 424 | client._listen_feed(mock_object, "_changes", mock_feed_reader) 425 | 426 | mock_reader.on_message.assert_called_once() 427 | mock_reader.on_heartbeat.assert_called_once() 428 | mock_reader.on_close.assert_called_once() 429 | 430 | def test_listen_feed_with_feed_reader_class(self): 431 | """Test _listen_feed with BaseFeedReader class.""" 432 | mock_object = Mock() 433 | mock_resource = Mock() 434 | mock_object.resource.return_value = mock_resource 435 | 436 | # Mock successful POST response with stream 437 | mock_response = Mock() 438 | mock_response.status_code = 200 439 | mock_response.iter_lines.return_value = [ 440 | b'{"seq": 1, "id": "doc1"}' 441 | ] 442 | mock_resource.post.return_value = (mock_response, None) 443 | 444 | class MockFeedReader(client.feedreader.BaseFeedReader): 445 | def __init__(self): 446 | super().__init__() 447 | self.messages = [] 448 | 449 | def on_message(self, message): 450 | self.messages.append(message) 451 | raise exceptions.FeedReaderExited() 452 | 453 | mock_feed_reader = MockFeedReader() 454 | 455 | client._listen_feed(mock_object, "_changes", mock_feed_reader) 456 | 457 | assert len(mock_feed_reader.messages) == 1 458 | assert mock_feed_reader.messages[0] == {"seq": 1, "id": "doc1"} 459 | 460 | def test_listen_feed_invalid_reader(self): 461 | """Test _listen_feed with invalid feed reader.""" 462 | mock_object = Mock() 463 | 464 | with pytest.raises(exceptions.UnexpectedError, match="feed_reader must be callable or class"): 465 | client._listen_feed(mock_object, "_changes", "invalid_reader") 466 | 467 | 468 | class TestStreamResponse: 469 | """Test _StreamResponse class.""" 470 | 471 | def test_stream_response_initialization(self): 472 | """Test _StreamResponse initialization.""" 473 | mock_response = Mock() 474 | stream_response = client._StreamResponse(mock_response) 475 | 476 | assert stream_response._response == mock_response 477 | 478 | def test_stream_response_iter_content(self): 479 | """Test _StreamResponse iter_content method.""" 480 | mock_response = Mock() 481 | mock_response.iter_content.return_value = [b"chunk1", b"chunk2"] 482 | 483 | stream_response = client._StreamResponse(mock_response) 484 | result = list(stream_response.iter_content(chunk_size=1024, decode_unicode=True)) 485 | 486 | assert result == [b"chunk1", b"chunk2"] 487 | mock_response.iter_content.assert_called_once_with(chunk_size=1024, decode_unicode=True) 488 | 489 | def test_stream_response_iter_lines(self): 490 | """Test _StreamResponse iter_lines method.""" 491 | mock_response = Mock() 492 | mock_response.iter_lines.return_value = [b"line1", b"line2"] 493 | 494 | stream_response = client._StreamResponse(mock_response) 495 | result = list(stream_response.iter_lines(chunk_size=512, decode_unicode=True)) 496 | 497 | assert result == [b"line1", b"line2"] 498 | mock_response.iter_lines.assert_called_once_with(chunk_size=512, decode_unicode=True) 499 | 500 | def test_stream_response_raw_property(self): 501 | """Test _StreamResponse raw property.""" 502 | mock_response = Mock() 503 | mock_raw = Mock() 504 | mock_response.raw = mock_raw 505 | 506 | stream_response = client._StreamResponse(mock_response) 507 | 508 | assert stream_response.raw == mock_raw 509 | 510 | def test_stream_response_url_property(self): 511 | """Test _StreamResponse url property.""" 512 | mock_response = Mock() 513 | mock_response.url = "http://example.com/test" 514 | 515 | stream_response = client._StreamResponse(mock_response) 516 | 517 | assert stream_response.url == "http://example.com/test" -------------------------------------------------------------------------------- /pycouchdb/client.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import os 4 | import json 5 | import uuid 6 | import copy 7 | import mimetypes 8 | import warnings 9 | from typing import Any, Dict, List, Optional, Union, Iterator, Callable, TYPE_CHECKING 10 | 11 | from . import utils 12 | from . import feedreader 13 | from . import exceptions as exp 14 | from .resource import Resource 15 | from .types import ( 16 | Json, Document, Row, BulkItem, ServerInfo, DatabaseInfo, 17 | ChangeResult, ViewResult, Credentials, AuthMethod, DocId, Rev 18 | ) 19 | 20 | # Type alias for feed reader parameter 21 | FeedReader = Union[Callable[[Dict[str, Any]], None], feedreader.BaseFeedReader] 22 | 23 | 24 | DEFAULT_BASE_URL: str = os.environ.get('COUCHDB_URL', 'http://localhost:5984/') 25 | 26 | 27 | def _id_to_path(_id: str) -> List[str]: 28 | if _id[:1] == "_": 29 | return _id.split("/", 1) 30 | return [_id] 31 | 32 | 33 | def _listen_feed(object: Any, node: str, feed_reader: FeedReader, **kwargs: Any) -> None: 34 | if not callable(feed_reader): 35 | raise exp.UnexpectedError("feed_reader must be callable or class") 36 | 37 | if isinstance(feed_reader, feedreader.BaseFeedReader): 38 | reader = feed_reader(object) 39 | else: 40 | def wrapped_callback(message: Dict[str, Any], db: Any) -> None: 41 | try: 42 | feed_reader(message, db) # type: ignore[call-arg] 43 | except TypeError: 44 | feed_reader(message) 45 | reader = feedreader.SimpleFeedReader()(object, wrapped_callback) 46 | 47 | # Possible options: "continuous", "longpoll" 48 | kwargs.setdefault("feed", "continuous") 49 | data = utils.force_bytes(json.dumps(kwargs.pop('data', {}))) 50 | 51 | (resp, result) = object.resource(node).post( 52 | params=kwargs, data=data, stream=True) 53 | try: 54 | for line in resp.iter_lines(): 55 | # ignore heartbeats 56 | if not line: 57 | reader.on_heartbeat() 58 | else: 59 | reader.on_message(json.loads(utils.force_text(line))) 60 | except exp.FeedReaderExited: 61 | reader.on_close() 62 | 63 | 64 | class _StreamResponse: 65 | """ 66 | Proxy object for python-requests stream response. 67 | 68 | See more on: 69 | http://docs.python-requests.org/en/latest/user/advanced/#streaming-requests 70 | """ 71 | 72 | def __init__(self, response: Any) -> None: 73 | self._response = response 74 | 75 | def iter_content(self, chunk_size: int = 1, decode_unicode: bool = False) -> Iterator[bytes]: 76 | return self._response.iter_content(chunk_size=chunk_size, 77 | decode_unicode=decode_unicode) 78 | 79 | def iter_lines(self, chunk_size: int = 512, decode_unicode: Optional[bool] = None) -> Iterator[bytes]: 80 | return self._response.iter_lines(chunk_size=chunk_size, 81 | decode_unicode=decode_unicode) 82 | 83 | @property 84 | def raw(self) -> Any: 85 | return self._response.raw 86 | 87 | @property 88 | def url(self) -> str: 89 | return self._response.url 90 | 91 | 92 | class Server: 93 | """ 94 | Class that represents a couchdb connection. 95 | 96 | :param verify: setup ssl verification. 97 | :param base_url: a full url to couchdb (can contain auth data). 98 | :param full_commit: If ``False``, couchdb not commits all data on a 99 | request is finished. 100 | :param authmethod: specify a authentication method. By default "basic" 101 | method is used but also exists "session" (that requires 102 | some server configuration changes). 103 | 104 | .. versionchanged: 1.4 105 | Set basic auth method as default instead of session method. 106 | 107 | .. versionchanged: 1.5 108 | Add verify parameter for setup ssl verificaton 109 | 110 | """ 111 | 112 | def __init__(self, base_url: str = DEFAULT_BASE_URL, full_commit: bool = True, 113 | authmethod: AuthMethod = "basic", verify: bool = False) -> None: 114 | 115 | self.base_url, credentials = utils.extract_credentials(base_url) 116 | self.resource = Resource(self.base_url, full_commit, 117 | credentials=credentials, 118 | authmethod=authmethod, 119 | verify=verify) 120 | 121 | def __repr__(self) -> str: 122 | return ''.format(self.base_url) 123 | 124 | def __contains__(self, name: str) -> bool: 125 | try: 126 | self.resource.head(name) 127 | except exp.NotFound: 128 | return False 129 | else: 130 | return True 131 | 132 | def __iter__(self) -> Iterator[str]: 133 | (r, result) = self.resource.get('_all_dbs') 134 | if result is None: 135 | return iter([]) 136 | return iter(result) 137 | 138 | def __len__(self) -> int: 139 | (r, result) = self.resource.get('_all_dbs') 140 | if result is None: 141 | return 0 142 | return len(result) 143 | 144 | def info(self) -> ServerInfo: 145 | """ 146 | Get server info. 147 | 148 | :returns: dict with all data that couchdb returns. 149 | :rtype: dict 150 | """ 151 | (r, result) = self.resource.get() 152 | if result is None: 153 | return {} 154 | return result 155 | 156 | def delete(self, name: str) -> None: 157 | """ 158 | Delete some database. 159 | 160 | :param name: database name 161 | :raises: :py:exc:`~pycouchdb.exceptions.NotFound` 162 | if a database does not exists 163 | """ 164 | 165 | self.resource.delete(name) 166 | 167 | def database(self, name: str) -> "Database": 168 | """ 169 | Get a database instance. 170 | 171 | :param name: database name 172 | :raises: :py:exc:`~pycouchdb.exceptions.NotFound` 173 | if a database does not exists 174 | 175 | :returns: a :py:class:`~pycouchdb.client.Database` instance 176 | """ 177 | (r, result) = self.resource.head(name) 178 | if r.status_code == 404: 179 | raise exp.NotFound("Database '{0}' does not exists".format(name)) 180 | 181 | db = Database(self.resource(name), name) 182 | return db 183 | 184 | # TODO: Config in 2.0 are applicable for nodes only 185 | # TODO: Reimplement when nodes endpoint will be ready 186 | # def config(self): 187 | # pass 188 | 189 | def version(self) -> str: 190 | """ 191 | Get the current version of a couchdb server. 192 | """ 193 | (resp, result) = self.resource.get() 194 | if result is None: 195 | return "" 196 | return result["version"] 197 | 198 | # TODO: Stats in 2.0 are applicable for nodes only 199 | # TODO: Reimplement when nodes endpoint will be ready 200 | # def stats(self, name=None): 201 | # pass 202 | 203 | def create(self, name: str) -> Optional["Database"]: 204 | """ 205 | Create a database. 206 | 207 | :param name: database name 208 | :raises: :py:exc:`~pycouchdb.exceptions.Conflict` 209 | if a database already exists 210 | :returns: a :py:class:`~pycouchdb.client.Database` instance 211 | """ 212 | (resp, result) = self.resource.put(name) 213 | if resp.status_code in (200, 201): 214 | return self.database(name) 215 | return None 216 | 217 | def replicate(self, source: str, target: str, **kwargs: Any) -> Dict[str, Any]: 218 | """ 219 | Replicate the source database to the target one. 220 | 221 | .. versionadded:: 1.3 222 | 223 | :param source: full URL to the source database 224 | :param target: full URL to the target database 225 | """ 226 | 227 | data_dict = {'source': source, 'target': target} 228 | data_dict.update(kwargs) 229 | 230 | data = utils.force_bytes(json.dumps(data_dict)) 231 | 232 | (resp, result) = self.resource.post('_replicate', data=data) 233 | if result is None: 234 | return {} 235 | return result 236 | 237 | def changes_feed(self, feed_reader: FeedReader, **kwargs: Any) -> None: 238 | """ 239 | Subscribe to changes feed of the whole CouchDB server. 240 | 241 | Note: this method is blocking. 242 | 243 | 244 | :param feed_reader: callable or :py:class:`~BaseFeedReader` 245 | instance 246 | 247 | .. [Ref] http://docs.couchdb.org/en/1.6.1/api/server/common.html#db-updates 248 | .. versionadded: 1.10 249 | """ 250 | object = self 251 | _listen_feed(object, "_db_updates", feed_reader, **kwargs) 252 | 253 | 254 | class Database: 255 | """ 256 | Class that represents a couchdb database. 257 | """ 258 | 259 | def __init__(self, resource: Resource, name: str) -> None: 260 | self.resource = resource 261 | self.name = name 262 | 263 | def __repr__(self) -> str: 264 | return ''.format(self.name) 265 | 266 | def __contains__(self, doc_id: str) -> bool: 267 | try: 268 | (resp, result) = self.resource.head(_id_to_path(doc_id)) 269 | return resp.status_code < 206 270 | except exp.NotFound: 271 | return False 272 | 273 | def config(self) -> DatabaseInfo: 274 | """ 275 | Get database status data such as document count, update sequence etc. 276 | :return: dict 277 | """ 278 | (resp, result) = self.resource.get() 279 | if result is None: 280 | return {} 281 | return result 282 | 283 | def __nonzero__(self) -> bool: 284 | """Is the database available""" 285 | resp, _ = self.resource.head() 286 | return resp.status_code == 200 287 | 288 | def __len__(self) -> int: 289 | return self.config()['doc_count'] 290 | 291 | def delete(self, doc_or_id: Union[Document, str]) -> None: 292 | """ 293 | Delete document by id. 294 | 295 | .. versionchanged:: 1.2 296 | Accept document or id. 297 | 298 | :param doc_or_id: document or id 299 | :raises: :py:exc:`~pycouchdb.exceptions.NotFound` if a document 300 | not exists 301 | :raises: :py:exc:`~pycouchdb.exceptions.Conflict` if delete with 302 | wrong revision. 303 | """ 304 | 305 | _id = None 306 | if isinstance(doc_or_id, dict): 307 | if "_id" not in doc_or_id: 308 | raise ValueError("Invalid document, missing _id attr") 309 | _id = doc_or_id['_id'] 310 | else: 311 | _id = doc_or_id 312 | 313 | resource = self.resource(*_id_to_path(_id)) 314 | 315 | (r, result) = resource.head() 316 | (r, result) = resource.delete( 317 | params={"rev": r.headers["etag"].strip('"')}) 318 | 319 | def delete_bulk(self, docs: List[Document], transaction: bool = True) -> List[BulkItem]: 320 | """ 321 | Delete a bulk of documents. 322 | 323 | .. versionadded:: 1.2 324 | 325 | :param docs: list of docs 326 | :raises: :py:exc:`~pycouchdb.exceptions.Conflict` if a delete 327 | is not success 328 | :returns: raw results from server 329 | """ 330 | 331 | _docs = copy.copy(docs) 332 | for doc in _docs: 333 | if "_deleted" not in doc: 334 | doc["_deleted"] = True 335 | 336 | data = utils.force_bytes(json.dumps({"docs": _docs})) 337 | params = {"all_or_nothing": "true" if transaction else "false"} 338 | (resp, results) = self.resource.post( 339 | "_bulk_docs", data=data, params=params) 340 | 341 | if results is None: 342 | return [] 343 | 344 | for result, doc in zip(results, _docs): 345 | if "error" in result: 346 | raise exp.Conflict("one or more docs are not saved") 347 | 348 | return results 349 | 350 | def get(self, doc_id: str, params: Optional[Dict[str, Any]] = None, **kwargs: Any) -> Document: 351 | """ 352 | Get a document by id. 353 | 354 | .. versionadded: 1.5 355 | Now the prefered method to pass params is via **kwargs 356 | instead of params argument. **params** argument is now 357 | deprecated and will be deleted in future versions. 358 | 359 | :param doc_id: document id 360 | :raises: :py:exc:`~pycouchdb.exceptions.NotFound` if a document 361 | not exists 362 | 363 | :returns: document (dict) 364 | """ 365 | 366 | if params: 367 | warnings.warn("params parameter is now deprecated in favor to" 368 | "**kwargs usage.", DeprecationWarning) 369 | 370 | if params is None: 371 | params = {} 372 | 373 | params.update(kwargs) 374 | 375 | (resp, result) = self.resource(*_id_to_path(doc_id)).get(params=params) 376 | if result is None: 377 | return {} 378 | return result 379 | 380 | def save(self, doc: Document, batch: bool = False) -> Document: 381 | """ 382 | Save or update a document. 383 | 384 | .. versionchanged:: 1.2 385 | Now returns a new document instead of modify the original. 386 | 387 | :param doc: document 388 | :param batch: allow batch=ok inserts (default False) 389 | :raises: :py:exc:`~pycouchdb.exceptions.Conflict` if save with wrong 390 | revision. 391 | :returns: doc 392 | """ 393 | 394 | _doc = copy.copy(doc) 395 | if "_id" not in _doc: 396 | _doc['_id'] = uuid.uuid4().hex 397 | 398 | if batch: 399 | params = {'batch': 'ok'} 400 | else: 401 | params = {} 402 | 403 | data = utils.force_bytes(json.dumps(_doc)) 404 | (resp, result) = self.resource(_doc['_id']).put( 405 | data=data, params=params) 406 | 407 | if resp.status_code == 409: 408 | if result is not None and 'reason' in result: 409 | raise exp.Conflict(result['reason']) 410 | else: 411 | raise exp.Conflict("Conflict") 412 | 413 | if result is not None and "rev" in result and result["rev"] is not None: 414 | _doc["_rev"] = result["rev"] 415 | 416 | return _doc 417 | 418 | def save_bulk(self, docs: List[Document], try_setting_ids: bool = True, transaction: bool = True) -> List[Document]: 419 | """ 420 | Save a bulk of documents. 421 | 422 | .. versionchanged:: 1.2 423 | Now returns a new document list instead of modify the original. 424 | 425 | :param docs: list of docs 426 | :param try_setting_ids: if ``True``, we loop through docs and generate/set 427 | an id in each doc if none exists 428 | :param transaction: if ``True``, couchdb do a insert in transaction 429 | model. 430 | :returns: docs 431 | """ 432 | 433 | _docs = copy.deepcopy(docs) 434 | 435 | # Insert _id field if it not exists and try_setting_ids is true 436 | if try_setting_ids: 437 | for doc in _docs: 438 | if "_id" not in doc: 439 | doc["_id"] = uuid.uuid4().hex 440 | 441 | data = utils.force_bytes(json.dumps({"docs": _docs})) 442 | params = {"all_or_nothing": "true" if transaction else "false"} 443 | 444 | (resp, results) = self.resource.post("_bulk_docs", data=data, 445 | params=params) 446 | 447 | if results is not None: 448 | for result, doc in zip(results, _docs): 449 | if "rev" in result: 450 | doc['_rev'] = result['rev'] 451 | 452 | return _docs 453 | 454 | def all(self, wrapper: Optional[Callable[[Any], Any]] = None, flat: Optional[str] = None, as_list: bool = False, **kwargs: Any) -> Union[Iterator[Any], List[Any]]: 455 | """ 456 | Execute a builtin view for get all documents. 457 | 458 | :param wrapper: wrap result into a specific class. 459 | :param as_list: return a list of results instead of a 460 | default lazy generator. 461 | :param flat: get a specific field from a object instead 462 | of a complete object. 463 | 464 | .. versionadded: 1.4 465 | Add as_list parameter. 466 | Add flat parameter. 467 | 468 | :returns: generator object 469 | """ 470 | 471 | params = {"include_docs": "true"} 472 | params.update(kwargs) 473 | 474 | data = None 475 | 476 | if "keys" in params: 477 | data_dict = {"keys": params.pop("keys")} 478 | data = utils.force_bytes(json.dumps(data_dict)) 479 | 480 | params = utils.encode_view_options(params) 481 | if data: 482 | (resp, result) = self.resource.post( 483 | "_all_docs", params=params, data=data) 484 | else: 485 | (resp, result) = self.resource.get("_all_docs", params=params) 486 | 487 | if result is None: 488 | if as_list: 489 | return [] 490 | return iter([]) 491 | 492 | if wrapper is None: 493 | wrapper = lambda doc: doc 494 | 495 | if flat is not None: 496 | wrapper = lambda doc: doc[flat] 497 | 498 | def _iterate() -> Iterator[Any]: 499 | for row in result["rows"]: 500 | yield wrapper(row) 501 | 502 | if as_list: 503 | return list(_iterate()) 504 | return _iterate() 505 | 506 | def cleanup(self) -> Dict[str, Any]: 507 | """ 508 | Execute a cleanup operation. 509 | """ 510 | (r, result) = self.resource('_view_cleanup').post() 511 | if result is None: 512 | return {} 513 | return result 514 | 515 | def commit(self) -> Dict[str, Any]: 516 | """ 517 | Send commit message to server. 518 | """ 519 | (resp, result) = self.resource.post('_ensure_full_commit') 520 | if result is None: 521 | return {} 522 | return result 523 | 524 | def compact(self) -> Dict[str, Any]: 525 | """ 526 | Send compact message to server. Compacting write-heavy databases 527 | should be avoided, otherwise the process may not catch up with 528 | the writes. Read load has no effect. 529 | """ 530 | (r, result) = self.resource("_compact").post() 531 | if result is None: 532 | return {} 533 | return result 534 | 535 | def compact_view(self, ddoc: str) -> Dict[str, Any]: 536 | """ 537 | Execute compact over design view. 538 | 539 | :raises: :py:exc:`~pycouchdb.exceptions.NotFound` 540 | if a view does not exists. 541 | """ 542 | (r, result) = self.resource("_compact", ddoc).post() 543 | if result is None: 544 | return {} 545 | return result 546 | 547 | def revisions(self, doc_id: str, status: str = 'available', params: Optional[Dict[str, Any]] = None, **kwargs: Any) -> Iterator[Document]: 548 | """ 549 | Get all revisions of one document. 550 | 551 | :param doc_id: document id 552 | :param status: filter of revision status, set empty to list all 553 | :raises: :py:exc:`~pycouchdb.exceptions.NotFound` 554 | if a view does not exists. 555 | 556 | :returns: generator object 557 | """ 558 | if params: 559 | warnings.warn("params parameter is now deprecated in favor to" 560 | "**kwargs usage.", DeprecationWarning) 561 | 562 | if params is None: 563 | params = {} 564 | 565 | params.update(kwargs) 566 | 567 | if not params.get('revs_info'): 568 | params['revs_info'] = 'true' 569 | 570 | resource = self.resource(doc_id) 571 | (resp, result) = resource.get(params=params) 572 | if resp.status_code == 404: 573 | raise exp.NotFound("Document id `{0}` not found".format(doc_id)) 574 | 575 | if result is None or '_revs_info' not in result: 576 | return 577 | 578 | for rev in result['_revs_info']: 579 | if status and rev['status'] == status: 580 | yield self.get(doc_id, rev=rev['rev']) 581 | elif not status: 582 | yield self.get(doc_id, rev=rev['rev']) 583 | 584 | def delete_attachment(self, doc: Document, filename: str) -> Document: 585 | """ 586 | Delete attachment by filename from document. 587 | 588 | .. versionchanged:: 1.2 589 | Now returns a new document instead of modify the original. 590 | 591 | :param doc: document dict 592 | :param filename: name of attachment. 593 | :raises: :py:exc:`~pycouchdb.exceptions.Conflict` 594 | if save with wrong revision. 595 | :returns: doc 596 | """ 597 | 598 | _doc = copy.deepcopy(doc) 599 | resource = self.resource(_doc['_id']) 600 | 601 | (resp, result) = resource.delete(filename, params={'rev': _doc['_rev']}) 602 | if resp.status_code == 404: 603 | raise exp.NotFound("filename {0} not found".format(filename)) 604 | 605 | if resp.status_code > 205: 606 | if result is not None and 'reason' in result: 607 | raise exp.Conflict(result['reason']) 608 | else: 609 | raise exp.Conflict("Conflict") 610 | 611 | if result is not None and 'rev' in result: 612 | _doc['_rev'] = result['rev'] 613 | try: 614 | del _doc['_attachments'][filename] 615 | 616 | if not _doc['_attachments']: 617 | del _doc['_attachments'] 618 | except KeyError: 619 | pass 620 | 621 | return _doc 622 | 623 | def get_attachment(self, doc, filename, stream=False, **kwargs): 624 | """ 625 | Get attachment by filename from document. 626 | 627 | :param doc: document dict 628 | :param filename: attachment file name. 629 | :param stream: setup streaming output (default: False) 630 | 631 | .. versionchanged: 1.5 632 | Add stream parameter for obtain very large attachments 633 | without load all file to the memory. 634 | 635 | :returns: binary data or 636 | """ 637 | 638 | params = {"rev": doc["_rev"]} 639 | params.update(kwargs) 640 | 641 | r, result = self.resource(doc['_id']).get(filename, stream=stream, 642 | params=params) 643 | if stream: 644 | return _StreamResponse(r) 645 | 646 | return r.content 647 | 648 | def put_attachment(self, doc, content, filename=None, content_type=None): 649 | """ 650 | Put a attachment to a document. 651 | 652 | .. versionchanged:: 1.2 653 | Now returns a new document instead of modify the original. 654 | 655 | :param doc: document dict. 656 | :param content: the content to upload, either a file-like object or 657 | bytes 658 | :param filename: the name of the attachment file; if omitted, this 659 | function tries to get the filename from the file-like 660 | object passed as the `content` argument value 661 | :raises: :py:exc:`~pycouchdb.exceptions.Conflict` 662 | if save with wrong revision. 663 | :raises: ValueError 664 | :returns: doc 665 | """ 666 | 667 | if filename is None: 668 | if hasattr(content, 'name'): 669 | filename = os.path.basename(content.name) 670 | else: 671 | raise ValueError('no filename specified for attachment') 672 | 673 | if content_type is None: 674 | content_type = ';'.join( 675 | filter(None, mimetypes.guess_type(filename))) 676 | 677 | headers = {"Content-Type": content_type} 678 | resource = self.resource(doc['_id']) 679 | 680 | (resp, result) = resource.put( 681 | filename, data=content, params={'rev': doc['_rev']}, headers=headers) 682 | 683 | if resp.status_code < 206: 684 | return self.get(doc["_id"]) 685 | 686 | raise exp.Conflict(result['reason']) 687 | 688 | def one(self, name, flat=None, wrapper=None, **kwargs): 689 | """ 690 | Execute a design document view query and returns a first 691 | result. 692 | 693 | :param name: name of the view (eg: docidname/viewname). 694 | :param wrapper: wrap result into a specific class. 695 | :param flat: get a specific field from a object instead 696 | of a complete object. 697 | 698 | .. versionadded: 1.4 699 | 700 | :returns: object or None 701 | """ 702 | 703 | params = {"limit": 1} 704 | params.update(kwargs) 705 | 706 | path = utils._path_from_name(name, '_view') 707 | data = None 708 | 709 | if "keys" in params: 710 | data = {"keys": params.pop('keys')} 711 | 712 | if data: 713 | data = utils.force_bytes(json.dumps(data)) 714 | 715 | params = utils.encode_view_options(params) 716 | result = list(self._query(self.resource(*path), wrapper=wrapper, 717 | flat=flat, params=params, data=data)) 718 | 719 | return result[0] if len(result) > 0 else None 720 | 721 | def _query(self, resource, data=None, params=None, headers=None, 722 | flat=None, wrapper=None): 723 | 724 | if data is None: 725 | (resp, result) = resource.get(params=params, headers=headers) 726 | else: 727 | (resp, result) = resource.post( 728 | data=data, params=params, headers=headers) 729 | 730 | if wrapper is None: 731 | wrapper = lambda row: row 732 | 733 | if flat is not None: 734 | wrapper = lambda row: row[flat] 735 | 736 | for row in result["rows"]: 737 | yield wrapper(row) 738 | 739 | def _query_paginate(self, resource, pagesize, data=None, params=None, headers=None, 740 | flat=None, wrapper=None): 741 | if wrapper is None: 742 | wrapper = lambda row: row 743 | 744 | if flat is not None: 745 | wrapper = lambda row: row[flat] 746 | 747 | limit = params.get('limit', float('inf')) 748 | params['limit'] = pagesize + 1 749 | 750 | if data is None: 751 | (resp, result) = resource.get(params=params, headers=headers) 752 | else: 753 | (resp, result) = resource.post( 754 | data=data, params=params, headers=headers) 755 | 756 | startkey = result["rows"][0]["key"] 757 | 758 | while len(result["rows"]) == pagesize + 1: 759 | 760 | next_startkey = result["rows"][-1]["key"] 761 | next_startkey_docid = result["rows"][-1]["id"] 762 | 763 | for row in result["rows"][:-1]: 764 | if limit <= 0: return 765 | 766 | yield wrapper(row) 767 | limit -= 1 768 | 769 | # do this regardless? 770 | # if startkey == next_startkey: 771 | params['startkey_docid'] = next_startkey_docid 772 | 773 | startkey = next_startkey 774 | params['startkey'] = startkey 775 | 776 | params = utils.encode_view_options(params) 777 | 778 | if data is None: 779 | (resp, result) = resource.get(params=params, headers=headers) 780 | else: 781 | (resp, result) = resource.post( 782 | data=data, params=params, headers=headers) 783 | 784 | for row in result["rows"]: 785 | if limit <= 0: return 786 | yield wrapper(row) 787 | limit -= 1 788 | 789 | def query(self, name, wrapper=None, flat=None, pagesize=None, as_list=False, **kwargs): 790 | """ 791 | Execute a design document view query. 792 | 793 | :param name: name of the view (eg: docidname/viewname). 794 | :param wrapper: wrap result into a specific class. 795 | :param as_list: return a list of results instead of a 796 | default lazy generator. 797 | :param flat: get a specific field from a object instead 798 | of a complete object. 799 | :param pagesize: Paginate the query response with `pagesize` rows per page. 800 | 801 | .. versionadded: 1.4 802 | Add as_list parameter. 803 | Add flat parameter. 804 | 805 | :returns: generator object 806 | """ 807 | params = copy.copy(kwargs) 808 | path = utils._path_from_name(name, '_view') 809 | data = None 810 | 811 | if "keys" in params: 812 | data = {"keys": params.pop('keys')} 813 | 814 | if data: 815 | data = utils.force_bytes(json.dumps(data)) 816 | 817 | params = utils.encode_view_options(params) 818 | 819 | if pagesize is None: 820 | result = self._query(self.resource(*path), wrapper=wrapper, 821 | flat=flat, params=params, data=data) 822 | else: 823 | assert isinstance(pagesize, int), "pagesize should be a positive integer" 824 | assert pagesize > 0, "pagesize should be a positive integer" 825 | 826 | result = self._query_paginate(self.resource(*path), pagesize=pagesize, wrapper=wrapper, 827 | flat=flat, params=params, data=data) 828 | 829 | if as_list: 830 | return list(result) 831 | return result 832 | 833 | def changes_feed(self, feed_reader, **kwargs): 834 | """ 835 | Subscribe to changes feed of couchdb database. 836 | 837 | Note: this method is blocking. 838 | 839 | 840 | :param feed_reader: callable or :py:class:`~BaseFeedReader` 841 | instance 842 | 843 | .. versionadded: 1.5 844 | """ 845 | 846 | object = self 847 | _listen_feed(object, "_changes", feed_reader, **kwargs) 848 | 849 | def changes_list(self, **kwargs): 850 | """ 851 | Obtain a list of changes from couchdb. 852 | 853 | .. versionadded: 1.5 854 | """ 855 | 856 | (resp, result) = self.resource("_changes").get(params=kwargs) 857 | return result['last_seq'], result['results'] 858 | -------------------------------------------------------------------------------- /test/integration/test_integration.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import pytest 4 | import types 5 | 6 | import pycouchdb 7 | 8 | from pycouchdb.exceptions import Conflict, NotFound 9 | from pycouchdb import exceptions as exp 10 | 11 | SERVER_URL = 'http://admin:password@localhost:5984/' 12 | 13 | 14 | @pytest.fixture 15 | def server(): 16 | server = pycouchdb.Server(SERVER_URL) 17 | for db in server: 18 | server.delete(db) 19 | if "_users" not in server: 20 | server.create("_users") 21 | return server 22 | 23 | 24 | @pytest.fixture 25 | def db(server, request): 26 | db_name = 'pycouchdb-testing-' + request.node.name 27 | yield server.create(db_name) 28 | server.delete(db_name) 29 | 30 | 31 | @pytest.fixture 32 | def rec(db): 33 | querydoc = { 34 | "_id": "_design/testing", 35 | "views": { 36 | "names": { 37 | "map": "function(doc) { emit(doc.name, 1); }", 38 | "reduce": "function(keys, values) { return sum(values); }", 39 | } 40 | } 41 | } 42 | db.save(querydoc) 43 | db.save_bulk([ 44 | {"_id": "kk1", "name": "Andrey"}, 45 | {"_id": "kk2", "name": "Pepe"}, 46 | {"_id": "kk3", "name": "Alex"}, 47 | ]) 48 | yield 49 | db.delete("_design/testing") 50 | 51 | 52 | @pytest.fixture 53 | def rec_with_attachment(db, rec, tmpdir): 54 | doc = db.get("kk1") 55 | att = tmpdir.join('sample.txt') 56 | att.write(b"Hello") 57 | with open(str(att)) as f: 58 | doc = db.put_attachment(doc, f, "sample.txt") 59 | 60 | 61 | def test_server_contains(server): 62 | server.create("testing5") 63 | assert "testing5" in server 64 | server.delete("testing5") 65 | assert "testing5" not in server 66 | 67 | 68 | def test_iter(server): 69 | assert list(server) == ['_users', ] 70 | server.create("testing3") 71 | server.create("testing4") 72 | assert list(server) == ['_users', 'testing3', 'testing4'] 73 | 74 | 75 | def test_len(server): 76 | assert len(server) == 1 77 | server.create("testing3") 78 | assert len(server) == 2 79 | server.delete("testing3") 80 | assert len(server) == 1 81 | 82 | 83 | def test_create_delete_db(server): 84 | server.create("testing2") 85 | assert "testing2" in server 86 | server.delete("testing2") 87 | assert "testing2" not in server 88 | 89 | 90 | def test_create(server): 91 | server.create("testing1") 92 | with pytest.raises(Conflict): 93 | server.create("testing1") 94 | 95 | 96 | def test_version(server): 97 | version = server.version() 98 | assert version 99 | 100 | 101 | def test_info(server): 102 | data = server.info() 103 | assert "version" in data 104 | 105 | 106 | def test_replicate(server): 107 | db1 = server.create("testing1") 108 | db2 = server.create("testing2") 109 | db1.save({'_id': '1', 'title': 'hello'}) 110 | assert len(db1) == 1 111 | assert len(db2) == 0 112 | server.replicate(SERVER_URL + "testing1", SERVER_URL + "testing2") 113 | assert len(db1) == 1 114 | assert len(db2) == 1 115 | 116 | 117 | def test_replicate_create(server): 118 | server.create('testing1') 119 | assert "testing2" not in server 120 | server.replicate( 121 | SERVER_URL + "testing1", 122 | SERVER_URL + "testing2", 123 | create_target=True) 124 | assert "testing2" in server 125 | 126 | 127 | def test_save_01(db): 128 | doc = {"foo": "bar"} 129 | doc2 = db.save(doc) 130 | 131 | assert "_id" in doc2 132 | assert "_rev" in doc2 133 | assert "_id" not in doc 134 | assert "_rev" not in doc 135 | 136 | 137 | def test_save_02(db): 138 | doc = db.save({'_id': 'kk', 'foo': 'bar'}) 139 | assert "_rev" in doc 140 | assert doc["_id"] == "kk" 141 | 142 | 143 | def test_save_03(db): 144 | doc1 = {'_id': 'kk2', 'foo': 'bar'} 145 | doc2 = db.save(doc1) 146 | db.save(doc2) 147 | assert "_rev" in doc2 148 | assert doc2["_id"] == "kk2" 149 | 150 | 151 | def test_save_04(db): 152 | doc = db.save({'_id': 'kk3', 'foo': 'bar'}) 153 | del doc["_rev"] 154 | with pytest.raises(Conflict): 155 | db.save(doc) 156 | 157 | 158 | def test_save_batch(db): 159 | doc = {"foo": "bar"} 160 | doc2 = db.save(doc, batch=True) 161 | assert "_id" in doc2 162 | 163 | 164 | def test_special_chars1(db): 165 | text = "Lürem ipsüm." 166 | db.save({"_id": "special1", "text": text}) 167 | doc = db.get("special1") 168 | assert text == doc["text"] 169 | 170 | 171 | def test_special_chars2(db): 172 | text = "Mal sehen ob ich früh aufstehen mag." 173 | db.save({"_id": "special2", "text": text}) 174 | 175 | doc = db.get("special2") 176 | assert text == doc["text"] 177 | 178 | 179 | def test_db_len(db): 180 | doc1 = {'_id': 'kk4', 'foo': 'bar'} 181 | db.save(doc1) 182 | assert len(db) > 0 183 | 184 | 185 | def test_delete(db): 186 | db.save({'_id': 'kk5', 'foo': 'bar'}) 187 | db.delete("kk5") 188 | assert len(db) == 0 189 | 190 | with pytest.raises(NotFound): 191 | db.delete("kk6") 192 | 193 | 194 | def test_save_bulk_01(db): 195 | docs = db.save_bulk([ 196 | {"name": "Andrey"}, 197 | {"name": "Pepe"}, 198 | {"name": "Alex"}, 199 | ]) 200 | 201 | assert len(docs) == 3 202 | 203 | 204 | def test_save_bulk_02(db): 205 | db.save_bulk([ 206 | {"_id": "kk6", "name": "Andrey"}, 207 | {"_id": "kk7", "name": "Pepe"}, 208 | {"_id": "kk8", "name": "Alex"}, 209 | ]) 210 | 211 | with pytest.raises(Conflict): 212 | db.save_bulk([ 213 | {"_id": "kk6", "name": "Andrey"}, 214 | {"_id": "kk7", "name": "Pepe"}, 215 | {"_id": "kk8", "name": "Alex"}, 216 | ]) 217 | 218 | 219 | def test_delete_bulk(db): 220 | docs = db.save_bulk([ 221 | {"_id": "kj1", "name": "Andrey"}, 222 | {"_id": "kj2", "name": "Pepe"}, 223 | {"_id": "kj3", "name": "Alex"}, 224 | ]) 225 | 226 | results = db.delete_bulk(docs) 227 | assert len(results) == 3 228 | 229 | 230 | def test_cleanup(db): 231 | assert db.cleanup() 232 | 233 | 234 | def test_commit(db): 235 | assert db.commit() 236 | 237 | 238 | def test_compact(db): 239 | assert db.compact() 240 | 241 | 242 | def create_changes(dstdb): 243 | doc1 = {"_id": "kk1", "counter": 1} 244 | doc2 = {"_id": "kk2", "counter": 1} 245 | doc1 = dstdb.save(doc1) 246 | doc2 = dstdb.save(doc2) 247 | 248 | return doc1, doc2 249 | 250 | 251 | def test_changes_list(db): 252 | doc1, doc2 = create_changes(db) 253 | last_seq, changes = db.changes_list() 254 | assert len(changes) == 2 255 | 256 | db.save({"_id": "kk3", "counter": 1}) 257 | _, changes = db.changes_list(since=last_seq) 258 | assert len(changes) == 1 259 | 260 | db.delete(doc1) 261 | db.delete(doc2) 262 | 263 | 264 | def test_changes_feed_01(db): 265 | doc1, doc2 = create_changes(db) 266 | messages = [] 267 | 268 | def reader(message, db): 269 | messages.append(message) 270 | raise exp.FeedReaderExited() 271 | 272 | db.changes_feed(reader) 273 | assert len(messages) == 1 274 | 275 | db.delete(doc1) 276 | db.delete(doc2) 277 | 278 | 279 | def test_get_not_existent(db): 280 | with pytest.raises(NotFound): 281 | db.get("does_not_exist_in_db") 282 | 283 | 284 | def test_db_contains(db): 285 | db.save({"_id": "test_db_contains"}) 286 | assert "test_db_contains" in db 287 | assert "does_not_exist_in_db" not in db 288 | 289 | 290 | def test_all_01(db, rec): 291 | result = [x for x in db.all() if not x['key'].startswith("_")] 292 | assert len(result) == 3 293 | 294 | 295 | def test_all_02(db, rec): 296 | result = list(db.all(keys=['kk1', 'kk2'])) 297 | assert len(result) == 2 298 | 299 | 300 | def test_all_03(db, rec): 301 | result = list(db.all(keys=['kk1', 'kk2'], flat="key")) 302 | assert result == ['kk1', 'kk2'] 303 | 304 | 305 | def test_all_04(db, rec): 306 | result = db.all(keys=['kk1', 'kk2'], flat="key") 307 | assert isinstance(result, types.GeneratorType) 308 | 309 | 310 | def test_all_05(db, rec): 311 | result = db.all(keys=['kk1', 'kk2'], flat="key", as_list=True) 312 | assert isinstance(result, list) 313 | 314 | 315 | def test_all_404(db, rec): 316 | result = db.all(keys=['nonexisting'], as_list=True, 317 | include_docs='false') 318 | assert isinstance(result, list) 319 | 320 | 321 | def test_all_startkey_endkey(db, rec): 322 | result = list(db.all(startkey='kk1', endkey='kk2')) 323 | assert len(result) == 2 324 | 325 | 326 | def test_revisions_01(db, rec): 327 | doc = db.get("kk1") 328 | 329 | initial_revisions = list(db.revisions("kk1")) 330 | assert len(initial_revisions) == 1 331 | 332 | doc["name"] = "Fooo" 333 | db.save(doc) 334 | 335 | revisions = list(db.revisions("kk1")) 336 | assert len(revisions) == 2 337 | 338 | 339 | def test_revisions_02(db, rec): 340 | with pytest.raises(NotFound): 341 | list(db.revisions("kk12")) 342 | 343 | 344 | def test_query_01(db, rec): 345 | result = db.query("testing/names", group='true', 346 | keys=['Andrey'], as_list=True) 347 | assert len(result) == 1 348 | 349 | 350 | def test_query_02(db, rec): 351 | result = db.query("testing/names", as_list=False) 352 | assert isinstance(result, types.GeneratorType) 353 | 354 | 355 | def test_query_03(db, rec): 356 | result = db.query("testing/names", as_list=True, flat="value") 357 | assert result == [3] 358 | 359 | 360 | def test_query_04(db, rec): 361 | result = db.one("testing/names", flat="value") 362 | assert result == 3 363 | 364 | 365 | def test_query_05(db, rec): 366 | result = db.one("testing/names", flat="value", 367 | group='true', keys=['KK']) 368 | assert result is None 369 | 370 | 371 | def test_query_06(db, rec): 372 | result = db.one("testing/names", flat="value", 373 | group='true', keys=['Andrey']) 374 | assert result == 1 375 | 376 | 377 | def test_compact_view_01(db): 378 | doc = { 379 | "_id": "_design/testing2", 380 | "views": { 381 | "names": { 382 | "map": "function(doc) { emit(doc.name, 1); }", 383 | "reduce": "function(keys, values) { return sum(values); }", 384 | } 385 | } 386 | } 387 | 388 | db.save(doc) 389 | db.compact_view("testing2") 390 | 391 | 392 | def test_compact_view_02(db): 393 | with pytest.raises(NotFound): 394 | db.compact_view("fooo") 395 | 396 | 397 | def test_attachments_01(db, rec_with_attachment): 398 | doc = db.get("kk1") 399 | assert "_attachments" in doc 400 | 401 | data = db.get_attachment(doc, "sample.txt") 402 | assert data == b"Hello" 403 | 404 | doc = db.delete_attachment(doc, "sample.txt") 405 | assert "_attachments" not in doc 406 | 407 | doc = db.get("kk1") 408 | assert "_attachments" not in doc 409 | 410 | 411 | def test_attachments_02(db, rec_with_attachment): 412 | doc = db.get("kk1") 413 | assert "_attachments" in doc 414 | 415 | 416 | def test_get_not_existent_attachment(db, rec): 417 | doc = db.get("kk1") 418 | with pytest.raises(NotFound): 419 | db.get_attachment(doc, "kk.txt") 420 | 421 | 422 | def test_attachments_03_stream(db, rec_with_attachment): 423 | doc = db.get("kk1") 424 | 425 | response = db.get_attachment(doc, "sample.txt", stream=True) 426 | stream = response.iter_content() 427 | 428 | assert next(stream) == b"H" 429 | assert next(stream) == b"e" 430 | assert next(stream) == b"l" 431 | assert next(stream) == b"l" 432 | assert next(stream) == b"o" 433 | 434 | with pytest.raises(StopIteration): 435 | next(stream) 436 | 437 | 438 | def test_regression_unexpected_deletion_of_attachment(db, rec_with_attachment): 439 | """ 440 | When I upload one file the code looks like: 441 | 442 | doc = db.put_attachment(doc, file_object) 443 | 444 | Ok, but now I want to update one field: 445 | 446 | doc['onefield'] = 'newcontent' 447 | doc = db.save(doc) 448 | 449 | et voilà, the previously uploaded file has been deleted! 450 | """ 451 | 452 | doc = db.get("kk1") 453 | 454 | assert "_attachments" in doc 455 | assert "sample.txt" in doc["_attachments"] 456 | 457 | doc["some_attr"] = 1 458 | doc = db.save(doc) 459 | 460 | assert "_attachments" in doc 461 | assert "sample.txt" in doc["_attachments"] 462 | 463 | 464 | @pytest.fixture 465 | def view(db): 466 | querydoc = { 467 | "_id": "_design/testing", 468 | "views": { 469 | "names": { 470 | "map": "function(doc) { emit(doc.name, 1); }", 471 | # "reduce": "function(keys, values) { return sum(values); }", 472 | } 473 | } 474 | } 475 | db.save(querydoc) 476 | db.save_bulk([ 477 | {"_id": "kk1", "name": "Florian"}, 478 | {"_id": "kk2", "name": "Raphael"}, 479 | {"_id": "kk3", "name": "Jaideep"}, 480 | {"_id": "kk4", "name": "Andrew"}, 481 | {"_id": "kk5", "name": "Pepe"}, 482 | {"_id": "kk6", "name": "Alex"}, 483 | 484 | ]) 485 | yield 486 | db.delete("_design/testing") 487 | 488 | 489 | @pytest.fixture 490 | def view_duplicate_keys(db): 491 | querydoc = { 492 | "_id": "_design/testing", 493 | "views": { 494 | "names": { 495 | "map": "function(doc) { emit(doc.name, 1); }", 496 | # "reduce": "function(keys, values) { return sum(values); }", 497 | } 498 | } 499 | } 500 | db.save(querydoc) 501 | db.save_bulk([ 502 | {"_id": "kk1", "name": "Andrew"}, 503 | {"_id": "kk2", "name": "Andrew"}, 504 | {"_id": "kk3", "name": "Andrew"}, 505 | {"_id": "kk4", "name": "Andrew"}, 506 | {"_id": "kk5", "name": "Andrew"}, 507 | {"_id": "kk6", "name": "Andrew"}, 508 | 509 | ]) 510 | yield 511 | db.delete("_design/testing") 512 | 513 | 514 | def test_pagination(db, view): 515 | # Check if invariants on pagesize are followed 516 | with pytest.raises(AssertionError) as err: 517 | db.query("testing/names", pagesize="123") 518 | 519 | assert ("pagesize should be a positive integer" in str(err.value)) 520 | 521 | with pytest.raises(AssertionError) as err: 522 | db.query("testing/names", pagesize=0) 523 | 524 | assert ("pagesize should be a positive integer" in str(err.value)) 525 | 526 | # Check if the number of records retrieved are correct 527 | records = list(db.query("testing/names", pagesize=1)) 528 | assert (len(records) == 6) 529 | 530 | # Check no duplicate records are retrieved 531 | record_ids = set(record['id'] for record in records) 532 | assert (len(record_ids) == 6) 533 | 534 | 535 | def test_duplicate_keys_pagination(db, view_duplicate_keys): 536 | # Check if the number of records retrieved are correct 537 | records = list(db.query("testing/names", pagesize=4)) 538 | print(type(records[0])) 539 | assert (len(records) == 6) 540 | 541 | # Check no duplicate records are retrieved 542 | record_ids = set(record['id'] for record in records) 543 | assert (len(record_ids) == 6) 544 | 545 | 546 | def test_limit_pagination(db, view_duplicate_keys): 547 | # Case 1: the paginator follows the limit 548 | # Request only first three documents 549 | records = list(db.query("testing/names", pagesize=10, limit=3)) 550 | assert len(records) == 3 551 | 552 | record_ids = set(record['id'] for record in records) 553 | assert len(record_ids) == 3 554 | 555 | # Case 2: limit > #documents 556 | records = list(db.query("testing/names", pagesize=10, limit=10)) 557 | assert len(records) == 6 558 | 559 | record_ids = set(record['id'] for record in records) 560 | assert len(record_ids) == 6 561 | 562 | 563 | def test_large_page_size(db, view_duplicate_keys): 564 | records = list(db.query("testing/names", pagesize=100)) 565 | assert len(records) == 6 566 | 567 | record_ids = set(record['id'] for record in records) 568 | assert len(record_ids) == 6 569 | 570 | # Authentication Tests 571 | def test_basic_auth_success(): 572 | """Test successful basic authentication.""" 573 | server = pycouchdb.Server('http://admin:password@localhost:5984/') 574 | info = server.info() 575 | assert 'version' in info 576 | 577 | 578 | def test_basic_auth_failure(): 579 | """Test basic authentication with invalid credentials.""" 580 | server = pycouchdb.Server('http://invalid:credentials@localhost:5984/') 581 | 582 | with pytest.raises(Exception): 583 | server.info() 584 | 585 | 586 | def test_no_auth_required(): 587 | """Test connection without authentication when not required.""" 588 | try: 589 | server = pycouchdb.Server('http://localhost:5984/') 590 | info = server.info() 591 | assert 'version' in info 592 | except Exception: 593 | pytest.skip("CouchDB requires authentication") 594 | 595 | 596 | # SSL and HTTPS Tests 597 | def test_https_connection(): 598 | """Test HTTPS connection if available.""" 599 | try: 600 | server = pycouchdb.Server('https://admin:password@localhost:6984/', verify=False) 601 | info = server.info() 602 | assert 'version' in info 603 | except Exception: 604 | pytest.skip("HTTPS not available or not configured") 605 | 606 | 607 | def test_ssl_verification(): 608 | """Test SSL verification behavior.""" 609 | try: 610 | server = pycouchdb.Server('https://admin:password@localhost:6984/', verify=True) 611 | server.info() 612 | pytest.fail("Expected SSL verification to fail with self-signed certificate") 613 | except Exception: 614 | pass 615 | 616 | 617 | # Concurrent Operations Tests 618 | def test_concurrent_document_updates(db): 619 | """Test concurrent updates to the same document.""" 620 | import threading 621 | import time 622 | 623 | doc = db.save({'_id': 'concurrent_test', 'counter': 0}) 624 | 625 | results = [] 626 | errors = [] 627 | 628 | def update_document(): 629 | try: 630 | for i in range(5): 631 | current_doc = db.get('concurrent_test') 632 | current_doc['counter'] += 1 633 | current_doc['thread_id'] = threading.current_thread().ident 634 | updated_doc = db.save(current_doc) 635 | results.append(updated_doc['counter']) 636 | time.sleep(0.01) 637 | except Exception as e: 638 | errors.append(e) 639 | 640 | threads = [] 641 | for i in range(3): 642 | thread = threading.Thread(target=update_document) 643 | threads.append(thread) 644 | thread.start() 645 | 646 | for thread in threads: 647 | thread.join() 648 | 649 | assert len(results) > 0 650 | assert len(errors) >= 0 651 | 652 | 653 | def test_concurrent_database_operations(server): 654 | """Test concurrent database creation and deletion.""" 655 | import threading 656 | import time 657 | 658 | results = [] 659 | errors = [] 660 | 661 | def create_and_delete_db(db_num): 662 | try: 663 | db_name = f'concurrent_db_{db_num}' 664 | db = server.create(db_name) 665 | time.sleep(0.1) 666 | server.delete(db_name) 667 | results.append(f'success_{db_num}') 668 | except Exception as e: 669 | errors.append(f'error_{db_num}: {e}') 670 | 671 | # Start multiple threads 672 | threads = [] 673 | for i in range(5): 674 | thread = threading.Thread(target=create_and_delete_db, args=(i,)) 675 | threads.append(thread) 676 | thread.start() 677 | 678 | # Wait for all threads to complete 679 | for thread in threads: 680 | thread.join() 681 | 682 | # Check results 683 | assert len(results) > 0 684 | # Some operations might fail due to timing, that's expected 685 | 686 | 687 | # Large Data Tests 688 | def test_large_document(db): 689 | """Test handling of large documents.""" 690 | # Create a large document (approaching CouchDB's 1MB limit) 691 | large_data = 'x' * (1024 * 1024) # 1MB of data 692 | doc = { 693 | '_id': 'large_doc', 694 | 'data': large_data, 695 | 'size': len(large_data) 696 | } 697 | 698 | saved_doc = db.save(doc) 699 | assert saved_doc['_id'] == 'large_doc' 700 | assert saved_doc['size'] == len(large_data) 701 | 702 | # Retrieve and verify 703 | retrieved_doc = db.get('large_doc') 704 | assert retrieved_doc['size'] == len(large_data) 705 | assert len(retrieved_doc['data']) == len(large_data) 706 | 707 | 708 | def test_bulk_operations_large_dataset(db): 709 | """Test bulk operations with large datasets.""" 710 | # Create a large number of documents 711 | docs = [] 712 | for i in range(1000): 713 | docs.append({ 714 | '_id': f'bulk_doc_{i}', 715 | 'index': i, 716 | 'data': f'content_{i}' * 100 # Make each doc reasonably sized 717 | }) 718 | 719 | # Save in bulk 720 | saved_docs = db.save_bulk(docs) 721 | assert len(saved_docs) == 1000 722 | 723 | # Verify some documents 724 | for i in range(0, 1000, 100): 725 | doc = db.get(f'bulk_doc_{i}') 726 | assert doc['index'] == i 727 | assert doc['data'].startswith(f'content_{i}') 728 | 729 | 730 | def test_memory_efficient_streaming(db): 731 | """Test memory-efficient streaming operations.""" 732 | # Create a document with attachment 733 | doc = db.save({'_id': 'streaming_test', 'type': 'test'}) 734 | 735 | # Create a large attachment 736 | large_content = b'x' * (100 * 1024) # 100KB 737 | import io 738 | content_stream = io.BytesIO(large_content) 739 | 740 | # Put attachment 741 | doc_with_attachment = db.put_attachment(doc, content_stream, 'large_file.txt') 742 | 743 | # Get attachment with streaming 744 | stream_response = db.get_attachment(doc_with_attachment, 'large_file.txt', stream=True) 745 | 746 | # Read in chunks to test streaming 747 | chunks = [] 748 | for chunk in stream_response.iter_content(chunk_size=1024): 749 | chunks.append(chunk) 750 | 751 | # Verify content 752 | retrieved_content = b''.join(chunks) 753 | assert retrieved_content == large_content 754 | 755 | 756 | # Changes Feed Tests 757 | def test_changes_feed_error_handling(db): 758 | """Test changes feed with error scenarios.""" 759 | messages = [] 760 | errors = [] 761 | 762 | def error_prone_reader(message, db): 763 | messages.append(message) 764 | if len(messages) > 2: 765 | raise Exception("Simulated error in feed reader") 766 | 767 | try: 768 | db.changes_feed(error_prone_reader, limit=5) 769 | except Exception as e: 770 | errors.append(e) 771 | 772 | assert len(messages) > 0 773 | 774 | 775 | def test_changes_feed_heartbeat_handling(db): 776 | """Test changes feed heartbeat handling.""" 777 | heartbeats = [] 778 | messages = [] 779 | 780 | class HeartbeatTestReader(pycouchdb.feedreader.BaseFeedReader): 781 | def on_message(self, message): 782 | messages.append(message) 783 | if len(messages) >= 2: 784 | raise pycouchdb.exceptions.FeedReaderExited() 785 | 786 | def on_heartbeat(self): 787 | heartbeats.append('heartbeat') 788 | 789 | reader = HeartbeatTestReader() 790 | db.changes_feed(reader, limit=5) 791 | 792 | assert len(heartbeats) >= 0 793 | 794 | 795 | # Unicode and Special Characters Tests 796 | def test_unicode_document_ids(db): 797 | """Test handling of unicode document IDs.""" 798 | unicode_ids = [ 799 | '测试文档', 800 | 'документ_тест', 801 | 'مستند_اختبار', 802 | 'ドキュメント_テスト', 803 | 'тест_документ_123' 804 | ] 805 | 806 | for doc_id in unicode_ids: 807 | doc = db.save({'_id': doc_id, 'content': f'Content for {doc_id}'}) 808 | assert doc['_id'] == doc_id 809 | 810 | # Retrieve and verify 811 | retrieved_doc = db.get(doc_id) 812 | assert retrieved_doc['_id'] == doc_id 813 | assert retrieved_doc['content'] == f'Content for {doc_id}' 814 | 815 | 816 | def test_unicode_content(db): 817 | """Test handling of unicode content in documents.""" 818 | unicode_content = { 819 | 'chinese': '这是中文内容', 820 | 'russian': 'Это русский текст', 821 | 'arabic': 'هذا نص عربي', 822 | 'japanese': 'これは日本語のテキストです', 823 | 'emoji': '🚀📚💻🎉' 824 | } 825 | 826 | doc = db.save({'_id': 'unicode_test', **unicode_content}) 827 | 828 | retrieved_doc = db.get('unicode_test') 829 | for key, value in unicode_content.items(): 830 | assert retrieved_doc[key] == value 831 | 832 | 833 | def test_special_characters_in_database_names(server): 834 | """Test handling of special characters in database names.""" 835 | # Test database names that are definitely allowed by CouchDB 836 | allowed_names = [ 837 | 'test_db_123', # underscores and numbers (most basic) 838 | 'test-db-123', # dashes and numbers 839 | ] 840 | 841 | invalid_names = [ 842 | 'TestDB', 843 | '123test', 844 | ] 845 | 846 | for db_name in allowed_names: 847 | try: 848 | db = server.create(db_name) 849 | assert db_name in server 850 | server.delete(db_name) 851 | assert db_name not in server 852 | except Exception as e: 853 | pytest.skip(f"Database name '{db_name}' not allowed: {e}") 854 | 855 | for db_name in invalid_names: 856 | with pytest.raises(Exception): 857 | server.create(db_name) 858 | 859 | 860 | # Performance and Timeout Tests 861 | 862 | 863 | def test_bulk_operation_performance(db): 864 | """Test performance of bulk operations.""" 865 | import time 866 | 867 | # Test bulk save performance 868 | docs = [{'index': i, 'data': f'content_{i}'} for i in range(100)] 869 | 870 | start_time = time.time() 871 | saved_docs = db.save_bulk(docs) 872 | end_time = time.time() 873 | 874 | assert len(saved_docs) == 100 875 | assert end_time - start_time < 10 876 | 877 | 878 | # Edge Cases Tests 879 | def test_empty_database_operations(db): 880 | """Test operations on empty database.""" 881 | # Test querying empty database 882 | results = list(db.all()) 883 | assert len(results) == 0 884 | 885 | # Test changes on empty database 886 | last_seq, changes = db.changes_list() 887 | assert len(changes) == 0 888 | 889 | try: 890 | result = db.query('nonexistent/view') 891 | assert list(result) == [] 892 | except pycouchdb.exceptions.NotFound: 893 | pass 894 | 895 | 896 | def test_document_with_system_fields(db): 897 | """Test handling of documents with system fields.""" 898 | doc = { 899 | '_id': 'system_fields_test', 900 | 'custom_field': 'value', 901 | } 902 | 903 | saved_doc = db.save(doc) 904 | assert saved_doc['_id'] == 'system_fields_test' 905 | assert saved_doc['custom_field'] == 'value' 906 | assert '_rev' in saved_doc 907 | assert saved_doc['_rev'].startswith('1-') 908 | 909 | saved_doc['custom_field'] = 'updated_value' 910 | updated_doc = db.save(saved_doc) 911 | assert updated_doc['custom_field'] == 'updated_value' 912 | assert updated_doc['_rev'].startswith('2-') 913 | 914 | 915 | def test_attachment_with_special_characters(db): 916 | """Test attachments with special characters in filenames.""" 917 | import io 918 | 919 | special_filenames = [ 920 | 'file_with_underscores.txt', 921 | 'file-with-dashes.txt', 922 | 'file.with.dots.txt', 923 | 'файл_с_кириллицей.txt' 924 | ] 925 | 926 | for i, filename in enumerate(special_filenames): 927 | try: 928 | doc = db.save({'_id': f'attachment_test_{i}', 'type': 'test'}) 929 | 930 | content = f'Content for {filename}'.encode('utf-8') 931 | content_stream = io.BytesIO(content) 932 | 933 | doc_with_attachment = db.put_attachment(doc, content_stream, filename) 934 | 935 | retrieved_content = db.get_attachment(doc_with_attachment, filename) 936 | assert retrieved_content.decode('utf-8') == f'Content for {filename}' 937 | 938 | except Exception as e: 939 | pytest.skip(f"Filename '{filename}' not allowed: {e}") 940 | 941 | 942 | # Advanced CouchDB Features Tests 943 | def test_design_document_management(db): 944 | """Test comprehensive design document operations.""" 945 | # Create a design document with multiple views 946 | design_doc = { 947 | "_id": "_design/test_views", 948 | "views": { 949 | "by_name": { 950 | "map": "function(doc) { if (doc.name) emit(doc.name, doc); }" 951 | }, 952 | "by_type": { 953 | "map": "function(doc) { if (doc.type) emit(doc.type, 1); }", 954 | "reduce": "function(keys, values) { return sum(values); }" 955 | }, 956 | "by_date": { 957 | "map": "function(doc) { if (doc.created_at) emit(doc.created_at, doc); }" 958 | } 959 | }, 960 | "filters": { 961 | "by_status": "function(doc, req) { return doc.status === req.query.status; }" 962 | }, 963 | "shows": { 964 | "item": "function(doc, req) { return {body: JSON.stringify(doc)}; }" 965 | }, 966 | "lists": { 967 | "items": "function(head, req) { var row; while (row = getRow()) { send(row.value); } }" 968 | } 969 | } 970 | 971 | # Save design document 972 | saved_design = db.save(design_doc) 973 | assert saved_design['_id'] == '_design/test_views' 974 | 975 | # Create some test documents 976 | test_docs = [ 977 | {'_id': 'doc1', 'name': 'Alice', 'type': 'user', 'status': 'active', 'created_at': '2023-01-01'}, 978 | {'_id': 'doc2', 'name': 'Bob', 'type': 'user', 'status': 'inactive', 'created_at': '2023-01-02'}, 979 | {'_id': 'doc3', 'name': 'Charlie', 'type': 'admin', 'status': 'active', 'created_at': '2023-01-03'}, 980 | ] 981 | db.save_bulk(test_docs) 982 | 983 | # Test different views 984 | by_name_results = list(db.query('test_views/by_name')) 985 | assert len(by_name_results) == 3 986 | 987 | by_type_results = list(db.query('test_views/by_type', group=True)) 988 | assert len(by_type_results) == 2 # user and admin types 989 | 990 | # Test reduce function 991 | total_by_type = db.one('test_views/by_type', flat='value') 992 | assert total_by_type == 3 # Total count of all documents 993 | 994 | # Test date range query 995 | date_results = list(db.query('test_views/by_date', 996 | startkey='2023-01-01', 997 | endkey='2023-01-02')) 998 | assert len(date_results) == 2 999 | 1000 | 1001 | def test_view_compaction_and_cleanup(db): 1002 | """Test view compaction and cleanup operations.""" 1003 | # Create a design document with a view 1004 | design_doc = { 1005 | "_id": "_design/compaction_test", 1006 | "views": { 1007 | "test_view": { 1008 | "map": "function(doc) { emit(doc.id, doc.value); }" 1009 | } 1010 | } 1011 | } 1012 | db.save(design_doc) 1013 | 1014 | # Add some documents to create view data 1015 | for i in range(100): 1016 | db.save({'_id': f'compaction_doc_{i}', 'id': i, 'value': f'value_{i}'}) 1017 | 1018 | # Test view compaction 1019 | result = db.compact_view('compaction_test') 1020 | assert result is not None 1021 | 1022 | # Test database cleanup 1023 | cleanup_result = db.cleanup() 1024 | assert cleanup_result is not None 1025 | 1026 | 1027 | def test_replication_edge_cases(server): 1028 | """Test replication with various edge cases.""" 1029 | # Create source and target databases 1030 | source_db = server.create('replication_source') 1031 | target_db = server.create('replication_target') 1032 | 1033 | try: 1034 | # Add documents to source 1035 | source_docs = [ 1036 | {'_id': 'doc1', 'content': 'source content 1'}, 1037 | {'_id': 'doc2', 'content': 'source content 2'}, 1038 | {'_id': 'doc3', 'content': 'source content 3'}, 1039 | ] 1040 | source_db.save_bulk(source_docs) 1041 | 1042 | # Test basic replication 1043 | replicate_result = server.replicate( 1044 | SERVER_URL + 'replication_source', 1045 | SERVER_URL + 'replication_target' 1046 | ) 1047 | assert replicate_result is not None 1048 | 1049 | # Verify documents were replicated 1050 | target_docs = list(target_db.all()) 1051 | assert len(target_docs) >= 3 1052 | 1053 | # Test replication with create_target=True 1054 | replicate_with_create = server.replicate( 1055 | SERVER_URL + 'replication_source', 1056 | SERVER_URL + 'replication_target_create', 1057 | create_target=True 1058 | ) 1059 | assert replicate_with_create is not None 1060 | 1061 | # Verify target database was created 1062 | assert 'replication_target_create' in server 1063 | 1064 | # Clean up created database 1065 | server.delete('replication_target_create') 1066 | 1067 | finally: 1068 | # Clean up 1069 | server.delete('replication_source') 1070 | server.delete('replication_target') 1071 | 1072 | 1073 | def test_library_compaction_api_behavior(db): 1074 | """Test pycouchdb library's compaction API behavior.""" 1075 | # Test that compact() method returns expected result 1076 | compact_result = db.compact() 1077 | assert compact_result is not None 1078 | 1079 | # Test that compact_view() works with valid design doc 1080 | design_doc = { 1081 | "_id": "_design/compact_test", 1082 | "views": { 1083 | "test_view": { 1084 | "map": "function(doc) { emit(doc.id, doc.value); }" 1085 | } 1086 | } 1087 | } 1088 | db.save(design_doc) 1089 | 1090 | # Add some documents to create view data 1091 | for i in range(10): 1092 | db.save({'_id': f'compact_doc_{i}', 'id': i, 'value': f'value_{i}'}) 1093 | 1094 | # Test view compaction API 1095 | view_compact_result = db.compact_view('compact_test') 1096 | assert view_compact_result is not None 1097 | 1098 | # Test cleanup API 1099 | cleanup_result = db.cleanup() 1100 | assert cleanup_result is not None 1101 | 1102 | 1103 | def test_changes_feed_with_filters(db): 1104 | """Test changes feed with different filter options.""" 1105 | # Create a design document with filter 1106 | design_doc = { 1107 | "_id": "_design/filters", 1108 | "filters": { 1109 | "by_type": "function(doc, req) { return doc.type === req.query.type; }" 1110 | } 1111 | } 1112 | db.save(design_doc) 1113 | 1114 | # Add documents of different types 1115 | docs = [ 1116 | {'_id': 'user1', 'type': 'user', 'name': 'Alice'}, 1117 | {'_id': 'admin1', 'type': 'admin', 'name': 'Bob'}, 1118 | {'_id': 'user2', 'type': 'user', 'name': 'Charlie'}, 1119 | ] 1120 | db.save_bulk(docs) 1121 | 1122 | # Test changes feed with filter 1123 | messages = [] 1124 | 1125 | def filter_reader(message, db): 1126 | messages.append(message) 1127 | if len(messages) >= 2: 1128 | raise pycouchdb.exceptions.FeedReaderExited() 1129 | 1130 | try: 1131 | db.changes_feed(filter_reader, filter='filters/by_type', type='user', limit=10) 1132 | except Exception: 1133 | pass # May not be supported in all CouchDB versions 1134 | 1135 | # Should have received some messages 1136 | assert len(messages) >= 0 1137 | 1138 | 1139 | def test_attachment_metadata_and_content_types(db): 1140 | """Test attachment handling with different content types and metadata.""" 1141 | doc = db.save({'_id': 'attachment_metadata_test', 'type': 'test'}) 1142 | 1143 | # Test different content types 1144 | content_types = [ 1145 | ('text.txt', 'text/plain', b'Plain text content'), 1146 | ('data.json', 'application/json', b'{"key": "value"}'), 1147 | ('image.png', 'image/png', b'fake_png_data'), 1148 | ('document.pdf', 'application/pdf', b'fake_pdf_data'), 1149 | ] 1150 | 1151 | for filename, content_type, content in content_types: 1152 | import io 1153 | content_stream = io.BytesIO(content) 1154 | 1155 | # Get fresh document for each attachment to avoid conflicts 1156 | current_doc = db.get('attachment_metadata_test') 1157 | 1158 | # Put attachment with specific content type 1159 | doc_with_attachment = db.put_attachment( 1160 | current_doc, content_stream, filename, content_type=content_type 1161 | ) 1162 | 1163 | # Verify attachment metadata 1164 | assert '_attachments' in doc_with_attachment 1165 | assert filename in doc_with_attachment['_attachments'] 1166 | 1167 | attachment_info = doc_with_attachment['_attachments'][filename] 1168 | assert attachment_info['content_type'] == content_type 1169 | assert attachment_info['length'] == len(content) 1170 | 1171 | # Retrieve and verify content 1172 | retrieved_content = db.get_attachment(doc_with_attachment, filename) 1173 | assert retrieved_content == content 1174 | 1175 | 1176 | def test_document_conflicts_resolution(db): 1177 | """Test document conflict resolution scenarios.""" 1178 | # Create initial document 1179 | doc1 = db.save({'_id': 'conflict_test', 'version': 1, 'data': 'initial'}) 1180 | 1181 | # Simulate concurrent updates by getting the same document twice 1182 | doc2 = db.get('conflict_test') 1183 | doc3 = db.get('conflict_test') 1184 | 1185 | # Update both copies 1186 | doc2['version'] = 2 1187 | doc2['data'] = 'updated_by_client_1' 1188 | doc3['version'] = 2 1189 | doc3['data'] = 'updated_by_client_2' 1190 | 1191 | # Save first update 1192 | updated_doc2 = db.save(doc2) 1193 | 1194 | # Second update should conflict 1195 | with pytest.raises(pycouchdb.exceptions.Conflict): 1196 | db.save(doc3) 1197 | 1198 | # Resolve conflict by getting latest and updating 1199 | latest_doc = db.get('conflict_test') 1200 | latest_doc['version'] = 3 1201 | latest_doc['data'] = 'resolved_conflict' 1202 | resolved_doc = db.save(latest_doc) 1203 | 1204 | assert resolved_doc['version'] == 3 1205 | assert resolved_doc['data'] == 'resolved_conflict' 1206 | 1207 | 1208 | def test_bulk_operations_with_conflicts(db): 1209 | """Test bulk operations handling conflicts.""" 1210 | # Create initial documents 1211 | initial_docs = [ 1212 | {'_id': 'bulk_conflict_1', 'version': 1}, 1213 | {'_id': 'bulk_conflict_2', 'version': 1}, 1214 | {'_id': 'bulk_conflict_3', 'version': 1}, 1215 | ] 1216 | db.save_bulk(initial_docs) 1217 | 1218 | # Get documents for update 1219 | docs_to_update = [db.get(f'bulk_conflict_{i}') for i in range(1, 4)] 1220 | 1221 | # Update all documents 1222 | for i, doc in enumerate(docs_to_update): 1223 | doc['version'] = 2 1224 | doc['updated_by'] = f'client_{i}' 1225 | 1226 | # Save in bulk - should succeed 1227 | updated_docs = db.save_bulk(docs_to_update) 1228 | assert len(updated_docs) == 3 1229 | 1230 | # Try to update again with old revision - should conflict 1231 | # We need to use the old revision numbers to create a conflict 1232 | old_docs = [ 1233 | {'_id': 'bulk_conflict_1', '_rev': '1-abc123', 'version': 1}, # Fake old rev 1234 | {'_id': 'bulk_conflict_2', '_rev': '1-def456', 'version': 1}, # Fake old rev 1235 | {'_id': 'bulk_conflict_3', '_rev': '1-ghi789', 'version': 1}, # Fake old rev 1236 | ] 1237 | with pytest.raises(pycouchdb.exceptions.Conflict): 1238 | db.save_bulk(old_docs) 1239 | 1240 | 1241 | def test_library_database_config_api(server): 1242 | """Test pycouchdb library's database config API.""" 1243 | # Create a test database 1244 | test_db = server.create('config_test') 1245 | 1246 | try: 1247 | # Test that config() method returns expected data structure 1248 | db_info = test_db.config() 1249 | assert isinstance(db_info, dict) 1250 | assert 'update_seq' in db_info 1251 | assert 'doc_count' in db_info 1252 | 1253 | # Test that we can access the database name 1254 | assert test_db.name == 'config_test' 1255 | 1256 | # Test that database length works 1257 | assert isinstance(len(test_db), int) 1258 | 1259 | finally: 1260 | server.delete('config_test') 1261 | 1262 | 1263 | def test_library_server_initialization(): 1264 | """Test pycouchdb library's server initialization with different parameters.""" 1265 | # Test default initialization 1266 | server1 = pycouchdb.Server() 1267 | assert server1.base_url == 'http://localhost:5984/' 1268 | 1269 | # Test custom URL initialization 1270 | server2 = pycouchdb.Server('http://custom:5984/') 1271 | assert server2.base_url == 'http://custom:5984/' 1272 | 1273 | # Test with credentials 1274 | server3 = pycouchdb.Server('http://user:pass@localhost:5984/') 1275 | assert server3.base_url == 'http://localhost:5984/' 1276 | 1277 | # Test with verify parameter 1278 | server4 = pycouchdb.Server(verify=True) 1279 | assert server4.base_url == 'http://localhost:5984/' 1280 | 1281 | 1282 | def test_custom_headers_and_parameters(db): 1283 | """Test custom headers and parameters in requests.""" 1284 | # Test with custom parameters in get request 1285 | doc = db.save({'_id': 'custom_params_test', 'data': 'test'}) 1286 | 1287 | # Test getting document with custom parameters 1288 | # Note: revs_info parameter should be passed to the underlying request 1289 | retrieved_doc = db.get('custom_params_test', revs=True, revs_info=True) 1290 | assert retrieved_doc['_id'] == 'custom_params_test' 1291 | # The revs_info parameter should be handled by the library 1292 | # We just verify the document was retrieved successfully 1293 | assert retrieved_doc['data'] == 'test' 1294 | 1295 | 1296 | def test_library_database_length_and_config_api(db): 1297 | """Test pycouchdb library's database length and config API.""" 1298 | # Test empty database 1299 | initial_length = len(db) 1300 | initial_config = db.config() 1301 | assert isinstance(initial_length, int) 1302 | assert isinstance(initial_config, dict) 1303 | assert 'doc_count' in initial_config 1304 | assert 'update_seq' in initial_config 1305 | 1306 | # Add some documents 1307 | docs = [{'index': i, 'data': f'length_test_{i}'} for i in range(5)] 1308 | db.save_bulk(docs) 1309 | 1310 | # Test that length reflects document count 1311 | new_length = len(db) 1312 | new_config = db.config() 1313 | 1314 | assert new_length >= initial_length + 5 1315 | assert new_config['doc_count'] >= initial_config['doc_count'] + 5 1316 | assert new_config['update_seq'] > initial_config['update_seq'] 1317 | 1318 | --------------------------------------------------------------------------------