├── 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 | [](https://github.com/histrio/py-couchdb/actions/workflows/main.yml)
4 | 
5 | 
6 | [](https://codecov.io/github/histrio/py-couchdb)
7 | [](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 |
--------------------------------------------------------------------------------