From 5304fe541c221f68b91319bca82691dee9b6ae06 Mon Sep 17 00:00:00 2001 From: Jared Casey Date: Tue, 10 Jun 2025 16:15:42 -0600 Subject: [PATCH 01/18] Initial dev commit. Changes ======= * Basic query functionality is available for both sync and async APIs * Rough handling of timeouts and cancellation is available (but more work needs to be done) * Error handling is incomplete * Linting is not in place (and will require changes) * Typing is not in place (and will require changes) * Test suite is minimal --- .gitignore | 182 ++++++++ LICENSE | 0 acouchbase_analytics/__init__.py | 91 ++++ acouchbase_analytics/cluster.py | 223 ++++++++++ acouchbase_analytics/cluster.pyi | 119 +++++ acouchbase_analytics/credential.py | 16 + acouchbase_analytics/database.py | 58 +++ acouchbase_analytics/database.pyi | 25 ++ acouchbase_analytics/deserializer.py | 18 + acouchbase_analytics/errors.py | 20 + acouchbase_analytics/options.py | 24 + acouchbase_analytics/protocol/__init__.py | 14 + acouchbase_analytics/protocol/cluster.py | 127 ++++++ acouchbase_analytics/protocol/cluster.pyi | 125 ++++++ .../protocol/core/__init__.py | 0 .../protocol/core/_anyio_utils.py | 61 +++ .../protocol/core/_request_context.py | 212 +++++++++ .../protocol/core/client_adapter.py | 155 +++++++ acouchbase_analytics/protocol/database.py | 57 +++ acouchbase_analytics/protocol/database.pyi | 29 ++ acouchbase_analytics/protocol/scope.py | 82 ++++ acouchbase_analytics/protocol/scope.pyi | 71 +++ acouchbase_analytics/protocol/streaming.py | 202 +++++++++ acouchbase_analytics/query.py | 19 + acouchbase_analytics/result.py | 17 + acouchbase_analytics/scope.py | 112 +++++ acouchbase_analytics/scope.pyi | 68 +++ acouchbase_analytics/tests/__init__.py | 0 acouchbase_analytics/tests/json_parsing_t.py | 415 ++++++++++++++++++ acouchbase_analytics/tests/test_server_t.py | 69 +++ conftest.py | 54 +++ couchbase_analytics/__init__.py | 18 + couchbase_analytics/cluster.py | 205 +++++++++ couchbase_analytics/cluster.pyi | 202 +++++++++ couchbase_analytics/common/__init__.py | 21 + couchbase_analytics/common/core/__init__.py | 19 + .../common/core/_certificates.py | 43 ++ .../common/core/async_json_stream.py | 183 ++++++++ .../common/core/async_json_token_parser.py | 87 ++++ .../common/core/duration_str_utils.py | 88 ++++ couchbase_analytics/common/core/exception.py | 66 +++ .../common/core/json_parsing.py | 65 +++ .../common/core/json_stream.py | 182 ++++++++ .../common/core/json_token_parser.py | 84 ++++ .../common/core/json_token_parser_base.py | 230 ++++++++++ couchbase_analytics/common/core/net_utils.py | 104 +++++ couchbase_analytics/common/core/query.py | 109 +++++ couchbase_analytics/common/core/result.py | 61 +++ couchbase_analytics/common/core/utils.py | 136 ++++++ couchbase_analytics/common/credential.py | 96 ++++ couchbase_analytics/common/deserializer.py | 69 +++ couchbase_analytics/common/enums.py | 38 ++ couchbase_analytics/common/errors.py | 173 ++++++++ couchbase_analytics/common/options.py | 168 +++++++ couchbase_analytics/common/options_base.py | 192 ++++++++ couchbase_analytics/common/query.py | 126 ++++++ couchbase_analytics/common/result.py | 139 ++++++ couchbase_analytics/common/streaming.py | 159 +++++++ couchbase_analytics/credential.py | 16 + couchbase_analytics/database.py | 58 +++ couchbase_analytics/database.pyi | 25 ++ couchbase_analytics/deserializer.py | 18 + couchbase_analytics/errors.py | 20 + couchbase_analytics/options.py | 23 + couchbase_analytics/protocol/__init__.py | 80 ++++ couchbase_analytics/protocol/cluster.py | 157 +++++++ couchbase_analytics/protocol/cluster.pyi | 209 +++++++++ couchbase_analytics/protocol/connection.py | 234 ++++++++++ couchbase_analytics/protocol/core/__init__.py | 14 + .../protocol/core/_http_transport.py | 269 ++++++++++++ .../protocol/core/_request_context.py | 297 +++++++++++++ .../protocol/core/client_adapter.py | 159 +++++++ couchbase_analytics/protocol/core/request.py | 224 ++++++++++ couchbase_analytics/protocol/core/utils.py | 35 ++ couchbase_analytics/protocol/database.py | 55 +++ couchbase_analytics/protocol/database.pyi | 34 ++ couchbase_analytics/protocol/errors.py | 92 ++++ couchbase_analytics/protocol/options.py | 311 +++++++++++++ couchbase_analytics/protocol/result.py | 16 + couchbase_analytics/protocol/scope.py | 86 ++++ couchbase_analytics/protocol/scope.pyi | 158 +++++++ couchbase_analytics/protocol/streaming.py | 223 ++++++++++ couchbase_analytics/query.py | 19 + couchbase_analytics/result.py | 18 + couchbase_analytics/scope.py | 115 +++++ couchbase_analytics/scope.pyi | 151 +++++++ couchbase_analytics/tests/__init__.py | 0 .../tests/duration_parsing_t.py | 97 ++++ couchbase_analytics/tests/json_parsing_t.py | 351 +++++++++++++++ couchbase_analytics/tests/test_server_t.py | 65 +++ couchbase_analytics_version.py | 206 +++++++++ dev_requirements.txt | 2 + pyproject.toml | 28 ++ requirements.txt | 6 + setup.py | 66 +++ sphinx_requirements.txt | 0 tests/__init__.py | 36 ++ tests/analytics_config.py | 142 ++++++ tests/environments/__init__.py | 0 tests/environments/base_environment.py | 260 +++++++++++ tests/environments/simple_environment.py | 146 ++++++ tests/test_config.ini | 8 + tests/utils/__init__.py | 90 ++++ tests/utils/_async_client_adapter.py | 84 ++++ tests/utils/_async_utils.py | 33 ++ tests/utils/_async_web_server.py | 112 +++++ tests/utils/_client_adapter.py | 85 ++++ tests/utils/_run_web_server.py | 101 +++++ tests/utils/_test_async_httpx.py | 253 +++++++++++ tests/utils/_test_httpx.py | 281 ++++++++++++ 110 files changed, 11296 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 acouchbase_analytics/__init__.py create mode 100644 acouchbase_analytics/cluster.py create mode 100644 acouchbase_analytics/cluster.pyi create mode 100644 acouchbase_analytics/credential.py create mode 100644 acouchbase_analytics/database.py create mode 100644 acouchbase_analytics/database.pyi create mode 100644 acouchbase_analytics/deserializer.py create mode 100644 acouchbase_analytics/errors.py create mode 100644 acouchbase_analytics/options.py create mode 100644 acouchbase_analytics/protocol/__init__.py create mode 100644 acouchbase_analytics/protocol/cluster.py create mode 100644 acouchbase_analytics/protocol/cluster.pyi create mode 100644 acouchbase_analytics/protocol/core/__init__.py create mode 100644 acouchbase_analytics/protocol/core/_anyio_utils.py create mode 100644 acouchbase_analytics/protocol/core/_request_context.py create mode 100644 acouchbase_analytics/protocol/core/client_adapter.py create mode 100644 acouchbase_analytics/protocol/database.py create mode 100644 acouchbase_analytics/protocol/database.pyi create mode 100644 acouchbase_analytics/protocol/scope.py create mode 100644 acouchbase_analytics/protocol/scope.pyi create mode 100644 acouchbase_analytics/protocol/streaming.py create mode 100644 acouchbase_analytics/query.py create mode 100644 acouchbase_analytics/result.py create mode 100644 acouchbase_analytics/scope.py create mode 100644 acouchbase_analytics/scope.pyi create mode 100644 acouchbase_analytics/tests/__init__.py create mode 100644 acouchbase_analytics/tests/json_parsing_t.py create mode 100644 acouchbase_analytics/tests/test_server_t.py create mode 100644 conftest.py create mode 100644 couchbase_analytics/__init__.py create mode 100644 couchbase_analytics/cluster.py create mode 100644 couchbase_analytics/cluster.pyi create mode 100644 couchbase_analytics/common/__init__.py create mode 100644 couchbase_analytics/common/core/__init__.py create mode 100644 couchbase_analytics/common/core/_certificates.py create mode 100644 couchbase_analytics/common/core/async_json_stream.py create mode 100644 couchbase_analytics/common/core/async_json_token_parser.py create mode 100644 couchbase_analytics/common/core/duration_str_utils.py create mode 100644 couchbase_analytics/common/core/exception.py create mode 100644 couchbase_analytics/common/core/json_parsing.py create mode 100644 couchbase_analytics/common/core/json_stream.py create mode 100644 couchbase_analytics/common/core/json_token_parser.py create mode 100644 couchbase_analytics/common/core/json_token_parser_base.py create mode 100644 couchbase_analytics/common/core/net_utils.py create mode 100644 couchbase_analytics/common/core/query.py create mode 100644 couchbase_analytics/common/core/result.py create mode 100644 couchbase_analytics/common/core/utils.py create mode 100644 couchbase_analytics/common/credential.py create mode 100644 couchbase_analytics/common/deserializer.py create mode 100644 couchbase_analytics/common/enums.py create mode 100644 couchbase_analytics/common/errors.py create mode 100644 couchbase_analytics/common/options.py create mode 100644 couchbase_analytics/common/options_base.py create mode 100644 couchbase_analytics/common/query.py create mode 100644 couchbase_analytics/common/result.py create mode 100644 couchbase_analytics/common/streaming.py create mode 100644 couchbase_analytics/credential.py create mode 100644 couchbase_analytics/database.py create mode 100644 couchbase_analytics/database.pyi create mode 100644 couchbase_analytics/deserializer.py create mode 100644 couchbase_analytics/errors.py create mode 100644 couchbase_analytics/options.py create mode 100644 couchbase_analytics/protocol/__init__.py create mode 100644 couchbase_analytics/protocol/cluster.py create mode 100644 couchbase_analytics/protocol/cluster.pyi create mode 100644 couchbase_analytics/protocol/connection.py create mode 100644 couchbase_analytics/protocol/core/__init__.py create mode 100644 couchbase_analytics/protocol/core/_http_transport.py create mode 100644 couchbase_analytics/protocol/core/_request_context.py create mode 100644 couchbase_analytics/protocol/core/client_adapter.py create mode 100644 couchbase_analytics/protocol/core/request.py create mode 100644 couchbase_analytics/protocol/core/utils.py create mode 100644 couchbase_analytics/protocol/database.py create mode 100644 couchbase_analytics/protocol/database.pyi create mode 100644 couchbase_analytics/protocol/errors.py create mode 100644 couchbase_analytics/protocol/options.py create mode 100644 couchbase_analytics/protocol/result.py create mode 100644 couchbase_analytics/protocol/scope.py create mode 100644 couchbase_analytics/protocol/scope.pyi create mode 100644 couchbase_analytics/protocol/streaming.py create mode 100644 couchbase_analytics/query.py create mode 100644 couchbase_analytics/result.py create mode 100644 couchbase_analytics/scope.py create mode 100644 couchbase_analytics/scope.pyi create mode 100644 couchbase_analytics/tests/__init__.py create mode 100644 couchbase_analytics/tests/duration_parsing_t.py create mode 100644 couchbase_analytics/tests/json_parsing_t.py create mode 100644 couchbase_analytics/tests/test_server_t.py create mode 100644 couchbase_analytics_version.py create mode 100644 dev_requirements.txt create mode 100644 pyproject.toml create mode 100644 requirements.txt create mode 100644 setup.py create mode 100644 sphinx_requirements.txt create mode 100644 tests/__init__.py create mode 100644 tests/analytics_config.py create mode 100644 tests/environments/__init__.py create mode 100644 tests/environments/base_environment.py create mode 100644 tests/environments/simple_environment.py create mode 100644 tests/test_config.ini create mode 100644 tests/utils/__init__.py create mode 100644 tests/utils/_async_client_adapter.py create mode 100644 tests/utils/_async_utils.py create mode 100644 tests/utils/_async_web_server.py create mode 100644 tests/utils/_client_adapter.py create mode 100644 tests/utils/_run_web_server.py create mode 100644 tests/utils/_test_async_httpx.py create mode 100644 tests/utils/_test_httpx.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c864a1a --- /dev/null +++ b/.gitignore @@ -0,0 +1,182 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +.idea/ + +# Distribution / packaging +build/ +couchbase_columnar/_version.py +couchbase_columnar/*.so +couchbase_columnar/*.dylib*.* +couchbase_columnar/*.dll +couchbase_columnar/*.pyd +deps/couchbase-cxx-cache/ + +# Sphinx +docs/_build/ + +# VS Code +.vscode/ + +# tests +tests/test_logs/ +CouchbaseMock*.jar +gocaves* +.pytest_cache/ +test_scripts/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e69de29 diff --git a/acouchbase_analytics/__init__.py b/acouchbase_analytics/__init__.py new file mode 100644 index 0000000..1d3fbe9 --- /dev/null +++ b/acouchbase_analytics/__init__.py @@ -0,0 +1,91 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import asyncio +import selectors +from asyncio import AbstractEventLoop +from typing import Optional + +from couchbase_analytics.common import JSONType as JSONType # noqa: F401 + + +class _LoopValidator: + """ + **INTERNAL** + """ + + REQUIRED_METHODS = {'add_reader', 'remove_reader', + 'add_writer', 'remove_writer'} + + @staticmethod + def _get_working_loop() -> AbstractEventLoop: + """ + **INTERNAL** + """ + evloop = asyncio.get_event_loop() + gen_new_loop = not _LoopValidator._is_valid_loop(evloop) + if gen_new_loop: + evloop.close() + selector = selectors.SelectSelector() + new_loop = asyncio.SelectorEventLoop(selector) + asyncio.set_event_loop(new_loop) + return new_loop + + return evloop + + @staticmethod + def _is_valid_loop(evloop: Optional[AbstractEventLoop] = None) -> bool: + """ + **INTERNAL** + """ + if not evloop: + return False + for meth in _LoopValidator.REQUIRED_METHODS: + abs_meth, actual_meth = ( + getattr(asyncio.AbstractEventLoop, meth), getattr(evloop.__class__, meth)) + if abs_meth == actual_meth: + return False + return True + + @staticmethod + def get_event_loop(evloop: Optional[AbstractEventLoop] = None) -> AbstractEventLoop: + """ + **INTERNAL** + """ + if evloop and _LoopValidator._is_valid_loop(evloop): + return evloop + return _LoopValidator._get_working_loop() + + @staticmethod + def close_loop() -> None: + """ + **INTERNAL** + """ + evloop = asyncio.get_event_loop() + evloop.close() + + +def get_event_loop(evloop: Optional[AbstractEventLoop] = None) -> AbstractEventLoop: + """ + Get an event loop compatible with acouchbase_analytics. + Some Event loops, such as ProactorEventLoop (the default asyncio event + loop for Python 3.8 on Windows) are not compatible with acouchbase_analytics as + they don't implement all members in the abstract base class. + + :param evloop: preferred event loop + :return: The preferred event loop, if compatible, otherwise, a compatible + alternative event loop. + """ + return _LoopValidator.get_event_loop(evloop) diff --git a/acouchbase_analytics/cluster.py b/acouchbase_analytics/cluster.py new file mode 100644 index 0000000..22ba39b --- /dev/null +++ b/acouchbase_analytics/cluster.py @@ -0,0 +1,223 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import sys +from typing import Awaitable, TYPE_CHECKING, Optional + +if sys.version_info < (3, 10): + from typing_extensions import TypeAlias +else: + from typing import TypeAlias + +from acouchbase_analytics.database import AsyncDatabase +from couchbase_analytics.result import AsyncQueryResult + +if TYPE_CHECKING: + from couchbase_analytics.credential import Credential + from couchbase_analytics.options import ClusterOptions + + +class AsyncCluster: + """Create an AsyncCluster instance. + + The cluster instance exposes the operations which are available to be performed against a Columnar cluster. + + .. important:: + Use the static :meth:`.AsyncCluster.create_instance` method to create an AsyncCluster. + + Args: + connstr: + The connection string to use for connecting to the cluster. + The format of the connection string is the *scheme* (``couchbases`` as TLS enabled connections are _required_), followed a hostname + credential: User credentials. + options: Global options to set for the cluster. + Some operations allow the global options to be overriden by passing in options to the operation. + **kwargs: keyword arguments that can be used in place or to overrride provided :class:`~acouchbase_analytics.options.ClusterOptions` + + Raises: + ValueError: If incorrect connstr is provided. + ValueError: If incorrect options are provided. + + """ # noqa: E501 + + def __init__(self, + connstr: str, + credential: Credential, + options: Optional[ClusterOptions] = None, + **kwargs: object) -> None: + from acouchbase_analytics.protocol.cluster import AsyncCluster as _AsyncCluster + self._impl = _AsyncCluster(connstr, credential, options, **kwargs) + + def database(self, name: str) -> AsyncDatabase: + """Creates a database instance. + + .. seealso:: + :class:`~acouchbase_analytics.database.AsyncDatabase` + + Args: + name: Name of the database + + Returns: + An AsyncDatabase instance. + + """ + return AsyncDatabase(self._impl, name) + + def execute_query(self, statement: str, *args: object, **kwargs: object) -> Awaitable[AsyncQueryResult]: + """Executes a query against a Capella Columnar cluster. + + .. note:: + A departure from the operational SDK, the query is *NOT* executed lazily. + + .. seealso:: + :meth:`acouchbase_analytics.AsyncScope.execute_query`: For how to execute scope-level queries. + + Args: + statement: The SQL++ statement to execute. + options (:class:`~acouchbase_analytics.options.QueryOptions`): Optional parameters for the query operation. + **kwargs (Dict[str, Any]): keyword arguments that can be used in place or to override provided :class:`~couchbase_analytics.options.QueryOptions` + + Returns: + Future[:class:`~couchbase_analytics.result.AsyncQueryResult`]: A :class:`~asyncio.Future` is returned. + Once the :class:`~asyncio.Future` completes, an instance of a :class:`~acouchbase_analytics.result.AsyncQueryResult` + is available to provide access to iterate over the query results and access metadata and metrics about the query. + + Examples: + Simple query:: + + q_str = 'SELECT * FROM `travel-sample`.inventory.airline WHERE country LIKE 'United%' LIMIT 2;' + q_res = cluster.execute_query(q_str) + async for row in q_res.rows(): + print(f'Found row: {row}') + + Simple query with positional parameters:: + + from acouchbase_analytics.options import QueryOptions + + # ... other code ... + + q_str = 'SELECT * FROM `travel-sample`.inventory.airline WHERE country LIKE $1 LIMIT $2;' + q_res = cluster.execute_query(q_str, QueryOptions(positional_parameters=['United%', 5])) + async for row in q_res.rows(): + print(f'Found row: {row}') + + Simple query with named parameters:: + + from acouchbase_analytics.options import QueryOptions + + # ... other code ... + + q_str = 'SELECT * FROM `travel-sample`.inventory.airline WHERE country LIKE $country LIMIT $lim;' + q_res = cluster.execute_query(q_str, QueryOptions(named_parameters={'country': 'United%', 'lim':2})) + async for row in q_res.rows(): + print(f'Found row: {row}') + + Retrieve metadata and/or metrics from query:: + + from acouchbase_analytics.options import QueryOptions + + # ... other code ... + + q_str = 'SELECT * FROM `travel-sample` WHERE country LIKE $country LIMIT $lim;' + q_res = cluster.execute_query(q_str, QueryOptions(named_parameters={'country': 'United%', 'lim':2})) + async for row in q_res.rows(): + print(f'Found row: {row}') + + print(f'Query metadata: {q_res.metadata()}') + print(f'Query metrics: {q_res.metadata().metrics()}') + + """ # noqa: E501 + return self._impl.execute_query(statement, *args, **kwargs) + + def shutdown(self) -> None: + """Shuts down this cluster instance. Cleaning up all resources associated with it. + + .. warning:: + Use of this method is almost *always* unnecessary. Cluster resources should be cleaned + up once the cluster instance falls out of scope. However, in some applications tuning resources + is necessary and in those types of applications, this method might be beneficial. + + """ + return self._impl.shutdown() + + @classmethod + def create_instance(cls, + connstr: str, + credential: Credential, + options: Optional[ClusterOptions] = None, + **kwargs: object) -> AsyncCluster: + """Create an AsyncCluster instance + + Args: + connstr: + The connection string to use for connecting to the cluster. + The format of the connection string is the *scheme* (``couchbases`` as TLS enabled connections are _required_), followed a hostname + credential: User credentials. + options: Global options to set for the cluster. + Some operations allow the global options to be overriden by passing in options to the operation. + **kwargs: Keyword arguments that can be used in place or to overrride provided :class:`~acouchbase_analytics.options.ClusterOptions` + + + Returns: + A Capella Columnar Cluster instance. + + Raises: + ValueError: If incorrect connstr is provided. + ValueError: If incorrect options are provided. + + + Examples: + Initialize cluster using default options:: + + from acouchbase_analytics import get_event_loop + from acouchbase_analytics.cluster import AsyncCluster + from acouchbase_analytics.credential import Credential + + async def main() -> None: + cred = Credential.from_username_and_password('username', 'password') + cluster = AsyncCluster.create_instance('couchbases://hostname', cred) + # ... other async code ... + + if __name__ == '__main__': + loop = get_event_loop() + loop.run_until_complete(main()) + + + Initialize cluster using with global timeout options:: + + from datetime import timedelta + + from acouchbase_analytics import get_event_loop + from acouchbase_analytics.cluster import AsyncCluster + from acouchbase_analytics.credential import Credential + from acouchbase_analytics.options import ClusterOptions, ClusterTimeoutOptions + + async def main() -> None: + cred = Credential.from_username_and_password('username', 'password') + opts = ClusterOptions(timeout_options=ClusterTimeoutOptions(query_timeout=timedelta(seconds=120))) + cluster = AsyncCluster.create_instance('couchbases://hostname', cred, opts) + # ... other async code ... + + if __name__ == '__main__': + loop = get_event_loop() + loop.run_until_complete(main()) + + """ # noqa: E501 + return cls(connstr, credential, options, **kwargs) + + +Cluster: TypeAlias = AsyncCluster diff --git a/acouchbase_analytics/cluster.pyi b/acouchbase_analytics/cluster.pyi new file mode 100644 index 0000000..f68c462 --- /dev/null +++ b/acouchbase_analytics/cluster.pyi @@ -0,0 +1,119 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import sys +from typing import Awaitable, overload + +if sys.version_info < (3, 11): + from typing_extensions import Unpack +else: + from typing import Unpack + +from acouchbase_analytics.database import AsyncDatabase +from couchbase_analytics.credential import Credential +from couchbase_analytics.options import (ClusterOptions, + ClusterOptionsKwargs, + QueryOptions, + QueryOptionsKwargs) +from couchbase_analytics.result import AsyncQueryResult + +class AsyncCluster: + @overload + def __init__(self, http_endpoint: str, credential: Credential) -> None: ... + + @overload + def __init__(self, + http_endpoint: str, + credential: Credential, + options: ClusterOptions) -> None: ... + + @overload + def __init__(self, + http_endpoint: str, + credential: Credential, + **kwargs: Unpack[ClusterOptionsKwargs]) -> None: ... + + @overload + def __init__(self, + http_endpoint: str, + credential: Credential, + options: ClusterOptions, + **kwargs: Unpack[ClusterOptionsKwargs]) -> None: ... + + def database(self, database_name: str) -> AsyncDatabase: ... + + @overload + def execute_query(self, statement: str) -> Awaitable[AsyncQueryResult]: ... + + @overload + def execute_query(self, statement: str, options: QueryOptions) -> Awaitable[AsyncQueryResult]: ... + + @overload + def execute_query(self, statement: str, **kwargs: Unpack[QueryOptionsKwargs]) -> Awaitable[AsyncQueryResult]: ... + + @overload + def execute_query(self, + statement: str, + options: QueryOptions, + **kwargs: Unpack[QueryOptionsKwargs]) -> Awaitable[AsyncQueryResult]: ... + + @overload + def execute_query(self, + statement: str, + options: QueryOptions, + *args: str, + **kwargs: Unpack[QueryOptionsKwargs]) -> Awaitable[AsyncQueryResult]: ... + + @overload + def execute_query(self, + statement: str, + options: QueryOptions, + *args: str, + **kwargs: str) -> Awaitable[AsyncQueryResult]: ... + + @overload + def execute_query(self, + statement: str, + *args: str, + **kwargs: str) -> Awaitable[AsyncQueryResult]: ... + + def shutdown(self) -> None: ... + + @overload + @classmethod + def create_instance(cls, http_endpoint: str, credential: Credential) -> AsyncCluster: ... + + @overload + @classmethod + def create_instance(cls, + http_endpoint: str, + credential: Credential, + options: ClusterOptions) -> AsyncCluster: ... + + @overload + @classmethod + def create_instance(cls, + http_endpoint: str, + credential: Credential, + **kwargs: Unpack[ClusterOptionsKwargs]) -> AsyncCluster: ... + + @overload + @classmethod + def create_instance(cls, + http_endpoint: str, + credential: Credential, + options: ClusterOptions, + **kwargs: Unpack[ClusterOptionsKwargs]) -> AsyncCluster: ... + diff --git a/acouchbase_analytics/credential.py b/acouchbase_analytics/credential.py new file mode 100644 index 0000000..c3aa770 --- /dev/null +++ b/acouchbase_analytics/credential.py @@ -0,0 +1,16 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from couchbase_analytics.common.credential import Credential as Credential # noqa: F401 diff --git a/acouchbase_analytics/database.py b/acouchbase_analytics/database.py new file mode 100644 index 0000000..4cb8300 --- /dev/null +++ b/acouchbase_analytics/database.py @@ -0,0 +1,58 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import sys +from typing import TYPE_CHECKING + +if sys.version_info < (3, 10): + from typing_extensions import TypeAlias +else: + from typing import TypeAlias + + +from acouchbase_analytics.scope import AsyncScope + +if TYPE_CHECKING: + from acouchbase_analytics.protocol.cluster import AsyncCluster + + +class AsyncDatabase: + def __init__(self, cluster: AsyncCluster, database_name: str) -> None: + from acouchbase_analytics.protocol.database import AsyncDatabase as _AsyncDatabase + self._impl = _AsyncDatabase(cluster, database_name) + + @property + def name(self) -> str: + """ + str: The name of this :class:`~acouchbase_analytics.database.AsyncDatabase` instance. + """ + return self._impl.name + + def scope(self, scope_name: str) -> AsyncScope: + """Creates a :class:`~acouchbase_analytics.scope.AsyncScope` instance. + + Args: + scope_name (str): Name of the scope. + + Returns: + :class:`~acouchbase_analytics.scope.AsyncScope` + + """ + return AsyncScope(self._impl, scope_name) + + +Database: TypeAlias = AsyncDatabase diff --git a/acouchbase_analytics/database.pyi b/acouchbase_analytics/database.pyi new file mode 100644 index 0000000..7960bd6 --- /dev/null +++ b/acouchbase_analytics/database.pyi @@ -0,0 +1,25 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from acouchbase_analytics.protocol.cluster import AsyncCluster as AsyncCluster +from acouchbase_analytics.scope import AsyncScope + +class AsyncDatabase: + def __init__(self, cluster: AsyncCluster, database_name: str) -> None: ... + + @property + def name(self) -> str: ... + + def scope(self, scope_name: str) -> AsyncScope: ... diff --git a/acouchbase_analytics/deserializer.py b/acouchbase_analytics/deserializer.py new file mode 100644 index 0000000..d5aed73 --- /dev/null +++ b/acouchbase_analytics/deserializer.py @@ -0,0 +1,18 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from couchbase_analytics.common.deserializer import DefaultJsonDeserializer as DefaultJsonDeserializer # noqa: F401 +from couchbase_analytics.common.deserializer import Deserializer as Deserializer # noqa: F401 +from couchbase_analytics.common.deserializer import PassthroughDeserializer as PassthroughDeserializer # noqa: F401 diff --git a/acouchbase_analytics/errors.py b/acouchbase_analytics/errors.py new file mode 100644 index 0000000..3b18f30 --- /dev/null +++ b/acouchbase_analytics/errors.py @@ -0,0 +1,20 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from couchbase_analytics.common.errors import AnalyticsError as AnalyticsError # noqa: F401 +from couchbase_analytics.common.errors import InternalSDKError as InternalSDKError # noqa: F401 +from couchbase_analytics.common.errors import InvalidCredentialError as InvalidCredentialError # noqa: F401 +from couchbase_analytics.common.errors import QueryError as QueryError # noqa: F401 +from couchbase_analytics.common.errors import TimeoutError as TimeoutError # noqa: F401 diff --git a/acouchbase_analytics/options.py b/acouchbase_analytics/options.py new file mode 100644 index 0000000..ce34875 --- /dev/null +++ b/acouchbase_analytics/options.py @@ -0,0 +1,24 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from couchbase_analytics.common.enums import IpProtocol as IpProtocol # noqa: F401 +from couchbase_analytics.common.options import ClusterOptions as ClusterOptions # noqa: F401 +from couchbase_analytics.common.options import ClusterOptionsKwargs as ClusterOptionsKwargs # noqa: F401 +from couchbase_analytics.common.options import QueryOptions as QueryOptions # noqa: F401 +from couchbase_analytics.common.options import QueryOptionsKwargs as QueryOptionsKwargs # noqa: F401 +from couchbase_analytics.common.options import SecurityOptions as SecurityOptions # noqa: F401 +from couchbase_analytics.common.options import SecurityOptionsKwargs as SecurityOptionsKwargs # noqa: F401 +from couchbase_analytics.common.options import TimeoutOptions as TimeoutOptions # noqa: F401 +from couchbase_analytics.common.options import TimeoutOptionsKwargs as TimeoutOptionsKwargs # noqa: F401 diff --git a/acouchbase_analytics/protocol/__init__.py b/acouchbase_analytics/protocol/__init__.py new file mode 100644 index 0000000..72df2de --- /dev/null +++ b/acouchbase_analytics/protocol/__init__.py @@ -0,0 +1,14 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/acouchbase_analytics/protocol/cluster.py b/acouchbase_analytics/protocol/cluster.py new file mode 100644 index 0000000..217f93b --- /dev/null +++ b/acouchbase_analytics/protocol/cluster.py @@ -0,0 +1,127 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import sys +from typing import Awaitable, TYPE_CHECKING, Optional +from uuid import uuid4 + +if sys.version_info < (3, 10): + from typing_extensions import TypeAlias +else: + from typing import TypeAlias + +from acouchbase_analytics.protocol.core.client_adapter import _AsyncClientAdapter +from acouchbase_analytics.protocol.core._anyio_utils import current_async_library +from acouchbase_analytics.protocol.core._request_context import AsyncRequestContext +from acouchbase_analytics.protocol.streaming import AsyncHttpStreamingResponse +from couchbase_analytics.common.result import AsyncQueryResult +from couchbase_analytics.protocol.core.request import _RequestBuilder + + +if TYPE_CHECKING: + from couchbase_analytics.common.credential import Credential + from couchbase_analytics.options import ClusterOptions + + +class AsyncCluster: + + def __init__(self, + connstr: str, + credential: Credential, + options: Optional[ClusterOptions] = None, + **kwargs: object) -> None: + self._client_adapter = _AsyncClientAdapter(connstr, credential, options, **kwargs) + self._cluster_id = str(uuid4()) + self._request_builder = _RequestBuilder(self._client_adapter) + self._backend = current_async_library() + + @property + def client_adapter(self) -> _AsyncClientAdapter: + """ + **INTERNAL** + """ + return self._client_adapter + + @property + def cluster_id(self) -> str: + """ + **INTERNAL** + """ + return self._cluster_id + + @property + def has_client(self) -> bool: + """ + bool: Indicator on if the cluster HTTP client has been created or not. + """ + return self._client_adapter.has_client + + async def _shutdown(self) -> None: + """ + **INTERNAL** + """ + await self._client_adapter.close_client() + self._client_adapter.reset_client() + + async def _create_client(self) -> None: + """ + **INTERNAL** + """ + await self._client_adapter.create_client() + + async def shutdown(self) -> None: + """Shuts down this cluster instance. Cleaning up all resources associated with it. + + .. warning:: + Use of this method is almost *always* unnecessary. Cluster resources should be cleaned + up once the cluster instance falls out of scope. However, in some applications tuning resources + is necessary and in those types of applications, this method might be beneficial. + + """ + if self.has_client: + await self._shutdown() + else: + # TODO: log warning + print('Cluster does not have a connection. Ignoring') + + async def _execute_query(self, http_resp: AsyncHttpStreamingResponse) -> AsyncQueryResult: + if not self.has_client: + # TODO: add log message?? + await self._create_client() + await http_resp.send_request() + return AsyncQueryResult(http_resp) + + def execute_query(self, statement: str, *args: object, **kwargs: object) -> Awaitable[AsyncQueryResult]: + base_req = self._request_builder.build_base_query_request(statement, *args, is_async=True, **kwargs) + stream_config = base_req.options.pop('stream_config', None) + request_context = AsyncRequestContext(client_adapter=self.client_adapter, request=base_req, backend=self._backend) + resp = AsyncHttpStreamingResponse(request_context, stream_config=stream_config) + if self._backend.backend_lib == 'asyncio': + return request_context.create_response_task(self._execute_query, resp) + return self._execute_query(resp) + + + @classmethod + def create_instance(cls, + connstr: str, + credential: Credential, + options: Optional[ClusterOptions] = None, + **kwargs: object) -> AsyncCluster: + return cls(connstr, credential, options, **kwargs) + + +Cluster: TypeAlias = AsyncCluster diff --git a/acouchbase_analytics/protocol/cluster.pyi b/acouchbase_analytics/protocol/cluster.pyi new file mode 100644 index 0000000..4e5c6ec --- /dev/null +++ b/acouchbase_analytics/protocol/cluster.pyi @@ -0,0 +1,125 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import sys +from typing import Awaitable, overload + +if sys.version_info < (3, 11): + from typing_extensions import Unpack +else: + from typing import Unpack + +from acouchbase_analytics.protocol.core.client_adapter import _ClientAdapter +from acouchbase_analytics.protocol.database import AsyncDatabase +from couchbase_analytics.common.credential import Credential +from couchbase_analytics.common.result import AsyncQueryResult +from couchbase_analytics.options import (ClusterOptions, + ClusterOptionsKwargs, + QueryOptions, + QueryOptionsKwargs) + +class AsyncCluster: + @overload + def __init__(self, connstr: str, credential: Credential) -> None: ... + + @overload + def __init__(self, + connstr: str, + credential: Credential, + options: ClusterOptions) -> None: ... + + @overload + def __init__(self, + connstr: str, + credential: Credential, + **kwargs: Unpack[ClusterOptionsKwargs]) -> None: ... + + @overload + def __init__(self, + connstr: str, + credential: Credential, + options: ClusterOptions, + **kwargs: Unpack[ClusterOptionsKwargs]) -> None: ... + + @property + def client_adapter(self) -> _ClientAdapter: ... + + @property + def connected(self) -> bool: ... + + def shutdown(self) -> None: ... + + def database(self, name: str) -> AsyncDatabase: ... + + @overload + def execute_query(self, statement: str) -> Awaitable[AsyncQueryResult]: ... + + @overload + def execute_query(self, statement: str, options: QueryOptions) -> Awaitable[AsyncQueryResult]: ... + + @overload + def execute_query(self, statement: str, **kwargs: Unpack[QueryOptionsKwargs]) -> Awaitable[AsyncQueryResult]: ... + + @overload + def execute_query(self, + statement: str, + options: QueryOptions, + **kwargs: Unpack[QueryOptionsKwargs]) -> Awaitable[AsyncQueryResult]: ... + + @overload + def execute_query(self, + statement: str, + options: QueryOptions, + *args: str, + **kwargs: Unpack[QueryOptionsKwargs]) -> Awaitable[AsyncQueryResult]: ... + + @overload + def execute_query(self, + statement: str, + options: QueryOptions, + *args: str, + **kwargs: str) -> Awaitable[AsyncQueryResult]: ... + + @overload + def execute_query(self, + statement: str, + *args: str, + **kwargs: str) -> Awaitable[AsyncQueryResult]: ... + + @overload + @classmethod + def create_instance(cls, connstr: str, credential: Credential) -> AsyncCluster: ... + + @overload + @classmethod + def create_instance(cls, + connstr: str, + credential: Credential, + options: ClusterOptions) -> AsyncCluster: ... + + @overload + @classmethod + def create_instance(cls, + connstr: str, + credential: Credential, + **kwargs: Unpack[ClusterOptionsKwargs]) -> AsyncCluster: ... + + @overload + @classmethod + def create_instance(cls, + connstr: str, + credential: Credential, + options: ClusterOptions, + **kwargs: Unpack[ClusterOptionsKwargs]) -> AsyncCluster: ... diff --git a/acouchbase_analytics/protocol/core/__init__.py b/acouchbase_analytics/protocol/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/acouchbase_analytics/protocol/core/_anyio_utils.py b/acouchbase_analytics/protocol/core/_anyio_utils.py new file mode 100644 index 0000000..9c4e9f2 --- /dev/null +++ b/acouchbase_analytics/protocol/core/_anyio_utils.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from asyncio import AbstractEventLoop +from typing import Optional + +import anyio + +def get_time() -> float: + """ + Get the current time in seconds since the epoch. + """ + return anyio.current_time() + +class AsyncBackend: + def __init__(self, backend_lib: str) -> None: + """ + Initialize the async backend. + """ + self._backend_lib = backend_lib + + @property + def backend_lib(self) -> str: + """ + Get the name of the async backend library + """ + return self._backend_lib + + @property + def loop(self) -> Optional[AbstractEventLoop]: + """ + Get the event loop for the async backend, if it exists + """ + if not hasattr(self, '_loop'): + if self._backend_lib == 'asyncio': + import asyncio + self._loop = asyncio.get_event_loop() + else: + raise RuntimeError('Unsupported async backend library.') + return self._loop + +def current_async_library() -> Optional[AsyncBackend]: + try: + import sniffio + except ImportError: + async_lib = 'asyncio' + + # TODO: This helps make tests work. + # Should we work through the scenario when sniffio cannot find the async library? + try: + async_lib = sniffio.current_async_library() + except sniffio.AsyncLibraryNotFoundError: + async_lib = 'asyncio' + + if async_lib not in ('asyncio', 'trio'): + raise RuntimeError('Running under an unsupported async environment.') + + # TODO: confirm trio support + if async_lib == 'trio': + raise RuntimeError('trio currently not supported') + + return AsyncBackend(async_lib) \ No newline at end of file diff --git a/acouchbase_analytics/protocol/core/_request_context.py b/acouchbase_analytics/protocol/core/_request_context.py new file mode 100644 index 0000000..0d98bc3 --- /dev/null +++ b/acouchbase_analytics/protocol/core/_request_context.py @@ -0,0 +1,212 @@ +from __future__ import annotations + +from asyncio import CancelledError, Task +from types import TracebackType +from typing import (Any, + Awaitable, + Callable, + Dict, + List, + Optional, + Type, + TYPE_CHECKING) +from uuid import uuid4 + +import anyio +from httpx import Response as HttpCoreResponse + +from acouchbase_analytics.protocol.core._anyio_utils import (AsyncBackend, + current_async_library, + get_time) +from couchbase_analytics.common.core.net_utils import get_request_ip_async +from couchbase_analytics.common.deserializer import Deserializer +from couchbase_analytics.common.errors import AnalyticsError +from couchbase_analytics.common.streaming import StreamingState +from couchbase_analytics.protocol.connection import DEFAULT_TIMEOUTS +from couchbase_analytics.protocol.errors import ErrorMapper + +if TYPE_CHECKING: + from acouchbase_analytics.protocol.core.client_adapter import _AsyncClientAdapter + from couchbase_analytics.protocol.core.request import QueryRequest + +class AsyncRequestContext: + # TODO: AsyncExitStack?? + # https://anyio.readthedocs.io/en/stable/cancellation.html + + def __init__(self, + client_adapter: _AsyncClientAdapter, + request: QueryRequest, + backend: Optional[AsyncBackend]=None) -> None: + self._id = str(uuid4()) + self._client_adapter = client_adapter + self._request = request + self._backend = backend or current_async_library() + self._response_task: Optional[Task] = None + self._request_state = StreamingState.NotStarted + self._stage_completed: Optional[anyio.Event] = None + self._request_error: Optional[Exception] = None + self._connect_timeout = self._client_adapter.connection_details.get_connect_timeout() + + @property + def deserializer(self) -> Deserializer: + """ + Returns the deserializer used by this request context. + """ + return self._request.deserializer + + @property + def has_stage_completed(self) -> bool: + return self._stage_completed is not None and self._stage_completed.is_set() + + @property + def okay_to_iterate(self) -> bool: + return StreamingState.okay_to_iterate(self._request_state) + + @property + def okay_to_stream(self) -> bool: + return StreamingState.okay_to_stream(self._request_state) + + @property + def request_error(self) -> Optional[Exception]: + return self._request_error + + @property + def request_state(self) -> StreamingState: + return self._request_state + + @request_state.setter + def request_state(self, state: StreamingState) -> None: + if not isinstance(state, StreamingState): + raise TypeError('request_state must be an instance of StreamingState') + self._request_state = state + + @property + def stage_completed(self) -> anyio.Event: + return self._stage_completed + + @property + def timed_out(self) -> bool: + return self._request_state == StreamingState.Timeout + + @property + def cancelled(self) -> bool: + return self._request_state == StreamingState.Cancelled + + async def _execute(self, fn: Callable[..., Awaitable[Any]], *args: object) -> None: + await fn(*args) + self._stage_completed.set() + + async def _trace_handler(self, event_name, _) -> None: + if event_name == 'connection.connect_tcp.complete': + # after connection is established, we need to update the cancel_scope deadline to match the query_timeout + self._update_cancel_scope_deadline(self._request_deadline, is_absolute=True) + + def _update_cancel_scope_deadline(self, deadline: float, is_absolute: Optional[bool]=False) -> None: + # TODO: confirm scenario of get_time() < self._taskgroup.cancel_scope.deadline is handled by anyio + + new_deadline = deadline if is_absolute else get_time() + deadline + if get_time() >= new_deadline: + self._taskgroup.cancel_scope.cancel() + else: + self._taskgroup.cancel_scope.deadline = new_deadline + + async def initialize(self) -> None: + await self.__aenter__() + self._request_state = StreamingState.Started + # we set the request timeout once the context is initialized in order to create the deadline + # closer to when the upstream logic will begin to use the request context + timeouts = self._request.get_request_timeouts() + self._request_deadline = get_time() + timeouts.get('read', DEFAULT_TIMEOUTS['query_timeout']) + self._update_cancel_scope_deadline(self._connect_timeout) + + async def send_request(self, enable_trace_handling: Optional[bool]=False) -> HttpCoreResponse: + ip = await get_request_ip_async(self._request.host, self._request.port, self._request.previous_ips) + if ip is None: + attempted_ips = ', '.join(self._request.previous_ips or []) + raise AnalyticsError(f'Connect failure. Attempted to connect to resolved IPs: {attempted_ips}.') + + if enable_trace_handling is True: + (self._request.update_url(ip, self._client_adapter.analytics_path) + .update_extensions({'trace': self._trace_handler}) + .update_previous_ips(ip)) + else: + self._request.update_url(ip, self._client_adapter.analytics_path).update_previous_ips(ip) + response = await self._client_adapter.send_request(self._request) + self._request.set_client_server_addrs(response) + return response + + async def shutdown(self, + exc_type: Optional[Type[BaseException]]=None, + exc_val: Optional[BaseException]=None, + exc_tb: Optional[TracebackType]=None) -> None: + if hasattr(self, '_taskgroup'): + await self.__aexit__(exc_type, exc_val, exc_tb) + elif isinstance(exc_val, CancelledError): + self._request_state = StreamingState.Cancelled + elif exc_val is not None: + self._request_state = StreamingState.Error + + if StreamingState.is_okay(self._request_state): + self._request_state = StreamingState.Completed + + def create_response_task(self, fn: Callable[..., Awaitable[Any]], *args: object) -> Task: + if self._backend is None or self._backend.backend_lib != 'asyncio': + raise RuntimeError('Must use the asyncio backend to create a response task.') + task_name = f'{self._id}-response-task' + print(f'Creating response task: {task_name}') + task = self._backend.loop.create_task(fn(*args), name=task_name) + # TODO: I don't think this callback is necessary...need to add more tests to confirm + def task_done(t: Task) -> None: + print(f'Task ({t.get_name()}) done: {t.done()}, cancelled: {t.cancelled()}') + + task.add_done_callback(task_done) + self._response_task = task + return task + + def set_state_to_streaming(self) -> None: + self._request_state = StreamingState.StreamingResults + + def start_next_stage(self, + fn: Callable[..., Awaitable[Any]], + *args: object, + reset_previous_stage: Optional[bool]=False) -> None: + if reset_previous_stage is True: + if self._stage_completed is not None: + self._stage_completed = None + elif self._stage_completed is not None: + raise RuntimeError('Task already running in this context.') + + self._stage_completed = anyio.Event() + self._taskgroup.start_soon(self._execute, fn, *args) + + async def process_error(self, json_data: List[Dict[str, Any]]) -> None: + self._request_state = StreamingState.Error + if not isinstance(json_data, list): + self._request_error = AnalyticsError('Cannot parse error response; expected JSON array') + + self._request_error = ErrorMapper.build_error_from_json(json_data, status_code=self._request.response_status_code) + await self.shutdown() + raise self._request_error + + async def __aenter__(self) -> AsyncRequestContext: + self._taskgroup = anyio.create_task_group() + await self._taskgroup.__aenter__() + return self + + async def __aexit__(self, + exc_type: Optional[Type[BaseException]], + exc_val: Optional[BaseException], + exc_tb: Optional[TracebackType]) -> Optional[bool]: + try: + res = await self._taskgroup.__aexit__(exc_type, exc_val, exc_tb) + return res + except BaseException as ex: + pass # we handle the error when the context is shutdown (which is what calls __aexit__()) + finally: + if self._taskgroup.cancel_scope.cancelled_caught and get_time() >= self._taskgroup.cancel_scope.deadline: + self._request_state = StreamingState.Timeout + elif isinstance(exc_val, CancelledError): + self._request_state = StreamingState.Cancelled + elif exc_val is not None: + self._request_state = StreamingState.Error + del self._taskgroup \ No newline at end of file diff --git a/acouchbase_analytics/protocol/core/client_adapter.py b/acouchbase_analytics/protocol/core/client_adapter.py new file mode 100644 index 0000000..81fb3e3 --- /dev/null +++ b/acouchbase_analytics/protocol/core/client_adapter.py @@ -0,0 +1,155 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import socket + +from typing import Optional, TYPE_CHECKING +from uuid import uuid4 + +from httpx import BasicAuth, AsyncClient, Response + +from couchbase_analytics.common.credential import Credential +from couchbase_analytics.common.deserializer import Deserializer +from couchbase_analytics.protocol.connection import _ConnectionDetails +from couchbase_analytics.protocol.options import OptionsBuilder + +if TYPE_CHECKING: + from couchbase_analytics.protocol.core.request import QueryRequest + + +class _AsyncClientAdapter: + """ + **INTERNAL** + """ + + _ANALYTICS_PATH = '/api/v1/request' + + def __init__(self, + http_endpoint: str, + credential: Credential, + options: Optional[object] = None, + **kwargs: object) -> None: + self._client_id = str(uuid4()) + self._opts_builder = OptionsBuilder() + self._conn_details = _ConnectionDetails.create(self._opts_builder, + http_endpoint, + credential, + options, + **kwargs) + # TODO: do we want to support custom HTTP transports for the async client? + self._http_transport_cls = None + + @property + def analytics_path(self) -> str: + """ + **INTERNAL** + """ + return self._ANALYTICS_PATH + + @property + def client(self) -> AsyncClient: + """ + **INTERNAL** + """ + return self._client + + @property + def client_id(self) -> str: + """ + **INTERNAL** + """ + return self._client_id + + @property + def connection_details(self) -> _ConnectionDetails: + """ + **INTERNAL** + """ + return self._conn_details + + @property + def default_deserializer(self) -> Deserializer: + """ + **INTERNAL** + """ + return self._conn_details.default_deserializer + + @property + def has_client(self) -> bool: + """ + **INTERNAL** + """ + return hasattr(self, '_client') + + @property + def options_builder(self) -> OptionsBuilder: + """ + **INTERNAL** + """ + return self._opts_builder + + async def close_client(self) -> None: + """ + **INTERNAL** + """ + if hasattr(self, '_client'): + await self._client.aclose() + + async def create_client(self) -> None: + """ + **INTERNAL** + """ + if not hasattr(self, '_client'): + if self._conn_details.is_secure(): + transport = None + if self._http_transport_cls is not None: + transport = self._http_transport_cls(verify=self._conn_details.ssl_context) + self._client = AsyncClient(verify=self._conn_details.ssl_context, + auth=BasicAuth(*self._conn_details.credential), + transport=transport) + else: + transport = None + if self._http_transport_cls is not None: + transport = self._http_transport_cls() + self._client = AsyncClient(auth=BasicAuth(*self._conn_details.credential), + transport=transport) + # TODO: log message + + + async def send_request(self, request: QueryRequest) -> Response: + """ + **INTERNAL** + """ + if not hasattr(self, '_client'): + raise RuntimeError('Client not created yet') + + req = self._client.build_request(request.method, + request.url, + json=request.body, + extensions=request.extensions) + try: + return await self._client.send(req, stream=True) + except socket.gaierror as err: + raise RuntimeError(f'Unable to connect to {self._conn_details.get_scheme_host_and_port()}') from err + + def reset_client(self) -> None: + """ + **INTERNAL** + """ + if hasattr(self, '_client'): + del self._client + diff --git a/acouchbase_analytics/protocol/database.py b/acouchbase_analytics/protocol/database.py new file mode 100644 index 0000000..117ad8c --- /dev/null +++ b/acouchbase_analytics/protocol/database.py @@ -0,0 +1,57 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import sys +from typing import TYPE_CHECKING + +if sys.version_info < (3, 10): + from typing_extensions import TypeAlias +else: + from typing import TypeAlias + + +from acouchbase_analytics.protocol.core.client_adapter import _AsyncClientAdapter +from acouchbase_analytics.protocol.scope import AsyncScope + +if TYPE_CHECKING: + from acouchbase_analytics.protocol.cluster import AsyncCluster + + +class AsyncDatabase: + def __init__(self, cluster: AsyncCluster, database_name: str) -> None: + self._database_name = database_name + self._cluster = cluster + + @property + def client_adapter(self) -> _AsyncClientAdapter: + """ + **INTERNAL** + """ + return self._cluster.client_adapter + + @property + def name(self) -> str: + """ + str: The name of this :class:`~acouchbase_analytics.protocol.database.Database` instance. + """ + return self._database_name + + def scope(self, scope_name: str) -> AsyncScope: + return AsyncScope(self, scope_name) + + +Database: TypeAlias = AsyncDatabase diff --git a/acouchbase_analytics/protocol/database.pyi b/acouchbase_analytics/protocol/database.pyi new file mode 100644 index 0000000..7d91d3f --- /dev/null +++ b/acouchbase_analytics/protocol/database.pyi @@ -0,0 +1,29 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from acouchbase_analytics.protocol.cluster import AsyncCluster as AsyncCluster +from acouchbase_analytics.protocol.core.client_adapter import _AsyncClientAdapter +from couchbase_analytics.protocol.scope import Scope + +class AsyncDatabase: + def __init__(self, cluster: AsyncCluster, database_name: str) -> None: ... + + @property + def client_adapter(self) -> _AsyncClientAdapter: ... + + @property + def name(self) -> str: ... + + def scope(self, scope_name: str) -> Scope: ... diff --git a/acouchbase_analytics/protocol/scope.py b/acouchbase_analytics/protocol/scope.py new file mode 100644 index 0000000..7a06752 --- /dev/null +++ b/acouchbase_analytics/protocol/scope.py @@ -0,0 +1,82 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import sys +from typing import Awaitable, TYPE_CHECKING + +if sys.version_info < (3, 10): + from typing_extensions import TypeAlias +else: + from typing import TypeAlias + +from acouchbase_analytics.protocol.core._anyio_utils import current_async_library +from acouchbase_analytics.protocol.core._request_context import AsyncRequestContext +from acouchbase_analytics.protocol.core.client_adapter import _AsyncClientAdapter +from acouchbase_analytics.protocol.streaming import AsyncHttpStreamingResponse +from couchbase_analytics.common.result import AsyncQueryResult +from couchbase_analytics.protocol.core.request import _RequestBuilder + +if TYPE_CHECKING: + from acouchbase_analytics.protocol.database import AsyncDatabase + + +class AsyncScope: + + def __init__(self, database: AsyncDatabase, scope_name: str) -> None: + self._database = database + self._scope_name = scope_name + self._request_builder = _RequestBuilder(self.client_adapter, self._database.name, self.name) + self._backend = current_async_library() + + @property + def client_adapter(self) -> _AsyncClientAdapter: + """ + **INTERNAL** + """ + return self._database.client_adapter + + @property + def name(self) -> str: + """ + str: The name of this :class:`~acouchbase_analytics.protocol.scope.Scope` instance. + """ + return self._scope_name + + async def _create_client(self) -> None: + """ + **INTERNAL** + """ + await self.client_adapter.create_client() + + async def _execute_query(self, http_resp: AsyncHttpStreamingResponse) -> AsyncQueryResult: + if not self.client_adapter.has_client: + # TODO: add log message?? + await self._create_client() + await http_resp.send_request() + return AsyncQueryResult(http_resp) + + def execute_query(self, statement: str, *args: object, **kwargs: object) -> Awaitable[AsyncQueryResult]: + base_req = self._request_builder.build_base_query_request(statement, *args, is_async=True, **kwargs) + stream_config = base_req.options.pop('stream_config', None) + request_context = AsyncRequestContext(client_adapter=self.client_adapter, request=base_req, backend=self._backend) + resp = AsyncHttpStreamingResponse(request_context, stream_config=stream_config) + if self._backend.backend_lib == 'asyncio': + return request_context.create_response_task(self._execute_query, resp) + return self._execute_query(resp) + + +Scope: TypeAlias = AsyncScope diff --git a/acouchbase_analytics/protocol/scope.pyi b/acouchbase_analytics/protocol/scope.pyi new file mode 100644 index 0000000..1c4f2d5 --- /dev/null +++ b/acouchbase_analytics/protocol/scope.pyi @@ -0,0 +1,71 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import sys +from typing import Awaitable, overload + +if sys.version_info < (3, 11): + from typing_extensions import Unpack +else: + from typing import Unpack + +from acouchbase_analytics.protocol.core.client_adapter import _ClientAdapter +from acouchbase_analytics.protocol.database import AsyncDatabase as AsyncDatabase +from couchbase_analytics.options import QueryOptions, QueryOptionsKwargs +from couchbase_analytics.result import AsyncQueryResult + +class AsyncScope: + def __init__(self, database: AsyncDatabase, scope_name: str) -> None: ... + + @property + def client_adapter(self) -> _ClientAdapter: ... + + @property + def name(self) -> str: ... + + @overload + def execute_query(self, statement: str) -> Awaitable[AsyncQueryResult]: ... + + @overload + def execute_query(self, statement: str, options: QueryOptions) -> Awaitable[AsyncQueryResult]: ... + + @overload + def execute_query(self, statement: str, **kwargs: Unpack[QueryOptionsKwargs]) -> Awaitable[AsyncQueryResult]: ... + + @overload + def execute_query(self, + statement: str, + options: QueryOptions, + **kwargs: Unpack[QueryOptionsKwargs]) -> Awaitable[AsyncQueryResult]: ... + + @overload + def execute_query(self, + statement: str, + options: QueryOptions, + *args: str, + **kwargs: Unpack[QueryOptionsKwargs]) -> Awaitable[AsyncQueryResult]: ... + + @overload + def execute_query(self, + statement: str, + options: QueryOptions, + *args: str, + **kwargs: str) -> Awaitable[AsyncQueryResult]: ... + + @overload + def execute_query(self, + statement: str, + *args: str, + **kwargs: str) -> Awaitable[AsyncQueryResult]: ... diff --git a/acouchbase_analytics/protocol/streaming.py b/acouchbase_analytics/protocol/streaming.py new file mode 100644 index 0000000..c74bfe2 --- /dev/null +++ b/acouchbase_analytics/protocol/streaming.py @@ -0,0 +1,202 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import json +from asyncio import CancelledError +from functools import wraps +from typing import (Any, + Callable, + Coroutine, + Optional) + +from httpx import Response as HttpCoreResponse + +# TODO: errors? +from couchbase_analytics.common.errors import (AnalyticsError, + InternalSDKError, + TimeoutError) +from acouchbase_analytics.protocol.core._request_context import AsyncRequestContext +from couchbase_analytics.common.core import (JsonStreamConfig, + ParsedResult, + ParsedResultType) +from couchbase_analytics.common.core.async_json_stream import AsyncJsonStream +from couchbase_analytics.common.core.query import build_query_metadata +from couchbase_analytics.common.query import QueryMetadata +from couchbase_analytics.common.streaming import StreamingState + + +class RequestWrapper: + """ + **INTERNAL** + """ + + @classmethod + def handle_retries(cls, # noqa: C901 + ) -> Callable[[Callable[[], None]], Callable[[AsyncHttpStreamingResponse], Coroutine[Any, Any, None]]]: + """ + **INTERNAL** + """ + + def decorator(fn: Callable[[], None] # noqa: C901 + ) -> Callable[[AsyncHttpStreamingResponse], Coroutine[Any, Any, None]]: + @wraps(fn) + async def wrapped_fn(self: AsyncHttpStreamingResponse) -> None: + try: + await fn(self) + except AnalyticsError: + # if an AnalyticsError is raised, we have already shut down the request context + raise + except RuntimeError as ex: + await self._request_context.shutdown(type(ex), ex, ex.__traceback__) + raise ex + except BaseException as ex: + await self._request_context.shutdown(type(ex), ex, ex.__traceback__) + if self._request_context.request_error is not None: + raise self._request_context.request_error from None + if self._request_context.timed_out: + raise TimeoutError(message='Request timed out.') from None + if self._request_context.cancelled: + raise CancelledError('Request was cancelled.') from None + raise InternalSDKError(ex) from None + finally: + if not StreamingState.is_okay(self._request_context.request_state): + await self.close() + + + return wrapped_fn + return decorator + +class AsyncHttpStreamingResponse: + def __init__(self, + request_context: AsyncRequestContext, + stream_config: Optional[JsonStreamConfig]=None) -> None: + self._metadata: Optional[QueryMetadata] = None + self._core_response: HttpCoreResponse + self._stream_config = stream_config or JsonStreamConfig() + self._json_stream: AsyncJsonStream + # Goal is to treat the AsyncHttpStreamingResponse as a "task group" + self._request_context = request_context + + async def _finish_processing_stream(self) -> None: + if not self._request_context.has_stage_completed: + await self._request_context.stage_completed.wait() + + while not self._json_stream.token_stream_exhausted: + self._request_context.start_next_stage(self._json_stream.continue_parsing, reset_previous_stage=True) + await self._request_context.stage_completed.wait() + + def _maybe_continue_to_process_stream(self) -> None: + if not self._request_context.has_stage_completed: + return + + if self._json_stream.token_stream_exhausted: + return + + self._request_context.start_next_stage(self._json_stream.continue_parsing, reset_previous_stage=True) + + async def _process_response(self, raw_response: Optional[ParsedResult]=None) -> None: + if raw_response is None: + raw_response = await self._json_stream.get_result() + if raw_response is None: + # TODO: logging?? + # TODO: exception?? + raise RuntimeError('No result from JsonStream') + + json_response = json.loads(raw_response.value) + if 'errors' in json_response: + await self._request_context.process_error(json_response['errors']) + self.set_metadata(json_data=json_response) + # we have all the data, close the core response/stream + await self.close() + + def _start(self) -> None: + """ + **INTERNAL** + """ + if hasattr(self, '_json_stream'): + # TODO: logging; I don't think this is an error... + return + + self._json_stream = AsyncJsonStream(self._core_response.aiter_bytes(), stream_config=self._stream_config) + self._request_context.start_next_stage(self._json_stream.start_parsing) + + async def close(self) -> None: + """ + **INTERNAL** + """ + if hasattr(self, '_core_response'): + await self._core_response.aclose() + del self._core_response + + async def cancel(self) -> None: + """ + **INTERNAL** + """ + await self.close() + + def get_metadata(self) -> QueryMetadata: + if self._metadata is None: + raise RuntimeError('Query metadata is only available after all rows have been iterated.') + return self._metadata + + def set_metadata(self, + json_data: Optional[Any]=None, + raw_metadata: Optional[bytes]=None) -> None: + try: + self._metadata = QueryMetadata(build_query_metadata(json_data=json_data, raw_metadata=raw_metadata)) + except AnalyticsError as err: + raise err + except Exception as ex: + raise InternalSDKError(str(ex)) + + async def get_next_row(self) -> Any: + """ + **INTERNAL** + """ + if self._core_response is None or not self._request_context.okay_to_iterate: + raise StopAsyncIteration + + self._maybe_continue_to_process_stream() + raw_response = await self._json_stream.get_result() + if raw_response.result_type == ParsedResultType.ROW: + return self._request_context.deserializer.deserialize(raw_response.value) + elif raw_response.result_type in [ParsedResultType.ERROR, ParsedResultType.UNKNOWN]: + await self._process_response(raw_response=raw_response) + elif raw_response.result_type == ParsedResultType.END: + self.set_metadata(raw_metadata=raw_response.value) + await self.close() + raise StopAsyncIteration + else: + await self._process_response(raw_response=raw_response) + + @RequestWrapper.handle_retries() + async def send_request(self) -> None: + if not self._request_context.okay_to_stream: + raise RuntimeError('Query has been canceled or previously executed.') + + # start cancel scope + await self._request_context.initialize() + self._core_response = await self._request_context.send_request(enable_trace_handling=True) + self._start() + # block until we either know we have rows or we have an error + await self._json_stream.has_results_or_errors.wait() + if self._json_stream.has_results_or_errors_type == ParsedResultType.ROW: + # we move to iterating rows + self._request_context.set_state_to_streaming() + else: + await self._finish_processing_stream() + await self._process_response() diff --git a/acouchbase_analytics/query.py b/acouchbase_analytics/query.py new file mode 100644 index 0000000..6d6520e --- /dev/null +++ b/acouchbase_analytics/query.py @@ -0,0 +1,19 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from couchbase_analytics.common.enums import QueryScanConsistency as QueryScanConsistency # noqa: F401 +from couchbase_analytics.common.query import QueryMetadata as QueryMetadata # noqa: F401 +from couchbase_analytics.common.query import QueryMetrics as QueryMetrics # noqa: F401 +from couchbase_analytics.common.query import QueryWarning as QueryWarning # noqa: F401 diff --git a/acouchbase_analytics/result.py b/acouchbase_analytics/result.py new file mode 100644 index 0000000..dedca6d --- /dev/null +++ b/acouchbase_analytics/result.py @@ -0,0 +1,17 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from couchbase_analytics.common.result import AsyncQueryResult as AsyncQueryResult # noqa: F401 +from couchbase_analytics.common.result import QueryResult as QueryResult # noqa: F401 diff --git a/acouchbase_analytics/scope.py b/acouchbase_analytics/scope.py new file mode 100644 index 0000000..b14ad37 --- /dev/null +++ b/acouchbase_analytics/scope.py @@ -0,0 +1,112 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import sys +from asyncio import Future +from typing import TYPE_CHECKING + +if sys.version_info < (3, 10): + from typing_extensions import TypeAlias +else: + from typing import TypeAlias + +from couchbase_analytics.result import AsyncQueryResult + +if TYPE_CHECKING: + from acouchbase_analytics.protocol.database import AsyncDatabase + + +class AsyncScope: + def __init__(self, database: AsyncDatabase, scope_name: str) -> None: + from acouchbase_analytics.protocol.scope import AsyncScope as _AsyncScope + self._impl = _AsyncScope(database, scope_name) + + @property + def name(self) -> str: + """ + str: The name of this :class:`~acouchbase_analytics.scope.AsyncScope` instance. + """ + return self._impl.name + + def execute_query(self, statement: str, *args: object, **kwargs: object) -> Future[AsyncQueryResult]: + """Executes a query against a Capella Columnar scope. + + .. note:: + A departure from the operational SDK, the query is *NOT* executed lazily. + + .. seealso:: + * :meth:`acouchbase_analytics.Cluster.execute_query`: For how to execute cluster-level queries. + + Args: + statement (str): The N1QL statement to execute. + options (:class:`~acouchbase_analytics.options.QueryOptions`): Optional parameters for the query operation. + **kwargs (Dict[str, Any]): keyword arguments that can be used in place or to override provided :class:`~acouchbase_analytics.options.QueryOptions` + + Returns: + Future[:class:`~couchbase_analytics.result.AsyncQueryResult`]: A :class:`~asyncio.Future` is returned. + Once the :class:`~asyncio.Future` completes, an instance of a :class:`~acouchbase_analytics.result.AsyncQueryResult` + is available to provide access to iterate over the query results and access metadata and metrics about the query. + + Examples: + Simple query:: + + q_str = 'SELECT * FROM airline WHERE country LIKE 'United%' LIMIT 2;' + q_res = scope.execute_query(q_str) + async for row in q_res.rows(): + print(f'Found row: {row}') + + Simple query with positional parameters:: + + from acouchbase_analytics.options import QueryOptions + + # ... other code ... + + q_str = 'SELECT * FROM airline WHERE country LIKE $1 LIMIT $2;' + q_res = scope.execute_query(q_str, QueryOptions(positional_parameters=['United%', 5])) + async for row in q_res.rows(): + print(f'Found row: {row}') + + Simple query with named parameters:: + + from acouchbase_analytics.options import QueryOptions + + # ... other code ... + + q_str = 'SELECT * FROM airline WHERE country LIKE $country LIMIT $lim;' + q_res = scope.execute_query(q_str, QueryOptions(named_parameters={'country': 'United%', 'lim':2})) + async for row in q_res.rows(): + print(f'Found row: {row}') + + Retrieve metadata and/or metrics from query:: + + from acouchbase_analytics.options import QueryOptions + + # ... other code ... + + q_str = 'SELECT * FROM `travel-sample` WHERE country LIKE $country LIMIT $lim;' + q_res = scope.execute_query(q_str, QueryOptions(named_parameters={'country': 'United%', 'lim':2})) + async for row in q_res.rows(): + print(f'Found row: {row}') + + print(f'Query metadata: {q_res.metadata()}') + print(f'Query metrics: {q_res.metadata().metrics()}') + + """ # noqa: E501 + return self._impl.execute_query(statement, *args, **kwargs) + + +Scope: TypeAlias = AsyncScope diff --git a/acouchbase_analytics/scope.pyi b/acouchbase_analytics/scope.pyi new file mode 100644 index 0000000..b1358bb --- /dev/null +++ b/acouchbase_analytics/scope.pyi @@ -0,0 +1,68 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import sys +from asyncio import Future +from typing import overload + +if sys.version_info < (3, 11): + from typing_extensions import Unpack +else: + from typing import Unpack + +from acouchbase_analytics.protocol.database import AsyncDatabase as AsyncDatabase +from couchbase_analytics.options import QueryOptions, QueryOptionsKwargs +from couchbase_analytics.result import AsyncQueryResult + +class AsyncScope: + def __init__(self, database: AsyncDatabase, scope_name: str) -> None: ... + + @property + def name(self) -> str: ... + + @overload + def execute_query(self, statement: str) -> Future[AsyncQueryResult]: ... + + @overload + def execute_query(self, statement: str, options: QueryOptions) -> Future[AsyncQueryResult]: ... + + @overload + def execute_query(self, statement: str, **kwargs: Unpack[QueryOptionsKwargs]) -> Future[AsyncQueryResult]: ... + + @overload + def execute_query(self, + statement: str, + options: QueryOptions, + **kwargs: Unpack[QueryOptionsKwargs]) -> Future[AsyncQueryResult]: ... + + @overload + def execute_query(self, + statement: str, + options: QueryOptions, + *args: str, + **kwargs: Unpack[QueryOptionsKwargs]) -> Future[AsyncQueryResult]: ... + + @overload + def execute_query(self, + statement: str, + options: QueryOptions, + *args: str, + **kwargs: str) -> Future[AsyncQueryResult]: ... + + @overload + def execute_query(self, + statement: str, + *args: str, + **kwargs: str) -> Future[AsyncQueryResult]: ... diff --git a/acouchbase_analytics/tests/__init__.py b/acouchbase_analytics/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/acouchbase_analytics/tests/json_parsing_t.py b/acouchbase_analytics/tests/json_parsing_t.py new file mode 100644 index 0000000..dc05a95 --- /dev/null +++ b/acouchbase_analytics/tests/json_parsing_t.py @@ -0,0 +1,415 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import json +from time import time +from typing import Dict, TYPE_CHECKING + +import pytest + +from couchbase_analytics.common.core import (JsonParsingError, + ParsedResult, + ParsedResultType) + +from couchbase_analytics.common.core.async_json_stream import AsyncJsonStream +from couchbase_analytics.common.core.json_stream import JsonStreamConfig +from couchbase_analytics.common.errors import AnalyticsError +from tests.environments.simple_environment import JsonDataType +from tests.utils import AsyncBytesIterator +from tests.utils._async_utils import TaskGroupResultCollector + +if TYPE_CHECKING: + from tests.environments.simple_environment import AsyncSimpleEnvironment + + +class JsonParsingTestSuite: + TEST_MANIFEST = [ + 'test_analytics_error', + 'test_analytics_many_rows', + 'test_analytics_parses_async', + 'test_analytics_simple_result', + + 'test_array', + 'test_array_empty', + 'test_array_mixed_types', + 'test_array_of_objects', + + 'test_invalid_empty', + 'test_invalid_garbage_between_objects', + 'test_invalid_leading_garbage', + 'test_invalid_trailing_garbage', + 'test_invalid_whitespace_only', + + 'test_object', + 'test_object_complex_nested_structure', + 'test_object_empty', + 'test_object_simple_nested', + 'test_object_with_empty_key_and_value', + 'test_object_with_unicode', + + 'test_value_bool', + 'test_value_null', + ] + + @pytest.mark.parametrize('buffered_result', [True, False]) + async def test_analytics_error(self, + async_test_env: AsyncSimpleEnvironment, + buffered_result: bool) -> None: + json_object, bytes_data = async_test_env.get_json_data(JsonDataType.FAILED_REQUEST) + if buffered_result: + parser = AsyncJsonStream(AsyncBytesIterator(bytes_data), + stream_config=JsonStreamConfig(buffer_entire_result=True)) + else: + parser = AsyncJsonStream(AsyncBytesIterator(bytes_data)) + await parser.start_parsing() + result = await parser.get_result() + assert isinstance(result, ParsedResult) + assert result.result_type == ParsedResultType.ERROR + assert isinstance(result.value, bytes) + assert json.loads(result.value.decode('utf-8')) == json_object + with pytest.raises(AnalyticsError): + await parser.get_result() + + async def test_analytics_many_rows(self, async_test_env: AsyncSimpleEnvironment) -> None: + json_object, bytes_data = async_test_env.get_json_data(JsonDataType.MULTIPLE_RESULTS) + parser = AsyncJsonStream(AsyncBytesIterator(bytes_data)) + await parser.start_parsing() + row_idx = 0 + while row_idx < 36: + result = await parser.get_result() + if result is None and not parser.token_stream_exhausted: + await parser.continue_parsing() + continue + assert isinstance(result, ParsedResult) + assert result.result_type == ParsedResultType.ROW + assert isinstance(result.value, bytes) + assert json.loads(result.value.decode('utf-8')) == json_object['results'][row_idx] + row_idx += 1 + + final_result = await parser.get_result() + assert isinstance(final_result, ParsedResult) + assert final_result.result_type == ParsedResultType.END + assert isinstance(final_result.value, bytes) + # if we are not buffering the entire result, the final result will exclude the results key + json_object.pop('results') + assert json.loads(final_result.value.decode('utf-8')) == json_object + with pytest.raises(AnalyticsError): + await parser.get_result() + + async def test_analytics_parses_async(self, async_test_env: AsyncSimpleEnvironment) -> None: + json_object, bytes_data = async_test_env.get_json_data(JsonDataType.MULTIPLE_RESULTS) + async def _run_async(idx: int) -> Dict[float, int]: + parser = AsyncJsonStream(AsyncBytesIterator(bytes_data, + simulate_delay=True, + simulate_delay_range=(0.01, 0.1))) + await parser.start_parsing() + row_idx = 0 + while row_idx < 36: + result = await parser.get_result() + if result is None and not parser.token_stream_exhausted: + await parser.continue_parsing() + continue + assert isinstance(result, ParsedResult) + assert result.result_type == ParsedResultType.ROW + assert isinstance(result.value, bytes) + assert json.loads(result.value.decode('utf-8')) == json_object['results'][row_idx] + row_idx += 1 + + return {time(): idx} + + async with TaskGroupResultCollector() as tg: + for idx in range(10): + tg.start_soon(_run_async, idx) + ordered_results = dict(sorted({k: v for r in tg.results for k, v in r.items()}.items())) + assert list(ordered_results.values()) != list(i for i in range(10)) + + @pytest.mark.parametrize('buffered_result', [True, False]) + async def test_analytics_simple_result(self, + async_test_env: AsyncSimpleEnvironment, + buffered_result: bool) -> None: + json_object, bytes_data = async_test_env.get_json_data(JsonDataType.SIMPLE_REQUEST) + if buffered_result: + parser = AsyncJsonStream(AsyncBytesIterator(bytes_data), + stream_config=JsonStreamConfig(buffer_entire_result=True)) + else: + parser = AsyncJsonStream(AsyncBytesIterator(bytes_data)) + await parser.start_parsing() + # check for individual rows when not buffering the result + if not buffered_result: + result = await parser.get_result() + assert isinstance(result, ParsedResult) + assert result.result_type == ParsedResultType.ROW + assert isinstance(result.value, bytes) + assert json.loads(result.value.decode('utf-8')) == json_object['results'][0] + + final_result = await parser.get_result() + assert isinstance(final_result, ParsedResult) + assert final_result.result_type == ParsedResultType.END + assert isinstance(final_result.value, bytes) + # we don't store the 'results' if buffering is not enabled + if not buffered_result: + json_object.pop('results') + assert json.loads(final_result.value.decode('utf-8')) == json_object + with pytest.raises(AnalyticsError): + await parser.get_result() + + @pytest.mark.anyio + async def test_array(self) -> None: + data = '[1,2,"three"]' + parser = AsyncJsonStream(AsyncBytesIterator(bytes(data, 'utf-8')), + stream_config=JsonStreamConfig(buffer_entire_result=True)) + await parser.start_parsing() + result = await parser.get_result() + assert isinstance(result, ParsedResult) + assert result.result_type == ParsedResultType.END + assert isinstance(result.value, bytes) + assert result.value.decode('utf-8') == data + with pytest.raises(AnalyticsError): + await parser.get_result() + + @pytest.mark.anyio + async def test_array_empty(self) -> None: + data = '[]' + parser = AsyncJsonStream(AsyncBytesIterator(bytes(data, 'utf-8')), + stream_config=JsonStreamConfig(buffer_entire_result=True)) + await parser.start_parsing() + result = await parser.get_result() + assert isinstance(result, ParsedResult) + assert result.result_type == ParsedResultType.END + assert isinstance(result.value, bytes) + assert result.value.decode('utf-8') == data + with pytest.raises(AnalyticsError): + await parser.get_result() + + @pytest.mark.anyio + async def test_array_mixed_types(self) -> None: + data = '[123,"text",true,null,{"key":"value"}]' + parser = AsyncJsonStream(AsyncBytesIterator(bytes(data, 'utf-8')), + stream_config=JsonStreamConfig(buffer_entire_result=True)) + await parser.start_parsing() + result = await parser.get_result() + assert isinstance(result, ParsedResult) + assert result.result_type == ParsedResultType.END + assert isinstance(result.value, bytes) + assert result.value.decode('utf-8') == data + with pytest.raises(AnalyticsError): + await parser.get_result() + + @pytest.mark.anyio + async def test_array_of_objects(self) -> None: + data = '[{"id":1,"name":"Alice"},{"id":2,"name":"Bob"}]' + parser = AsyncJsonStream(AsyncBytesIterator(bytes(data, 'utf-8')), + stream_config=JsonStreamConfig(buffer_entire_result=True)) + await parser.start_parsing() + result = await parser.get_result() + assert isinstance(result, ParsedResult) + assert result.result_type == ParsedResultType.END + assert isinstance(result.value, bytes) + assert result.value.decode('utf-8') == data + with pytest.raises(AnalyticsError): + await parser.get_result() + + @pytest.mark.anyio + async def test_invalid_empty(self) -> None: + try: + data = '' + parser = AsyncJsonStream(AsyncBytesIterator(bytes(data, 'utf-8')), + stream_config=JsonStreamConfig(buffer_entire_result=True)) + await parser.start_parsing() + except JsonParsingError as err: + assert isinstance(err, JsonParsingError) + assert err.cause is not None + assert 'parse error' in str(err.cause) + + @pytest.mark.anyio + async def test_invalid_garbage_between_objects(self) -> None: + try: + data = '[{"id":1,"name":"Alice"},garbage,{"id":2,"name":"Bob"}]' + parser = AsyncJsonStream(AsyncBytesIterator(bytes(data, 'utf-8')), + stream_config=JsonStreamConfig(buffer_entire_result=True)) + await parser.start_parsing() + except JsonParsingError as err: + assert isinstance(err, JsonParsingError) + assert err.cause is not None + assert 'lexical error' in str(err.cause) + + @pytest.mark.anyio + async def test_invalid_leading_garbage(self) -> None: + try: + data = 'garbage{"key":"value"}' + parser = AsyncJsonStream(AsyncBytesIterator(bytes(data, 'utf-8')), + stream_config=JsonStreamConfig(buffer_entire_result=True)) + await parser.start_parsing() + except JsonParsingError as err: + assert isinstance(err, JsonParsingError) + assert err.cause is not None + assert 'lexical error' in str(err.cause) + + @pytest.mark.anyio + async def test_invalid_trailing_garbage(self) -> None: + try: + data = '{"key":"value"}garbage' + parser = AsyncJsonStream(AsyncBytesIterator(bytes(data, 'utf-8')), + stream_config=JsonStreamConfig(buffer_entire_result=True)) + await parser.start_parsing() + except JsonParsingError as err: + assert isinstance(err, JsonParsingError) + assert err.cause is not None + assert 'parse error' in str(err.cause) + + @pytest.mark.anyio + async def test_invalid_whitespace_only(self) -> None: + try: + data = ' \n\t ' + parser = AsyncJsonStream(AsyncBytesIterator(bytes(data, 'utf-8')), + stream_config=JsonStreamConfig(buffer_entire_result=True)) + await parser.start_parsing() + except JsonParsingError as err: + assert isinstance(err, JsonParsingError) + assert err.cause is not None + assert 'parse error' in str(err.cause) + + @pytest.mark.anyio + async def test_value_bool(self) -> None: + data = 'true' + parser = AsyncJsonStream(AsyncBytesIterator(bytes(data, 'utf-8')), + stream_config=JsonStreamConfig(buffer_entire_result=True)) + await parser.start_parsing() + result = await parser.get_result() + assert isinstance(result, ParsedResult) + assert result.result_type == ParsedResultType.END + assert isinstance(result.value, bytes) + assert result.value.decode('utf-8') == data + with pytest.raises(AnalyticsError): + await parser.get_result() + + @pytest.mark.anyio + async def test_value_null(self) -> None: + data = 'null' + parser = AsyncJsonStream(AsyncBytesIterator(bytes(data, 'utf-8')), + stream_config=JsonStreamConfig(buffer_entire_result=True)) + await parser.start_parsing() + result = await parser.get_result() + assert isinstance(result, ParsedResult) + assert result.result_type == ParsedResultType.END + assert isinstance(result.value, bytes) + assert result.value.decode('utf-8') == data + with pytest.raises(AnalyticsError): + await parser.get_result() + + @pytest.mark.anyio + async def test_object(self) -> None: + data = '{"name":"John","age":30,"city":"New York"}' + parser = AsyncJsonStream(AsyncBytesIterator(bytes(data, 'utf-8')), + stream_config=JsonStreamConfig(buffer_entire_result=True)) + await parser.start_parsing() + result = await parser.get_result() + assert isinstance(result, ParsedResult) + assert result.result_type == ParsedResultType.END + assert isinstance(result.value, bytes) + assert result.value.decode('utf-8') == data + with pytest.raises(AnalyticsError): + await parser.get_result() + + @pytest.mark.anyio + async def test_object_complex_nested_structure(self) -> None: + data_list = ['{"users":[{"id":1,"name":"Alice","roles":["admin","editor"]},' + '{"id":2,"name":"Bob","roles":["viewer"]}],', + '"meta":{"count":2,"status":"success"}}'] + data = ''.join(data_list) + parser = AsyncJsonStream(AsyncBytesIterator(bytes(data, 'utf-8')), + stream_config=JsonStreamConfig(buffer_entire_result=True)) + await parser.start_parsing() + result = await parser.get_result() + assert isinstance(result, ParsedResult) + assert result.result_type == ParsedResultType.END + assert isinstance(result.value, bytes) + assert result.value.decode('utf-8') == data + with pytest.raises(AnalyticsError): + await parser.get_result() + + @pytest.mark.anyio + async def test_object_empty(self) -> None: + data = '{}' + parser = AsyncJsonStream(AsyncBytesIterator(bytes(data, 'utf-8')), + stream_config=JsonStreamConfig(buffer_entire_result=True)) + await parser.start_parsing() + result = await parser.get_result() + assert isinstance(result, ParsedResult) + assert result.result_type == ParsedResultType.END + assert isinstance(result.value, bytes) + assert result.value.decode('utf-8') == data + with pytest.raises(AnalyticsError): + await parser.get_result() + + @pytest.mark.anyio + async def test_object_simple_nested(self) -> None: + data = '{"outer":{"inner":{"key":"value"}}}' + parser = AsyncJsonStream(AsyncBytesIterator(bytes(data, 'utf-8')), + stream_config=JsonStreamConfig(buffer_entire_result=True)) + await parser.start_parsing() + result = await parser.get_result() + assert isinstance(result, ParsedResult) + assert result.result_type == ParsedResultType.END + assert isinstance(result.value, bytes) + assert result.value.decode('utf-8') == data + with pytest.raises(AnalyticsError): + await parser.get_result() + + @pytest.mark.anyio + async def test_object_with_empty_key_and_value(self) -> None: + data = '{"":""}' + parser = AsyncJsonStream(AsyncBytesIterator(bytes(data, 'utf-8')), + stream_config=JsonStreamConfig(buffer_entire_result=True)) + await parser.start_parsing() + result = await parser.get_result() + assert isinstance(result, ParsedResult) + assert result.result_type == ParsedResultType.END + assert isinstance(result.value, bytes) + assert result.value.decode('utf-8') == data + with pytest.raises(AnalyticsError): + await parser.get_result() + + @pytest.mark.anyio + async def test_object_with_unicode(self) -> None: + data = '{"name":"你好","city":"Denver"}' + parser = AsyncJsonStream(AsyncBytesIterator(bytes(data, 'utf-8')), + stream_config=JsonStreamConfig(buffer_entire_result=True)) + await parser.start_parsing() + result = await parser.get_result() + assert isinstance(result, ParsedResult) + assert result.result_type == ParsedResultType.END + assert isinstance(result.value, bytes) + assert result.value.decode('utf-8') == data + with pytest.raises(AnalyticsError): + await parser.get_result() + +class JsonParsingTests(JsonParsingTestSuite): + + @pytest.fixture(scope='class', autouse=True) + def validate_test_manifest(self) -> None: + def valid_test_method(meth: str) -> bool: + attr = getattr(JsonParsingTests, meth) + return callable(attr) and not meth.startswith('__') and meth.startswith('test') + method_list = [meth for meth in dir(JsonParsingTests) if valid_test_method(meth)] + test_list = set(JsonParsingTestSuite.TEST_MANIFEST).symmetric_difference(method_list) + if test_list: + pytest.fail(f'Test manifest invalid. Missing/extra tests: {test_list}.') + + @pytest.fixture(scope='class', name='async_test_env') + def acouchbase_test_environment(self, simple_async_test_env: AsyncSimpleEnvironment) -> AsyncSimpleEnvironment: + return simple_async_test_env \ No newline at end of file diff --git a/acouchbase_analytics/tests/test_server_t.py b/acouchbase_analytics/tests/test_server_t.py new file mode 100644 index 0000000..884663f --- /dev/null +++ b/acouchbase_analytics/tests/test_server_t.py @@ -0,0 +1,69 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from asyncio import Task +from typing import TYPE_CHECKING + +import pytest + +from tests import AsyncYieldFixture + +if TYPE_CHECKING: + from tests.environments.base_environment import AsyncTestEnvironment + + +class TestServerTestSuite: + + TEST_MANIFEST = [ + 'test_simple', + ] + + async def test_simple(self, test_env: AsyncTestEnvironment) -> None: + test_env.set_url_path('/test_post') + # test_env.update_request_json({'test_timeout': 10}) + # test_env.update_request_extensions({'timeout': {'pool': 5, + # 'test_pool_timeout': 5, + # 'test_connect_timeout': 5}}) + statement = 'SELECT "Hello, data!" AS greeting' + rtask = test_env.cluster.execute_query(statement) + print(f'Have result: {rtask=}') + if isinstance(rtask, Task): + print('Result is a Task') + res = await rtask + +class TestServerTests(TestServerTestSuite): + + @pytest.fixture(scope='class', autouse=True) + def validate_test_manifest(self) -> None: + def valid_test_method(meth: str) -> bool: + attr = getattr(TestServerTests, meth) + return callable(attr) and not meth.startswith('__') and meth.startswith('test') + method_list = [meth for meth in dir(TestServerTests) if valid_test_method(meth)] + test_list = set(TestServerTestSuite.TEST_MANIFEST).symmetric_difference(method_list) + if test_list: + pytest.fail(f'Test manifest invalid. Missing/extra tests: {test_list}.') + + @pytest.fixture(scope='class', name='test_env') + async def couchbase_test_environment(self, + async_test_env_with_server: AsyncTestEnvironment + ) -> AsyncYieldFixture[AsyncTestEnvironment]: + import asyncio + loop = asyncio.get_running_loop() + print(f'Running loop: {loop=}') + test_env = await async_test_env_with_server.enable_test_server() + yield test_env + test_env.disable_test_server() \ No newline at end of file diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..e51f3b5 --- /dev/null +++ b/conftest.py @@ -0,0 +1,54 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from typing import List + +import pytest + +pytest_plugins = [ + 'tests.analytics_config', + 'tests.environments.base_environment', + 'tests.environments.simple_environment' +] + +_UNIT_TESTS = [ + 'couchbase_analytics/tests/json_parsing_t.py::JsonParsingTests', +] + +_INTEGRATRION_TESTS = [ +] + +@pytest.fixture(scope='class') +def anyio_backend(): + return 'asyncio' + +# https://docs.pytest.org/en/stable/reference/reference.html#std-hook-pytest_collection_modifyitems +def pytest_collection_modifyitems(session: pytest.Session, + config: pytest.Config, + items: List[pytest.Item]) -> None: # noqa: C901 + for item in items: + item_details = item.nodeid.split('::') + + item_api = item_details[0].split('/') + if item_api[0] == 'couchbase_analytics': + item.add_marker(pytest.mark.pycbac_couchbase) + elif item_api[0] == 'acouchbase_analytics': + item.add_marker(pytest.mark.pycbac_acouchbase) + + test_class_path = '::'.join(item_details[:-1]) + if test_class_path in _UNIT_TESTS: + item.add_marker(pytest.mark.pycbac_unit) + elif test_class_path in _INTEGRATRION_TESTS: + item.add_marker(pytest.mark.pycbac_integration) \ No newline at end of file diff --git a/couchbase_analytics/__init__.py b/couchbase_analytics/__init__.py new file mode 100644 index 0000000..a133438 --- /dev/null +++ b/couchbase_analytics/__init__.py @@ -0,0 +1,18 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from couchbase_analytics.common import JSONType as JSONType # noqa: F401 +# TODO: logging +# from couchbase_analytics.protocol import configure_logging as configure_logging # noqa: F401 diff --git a/couchbase_analytics/cluster.py b/couchbase_analytics/cluster.py new file mode 100644 index 0000000..100f08e --- /dev/null +++ b/couchbase_analytics/cluster.py @@ -0,0 +1,205 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from concurrent.futures import Future +from typing import (TYPE_CHECKING, + Optional, + Union) + +from couchbase_analytics.database import Database +from couchbase_analytics.result import BlockingQueryResult + +if TYPE_CHECKING: + from couchbase_analytics.credential import Credential + from couchbase_analytics.options import ClusterOptions + + +class Cluster: + """Create a Cluster instance. + + The cluster instance exposes the operations which are available to be performed against an Analytics cluster. + + .. important:: + Use the static :meth:`.Cluster.create_instance` method to create a Cluster. + + Args: + http_endpoint: + The HTTP endpoint to use for sending requests to the Analytics server. + The format of the endpoint string is the *scheme* (``http`` or ``https`` is _required_), followed a hostname + credential: User credentials. + options: Global options to set for the cluster. + Some operations allow the global options to be overriden by passing in options to the operation. + **kwargs: keyword arguments that can be used in place or to overrride provided :class:`~couchbase_analytics.options.ClusterOptions` + + Raises: + ValueError: If incorrect connstr is provided. + ValueError: If incorrect options are provided. + + """ # noqa: E501 + + def __init__(self, + http_endpoint: str, + credential: Credential, + options: Optional[ClusterOptions] = None, + **kwargs: object) -> None: + from couchbase_analytics.protocol.cluster import Cluster as _Cluster + self._impl = _Cluster(http_endpoint, credential, options, **kwargs) + + def database(self, name: str) -> Database: + """Creates a database instance. + + .. seealso:: + :class:`~couchbase_analytics.database.Database` + + Args: + name: Name of the database + + Returns: + A Database instance. + + """ + return Database(self._impl, name) + + def execute_query(self, + statement: str, + *args: object, + **kwargs: object) -> Union[Future[BlockingQueryResult], BlockingQueryResult]: + """Executes a query against an Analytics cluster. + + .. note:: + A departure from the operational SDK, the query is *NOT* executed lazily. + + .. seealso:: + :meth:`couchbase_analytics.Scope.execute_query`: For how to execute scope-level queries. + + Args: + statement: The SQL++ statement to execute. + options (:class:`~couchbase_analytics.options.QueryOptions`): Optional parameters for the query operation. + **kwargs (Dict[str, Any]): keyword arguments that can be used in place or to override provided :class:`~couchbase_analytics.options.QueryOptions` + + Returns: + :class:`~couchbase_analytics.result.BlockingQueryResult`: An instance of a :class:`~couchbase_analytics.result.BlockingQueryResult` which + provides access to iterate over the query results and access metadata and metrics about the query. + + Examples: + Simple query:: + + q_str = 'SELECT * FROM `travel-sample`.inventory.airline WHERE country LIKE 'United%' LIMIT 2;' + q_res = cluster.execute_query(q_str) + for row in q_res.rows(): + print(f'Found row: {row}') + + Simple query with positional parameters:: + + from couchbase_analytics.options import QueryOptions + + # ... other code ... + + q_str = 'SELECT * FROM `travel-sample`.inventory.airline WHERE country LIKE $1 LIMIT $2;' + q_res = cluster.execute_query(q_str, QueryOptions(positional_parameters=['United%', 5])) + for row in q_res.rows(): + print(f'Found row: {row}') + + Simple query with named parameters:: + + from couchbase_analytics.options import QueryOptions + + # ... other code ... + + q_str = 'SELECT * FROM `travel-sample`.inventory.airline WHERE country LIKE $country LIMIT $lim;' + q_res = cluster.execute_query(q_str, QueryOptions(named_parameters={'country': 'United%', 'lim':2})) + for row in q_res.rows(): + print(f'Found row: {row}') + + Retrieve metadata and/or metrics from query:: + + from couchbase_analytics.options import QueryOptions + + # ... other code ... + + q_str = 'SELECT * FROM `travel-sample` WHERE country LIKE $country LIMIT $lim;' + q_res = cluster.execute_query(q_str, QueryOptions(named_parameters={'country': 'United%', 'lim':2})) + for row in q_res.rows(): + print(f'Found row: {row}') + + print(f'Query metadata: {q_res.metadata()}') + print(f'Query metrics: {q_res.metadata().metrics()}') + + """ # noqa: E501 + return self._impl.execute_query(statement, *args, **kwargs) + + def shutdown(self) -> None: + """Shuts down this cluster instance. Cleaning up all resources associated with it. + + .. warning:: + Use of this method is almost *always* unnecessary. Cluster resources should be cleaned + up once the cluster instance falls out of scope. However, in some applications tuning resources + is necessary and in those types of applications, this method might be beneficial. + + """ + return self._impl.shutdown() + + @classmethod + def create_instance(cls, + http_endpoint: str, + credential: Credential, + options: Optional[ClusterOptions] = None, + **kwargs: object) -> Cluster: + """Create a Cluster instance + + Args: + http_endpoint: + The HTTP endpoint to use for sending requests to the Analytics server. + The format of the endpoint string is the *scheme* (``http`` or ``https`` is _required_), followed a hostname + credential: User credentials. + options: Global options to set for the cluster. + Some operations allow the global options to be overriden by passing in options to the operation. + **kwargs: Keyword arguments that can be used in place or to overrride provided :class:`~couchbase_analytics.options.ClusterOptions` + + + Returns: + An Analytics Cluster instance. + + Raises: + ValueError: If incorrect connstr is provided. + ValueError: If incorrect options are provided. + + + Examples: + Initialize cluster using default options:: + + from couchbase_analytics.cluster import Cluster + from couchbase_analytics.credential import Credential + + cred = Credential.from_username_and_password('username', 'password') + cluster = Cluster.create_instance('https://hostname', cred) + + + Initialize cluster using with global timeout options:: + + from datetime import timedelta + + from couchbase_analytics.cluster import Cluster + from couchbase_analytics.credential import Credential + from couchbase_analytics.options import ClusterOptions, ClusterTimeoutOptions + + cred = Credential.from_username_and_password('username', 'password') + opts = ClusterOptions(timeout_options=ClusterTimeoutOptions(query_timeout=timedelta(seconds=120))) + cluster = Cluster.create_instance('https://hostname', cred, opts) + + """ # noqa: E501 + return cls(http_endpoint, credential, options, **kwargs) diff --git a/couchbase_analytics/cluster.pyi b/couchbase_analytics/cluster.pyi new file mode 100644 index 0000000..6d61e43 --- /dev/null +++ b/couchbase_analytics/cluster.pyi @@ -0,0 +1,202 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import sys +from concurrent.futures import Future +from typing import overload + +if sys.version_info < (3, 11): + from typing_extensions import Unpack +else: + from typing import Unpack + +from couchbase_analytics import JSONType +from couchbase_analytics.credential import Credential +from couchbase_analytics.database import Database +from couchbase_analytics.options import (ClusterOptions, + ClusterOptionsKwargs, + QueryOptions, + QueryOptionsKwargs) +from couchbase_analytics.result import BlockingQueryResult + +class Cluster: + @overload + def __init__(self, http_endpoint: str, credential: Credential) -> None: ... + + @overload + def __init__(self, + http_endpoint: str, + credential: Credential, + options: ClusterOptions) -> None: ... + + @overload + def __init__(self, + http_endpoint: str, + credential: Credential, + **kwargs: Unpack[ClusterOptionsKwargs]) -> None: ... + + @overload + def __init__(self, + http_endpoint: str, + credential: Credential, + options: ClusterOptions, + **kwargs: Unpack[ClusterOptionsKwargs]) -> None: ... + + def database(self, name: str) -> Database: ... + + @overload + def execute_query(self, statement: str) -> BlockingQueryResult: ... + + @overload + def execute_query(self, + statement: str, + options: QueryOptions) -> BlockingQueryResult: ... + + @overload + def execute_query(self, + statement: str, + **kwargs: Unpack[QueryOptionsKwargs]) -> BlockingQueryResult: ... + + @overload + def execute_query(self, + statement: str, + options: QueryOptions, + **kwargs: Unpack[QueryOptionsKwargs] + ) -> BlockingQueryResult: ... + + @overload + def execute_query(self, + statement: str, + options: QueryOptions, + *args: JSONType, + **kwargs: Unpack[QueryOptionsKwargs]) -> BlockingQueryResult: ... + + @overload + def execute_query(self, + statement: str, + options: QueryOptions, + *args: JSONType, + **kwargs: str) -> BlockingQueryResult: ... + + @overload + def execute_query(self, + statement: str, + *args: JSONType, + **kwargs: str) -> BlockingQueryResult: ... + + @overload + def execute_query(self, + statement: str, + enable_cancel: bool) -> Future[BlockingQueryResult]: ... + + @overload + def execute_query(self, + statement: str, + enable_cancel: bool, + *args: JSONType) -> Future[BlockingQueryResult]: ... + + @overload + def execute_query(self, + statement: str, + options: QueryOptions, + enable_cancel: bool) -> Future[BlockingQueryResult]: ... + + @overload + def execute_query(self, + statement: str, + enable_cancel: bool, + **kwargs: Unpack[QueryOptionsKwargs]) -> Future[BlockingQueryResult]: ... + + @overload + def execute_query(self, + statement: str, + options: QueryOptions, + enable_cancel: bool, + **kwargs: Unpack[QueryOptionsKwargs]) -> Future[BlockingQueryResult]: ... + + @overload + def execute_query(self, + statement: str, + options: QueryOptions, + enable_cancel: bool, + *args: JSONType, + **kwargs: Unpack[QueryOptionsKwargs]) -> Future[BlockingQueryResult]: ... + + @overload + def execute_query(self, + statement: str, + options: QueryOptions, + *args: JSONType, + enable_cancel: bool, + **kwargs: Unpack[QueryOptionsKwargs]) -> Future[BlockingQueryResult]: ... + + + @overload + def execute_query(self, + statement: str, + options: QueryOptions, + enable_cancel: bool, + *args: JSONType, + **kwargs: str) -> Future[BlockingQueryResult]: ... + + @overload + def execute_query(self, + statement: str, + options: QueryOptions, + *args: JSONType, + enable_cancel: bool, + **kwargs: str) -> Future[BlockingQueryResult]: ... + + @overload + def execute_query(self, + statement: str, + enable_cancel: bool, + *args: JSONType, + **kwargs: str) -> Future[BlockingQueryResult]: ... + + @overload + def execute_query(self, + statement: str, + *args: JSONType, + enable_cancel: bool, + **kwargs: str) -> Future[BlockingQueryResult]: ... + + def shutdown(self) -> None: ... + + @overload + @classmethod + def create_instance(cls, http_endpoint: str, credential: Credential) -> Cluster: ... + + @overload + @classmethod + def create_instance(cls, + http_endpoint: str, + credential: Credential, + options: ClusterOptions) -> Cluster: ... + + @overload + @classmethod + def create_instance(cls, + http_endpoint: str, + credential: Credential, + **kwargs: Unpack[ClusterOptionsKwargs]) -> Cluster: ... + + @overload + @classmethod + def create_instance(cls, + http_endpoint: str, + credential: Credential, + options: ClusterOptions, + **kwargs: Unpack[ClusterOptionsKwargs]) -> Cluster: ... diff --git a/couchbase_analytics/common/__init__.py b/couchbase_analytics/common/__init__.py new file mode 100644 index 0000000..04ab2ad --- /dev/null +++ b/couchbase_analytics/common/__init__.py @@ -0,0 +1,21 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from typing import (Any, + Dict, + List, + Union) + +JSONType = Union[str, int, float, bool, None, Dict[str, Any], List[Any]] diff --git a/couchbase_analytics/common/core/__init__.py b/couchbase_analytics/common/core/__init__.py new file mode 100644 index 0000000..1385a99 --- /dev/null +++ b/couchbase_analytics/common/core/__init__.py @@ -0,0 +1,19 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from .json_parsing import JsonParsingError +from .json_parsing import JsonStreamConfig +from .json_parsing import ParsedResult +from .json_parsing import ParsedResultType \ No newline at end of file diff --git a/couchbase_analytics/common/core/_certificates.py b/couchbase_analytics/common/core/_certificates.py new file mode 100644 index 0000000..a5ff4dd --- /dev/null +++ b/couchbase_analytics/common/core/_certificates.py @@ -0,0 +1,43 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +from __future__ import annotations + +from typing import List + + +class _Certificates: + """**INTERNAL**""" + @staticmethod + def get_nonprod_certificates() -> List[str]: + """ + **INTERNAL** Convenience method for access to non-prod Capella certificates. NOT + part of the public API. + + Returns: + List[str]: List of nonprod Capella certificates. + """ + import os + import warnings + from pathlib import Path + warnings.warn('Only use non-prod certificate in DEVELOPMENT environments.', ResourceWarning) + nonprod_cert_dir = Path(Path(__file__).resolve().parent, 'nonprod_certificates') + nonprod_certs: List[str] = [] + for cert in nonprod_cert_dir.iterdir(): + if os.path.isdir(cert) or cert.suffix != '.pem': + continue + nonprod_certs.append(cert.read_text()) + return nonprod_certs diff --git a/couchbase_analytics/common/core/async_json_stream.py b/couchbase_analytics/common/core/async_json_stream.py new file mode 100644 index 0000000..a4c1a55 --- /dev/null +++ b/couchbase_analytics/common/core/async_json_stream.py @@ -0,0 +1,183 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from typing import (AsyncIterator, + Optional) + +import ijson +from anyio import (create_memory_object_stream, + Event, + EndOfStream) + + +from couchbase_analytics.common.core.async_json_token_parser import AsyncJsonTokenParser +from couchbase_analytics.common.core.json_parsing import (JsonParsingError, + JsonStreamConfig, + ParsedResult, + ParsedResultType) +from couchbase_analytics.common.errors import AnalyticsError + +class AsyncJsonStream: + def __init__(self, + http_stream_iter: AsyncIterator[bytes], + *, + stream_config: Optional[JsonStreamConfig]=JsonStreamConfig(), + ) -> None: + # HTTP stream handling + self._http_stream_iter = http_stream_iter + self._http_stream_buffer_size = stream_config.http_stream_buffer_size + self._http_response_buffer = bytearray() + self._http_stream_exhausted = False + + # results handling + self._send_stream, self._receive_stream = create_memory_object_stream[ParsedResult](max_buffer_size=stream_config.buffered_row_max) + self._json_stream_parser = None + self._buffer_entire_result = stream_config.buffer_entire_result + handler = None if self._buffer_entire_result is True else self._handle_json_result + self._json_token_parser = AsyncJsonTokenParser(handler) + self._token_stream_exhausted = False + self._has_results_or_errors_evt = Event() + self._has_results_or_errors_type = ParsedResultType.UNKNOWN + + @property + def has_results_or_errors(self) -> Event: + """ + **INTERNAL** + """ + return self._has_results_or_errors_evt + + @property + def has_results_or_errors_type(self) -> ParsedResultType: + """ + **INTERNAL** + """ + return self._has_results_or_errors_type + + @property + def token_stream_exhausted(self) -> bool: + """ + **INTERNAL** + """ + return self._token_stream_exhausted + + def _continue_processing(self) -> bool: + """ + **INTERNAL** + """ + if self._token_stream_exhausted: + return False + if self._buffer_entire_result: + return True + + stats = self._receive_stream.statistics() + if stats.current_buffer_used >= stats.max_buffer_size: + return False + return True + + async def _send_to_stream(self, result: ParsedResult, close: Optional[bool]=False) -> None: + """ + **INTERNAL** + """ + await self._send_stream.send(result) + if close is True: + await self._send_stream.aclose() + + async def _handle_json_result(self, row: str) -> None: + """ + **INTERNAL** + """ + if not self._has_results_or_errors_evt.is_set(): + self._handle_notification(ParsedResultType.ROW) + await self._send_to_stream(ParsedResult(row, ParsedResultType.ROW)) + + def _handle_notification(self, result_type: Optional[ParsedResultType]=None) -> None: + if self._has_results_or_errors_evt.is_set(): + return + + if result_type is None: + self._has_results_or_errors_type = ParsedResultType.END + self._has_results_or_errors_evt.set() + return + + self._has_results_or_errors_type = result_type + self._has_results_or_errors_evt.set() + + async def _process_token_stream(self) -> None: + """ + **INTERNAL** + """ + if self._json_stream_parser is None: + self._json_stream_parser = ijson.parse_async(self, buf_size=self._http_stream_buffer_size) + + while self._continue_processing(): + try: + _, event, value = await self._json_stream_parser.__anext__() + # this is a hack b/c the ijson.parse_async iterator does not yield to the event loop + # TODO: create PYCO to either build custom JSON parsing, or dig into ijson root cause + await self._json_token_parser.parse_token(event, value) + except StopAsyncIteration as ex: + self._token_stream_exhausted = True + except ijson.common.IncompleteJSONError as ex: + raise JsonParsingError(cause=ex) from None + + + if self._token_stream_exhausted: + result_type = ParsedResultType.ERROR if self._json_token_parser.has_errors else ParsedResultType.END + await self._send_to_stream(ParsedResult(self._json_token_parser.get_result(), result_type), close=True) + self._handle_notification(result_type) + + async def read(self, size=-1) -> bytes: + """ + **INTERNAL** + """ + if size == 0 or self._http_stream_exhausted: + return b'' + + while not self._http_stream_exhausted: + if size >= 0 and len(self._http_response_buffer) > size: + break + try: + chunk = await self._http_stream_iter.__anext__() + self._http_response_buffer += chunk + except StopAsyncIteration: + self._http_stream_exhausted = True + break + + if size == -1: + data = bytes(self._http_response_buffer[:]) + del self._http_response_buffer[:] + else: + end = min(size, len(self._http_response_buffer)) + data = bytes(self._http_response_buffer[:end]) + del self._http_response_buffer[:end] + return data + + async def get_result(self) -> ParsedResult: + try: + return await self._receive_stream.receive() + except EndOfStream as ex: + raise AnalyticsError(ex, 'AsyncJsonStream has been closed.') from None + + async def start_parsing(self) -> None: + if self._json_stream_parser is not None: + # TODO: logging; I don't think this is an error... + return + await self._process_token_stream() + + async def continue_parsing(self) -> None: + # TODO: error is _json_stream_parser is None? + await self._process_token_stream() \ No newline at end of file diff --git a/couchbase_analytics/common/core/async_json_token_parser.py b/couchbase_analytics/common/core/async_json_token_parser.py new file mode 100644 index 0000000..b81720b --- /dev/null +++ b/couchbase_analytics/common/core/async_json_token_parser.py @@ -0,0 +1,87 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from typing import (Any, + Coroutine, + Optional, + Tuple) + +from couchbase_analytics.common.core.json_token_parser_base import (JsonTokenParserBase, + ParsingState, + TokenType, + POP_EVENTS, + START_EVENTS, + VALUE_TOKENS,) + +class AsyncJsonTokenParser(JsonTokenParserBase): + def __init__(self, + results_handler: Optional[Coroutine[Any, Any, None]]=None) -> None: + self._results_handler = results_handler + super().__init__(emit_results_enabled=results_handler is not None) + + async def _handle_obj_emit(self, obj: str) -> None: + should_emit_result = (self._emit_results_enabled + and self._results_handler is not None + and self._state == ParsingState.PROCESSING_RESULTS) + if should_emit_result: + await self._results_handler(bytes(obj, 'utf-8')) + return True + return False + + async def _handle_pop_event(self, token_type: TokenType) -> None: + matching_token = self._get_matching_token(token_type) + obj_pairs = [] + while self._stack: + next_token = self._pop() + if next_token.type == matching_token.type: + should_emit = self._handle_pop_transition(next_token.state) + # I think obj_pairs.reverse() is O(n); while reversed is O(1) + if matching_token.type == TokenType.START_ARRAY: + obj = f'[{",".join(reversed(obj_pairs))}]' + else: + obj = f'{{{",".join(reversed(obj_pairs))}}}' + object_emitted = await self._handle_obj_emit(obj) + if should_emit and object_emitted: + break # this means we emiited the result/error, so stop processing the stack + + if len(self._stack) > 0 and self._stack[-1].type == TokenType.MAP_KEY: + map_key = self._pop() + # If we are emitting rows and/or errors, we don't keep them in the stack and therefore don't need to return the results + if self._should_push_pair(next_token): + self._push(TokenType.PAIR, f'{map_key.value}:{obj}') + else: + self._push(TokenType.OBJECT, obj) + + break + obj_pairs.append(next_token.value) + + def get_result(self) -> Optional[bytes]: + return bytes(self._stack.pop().value, 'utf-8') if self._stack else None + + async def parse_token(self, token: str, value: str) -> None: + token_type = TokenType.from_str(token) + if token_type in VALUE_TOKENS: + self._handle_value_token(token_type, value) + elif token_type == TokenType.MAP_KEY: + self._handle_map_key_token(value) + elif token_type in START_EVENTS: + self._handle_start_event(token_type) + elif token_type in POP_EVENTS: + await self._handle_pop_event(token_type) + else: + # TODO: custom exception + raise ValueError(f'Invalid token type: {token_type}; {value=}') \ No newline at end of file diff --git a/couchbase_analytics/common/core/duration_str_utils.py b/couchbase_analytics/common/core/duration_str_utils.py new file mode 100644 index 0000000..43ec3ef --- /dev/null +++ b/couchbase_analytics/common/core/duration_str_utils.py @@ -0,0 +1,88 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import re +from typing import Optional + +from couchbase_analytics.common.core.utils import is_null_or_empty + +# TODO: Apparently Go does not allow a leading decimal point without a leading zero, e.g., ".5s" is invalid. +# We allowed this in the Columnar SDK due to how the C++ client parsed durations +DURATION_PATTERN = re.compile(r'^([-+]?)((\d*(\.\d*)?){1}(?:ns|us|µs|μs|ms|s|m|h){1})+$') +DURATION_PAIRS_PATTERN = re.compile(r'(\d*(?:\.\d*)?)(ns|us|ms|s|m|h)') + +def check_valid_duration_str(duration_str: str) -> None: + """ + Validates if the given string is a valid duration string. + + :param value: The duration string to validate. + :return: True if valid, False otherwise. + """ + if not isinstance(duration_str, str): + raise ValueError(f'Expected a string, got {type(duration_str).__name__} instead.') + + if is_null_or_empty(duration_str): + raise ValueError('Duration string cannot be empty.') + + if duration_str.startswith('-'): + raise ValueError('Negative durations are not supported.') + + # Special case: "0" duration + if duration_str == '0': + return + + match = DURATION_PATTERN.fullmatch(duration_str) + + if not match: + raise ValueError('Duration string has invalid format') + +def parse_duration_str(duration_str: str, in_millis: Optional[bool]=False) -> float: + check_valid_duration_str(duration_str) + + # Special case: "0" duration + if duration_str == '0': + return 0.0 + + # Normalize 'µs' (micro) + duration_str = duration_str.replace('µs', 'us').replace('μs', 'us') + + # Mapping of units to their multiplier to convert to seconds + unit_multipliers = { + 'ns': 1e-9, # nanoseconds + 'us': 1e-6, # microseconds + 'ms': 1e-3, # milliseconds + 's': 1.0, # seconds + 'm': 60.0, # minutes + 'h': 3600.0 # hours + } + + segments = DURATION_PAIRS_PATTERN.findall(duration_str) + total_seconds = 0.0 + for num_str, unit_str in segments: + try: + value = float(num_str) + total_seconds += value * unit_multipliers[unit_str] + except OverflowError as e: + raise ValueError((f'Invalid duration. Overflow error while parsing number "{num_str}{unit_str}". ' + f'Error details: {e}')) + except ValueError as e: + raise ValueError((f'Invalid duration. Parsing error while parsing number "{num_str}{unit_str}". ' + f'Error details: {e}')) + except KeyError: + raise ValueError(f'Invalid duration. Unknown unit "{unit_str}"') + + if in_millis: + total_seconds *= 1e3 + return total_seconds \ No newline at end of file diff --git a/couchbase_analytics/common/core/exception.py b/couchbase_analytics/common/core/exception.py new file mode 100644 index 0000000..355064a --- /dev/null +++ b/couchbase_analytics/common/core/exception.py @@ -0,0 +1,66 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from typing import (Optional, + Set, + Tuple, + TypedDict) + + +class GenericErrorContextCore(TypedDict, total=False): + # base + cinfo: Optional[Tuple[str, int]] + context_type: Optional[str] + error_message: Optional[str] + last_dispatched_from: Optional[str] + last_dispatched_to: Optional[str] + retry_attempts: Optional[int] + retry_reasons: Optional[Set[str]] + # http + client_context_id: Optional[str] + context_detail_type: Optional[str] + http_body: Optional[str] + http_status: Optional[int] + method: Optional[str] + path: Optional[str] + # mgmt + content: Optional[str] + # query/analytics/search + parameters: Optional[str] + # query/analytics + first_error_code: Optional[int] + first_error_message: Optional[str] + statement: Optional[str] + + +class ErrorContextCore(TypedDict, total=False): + cinfo: Optional[Tuple[str, int]] + context_type: Optional[str] + error_message: Optional[str] + last_dispatched_from: Optional[str] + last_dispatched_to: Optional[str] + retry_attempts: Optional[int] + retry_reasons: Optional[Set[str]] + + +class HTTPErrorContextCore(ErrorContextCore, total=False): + client_context_id: Optional[str] + context_detail_type: Optional[str] + http_body: Optional[str] + http_status: Optional[int] + method: Optional[str] + path: Optional[str] diff --git a/couchbase_analytics/common/core/json_parsing.py b/couchbase_analytics/common/core/json_parsing.py new file mode 100644 index 0000000..2faa506 --- /dev/null +++ b/couchbase_analytics/common/core/json_parsing.py @@ -0,0 +1,65 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from dataclasses import dataclass +from enum import IntEnum +from typing import Optional, NamedTuple + +class JsonParsingError(Exception): + def __init__(self, cause: Optional[Exception]=None) -> None: + super().__init__(cause) + self._cause = cause + + @property + def cause(self) -> Optional[Exception]: + return self._cause + + def __repr__(self): + return f'JsonParsingError(cause={self._cause})' + + def __str__(self) -> str: + return self.__repr__() + + +# buffer size in httpcore is 2 ** 16 (65kiB) which matches the default buffer size in ijson +# passing in a chunk_size is only applying an abstraction over the httpcore stream +DEFAULT_HTTP_STREAM_BUFFER_SIZE = 2**16 + +@dataclass +class JsonStreamConfig: + http_stream_buffer_size: int = DEFAULT_HTTP_STREAM_BUFFER_SIZE + buffer_entire_result: bool = False + buffered_row_max: int = 100 + buffered_row_threshold_percent: float = 0.75 + queue_timeout: float = 0.25 + + +class ParsedResultType(IntEnum): + """ + **INTERNAL** + """ + ROW = 0 + ERROR = 1 + END = 2 + UNKNOWN = 3 + +class ParsedResult(NamedTuple): + """ + **INTERNAL** + """ + value: bytes + result_type: ParsedResultType \ No newline at end of file diff --git a/couchbase_analytics/common/core/json_stream.py b/couchbase_analytics/common/core/json_stream.py new file mode 100644 index 0000000..ffb1e7b --- /dev/null +++ b/couchbase_analytics/common/core/json_stream.py @@ -0,0 +1,182 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from concurrent.futures import Future +from queue import (Queue, + Full as QueueFull, + Empty as QueueEmpty) +from threading import get_ident +from typing import (Iterator, + Optional) + +import ijson + + +from couchbase_analytics.common.core.json_token_parser import JsonTokenParser + +from couchbase_analytics.common.core.json_parsing import (JsonParsingError, + JsonStreamConfig, + ParsedResult, + ParsedResultType) +from couchbase_analytics.protocol.core._request_context import RequestContext, ThreadSafeBytesIterator + +class JsonStream: + DEFAULT_HTTP_STREAM_BUFFER_SIZE = 2**16 + + def __init__(self, + http_stream_iter: Iterator[bytes], + *, + stream_config: Optional[JsonStreamConfig]=JsonStreamConfig(), + ) -> None: + # HTTP stream handling + self._http_stream_iter = http_stream_iter + self._http_stream_buffer_size = stream_config.http_stream_buffer_size + self._http_response_buffer = bytearray() + self._http_stream_exhausted = False + + # results handling + self._buffered_row_max = stream_config.buffered_row_max + self._buffered_row_threshold = int(self._buffered_row_max * stream_config.buffered_row_threshold_percent) + self._json_stream_parser = None + self._buffer_entire_result = stream_config.buffer_entire_result + handler = None if self._buffer_entire_result is True else self._handle_json_result + self._json_token_parser = JsonTokenParser(handler) + self._token_stream_exhausted = False + self._results_queue: Queue[ParsedResult] = Queue() + self._queue_timeout = stream_config.queue_timeout + self._notify_on_results_or_error: Optional[Future[ParsedResultType]] = None + + @property + def http_stream_exhausted(self) -> bool: + """ + **INTERNAL** + """ + return self._http_stream_exhausted + + @property + def token_stream_exhausted(self) -> bool: + """ + **INTERNAL** + """ + return self._token_stream_exhausted + + def _continue_processing(self, request_context: Optional[RequestContext]=None) -> bool: + """ + **INTERNAL** + """ + if self._token_stream_exhausted: + return False + if self._buffer_entire_result: + return True + if request_context is not None and (request_context.cancelled or request_context.timed_out): + return False + if self._results_queue.qsize() >= self._buffered_row_threshold: + return False + return True + + def _put(self, result: ParsedResult) -> None: + """ + **INTERNAL** + """ + while True: + try: + self._results_queue.put(result, timeout=self._queue_timeout) + break + except QueueFull: + # TODO: log error as this is unexpected + pass + + + def _handle_json_result(self, row: str) -> None: + """ + **INTERNAL** + """ + if self._notify_on_results_or_error is not None and not self._notify_on_results_or_error.done(): + self._handle_notification(ParsedResultType.ROW) + self._put(ParsedResult(row, ParsedResultType.ROW)) + + def _handle_notification(self, result_type: ParsedResultType) -> None: + if self._notify_on_results_or_error is None or self._notify_on_results_or_error.done(): + return + + self._notify_on_results_or_error.set_result(result_type) + + def _process_token_stream(self, request_context: Optional[RequestContext]=None) -> None: + """ + **INTERNAL** + """ + if self._json_stream_parser is None: + self._json_stream_parser = ijson.parse(self, buf_size=self._http_stream_buffer_size) + + while self._continue_processing(request_context=request_context): + try: + _, event, value = next(self._json_stream_parser) + self._json_token_parser.parse_token(event, value) + except StopIteration as ex: + self._token_stream_exhausted = True + except ijson.common.IncompleteJSONError as ex: + raise JsonParsingError(cause=ex) from None + + if self._token_stream_exhausted: + result_type = ParsedResultType.ERROR if self._json_token_parser.has_errors else ParsedResultType.END + self._put(ParsedResult(self._json_token_parser.get_result(), result_type)) + self._handle_notification(result_type) + + def read(self, size=-1) -> bytes: + """ + **INTERNAL** + """ + if size == 0 or self._http_stream_exhausted: + return b'' + + while not self._http_stream_exhausted: + if size >= 0 and len(self._http_response_buffer) > size: + break + try: + chunk = next(self._http_stream_iter) + self._http_response_buffer += chunk + except StopIteration: + self._http_stream_exhausted = True + break + + if size == -1: + data = bytes(self._http_response_buffer[:]) + del self._http_response_buffer[:] + else: + end = min(size, len(self._http_response_buffer)) + data = bytes(self._http_response_buffer[:end]) + del self._http_response_buffer[:end] + return data + + def get_result(self, timeout: float) -> Optional[ParsedResult]: + try: + return self._results_queue.get(timeout=timeout) + except QueueEmpty: + # TODO: log a message here as indication the stream is slow + return None + + def start_parsing(self, + request_context: Optional[RequestContext]=None, + notify_on_results_or_error: Optional[Future[ParsedResultType]]=None) -> None: + if self._json_stream_parser is not None: + # TODO: logging; I don't think this is an error... + return + self._notify_on_results_or_error = notify_on_results_or_error + self._process_token_stream(request_context=request_context) + + def continue_parsing(self, request_context: Optional[RequestContext]=None,) -> None: + self._process_token_stream(request_context=request_context) \ No newline at end of file diff --git a/couchbase_analytics/common/core/json_token_parser.py b/couchbase_analytics/common/core/json_token_parser.py new file mode 100644 index 0000000..98199b2 --- /dev/null +++ b/couchbase_analytics/common/core/json_token_parser.py @@ -0,0 +1,84 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from typing import Callable, Optional + +from couchbase_analytics.common.core.json_token_parser_base import (JsonTokenParserBase, + ParsingState, + TokenType, + POP_EVENTS, + START_EVENTS, + VALUE_TOKENS,) + +class JsonTokenParser(JsonTokenParserBase): + def __init__(self, + result_handler: Optional[Callable[[str], None]]=None) -> None: + self._result_handler = result_handler + super().__init__(emit_results_enabled=result_handler is not None) + + + def _handle_obj_emit(self, obj: str) -> None: + should_emit_result = (self._emit_results_enabled + and self._result_handler is not None + and self._state == ParsingState.PROCESSING_RESULTS) + if should_emit_result: + self._result_handler(bytes(obj, 'utf-8')) + return True + return False + + def _handle_pop_event(self, token_type: TokenType) -> None: + matching_token = self._get_matching_token(token_type) + obj_pairs = [] + while self._stack: + next_token = self._pop() + if next_token.type == matching_token.type: + should_emit = self._handle_pop_transition(next_token.state) + # I think obj_pairs.reverse() is O(n); while reversed is O(1) + if matching_token.type == TokenType.START_ARRAY: + obj = f'[{",".join(reversed(obj_pairs))}]' + else: + obj = f'{{{",".join(reversed(obj_pairs))}}}' + if should_emit and self._handle_obj_emit(obj): + break # this means we emiited the result/error, so stop processing the stack + + if len(self._stack) > 0 and self._stack[-1].type == TokenType.MAP_KEY: + map_key = self._pop() + # If we are emitting rows and/or errors, we don't keep them in the stack and therefore don't need to return the results + if self._should_push_pair(next_token): + self._push(TokenType.PAIR, f'{map_key.value}:{obj}') + else: + self._push(TokenType.OBJECT, obj) + + break + obj_pairs.append(next_token.value) + + def get_result(self) -> Optional[bytes]: + return bytes(self._stack.pop().value, 'utf-8') if self._stack else None + + def parse_token(self, token: str, value: str) -> None: + token_type = TokenType.from_str(token) + if token_type in VALUE_TOKENS: + self._handle_value_token(token_type, value) + elif token_type == TokenType.MAP_KEY: + self._handle_map_key_token(value) + elif token_type in START_EVENTS: + self._handle_start_event(token_type) + elif token_type in POP_EVENTS: + self._handle_pop_event(token_type) + else: + # TODO: custom exception + raise ValueError(f'Invalid token type: {token_type}; {value=}') diff --git a/couchbase_analytics/common/core/json_token_parser_base.py b/couchbase_analytics/common/core/json_token_parser_base.py new file mode 100644 index 0000000..a711010 --- /dev/null +++ b/couchbase_analytics/common/core/json_token_parser_base.py @@ -0,0 +1,230 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from collections import deque +from enum import Enum +from typing import (Deque, + Optional, + NamedTuple) + +class ParsingState(Enum): + PROCESSING = 'processing' + START_RESULTS_PROCESSING = 'start_results_processing' + PROCESSING_RESULTS = 'processing_results' + PROCESSING_RESULT = 'processing_result' + START_ERRORS_PROCESSING = 'start_errors_processing' + PROCESSING_ERRORS = 'processing_errors' + PROCESSING_ERROR = 'processing_error' + # RESULTS_START = 'results_start' + # RESULT_START = 'result_start' + # ERRORS_START = 'errors_start' + # ERROR_START = 'error_start' + UNDEFINED = 'undefined' + + def __str__(self): + return self.value + + +class TokenState(Enum): + # PROCESSING = 'processing' + # START_RESULTS_PROCESSING = 'start_results_processing' + # PROCESSING_RESULTS = 'processing_results' + # PROCESSING_RESULT = 'processing_result' + # START_ERRORS_PROCESSING = 'start_errors_processing' + # PROCESSING_ERRORS = 'processing_errors' + # PROCESSING_ERROR = 'processing_error' + RESULTS_START = 'results_start' + RESULT_START = 'result_start' + ERRORS_START = 'errors_start' + ERROR_START = 'error_start' + UNDEFINED = 'undefined' + + def __str__(self): + return self.value + +class TokenType(Enum): + START_MAP = 'start_map' + END_MAP = 'end_map' + START_ARRAY = 'start_array' + END_ARRAY = 'end_array' + MAP_KEY = 'map_key' + STRING = 'string' + BOOLEAN = 'boolean' + NULL = 'null' + INTEGER = 'integer' + DOUBLE = 'double' + NUMBER = 'number' + PAIR = 'pair' + VALUE = 'value' + OBJECT = 'object' + + @classmethod + def from_str(cls, value: str) -> TokenType: + try: + return cls[value.upper()] + except KeyError: + raise ValueError(f'Invalid token type: {value}') + + def __str__(self): + return self.value + +class Token(NamedTuple): + type: TokenType + value: str + state: Optional[TokenState]=None + +VALUE_TOKENS = [TokenType.STRING, + TokenType.BOOLEAN, + TokenType.NULL, + TokenType.INTEGER, + TokenType.DOUBLE, + TokenType.NUMBER] + +EVENT_TOKENS = { + TokenType.START_ARRAY: Token(TokenType.START_ARRAY, '['), + TokenType.END_ARRAY: Token(TokenType.END_ARRAY, ']'), + TokenType.START_MAP: Token(TokenType.START_MAP, '{'), + TokenType.END_MAP: Token(TokenType.END_MAP, '}'), +} + +POP_EVENTS = [TokenType.END_ARRAY, TokenType.END_MAP] + +START_EVENTS = [TokenType.START_ARRAY, TokenType.START_MAP] + +START_EVENT_TRANSITION_STATES = [ParsingState.START_RESULTS_PROCESSING, + ParsingState.START_ERRORS_PROCESSING, + ParsingState.PROCESSING_RESULTS] + +class JsonTokenParserBase: + def __init__(self, emit_results_enabled: bool) -> None: + self._stack: Deque[Token] = deque() + self._state = ParsingState.PROCESSING + self._previous_state = ParsingState.UNDEFINED + self._emit_results_enabled = emit_results_enabled + self._has_errors = False + + @property + def has_errors(self) -> bool: + return self._has_errors + + def _get_matching_token(self, token_type: TokenType) -> Token: + if token_type == TokenType.END_ARRAY: + return EVENT_TOKENS[TokenType.START_ARRAY] + elif token_type == TokenType.END_MAP: + return EVENT_TOKENS[TokenType.START_MAP] + else: + raise ValueError(f'Invalid token type (cannot match): {token_type}') + + def _handle_map_key_token(self, value: str) -> None: + if self._state == ParsingState.PROCESSING: + if value == 'results': + self._state = ParsingState.START_RESULTS_PROCESSING + self._previous_state = ParsingState.PROCESSING + elif value == 'errors': + self._has_errors = True + self._state = ParsingState.START_ERRORS_PROCESSING + self._previous_state = ParsingState.PROCESSING + self._push(TokenType.MAP_KEY, f'"{value}"') + + def _handle_pop_transition(self, token_state: Optional[TokenState]=None) -> bool: + if token_state is not None: + if token_state == TokenState.RESULTS_START: + self._previous_state = self._state + self._state = ParsingState.PROCESSING + elif token_state == TokenState.ERRORS_START: + self._previous_state = self._state + self._state = ParsingState.PROCESSING + elif token_state == TokenState.RESULT_START: + self._previous_state = self._state + self._state = ParsingState.PROCESSING_RESULTS + return True + return False + + def _handle_push_transition(self) -> Optional[TokenState]: + if self._state == ParsingState.START_RESULTS_PROCESSING: + self._previous_state = self._state + self._state = ParsingState.PROCESSING_RESULTS + return TokenState.RESULTS_START + elif self._state == ParsingState.START_ERRORS_PROCESSING: + self._previous_state = self._state + self._state = ParsingState.PROCESSING_ERRORS + return TokenState.ERRORS_START + elif self._state == ParsingState.PROCESSING_RESULTS: + self._previous_state = self._state + self._state = ParsingState.PROCESSING_RESULT + return TokenState.RESULT_START + elif self._state == ParsingState.PROCESSING_ERRORS: + self._previous_state = self._state + self._state = ParsingState.PROCESSING_ERROR + return TokenState.ERROR_START + # TODO: Handle other states?? or error? + + def _handle_start_event(self, token_type: TokenType) -> None: + transition = False + if self._state in START_EVENT_TRANSITION_STATES: + transition = True + + self._push(token_type, EVENT_TOKENS[token_type].value, transition) + + def _handle_value_token(self, token_type: TokenType, value: str) -> None: + pair_key = val = None + if len(self._stack) > 0 and self._stack[-1].type == TokenType.MAP_KEY: + # no state transitions for a map_key token + pair_key = self._pop().value + if token_type == TokenType.STRING: + if '"' in value: + value = value.replace('"', '\\"') + if "\\'" in value: + value = value.replace("\\'", "\\\\'") + val = f'"{value}"' + elif token_type == TokenType.NULL: + val = f'null' + elif token_type == TokenType.BOOLEAN: + val = f'{value}'.lower() + else: + val = f'{value}' + if pair_key is not None: + self._push(TokenType.PAIR, f'{pair_key}:{val}') + else: + self._push(TokenType.VALUE, val) + + def _push(self, token_type: TokenType, value: str, transition: Optional[bool]=False) -> None: + token_state = None + if transition is True: + token_state = self._handle_push_transition() + + self._stack.append(Token(token_type, value, token_state)) + + def _pop(self) -> Token: + if self._stack: + return self._stack.pop() + raise ValueError('Stack is empty') + + def _should_push_pair(self, token: Token) -> bool: + # when a results object is complete, the state will have transactioned back to PROCESSING + # if we are not emitting rows or errors, we want to keep the results/errors object on the stack + if (self._previous_state == ParsingState.PROCESSING_RESULTS + and self._state == ParsingState.PROCESSING + and self._emit_results_enabled is False): + return True + + # the initial results object token will have a state of RESULTS_START + # and we don't want to push them onto the stack + if token.state != TokenState.RESULTS_START: + return True + + return False \ No newline at end of file diff --git a/couchbase_analytics/common/core/net_utils.py b/couchbase_analytics/common/core/net_utils.py new file mode 100644 index 0000000..1441057 --- /dev/null +++ b/couchbase_analytics/common/core/net_utils.py @@ -0,0 +1,104 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import socket + +from ipaddress import ip_address +from random import choice +from typing import (Any, + Dict, + List, + Optional) +from urllib.parse import quote + +import anyio + +def get_request_ip(host: str, + port: int, + previous_ips: Optional[List[str]]=None) -> Optional[str]: + # Lets not call getaddrinfo, if the host is already an IP address + try: + ip = ip_address(host) + except ValueError: + ip = None + + # if we have localhost, httpx does not seem to be able to resolve IPv6 localhost (::1) properly + # TODO: IPv6 support for localhost?? + if host == 'localhost': + ip = '127.0.0.1' + + if previous_ips is None: + previous_ips = [] + if not ip: + # TODO: getaddrinfo() will raise an exception if name resolution fails + result = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM, family=socket.AF_UNSPEC) + # TODO: Handle IPv4 vs IPv6; with or without port? + # ips = [f'{addr[4][0]}:{addr[4][1]}' for addr in result] + try: + ip = choice([addr[4][0] for addr in result if addr[4][0] not in previous_ips]) + except IndexError: + ip = None + else: + ip_str = str(ip) if not isinstance(ip, str) else ip + ip = None if ip_str in previous_ips else ip_str + + return ip + + +async def get_request_ip_async(host: str, + port: int, + previous_ips: Optional[List[str]]=None) -> Optional[str]: + # Lets not call getaddrinfo, if the host is already an IP address + try: + ip = ip_address(host) + except ValueError: + ip = None + + # if we have localhost, httpx does not seem to be able to resolve IPv6 localhost (::1) properly + # TODO: IPv6 support for localhost?? + if host == 'localhost': + ip = '127.0.0.1' + + if previous_ips is None: + previous_ips = [] + + if not ip: + # TODO: getaddrinfo() will raise an exception if name resolution fails + result = await anyio.getaddrinfo(host, port, type=socket.SOCK_STREAM, family=socket.AF_UNSPEC) + # TODO: Handle IPv4 vs IPv6; with or without port? + # ips = [f'{addr[4][0]}:{addr[4][1]}' for addr in result] + try: + ip = choice([addr[4][0] for addr in result if addr[4][0] not in previous_ips]) + except IndexError: + ip = None + else: + ip_str = str(ip) if not isinstance(ip, str) else ip + ip = None if ip_str in previous_ips else ip_str + + return ip + + +# TODO: unused?? +def to_query_str(params: Dict[str, Any]) -> str: + encoded_params = [] + for k, v in params.items(): + if v in [True, False]: + encoded_params.append(f'{quote(k)}={quote(str(v).lower())}') + else: + encoded_params.append(f'{quote(k)}={quote(str(v))}') + + return '&'.join(encoded_params) \ No newline at end of file diff --git a/couchbase_analytics/common/core/query.py b/couchbase_analytics/common/core/query.py new file mode 100644 index 0000000..261154a --- /dev/null +++ b/couchbase_analytics/common/core/query.py @@ -0,0 +1,109 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import json +from typing import (Any, + List, + TypedDict, + Optional) + +from couchbase_analytics.common.core.duration_str_utils import parse_duration_str + + +class QueryMetricsCore(TypedDict, total=False): + """ + **INTERNAL** + """ + + elapsed_time: float + execution_time: float + compile_time: float + queue_wait_time: float + result_count: int + result_size: int + processed_objects: int + buffer_cache_hit_ratio: str + buffer_cache_page_read_count: int + + +class QueryWarningCore(TypedDict, total=False): + """ + **INTERNAL** + """ + + code: int + message: str + + +class QueryMetadataCore(TypedDict, total=False): + """ + **INTERNAL** + """ + + request_id: str + client_context_id: str + warnings: List[QueryWarningCore] + metrics: QueryMetricsCore + status: Optional[str] + + +def build_query_metadata(json_data: Optional[Any]=None, + raw_metadata: Optional[bytes]=None) -> QueryMetadataCore: + """ + Builds the query metadata from the raw bytes. + + Args: + metadata (bytes): The raw metadata bytes. + + Returns: + QueryMetadataCore: The parsed query metadata. + """ + if json_data is None and raw_metadata is None: + raise ValueError("Either json_data or raw_metadata must be provided") + + if json_data is None: + json_data = json.loads(raw_metadata.decode('utf-8')) + warnings = [] + for warning in json_data.get('warnings', []): + warnings.append({'code':warning.get('code', 0), 'message': warning.get('msg', '')}) + + metadata = {'request_id':json_data.get('requestID', ''), + 'client_context_id':json_data.get('clientContextID', ''), + 'warnings':warnings} + + # TODO: include status in metadata?? Seems to only be populated in error scenario + if 'status' in json_data: + metadata['status'] = json_data.get('status', '') + + if 'metrics' not in json_data: + metadata['metrics'] = {} + return metadata + + metrics: QueryMetricsCore = { + 'elapsed_time': parse_duration_str(json_data['metrics'].get('elapsedTime', '0'), in_millis=True), + 'execution_time': parse_duration_str(json_data['metrics'].get('executionTime', '0'), in_millis=True), + 'compile_time': parse_duration_str(json_data['metrics'].get('compileTime', '0'), in_millis=True), + 'queue_wait_time': parse_duration_str(json_data['metrics'].get('queueWaitTime', '0'), in_millis=True), + 'result_count': json_data['metrics'].get('resultCount', 0), + 'result_size': json_data['metrics'].get('resultSize', 0), + 'processed_objects': json_data['metrics'].get('processedObjects', 0), + 'buffer_cache_hit_ratio': json_data['metrics'].get('bufferCacheHitRatio', ''), + 'buffer_cache_page_read_count': json_data['metrics'].get('bufferCachePageReadCount', 0) + } + + metadata['metrics'] = metrics + return metadata diff --git a/couchbase_analytics/common/core/result.py b/couchbase_analytics/common/core/result.py new file mode 100644 index 0000000..a3b0ef3 --- /dev/null +++ b/couchbase_analytics/common/core/result.py @@ -0,0 +1,61 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import sys +from abc import ABC, abstractmethod +from typing import (Any, + Coroutine, + List, + Optional, + Union) + +if sys.version_info < (3, 9): + from typing import AsyncIterator as PyAsyncIterator + from typing import Iterator +else: + from collections.abc import AsyncIterator as PyAsyncIterator + from collections.abc import Iterator + +from couchbase_analytics.common.query import QueryMetadata + + +class QueryResult(ABC): + """Abstract base class for query results.""" + + @abstractmethod + def cancel(self) -> None: + """ + Cancel streaming the query results. + + **VOLATILE** This API is subject to change at any time. + """ + raise NotImplementedError + + @abstractmethod + def get_all_rows(self) -> Union[Coroutine[Any, Any, List[Any]], List[Any]]: + """Convenience method to load all query results into memory.""" + raise NotImplementedError + + @abstractmethod + def metadata(self) -> Optional[QueryMetadata]: + """Get the query metadata.""" + raise NotImplementedError + + @abstractmethod + def rows(self) -> Union[PyAsyncIterator[Any], Iterator[Any]]: + """Retrieve the rows which have been returned by the query.""" + raise NotImplementedError diff --git a/couchbase_analytics/common/core/utils.py b/couchbase_analytics/common/core/utils.py new file mode 100644 index 0000000..aea529d --- /dev/null +++ b/couchbase_analytics/common/core/utils.py @@ -0,0 +1,136 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from datetime import timedelta +from enum import Enum +from os import path +from typing import (Any, + Dict, + Generic, + List, + Optional, + TypeVar, + Union) + +from couchbase_analytics.common.deserializer import Deserializer + +T = TypeVar('T') +E = TypeVar('E', bound=Enum) + + +def is_null_or_empty(value: Optional[str]) -> bool: + return value is None or value.isspace() + + +def timedelta_as_seconds(duration: timedelta) -> int: + if duration and not isinstance(duration, timedelta): + raise ValueError(f"Expected timedelta instead of {duration}") + if duration.total_seconds() < 0: + raise ValueError('Timeout must be non-negative.') + return int(duration.total_seconds() if duration else 0) + + +def to_microseconds(value: Union[timedelta, float, int]) -> int: + if value and not isinstance(value, (timedelta, float, int)): + raise ValueError(f"Excepted value to be of type Union[timedelta, float, int] instead of {value}") + if not value: + total_us = 0 + elif isinstance(value, timedelta): + if value.total_seconds() < 0: + raise ValueError('Timeout must be non-negative.') + total_us = int(value.total_seconds() * 1e6) + else: + if value < 0: + raise ValueError('Timeout must be non-negative.') + total_us = int(value * 1e6) + + return total_us + + +def validate_raw_dict(value: Dict[str, Any]) -> Dict[str, Any]: + if not isinstance(value, dict): + raise ValueError("Raw option must be of type Dict[str, Any].") + if not all(map(lambda k: isinstance(k, str), value.keys())): + raise ValueError("All keys in raw dict must be a str.") + return value + + +def validate_path(value: str) -> str: + if not isinstance(value, str): + raise ValueError("Path option must be str.") + if not path.exists(value): + raise FileNotFoundError("Provided path does not exist.") + + return value + + +class ValidateBaseClass(Generic[T]): + """ **INTERNAL** """ + + def __call__(self, value: Any) -> T: + expected_base_class = self.__orig_class__.__args__[0] # type: ignore[attr-defined] + # this will pass w/ duck-typing which is okay + if not issubclass(value.__class__, expected_base_class): + raise ValueError((f"Expected value to be subclass of {expected_base_class} " + "(or implement necessary functionality for the " + f"{expected_base_class} base class).")) + return value # type: ignore[no-any-return] + + +class EnumToStr(Generic[E]): + def __call__(self, value: Any) -> str: + expected_type = self.__orig_class__.__args__[0] # type: ignore[attr-defined] + + if isinstance(value, str): + if value in map(lambda x: x.value, expected_type): + # TODO: use warning -- maybe don't want to allow str representation? + return value + raise ValueError(f"Invalid str representation of {expected_type}. Received '{value}'.") + + if not isinstance(value, expected_type): + raise ValueError(f"Expected value to be of type {expected_type} instead of {type(value)}") + + return value.value # type: ignore[no-any-return] + + +class ValidateType(Generic[T]): + def __call__(self, value: Any) -> T: + expected_type = self.__orig_class__.__args__[0] # type: ignore[attr-defined] + if not isinstance(value, expected_type): + raise ValueError(f"Expected value to be of type {expected_type} instead of {type(value)}") + return value # type: ignore[no-any-return] + + +class ValidateList(Generic[T]): + def __call__(self, value: Any) -> List[T]: + expected_type = self.__orig_class__.__args__[0] # type: ignore[attr-defined] + if not isinstance(value, list): + raise ValueError("Expected value to be a list.") + if not all(map(lambda x: isinstance(x, expected_type), value)): + item_types = list(map(lambda x: type(x), value)) + raise ValueError(("Expected all items in list to be of type " + f"{expected_type}. Provided item types {item_types}.")) + # we are returning List[T] + return value + + +VALIDATE_BOOL = ValidateType[bool]() +VALIDATE_INT = ValidateType[int]() +VALIDATE_FLOAT = ValidateType[float]() +VALIDATE_STR = ValidateType[str]() +VALIDATE_DESERIALIZER = ValidateBaseClass[Deserializer]() +VALIDATE_STR_LIST = ValidateList[str]() \ No newline at end of file diff --git a/couchbase_analytics/common/credential.py b/couchbase_analytics/common/credential.py new file mode 100644 index 0000000..4fcc08c --- /dev/null +++ b/couchbase_analytics/common/credential.py @@ -0,0 +1,96 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from typing import Callable, Tuple + + +class Credential: + """Create a Credential instance. + + A Credential is required in order to connect to a Analytics endpoint. + + .. important:: + Use the the provided classmethods to create a :class:`.Credential` instance. + + """ + + def __init__(self, **kwargs: str) -> None: + username = kwargs.pop('username', None) + password = kwargs.pop('password', None) + + if username is None: + raise ValueError('Must provide a username.') + if not isinstance(username, str): + raise ValueError('The username must be a str.') + + if password is None: + raise ValueError('Must provide a password.') + if not isinstance(password, str): + raise ValueError('The password must be a str.') + + self._username = username + self._password = password + + def astuple(self) -> Tuple[bytes, bytes]: + """ + **INTERNAL** + """ + return self._username.encode(), self._password.encode() + + @classmethod + def from_username_and_password(cls, username: str, password: str) -> Credential: + """Create a :class:`.Credential` from a username and password. + + Args: + username: The username for the Analytics endpoint. + password: The password for the Analytics endpoint. + + Returns: + A Credential instance. + """ + return Credential(username=username, password=password) + + @classmethod + def from_callable(cls, callback: Callable[[], Credential]) -> Credential: + """Create a :class:`.Credential` from provided callback. + + The callback is + + Args: + callback: Callback that returns a :class:`.Credential`. + + Returns: + A Credential instance. + + Example: + Retrieve credentials from environment variables:: + + def _cred_from_env() -> Credential: + from os import getenv + return Credential.from_username_and_password(getenv('PYCBCC_USERNAME'), + getenv('PYCBCC_PW')) + + cred = Credential.from_callable(_cred_from_env) + + """ + return Credential(**callback().asdict()) + + def __repr__(self): + return f'Credential(username={self._username}, password=****)' + + def __str__(self): + return self.__repr__() diff --git a/couchbase_analytics/common/deserializer.py b/couchbase_analytics/common/deserializer.py new file mode 100644 index 0000000..cab9b72 --- /dev/null +++ b/couchbase_analytics/common/deserializer.py @@ -0,0 +1,69 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import json +from abc import ABC, abstractmethod +from typing import Any + + +class Deserializer(ABC): + """ + Interface a Custom Deserializer must implement + """ + + @abstractmethod + def deserialize(self, value: bytes) -> Any: + raise NotImplementedError + + @classmethod + def __subclasshook__(cls, subclass: type) -> bool: + return (hasattr(subclass, 'deserialize') and + callable(subclass.deserialize)) + + +class DefaultJsonDeserializer(Deserializer): + """ + Deserializer using the default Python json library. + """ + + def deserialize(self, value: bytes) -> Any: + """Decodes the received bytes into a utf-8 string and deserializes using Python's json library. + + Args: + value: The bytes to deserialize. + + Returns: + The deserialized Python object. + """ + return json.loads(value.decode('utf-8')) + + +class PassthroughDeserializer(Deserializer): + """ + Deserializer used in order to skip deserializing rows and simply pass the bytes along. + """ + + def deserialize(self, value: bytes) -> bytes: + """Needed to abide by the :class:`.Deserializer` abstract class. No deserializing is done. + + Args: + value: The bytes to passthrough. + + Returns: + The received bytes. + """ + return value diff --git a/couchbase_analytics/common/enums.py b/couchbase_analytics/common/enums.py new file mode 100644 index 0000000..c720155 --- /dev/null +++ b/couchbase_analytics/common/enums.py @@ -0,0 +1,38 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from enum import Enum + + +class QueryScanConsistency(Enum): + """ + Represents the various scan consistency options that are available. + """ + + NOT_BOUNDED = 'not_bounded' + REQUEST_PLUS = 'request_plus' + + +# This is unfortunate, but Enum is 'special' and this is one of the least invasive manners to document the members +QueryScanConsistency.NOT_BOUNDED.__doc__ = ('Indicates that no specific consistency is required, ' + 'this is the fastest options, but results may not include ' + 'the most recent operations which have been performed.') +QueryScanConsistency.REQUEST_PLUS.__doc__ = ('Indicates that the results to the query should include ' + 'all operations that have occurred up until the query was started. ' + 'This incurs a performance penalty of waiting for the index to catch ' + 'up to the most recent operations, but provides the highest level ' + 'of consistency.') diff --git a/couchbase_analytics/common/errors.py b/couchbase_analytics/common/errors.py new file mode 100644 index 0000000..f7a4553 --- /dev/null +++ b/couchbase_analytics/common/errors.py @@ -0,0 +1,173 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from typing import (Dict, + Optional, + Union, + cast) + +""" + +Error Classes + +""" + + +class AnalyticsError(Exception): + """ + Generic base error. Analytics specific errors inherit from this base error. + """ + + def __init__(self, cause: Optional[Exception] = None, message: Optional[str] = None) -> None: + self._cause = cause + self._message = message + super().__init__(message) + + def __repr__(self) -> str: + details: Dict[str, str] = {} + if self._cause is not None: + details['cause'] = self._base.__repr__() + + if self._message is not None and not self._message.isspace(): + details['message'] = self._message + + if details: + return f'{type(self).__name__}({details})' + return f'{type(self).__name__}()' + + def __str__(self) -> str: + return self.__repr__() + + +class InvalidCredentialError(AnalyticsError): + """ + Indicates that an error occurred authenticating the user to the cluster. + """ + + def __init__(self, context: str, cause: Optional[Exception] = None, message: Optional[str] = None) -> None: + super().__init__(cause=cause, message=message) + self._context = context + + def __repr__(self) -> str: + details: Dict[str, str] = { + 'context': self._context + } + if self._cause is not None: + details['cause'] = self._base.__repr__() + + if self._message is not None and not self._message.isspace(): + details['message'] = self._message + + return f'{type(self).__name__}({details})' + + def __str__(self) -> str: + return self.__repr__() + + +class QueryError(AnalyticsError): + """ + Indicates that an query request received an error from the Analytics server. + """ + + def __init__(self, + code: int, + server_message: str, + context: str, + message: Optional[str] = None) -> None: + super().__init__(message=message) + self._code = code + self._server_message = server_message + self._context = context + + @property + def code(self) -> int: + """ + Returns: + Error code from Analytics server + """ + return self._code + + @property + def server_message(self) -> str: + """ + Returns: + Error message from Analytics server + """ + return self._server_message + + def __repr__(self) -> str: + details: Dict[str, str] = { + 'code': str(self._code), + 'server_message': self._server_message, + 'context': self._context + } + return f"{type(self).__name__}({details})" + + def __str__(self) -> str: + return self.__repr__() + + +class TimeoutError(AnalyticsError): + """ + Indicates that a request was unable to complete prior to reaching the deadline specified for the reqest. + """ + + def __init__(self, base: Optional[Exception] = None, message: Optional[str] = None) -> None: + super().__init__(base, message) + + def __repr__(self) -> str: + return super().__repr__() + + def __str__(self) -> str: + return self.__repr__() + + +class FeatureUnavailableError(Exception): + """ + Raised when feature that is not available with the current server version is used. + """ + + def __repr__(self) -> str: + return f"{type(self).__name__}({super().__repr__()})" + + def __str__(self) -> str: + return self.__repr__() + + +class InternalSDKError(Exception): + """ + This means the SDK has done something wrong. Get support. + (this doesn't mean *you* didn't do anything wrong, it does mean you should not be seeing this message) + """ + + def __repr__(self) -> str: + return f"{type(self).__name__}({super().__repr__()})" + + def __str__(self) -> str: + return self.__repr__() + + +class QueryOperationCanceledError(Exception): + """ + **INTERNAL** + """ + + def __repr__(self) -> str: + return f"{type(self).__name__}({super().__repr__()})" + + def __str__(self) -> str: + return self.__repr__() diff --git a/couchbase_analytics/common/options.py b/couchbase_analytics/common/options.py new file mode 100644 index 0000000..b2d352a --- /dev/null +++ b/couchbase_analytics/common/options.py @@ -0,0 +1,168 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +from __future__ import annotations + +import sys +from typing import List, Union + +if sys.version_info < (3, 10): + from typing_extensions import TypeAlias +else: + from typing import TypeAlias + +from couchbase_analytics.common.options_base import ClusterOptionsBase +from couchbase_analytics.common.options_base import ClusterOptionsKwargs as ClusterOptionsKwargs # noqa: F401 +from couchbase_analytics.common.options_base import QueryOptionsBase +from couchbase_analytics.common.options_base import QueryOptionsKwargs as QueryOptionsKwargs # noqa: F401 +from couchbase_analytics.common.options_base import SecurityOptionsBase +from couchbase_analytics.common.options_base import SecurityOptionsKwargs as SecurityOptionsKwargs # noqa: F401 +from couchbase_analytics.common.options_base import TimeoutOptionsBase +from couchbase_analytics.common.options_base import TimeoutOptionsKwargs as TimeoutOptionsKwargs # noqa: F401 + +""" + Python SDK Cluster Options Classes +""" + + +class ClusterOptions(ClusterOptionsBase): + """Available options to set when creating a cluster. + + Cluster options enable the configuration of various global cluster settings. + Some options can be set globally for the cluster, but overridden for specific operations (i.e. :class:`.TimeoutOptions`). + + .. note:: + Options and methods marked **VOLATILE** are subject to change at any time. + + Args: + deserializer (Optional[Deserializer]): Set to configure global serializer to translate JSON to Python objects. Defaults to `None` (:class:`~couchbase_analytics.deserializer.DefaultJsonDeserializer`). + security_options (Optional[:class:`.SecurityOptions`]): Security options for SDK connection. + timeout_options (Optional[:class:`.TimeoutOptions`]): Timeout options for various SDK operations. See :class:`.TimeoutOptions` for details. + """ # noqa: E501 + + +class SecurityOptions(SecurityOptionsBase): + """Available security options to set when creating a cluster. + + All options are optional and not required to be specified. By default the SDK will trust only the Capella CA certificate(s). + Only a single option related to which certificate(s) the SDK should trust can be used. + The `disable_server_certificate_verification` option can either be enabled or disabled for any of the specified trust settings. + + Args: + trust_only_capella (Optional[bool]): If enabled, SDK will trust only the Capella CA certificate(s). Defaults to `True` (enabled). + trust_only_pem_file (Optional[str]): If set, SDK will trust only the PEM-encoded certificate(s) at the specified file path. Defaults to `None`. + trust_only_pem_str (Optional[str]): If set, SDK will trust only the PEM-encoded certificate(s) in the specified str. Defaults to `None`. + trust_only_certificates (Optional[List[str]]): If set, SDK will trust only the PEM-encoded certificate(s) specified. Defaults to `None`. + disable_server_certificate_verification (Optional[bool]): If disabled, SDK will trust any certificate regardless of validity. + Should not be disabled in production environments. Defaults to `True` (enabled). + """ # noqa: E501 + + @classmethod + def trust_only_capella(cls) -> SecurityOptions: + """ + Convenience method that returns `SecurityOptions` instance with `trust_only_capella=True`. + + Returns: + :class:`~couchbase_analytics.common.options.SecurityOptions` + """ + return cls(trust_only_capella=True) + + @classmethod + def trust_only_pem_file(cls, pem_file: str) -> SecurityOptions: + """ + Convenience method that returns `SecurityOptions` instance with `trust_only_pem_file` set to provided certificate(s) path. + + Args: + pem_file (str): Path to PEM-encoded certificate(s) the SDK should trust. + + Returns: + :class:`~couchbase_analytics.common.options.SecurityOptions` + """ # noqa: E501 + return cls(trust_only_capella=False, trust_only_pem_file=pem_file) + + @classmethod + def trust_only_pem_str(cls, pem_str: str) -> SecurityOptions: + """ + Convenience method that returns `SecurityOptions` instance with `trust_only_pem_str` set to provided certificate(s) str. + + Args: + pem_str (str): PEM-encoded certificate(s) the SDK should trust. + + Returns: + :class:`~couchbase_analytics.common.options.SecurityOptions` + """ # noqa: E501 + return cls(trust_only_capella=False, trust_only_pem_str=pem_str) + + @classmethod + def trust_only_certificates(cls, certificates: List[str]) -> SecurityOptions: + """ + Convenience method that returns `SecurityOptions` instance with `trust_only_certificates` set to provided certificates. + + Args: + trust_only_certificates (List[str]): List of PEM-encoded certificate(s) the SDK should trust. + + Returns: + :class:`~couchbase_analytics.common.options.SecurityOptions` + """ # noqa: E501 + return cls(trust_only_capella=False, trust_only_certificates=certificates) + + +class TimeoutOptions(TimeoutOptionsBase): + """Available timeout options to set when creating a cluster. + + These options set the default timeouts for operations for the cluster. Some operations allow the timeout to be overridden on a per operation basis. + All options are optional and default to `None`. + + .. note:: + Options marked **VOLATILE** are subject to change at any time. + + Args: + connect_timeout (Optional[timedelta]): Set to configure the period of time allowed to complete bootstrap connection. Defaults to `None` (10s). + dispatch_timeout (Optional[timedelta]): Set to configure the period of time allowed to complete HTTP connection prior to sending request. Defaults to `None` (30s). + query_timeout (Optional[timedelta]): Set to configure the period of time allowed for query operations. Defaults to `None` (10m). + """ # noqa: E501 + + +class QueryOptions(QueryOptionsBase): + """Available options for Analytics query operation. + + Timeout will default to cluster setting if not set for the operation. + + .. note:: + Options marked **VOLATILE** are subject to change at any time. + + Args: + client_context_id (Optional[str]): Set to configure a unique identifier for this query request. Defaults to `None` (autogenerated by client). + deserializer (Optional[Deserializer]): Specifies a :class:`~couchbase_analytics.deserializer.Deserializer` to apply to results. Defaults to `None` (:class:`~couchbase_analytics.deserializer.DefaultJsonDeserializer`). + lazy_execute (Optional[bool]): **VOLATILE** If enabled, the query will not execute until the application begins to iterate over results. Defaulst to `None` (disabled). + named_parameters (Optional[Dict[str, :py:type:`~couchbase_analytics.JSONType`]]): Values to use for positional placeholders in query. + positional_parameters (Optional[List[:py:type:`~couchbase_analytics.JSONType`]]):, optional): Values to use for named placeholders in query. + priority (Optional[bool]): Indicates whether this query should be executed with a specific priority level. + query_context (Optional[str]): Specifies the context within which this query should be executed. + raw (Optional[Dict[str, Any]]): Specifies any additional parameters which should be passed to the Analytics engine when executing the query. + read_only (Optional[bool]): Specifies that this query should be executed in read-only mode, disabling the ability for the query to make any changes to the data. + scan_consistency (Optional[QueryScanConsistency]): Specifies the consistency requirements when executing the query. + timeout (Optional[timedelta]): Set to configure allowed time for operation to complete. Defaults to `None` (75s). + stream_config (Optional[JsonStreamConfig]): **VOLATILE** Configuration for JSON stream processing. Defaults to `None` (default configuration). See :class:`~couchbase_analytics.common.json_parsing.JsonStreamConfig` for details. + """ # noqa: E501 + + +OptionsClass: TypeAlias = Union[ + ClusterOptions, + SecurityOptions, + TimeoutOptions, + QueryOptions, +] diff --git a/couchbase_analytics/common/options_base.py b/couchbase_analytics/common/options_base.py new file mode 100644 index 0000000..1c31b56 --- /dev/null +++ b/couchbase_analytics/common/options_base.py @@ -0,0 +1,192 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +from __future__ import annotations + +import sys +from datetime import timedelta +from typing import (Any, + Dict, + Iterable, + List, + Literal, + Optional, + TypedDict) + +if sys.version_info < (3, 10): + from typing_extensions import TypeAlias, Unpack +else: + if sys.version_info < (3, 11): + from typing import TypeAlias + + from typing_extensions import Unpack + else: + from typing import TypeAlias, Unpack + +from couchbase_analytics.common import JSONType +from couchbase_analytics.common.core import JsonStreamConfig +from couchbase_analytics.common.deserializer import Deserializer +from couchbase_analytics.common.enums import QueryScanConsistency + +""" + Python Analytics SDK Cluster Options Classes +""" + + +class ClusterOptionsKwargs(TypedDict, total=False): + deserializer: Optional[Deserializer] + security_options: Optional[SecurityOptionsBase] + timeout_options: Optional[TimeoutOptionsBase] + + +ClusterOptionsValidKeys: TypeAlias = Literal[ + 'deserializer', + 'security_options', + 'timeout_options', +] + + +class ClusterOptionsBase(Dict[str, Any]): + """ + **INTERNAL** + """ + + VALID_OPTION_KEYS: List[ClusterOptionsValidKeys] = [ + 'deserializer', + 'security_options', + 'timeout_options', + ] + + def __init__(self, **kwargs: Unpack[ClusterOptionsKwargs]) -> None: + filtered_kwargs = {k: v for k, v in kwargs.items() if v is not None} + super().__init__(**filtered_kwargs) + + +class SecurityOptionsKwargs(TypedDict, total=False): + trust_only_capella: Optional[bool] + trust_only_pem_file: Optional[str] + trust_only_pem_str: Optional[str] + trust_only_certificates: Optional[List[str]] + disable_server_certificate_verification: Optional[bool] + + +SecurityOptionsValidKeys: TypeAlias = Literal[ + 'trust_only_capella', + 'trust_only_pem_file', + 'trust_only_pem_str', + 'trust_only_certificates', + 'disable_server_certificate_verification', +] + + +class SecurityOptionsBase(Dict[str, object]): + """ + **INTERNAL** + """ + + VALID_OPTION_KEYS: List[SecurityOptionsValidKeys] = [ + 'trust_only_capella', + 'trust_only_pem_file', + 'trust_only_pem_str', + 'trust_only_certificates', + 'disable_server_certificate_verification', + ] + + def __init__(self, **kwargs: Unpack[SecurityOptionsKwargs]) -> None: + filtered_kwargs = {k: v for k, v in kwargs.items() if v is not None} + super().__init__(**filtered_kwargs) + + +class TimeoutOptionsKwargs(TypedDict, total=False): + connect_timeout: Optional[timedelta] + dispatch_timeout: Optional[timedelta] + query_timeout: Optional[timedelta] + + +TimeoutOptionsValidKeys: TypeAlias = Literal[ + 'connect_timeout', + 'dispatch_timeout', + 'query_timeout', +] + + +class TimeoutOptionsBase(Dict[str, object]): + """ + **INTERNAL** + """ + + VALID_OPTION_KEYS: List[TimeoutOptionsValidKeys] = [ + 'connect_timeout', + 'dispatch_timeout', + 'query_timeout', + ] + + def __init__(self, **kwargs: Unpack[TimeoutOptionsKwargs]) -> None: + filtered_kwargs = {k: v for k, v in kwargs.items() if v is not None} + super().__init__(**filtered_kwargs) + + +class QueryOptionsKwargs(TypedDict, total=False): + client_context_id: Optional[str] + deserializer: Optional[Deserializer] + lazy_execute: Optional[bool] + named_parameters: Optional[Dict[str, JSONType]] + positional_parameters: Optional[Iterable[JSONType]] + priority: Optional[bool] + query_context: Optional[str] + raw: Optional[Dict[str, Any]] + read_only: Optional[bool] + scan_consistency: Optional[QueryScanConsistency] + stream_config: Optional[JsonStreamConfig] + timeout: Optional[timedelta] + + +QueryOptionsValidKeys: TypeAlias = Literal[ + 'client_context_id', + 'deserializer', + 'lazy_execute', + 'named_parameters', + 'positional_parameters', + 'priority', + 'query_context', + 'raw', + 'read_only', + 'scan_consistency', + 'stream_config', + 'timeout', +] + + +class QueryOptionsBase(Dict[str, object]): + + VALID_OPTION_KEYS: List[QueryOptionsValidKeys] = [ + 'client_context_id', + 'deserializer', + 'lazy_execute', + 'named_parameters', + 'positional_parameters', + 'priority', + 'query_context', + 'raw', + 'read_only', + 'scan_consistency', + 'stream_config', + 'timeout', + ] + + def __init__(self, **kwargs: Unpack[QueryOptionsKwargs]) -> None: + filtered_kwargs = {k: v for k, v in kwargs.items() if v is not None} + super().__init__(**filtered_kwargs) diff --git a/couchbase_analytics/common/query.py b/couchbase_analytics/common/query.py new file mode 100644 index 0000000..fe67952 --- /dev/null +++ b/couchbase_analytics/common/query.py @@ -0,0 +1,126 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from datetime import timedelta +from typing import List, Optional + +from couchbase_analytics.common.core.query import (QueryMetadataCore, + QueryMetricsCore, + QueryWarningCore) + +class QueryWarning: + def __init__(self, raw: QueryWarningCore) -> None: + self._raw = raw + + def code(self) -> int: + """ + Returns: + The query warning code. + """ + return self._raw['code'] + + def message(self) -> str: + """ + Returns: + The query warning message. + """ + return self._raw['message'] + + def __repr__(self) -> str: + return "QueryWarning:{}".format(self._raw) + + +class QueryMetrics: + def __init__(self, raw: QueryMetricsCore) -> None: + self._raw = raw + + def elapsed_time(self) -> timedelta: + """Get the total amount of time spent running the query. + + Returns: + The total amount of time spent running the query. + """ + us = (self._raw.get('elapsed_time') or 0) / 1000 + return timedelta(microseconds=us) + + def execution_time(self) -> timedelta: + """Get the total amount of time spent executing the query. + + Returns: + The total amount of time spent executing the query. + """ + us = (self._raw.get('execution_time') or 0) / 1000 + return timedelta(microseconds=us) + + def result_count(self) -> int: + """Get the total number of rows which were part of the result set. + + Returns: + The total number of rows which were part of the result set. + """ + return self._raw.get('result_count') or 0 + + def result_size(self) -> int: + """Get the total number of bytes which were generated as part of the result set. + + Returns: + The total number of bytes which were generated as part of the result set. + """ # noqa: E501 + return self._raw.get('result_size') or 0 + + def processed_objects(self) -> int: + """Get the total number of objects that were processed to create the result set. + + Returns: + The total number of objects that were processed to create the result set. + """ + return self._raw.get('processed_objects') or 0 + + def __repr__(self) -> str: + return "QueryMetrics:{}".format(self._raw) + + +class QueryMetadata: + def __init__(self, raw: Optional[QueryMetadataCore]) -> None: + self._raw = raw if raw is not None else {} + + def request_id(self) -> str: + """Get the request ID which is associated with the executed query. + + Returns: + The request ID which is associated with the executed query. + """ + return self._raw['request_id'] + + def warnings(self) -> List[QueryWarning]: + """Get warnings that occurred during the execution of the query. + + Returns: + Any warnings that occurred during the execution of the query. + """ + return list(map(QueryWarning, self._raw['warnings'])) + + def metrics(self) -> QueryMetrics: + """Get the various metrics which are made available by the query engine. + + Returns: + A :class:`~couchbase_analytics.query.QueryMetrics` instance. + """ + return QueryMetrics(self._raw['metrics']) + + def __repr__(self) -> str: + return "QueryMetadata:{}".format(self._raw) diff --git a/couchbase_analytics/common/result.py b/couchbase_analytics/common/result.py new file mode 100644 index 0000000..c9c4ebc --- /dev/null +++ b/couchbase_analytics/common/result.py @@ -0,0 +1,139 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from typing import (Any, + List, + Optional, + TYPE_CHECKING) + +from couchbase_analytics.common.core.result import QueryResult as QueryResult +from couchbase_analytics.common.query import QueryMetadata +from couchbase_analytics.common.streaming import (AsyncIterator, + BlockingIterator) + +if TYPE_CHECKING: + from acouchbase_analytics.protocol.streaming import AsyncHttpStreamingResponse + from couchbase_analytics.protocol.streaming import HttpStreamingResponse + + +class BlockingQueryResult(QueryResult): + def __init__(self, http_response: HttpStreamingResponse, lazy_execute: Optional[bool] = None) -> None: + self._http_response = http_response + self._lazy_execute = lazy_execute + + def cancel(self) -> None: + """Cancel streaming the query results. + + **VOLATILE** This API is subject to change at any time. + """ + self._http_response.cancel() + + def get_all_rows(self) -> List[Any]: + """Convenience method to load all query results into memory. + + Returns: + A list of query results. + + Example: + Read all rows from simple query:: + + q_str = 'SELECT * FROM `travel-sample`.inventory WHERE country LIKE 'United%' LIMIT 2;' + q_rows = cluster.execute_query(q_str).all_rows() + + """ + return BlockingIterator(self._http_response).get_all_rows() + + def metadata(self) -> QueryMetadata: + """Get the query metadata. + + Returns: + A QueryMetadata instance (if available). + + Raises: + RuntimeError: When the metadata is not available. Metadata is only available once all rows have been iterated. + """ # noqa: E501 + return self._http_response.get_metadata() + + def rows(self) -> BlockingIterator: + """Retrieve the rows which have been returned by the query. + + Returns: + A blocking iterator for iterating over query results. + """ + return BlockingIterator(self._http_response) + + def __iter__(self) -> BlockingIterator: + return iter(BlockingIterator(self._http_response)) + + def __repr__(self) -> str: + return "BlockingQueryResult()" + + +class AsyncQueryResult(QueryResult): + def __init__(self, http_response: AsyncHttpStreamingResponse) -> None: + self._http_response = http_response + + async def cancel(self) -> None: + """Cancel streaming the query results. + + **VOLATILE** This API is subject to change at any time. + """ + await self._http_response.cancel() + + async def get_all_rows(self) -> List[Any]: + """Convenience method to load all query results into memory. + + Returns: + A list of query results. + + Example: + + Read all rows from simple query:: + + q_str = 'SELECT * FROM `travel-sample`.inventory WHERE country LIKE 'United%' LIMIT 2;' + q_rows = await cluster.execute_query(q_str).all_rows() + + """ + return await AsyncIterator(self._http_response).get_all_rows() + + def metadata(self) -> QueryMetadata: + """The meta-data which has been returned by the query. + + Returns: + A QueryMetadata instance (if available). + + Raises: + RuntimeError: When the metadata is not available. Metadata is only available once all rows have been iterated. + """ # noqa: E501 + return self._http_response.get_metadata() + + def rows(self) -> AsyncIterator: + """Retrieve the rows which have been returned by the query. + + .. note:: + Bee sure to use ``async for`` when looping over rows. + + Returns: + An async iterator for iterating over query results. + """ + return AsyncIterator(self._http_response) + + def __aiter__(self) -> AsyncIterator: + return AsyncIterator(self._http_response).__aiter__() + + def __repr__(self) -> str: + return "AsyncQueryResult()" diff --git a/couchbase_analytics/common/streaming.py b/couchbase_analytics/common/streaming.py new file mode 100644 index 0000000..1d2a190 --- /dev/null +++ b/couchbase_analytics/common/streaming.py @@ -0,0 +1,159 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from collections.abc import AsyncIterator as PyAsyncIterator +from collections.abc import Iterator +from enum import IntEnum + +from typing import (Any, + List, + NamedTuple, + TYPE_CHECKING) + +from couchbase_analytics.common.errors import AnalyticsError, InternalSDKError + +if TYPE_CHECKING: + from acouchbase_analytics.protocol.streaming import AsyncHttpStreamingResponse + from couchbase_analytics.protocol.streaming import HttpStreamingResponse + + +class StreamingState(IntEnum): + """ + **INTERNAL + """ + NotStarted = 0 + Started = 1 + Cancelled = 2 + Completed = 3 + StreamingResults = 4 + Error = 5 + Timeout = 6 + SyncCancelledPriorToTimeout = 7 + + @staticmethod + def okay_to_stream(state: StreamingState) -> bool: + """ + **INTERNAL + """ + return state == StreamingState.NotStarted + + @staticmethod + def okay_to_iterate(state: StreamingState) -> bool: + """ + **INTERNAL + """ + return state == StreamingState.StreamingResults + + @staticmethod + def is_okay(state: StreamingState) -> bool: + """ + **INTERNAL + """ + return state not in [StreamingState.Cancelled, + StreamingState.Error, + StreamingState.Timeout] + + +class BlockingIterator(Iterator[Any]): + """ + **INTERNAL + """ + + def __init__(self, http_response: HttpStreamingResponse) -> None: + self._http_response = http_response + + def get_all_rows(self) -> List[Any]: + """ + **INTERNAL + """ + return [r for r in list(self)] + + def __iter__(self) -> BlockingIterator: + """ + **INTERNAL + """ + if self._http_response.lazy_execute is True: + self._http_response.send_request() + + return self + + def __next__(self) -> Any: + """ + **INTERNAL + """ + try: + return self._http_response.get_next_row() + except StopIteration: + # TODO: get metadata automatically? + # self._executor.set_metadata() + raise + except AnalyticsError as err: + raise err + except Exception as ex: + raise InternalSDKError(str(ex)) + +class AsyncIterator(PyAsyncIterator[Any]): + """ + **INTERNAL + """ + + def __init__(self, http_response: AsyncHttpStreamingResponse) -> None: + self._http_response = http_response + + async def get_all_rows(self) -> List[Any]: + """ + **INTERNAL + """ + return [r async for r in self] + + def __aiter__(self) -> AsyncIterator: + """ + **INTERNAL + """ + return self + + async def __anext__(self) -> Any: + """ + **INTERNAL + """ + try: + return await self._http_response.get_next_row() + except StopAsyncIteration: + raise + except AnalyticsError as err: + raise err + except Exception as ex: + raise InternalSDKError(str(ex)) + +class HttpResponseType(IntEnum): + """ + **INTERNAL** + """ + ROW = 0 + ERROR = 1 + END = 2 + +class ParsedResult(NamedTuple): + """ + **INTERNAL** + """ + result: str + result_type: HttpResponseType + + + + diff --git a/couchbase_analytics/credential.py b/couchbase_analytics/credential.py new file mode 100644 index 0000000..c3aa770 --- /dev/null +++ b/couchbase_analytics/credential.py @@ -0,0 +1,16 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from couchbase_analytics.common.credential import Credential as Credential # noqa: F401 diff --git a/couchbase_analytics/database.py b/couchbase_analytics/database.py new file mode 100644 index 0000000..71086e4 --- /dev/null +++ b/couchbase_analytics/database.py @@ -0,0 +1,58 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from couchbase_analytics.scope import Scope + +if TYPE_CHECKING: + from couchbase_analytics.protocol.cluster import Cluster + + +class Database: + """Create a Database instance. + + The database instance exposes the operations which are available to be performed against an Analytics database. + + Args: + cluster (:class:`~couchbase_analytics.cluster.Cluster`): A :class:`~couchbase_analytics.cluster.Cluster` instance. + database_name (str): The database name. + + """ # noqa: E501 + + def __init__(self, cluster: Cluster, database_name: str) -> None: + from couchbase_analytics.protocol.database import Database as _Database + self._impl = _Database(cluster, database_name) + + @property + def name(self) -> str: + """ + str: The name of this :class:`~couchbase_analytics.database.Database` instance. + """ + return self._impl.name + + def scope(self, scope_name: str) -> Scope: + """Creates a :class:`~couchbase_analytics.scope.Scope` instance. + + Args: + scope_name (str): Name of the scope. + + Returns: + :class:`~couchbase_analytics.scope.Scope` + + """ + return Scope(self._impl, scope_name) diff --git a/couchbase_analytics/database.pyi b/couchbase_analytics/database.pyi new file mode 100644 index 0000000..3acd867 --- /dev/null +++ b/couchbase_analytics/database.pyi @@ -0,0 +1,25 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from couchbase_analytics.protocol.cluster import Cluster +from couchbase_analytics.scope import Scope + +class Database: + def __init__(self, cluster: Cluster, database_name: str) -> None: ... + + @property + def name(self) -> str: ... + + def scope(self, scope_name: str) -> Scope: ... diff --git a/couchbase_analytics/deserializer.py b/couchbase_analytics/deserializer.py new file mode 100644 index 0000000..d5aed73 --- /dev/null +++ b/couchbase_analytics/deserializer.py @@ -0,0 +1,18 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from couchbase_analytics.common.deserializer import DefaultJsonDeserializer as DefaultJsonDeserializer # noqa: F401 +from couchbase_analytics.common.deserializer import Deserializer as Deserializer # noqa: F401 +from couchbase_analytics.common.deserializer import PassthroughDeserializer as PassthroughDeserializer # noqa: F401 diff --git a/couchbase_analytics/errors.py b/couchbase_analytics/errors.py new file mode 100644 index 0000000..3b18f30 --- /dev/null +++ b/couchbase_analytics/errors.py @@ -0,0 +1,20 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from couchbase_analytics.common.errors import AnalyticsError as AnalyticsError # noqa: F401 +from couchbase_analytics.common.errors import InternalSDKError as InternalSDKError # noqa: F401 +from couchbase_analytics.common.errors import InvalidCredentialError as InvalidCredentialError # noqa: F401 +from couchbase_analytics.common.errors import QueryError as QueryError # noqa: F401 +from couchbase_analytics.common.errors import TimeoutError as TimeoutError # noqa: F401 diff --git a/couchbase_analytics/options.py b/couchbase_analytics/options.py new file mode 100644 index 0000000..ef2074d --- /dev/null +++ b/couchbase_analytics/options.py @@ -0,0 +1,23 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from couchbase_analytics.common.options import ClusterOptions as ClusterOptions # noqa: F401 +from couchbase_analytics.common.options import ClusterOptionsKwargs as ClusterOptionsKwargs # noqa: F401 +from couchbase_analytics.common.options import QueryOptions as QueryOptions # noqa: F401 +from couchbase_analytics.common.options import QueryOptionsKwargs as QueryOptionsKwargs # noqa: F401 +from couchbase_analytics.common.options import SecurityOptions as SecurityOptions # noqa: F401 +from couchbase_analytics.common.options import SecurityOptionsKwargs as SecurityOptionsKwargs # noqa: F401 +from couchbase_analytics.common.options import TimeoutOptions as TimeoutOptions # noqa: F401 +from couchbase_analytics.common.options import TimeoutOptionsKwargs as TimeoutOptionsKwargs # noqa: F401 diff --git a/couchbase_analytics/protocol/__init__.py b/couchbase_analytics/protocol/__init__.py new file mode 100644 index 0000000..cb45b7a --- /dev/null +++ b/couchbase_analytics/protocol/__init__.py @@ -0,0 +1,80 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# TODO: versioning +import sys + +try: + from couchbase_analytics._version import __version__ +except ImportError: + __version__ = '0.0.0-could-not-find-version' + +PYCBAC_VERSION = f'pycbac/{__version__}' + +try: + python_version_info = sys.version.split(' ') + if len(python_version_info) > 0: + PYCBAC_VERSION = f'{PYCBAC_VERSION} (python/{python_version_info[0]})' +except Exception: # nosec + pass + +""" + +pycbac teardown methods + +""" +# import atexit # nopep8 # isort:skip # noqa: E402 + + +# def _pycbac_teardown(**kwargs: object) -> None: +# """**INTERNAL**""" +# global _PYCBAC_LOGGER +# if _PYCBAC_LOGGER: +# # TODO: see about synchronizing the logger's shutdown here +# _PYCBAC_LOGGER = None # type: ignore + + +# atexit.register(_pycbac_teardown) + + +""" + +Logging methods + +""" +# TODO: logging + +# def configure_console_logger() -> None: +# import os +# log_level = os.getenv('PYCBAC_LOG_LEVEL', None) +# if log_level: +# _PYCBAC_LOGGER.create_console_logger(log_level.lower()) +# logger = logging.getLogger() +# logger.info(f'Python Couchbase Analytics Client ({PYCBAC_VERSION})') +# logging.getLogger().debug(get_metadata(as_str=True)) + + +# def configure_logging(name: str, +# level: Optional[int] = logging.INFO, +# parent_logger: Optional[logging.Logger] = None) -> None: +# if parent_logger: +# name = f'{parent_logger.name}.{name}' +# logger = logging.getLogger(name) +# _PYCBAC_LOGGER.configure_logging_sink(logger, level) +# logger.info(f'Python Couchbase Analytics Client ({PYCBAC_VERSION})') +# logger.debug(get_metadata(as_str=True)) + + +# configure_console_logger() diff --git a/couchbase_analytics/protocol/cluster.py b/couchbase_analytics/protocol/cluster.py new file mode 100644 index 0000000..031646e --- /dev/null +++ b/couchbase_analytics/protocol/cluster.py @@ -0,0 +1,157 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import atexit +from concurrent.futures import Future, ThreadPoolExecutor +from typing import (TYPE_CHECKING, + Optional, + Union) +from uuid import uuid4 + +from couchbase_analytics.common.result import BlockingQueryResult +from couchbase_analytics.protocol.core._request_context import RequestContext +from couchbase_analytics.protocol.core.client_adapter import _ClientAdapter +from couchbase_analytics.protocol.core.request import _RequestBuilder +from couchbase_analytics.protocol.streaming import HttpStreamingResponse + + +if TYPE_CHECKING: + from couchbase_analytics.common.credential import Credential + from couchbase_analytics.options import ClusterOptions + + +class Cluster: + + def __init__(self, + http_endpoint: str, + credential: Credential, + options: Optional[ClusterOptions] = None, + **kwargs: object) -> None: + + self._client_adapter = _ClientAdapter(http_endpoint, credential, options, **kwargs) + self._request_builder = _RequestBuilder(self._client_adapter) + self._cluster_id = str(uuid4()) + self._create_client() + # TODO: make a custom ThreadPoolExecutor, so that we can override submit and have a way to get + # a "plain" future as the docs say we should create a future via an executor + # The RequestContext generates a future that enables some background processing + # Allow the default max_workers which is (as of Python 3.8): min(32, os.cpu_count() + 4). + # We can add an option later if we see a need + self._tp_executor = ThreadPoolExecutor() + self._tp_executor_shutdown_called = False + atexit.register(self._shutdown_executor) + + @property + def client_adapter(self) -> _ClientAdapter: + """ + **INTERNAL** + """ + return self._client_adapter + + @property + def cluster_id(self) -> str: + """ + **INTERNAL** + """ + return self._cluster_id + + @property + def has_client(self) -> bool: + """ + bool: Indicator on if the cluster HTTP client has been created or not. + """ + return self._client_adapter.has_client + + @property + def threadpool_executor(self) -> ThreadPoolExecutor: + """ + **INTERNAL** + """ + return self._tp_executor + + def _shutdown(self) -> None: + """ + **INTERNAL** + """ + self._client_adapter.close_client() + self._client_adapter.reset_client() + if self._tp_executor_shutdown_called is False: + self._tp_executor.shutdown() + + def _create_client(self) -> None: + """ + **INTERNAL** + """ + self._client_adapter.create_client() + + def _shutdown_executor(self) -> None: + if self._tp_executor_shutdown_called is False: + self._tp_executor.shutdown() + self._tp_executor_shutdown_called = True + + def shutdown(self) -> None: + """Shuts down this cluster instance. Cleaning up all resources associated with it. + + .. warning:: + Use of this method is almost *always* unnecessary. Cluster resources should be cleaned + up once the cluster instance falls out of scope. However, in some applications tuning resources + is necessary and in those types of applications, this method might be beneficial. + + """ + if self.has_client: + self._shutdown() + else: + # TODO: log warning and/or exception? + print('Cluster does not have a connection. Ignoring') + + def execute_query(self, + statement: str, + *args: object, + **kwargs: object) -> Union[BlockingQueryResult, Future[BlockingQueryResult]]: + from threading import get_ident + base_req = self._request_builder.build_base_query_request(statement, *args, **kwargs) + lazy_execute = base_req.options.pop('lazy_execute', None) + stream_config = base_req.options.pop('stream_config', None) + request_context = RequestContext(self.client_adapter, + base_req, + self.threadpool_executor) + resp = HttpStreamingResponse(request_context, + lazy_execute=lazy_execute, + stream_config=stream_config) + + def _execute_query(http_response: HttpStreamingResponse) -> BlockingQueryResult: + http_response.send_request() + return BlockingQueryResult(http_response) + + if request_context.cancel_enabled is True: + if lazy_execute is True: + raise RuntimeError(('Cannot cancel, via cancel token, a query that is executed lazily.' + ' Queries executed lazily can be cancelled only after iteration begins.')) + + return request_context.send_request_in_background(_execute_query, resp) + else: + if lazy_execute is not True: + resp.send_request() + return BlockingQueryResult(resp) + + @classmethod + def create_instance(cls, + http_endpoint: str, + credential: Credential, + options: Optional[ClusterOptions], + **kwargs: object) -> Cluster: + return cls(http_endpoint, credential, options, **kwargs) diff --git a/couchbase_analytics/protocol/cluster.pyi b/couchbase_analytics/protocol/cluster.pyi new file mode 100644 index 0000000..4e3b4a5 --- /dev/null +++ b/couchbase_analytics/protocol/cluster.pyi @@ -0,0 +1,209 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import sys +from concurrent.futures import Future, ThreadPoolExecutor +from typing import overload + +if sys.version_info < (3, 11): + from typing_extensions import Unpack +else: + from typing import Unpack + +from couchbase_analytics import JSONType +from couchbase_analytics.common.credential import Credential +from couchbase_analytics.common.result import BlockingQueryResult +from couchbase_analytics.options import (ClusterOptions, + ClusterOptionsKwargs, + QueryOptions, + QueryOptionsKwargs) +from couchbase_analytics.protocol.core.client_adapter import _ClientAdapter + +class Cluster: + @overload + def __init__(self, http_endpoint: str, credential: Credential) -> None: ... + + @overload + def __init__(self, + http_endpoint: str, + credential: Credential, + options: ClusterOptions) -> None: ... + + @overload + def __init__(self, + http_endpoint: str, + credential: Credential, + **kwargs: Unpack[ClusterOptionsKwargs]) -> None: ... + + @overload + def __init__(self, + http_endpoint: str, + credential: Credential, + options: ClusterOptions, + **kwargs: Unpack[ClusterOptionsKwargs]) -> None: ... + + @property + def client_adapter(self) -> _ClientAdapter: ... + + @property + def connected(self) -> bool: ... + + @property + def threadpool_executor(self) -> ThreadPoolExecutor: ... + + @overload + def execute_query(self, statement: str) -> BlockingQueryResult: ... + + @overload + def execute_query(self, + statement: str, + options: QueryOptions) -> BlockingQueryResult: ... + + @overload + def execute_query(self, + statement: str, + **kwargs: Unpack[QueryOptionsKwargs]) -> BlockingQueryResult: ... + + @overload + def execute_query(self, + statement: str, + options: QueryOptions, + **kwargs: Unpack[QueryOptionsKwargs] + ) -> BlockingQueryResult: ... + + @overload + def execute_query(self, + statement: str, + options: QueryOptions, + *args: JSONType, + **kwargs: Unpack[QueryOptionsKwargs]) -> BlockingQueryResult: ... + + @overload + def execute_query(self, + statement: str, + options: QueryOptions, + *args: JSONType, + **kwargs: str) -> BlockingQueryResult: ... + + @overload + def execute_query(self, + statement: str, + *args: JSONType, + **kwargs: str) -> BlockingQueryResult: ... + + @overload + def execute_query(self, + statement: str, + enable_cancel: bool) -> Future[BlockingQueryResult]: ... + + @overload + def execute_query(self, + statement: str, + enable_cancel: bool, + *args: JSONType) -> Future[BlockingQueryResult]: ... + + @overload + def execute_query(self, + statement: str, + options: QueryOptions, + enable_cancel: bool) -> Future[BlockingQueryResult]: ... + + @overload + def execute_query(self, + statement: str, + enable_cancel: bool, + **kwargs: Unpack[QueryOptionsKwargs]) -> Future[BlockingQueryResult]: ... + + @overload + def execute_query(self, + statement: str, + options: QueryOptions, + enable_cancel: bool, + **kwargs: Unpack[QueryOptionsKwargs]) -> Future[BlockingQueryResult]: ... + + @overload + def execute_query(self, + statement: str, + options: QueryOptions, + enable_cancel: bool, + *args: JSONType, + **kwargs: Unpack[QueryOptionsKwargs]) -> Future[BlockingQueryResult]: ... + + @overload + def execute_query(self, + statement: str, + options: QueryOptions, + *args: JSONType, + enable_cancel: bool, + **kwargs: Unpack[QueryOptionsKwargs]) -> Future[BlockingQueryResult]: ... + + + @overload + def execute_query(self, + statement: str, + options: QueryOptions, + enable_cancel: bool, + *args: JSONType, + **kwargs: str) -> Future[BlockingQueryResult]: ... + + @overload + def execute_query(self, + statement: str, + options: QueryOptions, + *args: JSONType, + enable_cancel: bool, + **kwargs: str) -> Future[BlockingQueryResult]: ... + + @overload + def execute_query(self, + statement: str, + enable_cancel: bool, + *args: JSONType, + **kwargs: str) -> Future[BlockingQueryResult]: ... + + @overload + def execute_query(self, + statement: str, + *args: JSONType, + enable_cancel: bool, + **kwargs: str) -> Future[BlockingQueryResult]: ... + + def shutdown(self) -> None: ... + + @overload + @classmethod + def create_instance(cls, http_endpoint: str, credential: Credential) -> Cluster: ... + + @overload + @classmethod + def create_instance(cls, + http_endpoint: str, + credential: Credential, + options: ClusterOptions) -> Cluster: ... + + @overload + @classmethod + def create_instance(cls, + http_endpoint: str, + credential: Credential, + **kwargs: Unpack[ClusterOptionsKwargs]) -> Cluster: ... + + @overload + @classmethod + def create_instance(cls, + http_endpoint: str, + credential: Credential, + options: ClusterOptions, + **kwargs: Unpack[ClusterOptionsKwargs]) -> Cluster: ... diff --git a/couchbase_analytics/protocol/connection.py b/couchbase_analytics/protocol/connection.py new file mode 100644 index 0000000..9ac36ae --- /dev/null +++ b/couchbase_analytics/protocol/connection.py @@ -0,0 +1,234 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import ssl + +from dataclasses import dataclass +from typing import (TYPE_CHECKING, + Dict, + List, + Optional, + Tuple, + TypedDict) +from urllib.parse import parse_qs, urlparse + +from couchbase_analytics.common.credential import Credential +from couchbase_analytics.common.deserializer import DefaultJsonDeserializer, Deserializer +from couchbase_analytics.common.options import ClusterOptions +from couchbase_analytics.protocol import PYCBAC_VERSION +from couchbase_analytics.protocol.options import (ClusterOptionsTransformedKwargs, + QueryStrVal, + SecurityOptionsTransformedKwargs, + TimeoutOptionsTransformedKwargs) + +from httpcore import (Origin, URL) + +if TYPE_CHECKING: + from couchbase_analytics.protocol.options import OptionsBuilder + + +class StreamingTimeouts(TypedDict, total=False): + query_timeout: Optional[int] + + + +class DefaultTimeouts(TypedDict): + connect_timeout: int + dispatch_timeout: int + query_timeout: int + +DEFAULT_TIMEOUTS: DefaultTimeouts = { + 'connect_timeout': 10, + 'dispatch_timeout': 30, + 'query_timeout': 60 * 10, +} + + +def parse_http_endpoint(http_endpoint: str) -> Tuple[URL, Dict[str, QueryStrVal]]: + """ **INTERNAL** + + Parse the provided HTTP endpoint + + The provided connection string will be parsed to split the connection string + and the the query options. Query options will be split into legacy options + and 'current' options. + + Args: + http_endpoint (str): The HTTP endpoint to use for requests. + + Returns: + Tuple[str, Dict[str, Any], Dict[str, Any]]: The parsed HTTP URL and options dict. + """ + parsed_endpoint = urlparse(http_endpoint) + if parsed_endpoint.scheme is None or parsed_endpoint.scheme not in ['http', 'https']: + raise ValueError(f"The endpoint scheme must be 'http[s]'. Found: {parsed_endpoint.scheme}.") + + port = parsed_endpoint.port + if parsed_endpoint.port is None: + port = 80 if parsed_endpoint.scheme == 'http' else 443 + + + url = URL(scheme=parsed_endpoint.scheme, + host=parsed_endpoint.hostname, + port=port, + target=parsed_endpoint.path or '/') + + return url, parse_query_string_options(parsed_endpoint.query) + + +def parse_query_string_options(query_str: str) -> Dict[str, QueryStrVal]: + """Parse the query string options + + Query options will be split into legacy options and 'current' options. The values for the + 'current' options are cast to integers or booleans where applicable + + Args: + query_str (str): The query string. + + Returns: + Tuple[Dict[str, QueryStrVal], Dict[str, QueryStrVal]]: The parsed current options and legacy options. + """ + options = parse_qs(query_str) + + query_str_opts: Dict[str, QueryStrVal] = {} + for k, v in options.items(): + query_str_opts[k] = parse_query_string_value(v) + + return query_str_opts + + +def parse_query_string_value(value: List[str]) -> QueryStrVal: + """Parse a query string value + + The provided value is a list of at least one element. Returns either a list of strings or a single element + which might be cast to an integer or a boolean if that's appropriate. + + Args: + value (List[str]): The query string value. + + Returns: + Union[List[str], str, bool, int]: The parsed current options and legacy options. + """ + + if len(value) > 1: + return value + v = value[0] + if v.isnumeric(): + return int(v) + elif v.lower() in ['true', 'false']: + return v.lower() == 'true' + return v + + +def parse_query_str_options(query_str_opts: Dict[str, QueryStrVal]) -> Dict[str, QueryStrVal]: + final_opts: Dict[str, QueryStrVal] = {} + for k, v in query_str_opts.items(): + tokens = k.split('.') + if len(tokens) == 2: + if tokens[0] in ['timeout', 'security']: + final_opts[tokens[1]] = v + else: + # TODO: exceptions -- this means the user passed in an invalid option + pass + else: + final_opts[k] = v + + return final_opts + + +@dataclass +class _ConnectionDetails: + """ + **INTERNAL** + """ + url: URL + cluster_options: ClusterOptionsTransformedKwargs + credential: Tuple[bytes, bytes] + default_deserializer: Deserializer + ssl_context: Optional[ssl.SSLContext] = None + sni_hostname: Optional[str] = None + + def get_connect_timeout(self) -> int: + timeout_opts: Optional[TimeoutOptionsTransformedKwargs] = self.cluster_options.get('timeout_options') + if timeout_opts is not None: + connect_timeout = timeout_opts.get('connect_timeout', None) + if connect_timeout is not None: + return connect_timeout + return DEFAULT_TIMEOUTS['connect_timeout'] + + def get_query_timeout(self) -> int: + timeout_opts: Optional[TimeoutOptionsTransformedKwargs] = self.cluster_options.get('timeout_options') + if timeout_opts is not None: + query_timeout = timeout_opts.get('query_timeout', None) + if query_timeout is not None: + return query_timeout + return DEFAULT_TIMEOUTS['query_timeout'] + + def get_scheme_host_and_port(self) -> Tuple[str, str, int]: + return self.url.scheme.decode(), self.url.host.decode(), self.url.port + + def is_secure(self) -> bool: + return self.url.scheme.decode() == 'https' + + def validate_security_options(self) -> None: + security_opts: Optional[SecurityOptionsTransformedKwargs] = self.cluster_options.get('security_options') + # TODO: security settings + if security_opts is not None: + # separate between value options and boolean option (trust_only_capella) + solo_security_opts = ['trust_only_pem_file', + 'trust_only_pem_str', + 'trust_only_certificates'] + trust_capella = security_opts.get('trust_only_capella', None) + security_opt_count = sum(map(lambda k: 1 if security_opts.get(k, None) is not None else 0, solo_security_opts)) + if security_opt_count > 1 or (security_opt_count == 1 and trust_capella is True): + raise ValueError(('Can only set one of the following options: ' + f'[{", ".join(["trust_only_capella"] + solo_security_opts)}]')) + + if self.is_secure(): + self.ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + self.ssl_context.set_default_verify_paths() + # ssl_context.load_verify_locations(cafile='.vscode/tls/cluster_ca.pem') + self.ssl_context.load_verify_locations(cafile='.vscode/tls/capella.pem') + self.ssl_context.load_verify_locations(cafile='.vscode/tls/dinocluster.pem') + self.ssl_context.load_verify_locations(cafile='.vscode/tls/dinoca.pem') + self.sni_hostname = self.url.host.decode() + + @classmethod + def create(cls, + opts_builder: OptionsBuilder, + http_endpoint: str, + credential: Credential, + options: Optional[object] = None, + **kwargs: object) -> _ConnectionDetails: + url, query_str_opts = parse_http_endpoint(http_endpoint) + + cluster_opts = opts_builder.build_cluster_options(ClusterOptions, + ClusterOptionsTransformedKwargs, + kwargs, + options, + query_str_opts=parse_query_str_options(query_str_opts)) + + default_deserializer = cluster_opts.pop('deserializer', None) + if default_deserializer is None: + default_deserializer = DefaultJsonDeserializer() + + conn_dtls = cls(url, + cluster_opts, + credential.astuple(), + default_deserializer) + conn_dtls.validate_security_options() + return conn_dtls diff --git a/couchbase_analytics/protocol/core/__init__.py b/couchbase_analytics/protocol/core/__init__.py new file mode 100644 index 0000000..72df2de --- /dev/null +++ b/couchbase_analytics/protocol/core/__init__.py @@ -0,0 +1,14 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/couchbase_analytics/protocol/core/_http_transport.py b/couchbase_analytics/protocol/core/_http_transport.py new file mode 100644 index 0000000..36c17ac --- /dev/null +++ b/couchbase_analytics/protocol/core/_http_transport.py @@ -0,0 +1,269 @@ + +import ssl +import time + +from typing import (Iterable, + Optional, + TypeVar, + Union) +from types import TracebackType + +from httpx import (BaseTransport, + HTTPTransport, + Limits, + Proxy, + Response, + SyncByteStream, + URL, + create_ssl_context) +from httpx._transports.default import (map_httpcore_exceptions, + ResponseStream, + SOCKET_OPTION) +from httpx._types import CertTypes, ProxyTypes +from httpcore import (ConnectionPool, + ConnectionInterface, + HTTP11Connection, + HTTP2Connection, + HTTPConnection, + Origin, + Request, + Response as CoreResponse) +from httpcore._sync.connection_pool import PoolRequest, PoolByteStream +from httpcore._exceptions import ConnectionNotAvailable, UnsupportedProtocol + +# httpx._transports.default.py +T = TypeVar("T", bound="HTTPTransport") +DEFAULT_LIMITS = Limits(max_connections=100, max_keepalive_connections=20) + +# ProxyTypes = Union["URL", str, "Proxy"] +# CertTypes = Union[str, Tuple[str, str], Tuple[str, str, str]] + +class AnalyticsHTTPConnection(HTTPConnection): + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + + # The logic is the exact same as httpcore's Connection.handle_request, with the following additions: + # - We update the request's read timeout to remove the time taken to establish a connection + # 2025-06-05: https://github.com/encode/httpcore/blob/98209758cc14e1a5f966fe1dfdc1064b94055d8c/httpcore/_sync/connection.py#L69 + def handle_request(self, request: Request) -> Response: + if not self.can_handle_request(request.url.origin): + raise RuntimeError( + f"Attempted to send request to {request.url.origin} on connection to {self._origin}" + ) + + # PYCBAC Addition: track the query deadline + timeouts = request.extensions.get("timeout", {}) + timeout = timeouts.get("read", None) + deadline = time.monotonic() + timeout + try: + with self._request_lock: + if self._connection is None: + stream = self._connect(request) + + ssl_object = stream.get_extra_info("ssl_object") + http2_negotiated = ( + ssl_object is not None + and ssl_object.selected_alpn_protocol() == "h2" + ) + if http2_negotiated or (self._http2 and not self._http1): + self._connection = HTTP2Connection( + origin=self._origin, + stream=stream, + keepalive_expiry=self._keepalive_expiry, + ) + else: + self._connection = HTTP11Connection( + origin=self._origin, + stream=stream, + keepalive_expiry=self._keepalive_expiry, + ) + except BaseException as exc: + self._connect_failed = True + raise exc + + # PYCBAC Addition: We _always_ set the request timeouts, so no need to validate keys + query_timeout = round(deadline - time.monotonic(), 6) # round to microseconds + request.extensions["timeout"]["read"] = query_timeout + + return self._connection.handle_request(request) + +class AnalyticsConnectionPool(ConnectionPool): + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + + # The logic is the exact same as httpcore's ConnectionPool.handle_request, with the following additions: + # - We update the request's connect timeout to remove the time taken to obtain a connection from the pool + # - For any retries in obtaining a connection from the pool, we update the timeout for subsequent attempts + # 2025.05.30: https://github.com/encode/httpcore/blob/98209758cc14e1a5f966fe1dfdc1064b94055d8c/httpcore/_sync/connection_pool.py#L199 + def handle_request(self, request: Request) -> CoreResponse: + """ + Send an HTTP request, and return an HTTP response. + + This is the core implementation that is called into by `.request()` or `.stream()`. + """ + scheme = request.url.scheme.decode() + if scheme == "": + raise UnsupportedProtocol( + "Request URL is missing an 'http://' or 'https://' protocol." + ) + if scheme not in ("http", "https", "ws", "wss"): + raise UnsupportedProtocol( + f"Request URL has an unsupported protocol '{scheme}://'." + ) + + timeouts = request.extensions.get("timeout", {}) + timeout = timeouts.get("pool", None) + + with self._optional_thread_lock: + # Add the incoming request to our request queue. + pool_request = PoolRequest(request) + self._requests.append(pool_request) + + # PYCBAC Addition: track the deadline + deadline = time.monotonic() + timeout + try: + while True: + with self._optional_thread_lock: + # Assign incoming requests to available connections, + # closing or creating new connections as required. + closing = self._assign_requests_to_connections() + self._close_connections(closing) + + # Wait until this request has an assigned connection. + connection = pool_request.wait_for_connection(timeout=timeout) + # PYCBAC Addition: We _always_ set the request timeouts, so no need to validate keys + connect_timeout = round(deadline - time.monotonic(), 6) # round to microseconds + pool_request.request.extensions["timeout"]["connect"] = connect_timeout + + try: + # Send the request on the assigned connection. + response = connection.handle_request( + pool_request.request + ) + except ConnectionNotAvailable: + # In some cases a connection may initially be available to + # handle a request, but then become unavailable. + # + # In this case we clear the connection and try again. + pool_request.clear_connection() + # PYCBAC Addition: We update the timeout for the next attempt + timeout = round(deadline - time.monotonic(), 6) # round to microseconds + else: + break # pragma: nocover + + except BaseException as exc: + with self._optional_thread_lock: + # For any exception or cancellation we remove the request from + # the queue, and then re-assign requests to connections. + self._requests.remove(pool_request) + closing = self._assign_requests_to_connections() + + self._close_connections(closing) + raise exc from None + + # Return the response. Note that in this case we still have to manage + # the point at which the response is closed. + assert isinstance(response.stream, Iterable) + return CoreResponse( + status=response.status, + headers=response.headers, + content=PoolByteStream( + stream=response.stream, pool_request=pool_request, pool=self + ), + extensions=response.extensions, + ) + + # Override httpcore's ConnectionPool.create_connection to only return our own AnalyticsHTTPConnection. + # 2025-06-05: https://github.com/encode/httpcore/blob/98209758cc14e1a5f966fe1dfdc1064b94055d8c/httpcore/_sync/connection_pool.py#L128 + def create_connection(self, origin: Origin) -> ConnectionInterface: + return AnalyticsHTTPConnection( + origin=origin, + ssl_context=self._ssl_context, + keepalive_expiry=self._keepalive_expiry, + http1=self._http1, + http2=self._http2, + retries=self._retries, + local_address=self._local_address, + uds=self._uds, + network_backend=self._network_backend, + socket_options=self._socket_options, + ) + +class AnalyticsHTTPTransport(BaseTransport): + def __init__( + self, + verify: Optional[Union[ssl.SSLContext, str, bool]] = True, + cert: Optional[CertTypes] = None, + trust_env: bool = True, + http1: bool = True, + http2: bool = False, + limits: Limits = DEFAULT_LIMITS, + proxy: Optional[ProxyTypes] = None, + uds: Optional[str] = None, + local_address: Optional[str] = None, + retries: int = 0, + socket_options: Optional[Iterable[SOCKET_OPTION]] = None, + ) -> None: + + proxy = Proxy(url=proxy) if isinstance(proxy, (str, URL)) else proxy + ssl_context = create_ssl_context(verify=verify, cert=cert, trust_env=trust_env) + + self._pool = AnalyticsConnectionPool( + ssl_context=ssl_context, + max_connections=limits.max_connections, + max_keepalive_connections=limits.max_keepalive_connections, + keepalive_expiry=limits.keepalive_expiry, + http1=http1, + http2=http2, + uds=uds, + local_address=local_address, + retries=retries, + socket_options=socket_options, + ) + + def __enter__(self: T) -> T: # Use generics for subclass support. + self._pool.__enter__() + return self + + def __exit__( + self, + exc_type: Optional[type[BaseException]] = None, + exc_value: Optional[BaseException] = None, + traceback: Optional[TracebackType] = None, + ) -> None: + with map_httpcore_exceptions(): + self._pool.__exit__(exc_type, exc_value, traceback) + + def handle_request( + self, + request: Request, + ) -> Response: + assert isinstance(request.stream, SyncByteStream) + import httpcore + + req = httpcore.Request( + method=request.method, + url=httpcore.URL( + scheme=request.url.raw_scheme, + host=request.url.raw_host, + port=request.url.port, + target=request.url.raw_path, + ), + headers=request.headers.raw, + content=request.stream, + extensions=request.extensions, + ) + with map_httpcore_exceptions(): + resp = self._pool.handle_request(req) + + assert isinstance(resp.stream, Iterable) + + return Response( + status_code=resp.status, + headers=resp.headers, + stream=ResponseStream(resp.stream), + extensions=resp.extensions, + ) + + def close(self) -> None: + self._pool.close() \ No newline at end of file diff --git a/couchbase_analytics/protocol/core/_request_context.py b/couchbase_analytics/protocol/core/_request_context.py new file mode 100644 index 0000000..77f4894 --- /dev/null +++ b/couchbase_analytics/protocol/core/_request_context.py @@ -0,0 +1,297 @@ +from __future__ import annotations + +import math +import time + +from concurrent.futures import (CancelledError, + Future, + ThreadPoolExecutor) +from threading import (Event, + Lock, + get_ident) +from typing import (Any, + Callable, + Dict, + Iterator, + List, + Optional, + TYPE_CHECKING) +from uuid import uuid4 + +from httpx import Response as HttpCoreResponse + +from couchbase_analytics.common.core import ParsedResult +from couchbase_analytics.common.core.net_utils import get_request_ip +from couchbase_analytics.common.deserializer import Deserializer +from couchbase_analytics.common.errors import AnalyticsError, InvalidCredentialError +from couchbase_analytics.common.result import BlockingQueryResult +from couchbase_analytics.common.streaming import StreamingState +from couchbase_analytics.protocol.connection import DEFAULT_TIMEOUTS +from couchbase_analytics.protocol.errors import ErrorMapper + +if TYPE_CHECKING: + from couchbase_analytics.protocol.core.client_adapter import _ClientAdapter + from couchbase_analytics.protocol.core.request import QueryRequest + +# TODO: might not be needed; need to validate httpx iterator behavior +class ThreadSafeBytesIterator: + def __init__(self, iterator: Iterator[bytes]): + if not hasattr(iterator, '__next__'): + raise TypeError("Provided object is not an iterator (missing __next__ method).") + self._iterator = iterator + self._lock = Lock() + + def __iter__(self) -> ThreadSafeBytesIterator: + return self + + def __next__(self) -> bytes: + with self._lock: # Acquire the lock before accessing the iterator + try: + item = next(self._iterator) + return item + except StopIteration: + # Always re-raise StopIteration to signal the end of iteration + raise + +class BackgroundRequest: + def __init__(self, bg_future: Future[BlockingQueryResult], + user_future: Future[BlockingQueryResult], + cancel_event: Event) -> None: + self._background_work_ft = bg_future + self._user_ft = user_future + self._cancel_event = cancel_event + self._background_work_ft.add_done_callback(self._background_work_done) + self._user_ft.add_done_callback(self._user_done) + + @property + def user_cancelled(self) -> bool: + return self._user_ft.cancelled() + + def _background_work_done(self, ft: Future[BlockingQueryResult]) -> None: + """ + Callback to handle when the background work future is done. + """ + if self._user_ft.done(): + return + if self._cancel_event.is_set(): + self._user_ft.cancel() + return + try: + result = ft.result() + self._user_ft.set_result(result) + except Exception as ex: + self._user_ft.set_exception(ex) + + def _user_done(self, ft: Future[BlockingQueryResult]) -> None: + """ + Callback to handle when the user future is done. + """ + if self._background_work_ft.done(): + # If the background work future is already done, we don't need to do anything + return + if ft.cancelled(): + self._cancel_event.set() + self._background_work_ft.cancel() + return + + + +class RequestContext: + + def __init__(self, + client_adapter: _ClientAdapter, + request: QueryRequest, + tp_executor: ThreadPoolExecutor) -> None: + self._id = str(uuid4()) + self._client_adapter = client_adapter + self._request = request + self._request_state = StreamingState.NotStarted + self._cancel_event = Event() + self._request_error: Optional[Exception] = None + self._tp_executor = tp_executor + self._stage_completed_ft: Optional[Future] = None + self._stage_notification_ft: Optional[Future[ParsedResult]] = None + self._request_deadline = math.inf + self._background_request: Optional[BackgroundRequest] = None + + # @property + # def stage_notification(self) -> Future[ParsedResult]: + # if self._stage_notification_ft is None: + # raise RuntimeError('Background future not created for this context.') + # return self._stage_notification_ft + + @property + def cancel_enabled(self) -> bool: + return self._request.enable_cancel + + @property + def cancel_event(self) -> Event: + return self._request._cancel_event + + @property + def deserializer(self) -> Deserializer: + """ + Returns the deserializer used by this request context. + """ + return self._request.deserializer + + @property + def has_stage_completed(self) -> bool: + return self._stage_completed_ft is not None and self._stage_completed_ft.done() + + @property + def okay_to_iterate(self) -> bool: + # Called prior to upstream logic attempting to iterate over results from HTTP client + self._check_cancelled_or_timed_out() + return StreamingState.okay_to_iterate(self._request_state) + + @property + def okay_to_stream(self) -> bool: + # Called prior to upstream logic attempting to send request to HTTP client + self._check_cancelled_or_timed_out() + return StreamingState.okay_to_stream(self._request_state) + + @property + def request_error(self) -> Optional[Exception]: + return self._request_error + + # @property + # def request_future(self) -> Future[BlockingQueryResult]: + # if self._request_future is None: + # raise RuntimeError('Request future not created for this context.') + # return self._request_future + + @property + def request_state(self) -> StreamingState: + return self._request_state + + @request_state.setter + def request_state(self, state: StreamingState) -> None: + if not isinstance(state, StreamingState): + raise TypeError('request_state must be an instance of StreamingState') + self._request_state = state + + @property + def timed_out(self) -> bool: + self._check_cancelled_or_timed_out() + return self._request_state == StreamingState.Timeout + + @property + def cancelled(self) -> bool: + self._check_cancelled_or_timed_out() + return self._request_state in [StreamingState.Cancelled, StreamingState.SyncCancelledPriorToTimeout] + + def _check_cancelled_or_timed_out(self) -> None: + if self._request_state in [StreamingState.Timeout, StreamingState.Cancelled, StreamingState.Error]: + return + + if (self._cancel_event.is_set() + or (self._background_request is not None + and self._background_request.user_cancelled)): + self._request_state = StreamingState.Cancelled + + timed_out = self._request_deadline < time.monotonic() + if timed_out: + if self._request_state == StreamingState.Cancelled: + self._request_state = StreamingState.SyncCancelledPriorToTimeout + else: + self._request_state = StreamingState.Timeout + + def _create_stage_notification_future(self) -> None: + # TODO: custom ThreadPoolExecutor, to get a "plain" future + if self._stage_notification_ft is not None: + raise RuntimeError('Stage notification future already created for this context.') + self._stage_notification_ft = Future[ParsedResult]() + + def _trace_handler(self, event_name, _) -> None: + if event_name == 'connection.connect_tcp.complete': + print('Connection established, updating cancel scope deadline') + + def initialize(self) -> None: + self._request_state = StreamingState.Started + timeouts = self._request.get_request_timeouts() + self._request_deadline = time.monotonic() + timeouts.get('read', DEFAULT_TIMEOUTS['query_timeout']) + + def process_error(self, json_data: List[Dict[str, Any]]) -> None: + self._request_state = StreamingState.Error + if not isinstance(json_data, list): + self._request_error = AnalyticsError('Cannot parse error response; expected JSON array') + + self._request_error = ErrorMapper.build_error_from_json(json_data, status_code=self._request.response_status_code) + raise self._request_error + + def send_request(self, enable_trace_handling: Optional[bool]=False) -> HttpCoreResponse: + ip = get_request_ip(self._request.host, self._request.port, self._request.previous_ips) + if ip is None: + attempted_ips = ', '.join(self._request.previous_ips or []) + raise AnalyticsError(f'Connect failure. Attempted to connect to resolved IPs: {attempted_ips}.') + + if enable_trace_handling is True: + (self._request.update_url(ip, self._client_adapter.analytics_path) + .update_extensions({'trace': self._trace_handler}) + .update_previous_ips(ip)) + else: + self._request.update_url(ip, self._client_adapter.analytics_path).update_previous_ips(ip) + response = self._client_adapter.send_request(self._request) + self._request.set_client_server_addrs(response) + if response.status_code == 401: + context = { + 'client_addr': self._request.client_addr, + 'server_addr': self._request.server_addr, + 'http_status': response.status_code, + } + raise InvalidCredentialError(context) + + return response + + def send_request_in_background(self, + fn: Callable[..., BlockingQueryResult], + *args: object,) -> Future[BlockingQueryResult]: + + if self._background_request is not None: + raise RuntimeError('Background reqeust already created for this context.') + # TODO: custom ThreadPoolExecutor, to get a "plain" future + user_ft = Future[BlockingQueryResult]() + background_work_ft = self._tp_executor.submit(fn, *args) + self._background_request = BackgroundRequest(background_work_ft, user_ft, self._cancel_event) + return user_ft + + def set_state_to_streaming(self) -> None: + self._request_state = StreamingState.StreamingResults + + def shutdown(self, exc_val: Optional[BaseException]=None) -> None: + if isinstance(exc_val, CancelledError): + self._request_state = StreamingState.Cancelled + elif exc_val is not None: + self._check_cancelled_or_timed_out() + if self._request_state not in [StreamingState.Timeout, + StreamingState.Cancelled, + StreamingState.SyncCancelledPriorToTimeout]: + self._request_state = StreamingState.Error + + if StreamingState.is_okay(self._request_state): + self._request_state = StreamingState.Completed + + def start_next_stage(self, + fn: Callable[..., Any], + *args: object, + create_notification: Optional[bool]=False, + reset_previous_stage: Optional[bool]=False) -> None: + if reset_previous_stage is True: + if self._stage_completed_ft is not None: + self._stage_completed_ft = None + elif self._stage_completed_ft is not None and not self._stage_completed_ft.done(): + raise RuntimeError('Future already running in this context.') + + kwargs = {'request_context': self} + if create_notification is True: + self._create_stage_notification_future() + kwargs['notify_on_results_or_error'] = self._stage_notification_ft + + self._stage_completed_ft = self._tp_executor.submit(fn, *args, **kwargs) + + def wait_for_stage_notification(self) -> ParsedResult: + # TODO: what if the deadline is already passed? + deadline = round(self._request_deadline - time.monotonic(), 6) # round to microseconds + res = self._stage_notification_ft.result(timeout=deadline) + return res \ No newline at end of file diff --git a/couchbase_analytics/protocol/core/client_adapter.py b/couchbase_analytics/protocol/core/client_adapter.py new file mode 100644 index 0000000..5fe0009 --- /dev/null +++ b/couchbase_analytics/protocol/core/client_adapter.py @@ -0,0 +1,159 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import socket + +from random import choice +from typing import Dict, Optional, TYPE_CHECKING +from uuid import uuid4 + +from httpx import BasicAuth, Client, Response + +from couchbase_analytics.common.credential import Credential +from couchbase_analytics.common.deserializer import Deserializer +from couchbase_analytics.protocol.connection import _ConnectionDetails +from couchbase_analytics.protocol.options import OptionsBuilder +from couchbase_analytics.protocol.core._http_transport import AnalyticsHTTPTransport + +if TYPE_CHECKING: + from couchbase_analytics.protocol.core.request import QueryRequest + + +class _ClientAdapter: + """ + **INTERNAL** + """ + + _ANALYTICS_PATH = '/api/v1/request' + + def __init__(self, + http_endpoint: str, + credential: Credential, + options: Optional[object] = None, + **kwargs: object) -> None: + self._client_id = str(uuid4()) + self._opts_builder = OptionsBuilder() + # TODO: We should limit the allowed transports to the ones we support + # Question is how do we want to limit the transports? Should users even need to override? + # self._http_transport_cls = kwargs.pop('http_transport_cls', AnalyticsHTTPTransport) + self._http_transport_cls = None + self._conn_details = _ConnectionDetails.create(self._opts_builder, + http_endpoint, + credential, + options, + **kwargs) + + @property + def analytics_path(self) -> str: + """ + **INTERNAL** + """ + return self._ANALYTICS_PATH + + @property + def client(self) -> Client: + """ + **INTERNAL** + """ + return self._client + + @property + def client_id(self) -> str: + """ + **INTERNAL** + """ + return self._client_id + + @property + def connection_details(self) -> _ConnectionDetails: + """ + **INTERNAL** + """ + return self._conn_details + + @property + def default_deserializer(self) -> Deserializer: + """ + **INTERNAL** + """ + return self._conn_details.default_deserializer + + @property + def has_client(self) -> bool: + """ + **INTERNAL** + """ + return hasattr(self, '_client') + + @property + def options_builder(self) -> OptionsBuilder: + """ + **INTERNAL** + """ + return self._opts_builder + + + def close_client(self) -> None: + """ + **INTERNAL** + """ + if hasattr(self, '_client'): + self._client.close() + + def create_client(self) -> None: + """ + **INTERNAL** + """ + if not hasattr(self, '_client'): + auth = BasicAuth(*self._conn_details.credential) + if self._conn_details.is_secure(): + transport = None + if self._http_transport_cls is not None: + transport = self._http_transport_cls(verify=self._conn_details.ssl_context) + self._client = Client(verify=self._conn_details.ssl_context, + auth=auth, + transport=transport) + else: + transport = None + if self._http_transport_cls is not None: + transport = self._http_transport_cls() + self._client = Client(auth=auth, transport=transport) + + + def send_request(self, request: QueryRequest) -> Response: + """ + **INTERNAL** + """ + if not hasattr(self, '_client'): + raise RuntimeError('Client not created yet') + + req = self._client.build_request(request.method, + request.url, + json=request.body, + extensions=request.extensions) + try: + return self._client.send(req, stream=True) + except socket.gaierror as err: + raise RuntimeError(f'Unable to connect to {self._conn_details.get_scheme_host_and_port()}') from err + + def reset_client(self) -> None: + """ + **INTERNAL** + """ + if hasattr(self, '_client'): + del self._client + diff --git a/couchbase_analytics/protocol/core/request.py b/couchbase_analytics/protocol/core/request.py new file mode 100644 index 0000000..347ce25 --- /dev/null +++ b/couchbase_analytics/protocol/core/request.py @@ -0,0 +1,224 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from copy import deepcopy +from dataclasses import dataclass + +from typing import (TYPE_CHECKING, + Dict, + Optional, + Set, + Tuple, + Union) +from uuid import uuid4 + +from couchbase_analytics.common.deserializer import Deserializer +from couchbase_analytics.common.options import QueryOptions +from couchbase_analytics.protocol.options import QueryOptionsTransformedKwargs + +if TYPE_CHECKING: + from httpx import Response as HttpCoreResponse + + from acouchbase_analytics.protocol.core.client_adapter import _AsyncClientAdapter as AsyncClientAdapter + from couchbase_analytics.protocol.core.client_adapter import _ClientAdapter as BlockingClientAdapter + + + +@dataclass +class QueryRequest: + scheme: str + host: str + port: int + deserializer: Deserializer + body: Dict[str, object] + extensions: Dict[str, str] + url: Optional[str] = None + method: Optional[str] = 'POST' + + options: Optional[QueryOptionsTransformedKwargs] = None + client_addr: Optional[Tuple[str, int]] = None + server_addr: Optional[Tuple[str, int]] = None + previous_ips: Optional[Set[str]] = None + response_status_code: Optional[int] = None + enable_cancel: Optional[bool] = None + + def get_request_timeouts(self) -> Dict[str, int]: + """ + **INTERNAL** + Get the request timeouts from the extensions. + Returns: + Dict[str, int]: The request timeouts. + """ + if self.extensions is None or 'timeout' not in self.extensions: + return {} + return self.extensions['timeout'] + + def set_client_server_addrs(self, response: HttpCoreResponse) -> None: + # TODO: this logic comes from httpcore, typing won't be happy + network_stream = response.extensions.get('network_stream', None) + # TODO: what if network_stream is None? + if network_stream is not None: + self.client_addr = network_stream.get_extra_info('client_addr') + self.server_addr = network_stream.get_extra_info('server_addr') + + self.response_status_code = response.status_code + + def update_extensions(self, new_extensions: Dict[str, str]) -> QueryRequest: + """ + **INTERNAL** + Update the extensions of the request. + Args: + new_extensions (Dict[str, str]): The new extension(s) to add. + """ + if self.extensions is None: + self.extensions = {} + self.extensions.update(new_extensions) + return self + + def update_previous_ips(self, ip: str) -> QueryRequest: + """ + **INTERNAL** + Update the previous IPs of the request. + Args: + ip (str): The new IP to add to the previous IPs. + """ + if self.previous_ips is None: + self.previous_ips = set() + self.previous_ips.add(ip) + return self + + def update_url(self, ip: str, path: str) -> QueryRequest: + """ + **INTERNAL** + Update the URL of the request. + Args: + new_url (str): The new URL to set. + """ + self.url = f'{self.scheme}://{ip}:{self.port}{path}' + return self + + +class _RequestBuilder: + + def __init__(self, + client: Union[AsyncClientAdapter, BlockingClientAdapter], + database_name: Optional[str]=None, + scope_name: Optional[str]=None + ) -> None: + self._conn_details = client.connection_details + self._opts_builder = client.options_builder + self._database_name = database_name + self._scope_name = scope_name + + connect_timeout = self._conn_details.get_connect_timeout() + self._default_query_timeout = self._conn_details.get_query_timeout() + self._extensions = { + 'timeout': { + 'pool': connect_timeout, + 'connect': connect_timeout, + 'read': self._default_query_timeout + } + } + # TODO: warning if we have a secure connection, but the sni_hostname is not set? + if self._conn_details.is_secure() and self._conn_details.sni_hostname is not None: + self._extensions['sni_hostname'] = self._conn_details.sni_hostname + + def build_base_query_request(self, # noqa: C901 + statement: str, + *args: object, + is_async: Optional[bool] = False, + **kwargs: object) -> QueryRequest: # noqa: C901 + enable_cancel: Optional[bool] = None + cancel_kwarg_token = kwargs.pop('enable_cancel', None) + if isinstance(cancel_kwarg_token, bool): + enable_cancel = cancel_kwarg_token + + # default if no options provided + opts = QueryOptions() + args_list = list(args) + parsed_args_list = [] + for arg in args_list: + if isinstance(arg, QueryOptions): + # we have options passed in + opts = arg + elif enable_cancel is None and isinstance(arg, bool): + enable_cancel = arg + else: + parsed_args_list.append(arg) + + # need to pop out named params prior to sending options to the builder + named_param_keys = list(filter(lambda k: k not in QueryOptions.VALID_OPTION_KEYS, kwargs.keys())) + named_params = {} + for key in named_param_keys: + named_params[key] = kwargs.pop(key) + + q_opts = self._opts_builder.build_options(QueryOptions, + QueryOptionsTransformedKwargs, + kwargs, + opts) + # positional params and named params passed in outside of QueryOptions serve as overrides + if parsed_args_list and len(parsed_args_list) > 0: + q_opts['positional_parameters'] = parsed_args_list + if named_params and len(named_params) > 0: + q_opts['named_parameters'] = named_params + # add the default serializer if one does not exist + deserializer = q_opts.pop('deserializer', None) or self._conn_details.default_deserializer + + body = {'statement': statement, + 'client_context_id': q_opts.get('client_context_id', str(uuid4()))} + + if self._database_name is not None and self._scope_name is not None: + body['query_context'] = f'default:`{self._database_name}`.`{self._scope_name}`' + + # handle timeouts + timeout = q_opts.get('timeout', self._default_query_timeout) + extensions = deepcopy(self._extensions) + if timeout != self._default_query_timeout: + extensions['timeout']['read'] = timeout + # in the async world we have our own cancel scope that handles the connect timeout + if is_async: + del extensions['timeout']['pool'] + del extensions['timeout']['connect'] + # we add 5 seconds to the server timeout to ensure we always trigger a client side timeout + timeout_ms = (timeout + 5) * 1e3 # convert to milliseconds + body['timeout'] = f'{timeout_ms}ms' + + for opt_key, opt_val in q_opts.items(): + if opt_key == 'deserializer': + continue + elif opt_key == 'raw': + for k, v in opt_val.items(): + body[k] = v + elif opt_key == 'positional_parameters': + body['args'] = [arg for arg in opt_val] + elif opt_key == 'named_parameters': + for k, v in opt_val.items(): + key = f'${k}' if not k.startswith('$') else k + body[key] = v + else: + # TODO: readonly, priority & scan_consistency + pass + + scheme, host, port = self._conn_details.get_scheme_host_and_port() + return QueryRequest(scheme, + host, + port, + deserializer, + body, + extensions=extensions, + options=q_opts, + enable_cancel=enable_cancel) diff --git a/couchbase_analytics/protocol/core/utils.py b/couchbase_analytics/protocol/core/utils.py new file mode 100644 index 0000000..a44106c --- /dev/null +++ b/couchbase_analytics/protocol/core/utils.py @@ -0,0 +1,35 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +from datetime import timedelta +from time import time + +THIRTY_DAYS_IN_SECONDS = 30 * 24 * 60 * 60 + + +def timedelta_as_timestamp(duration: timedelta) -> int: + if not isinstance(duration, timedelta): + raise ValueError(f'Expected timedelta instead of {duration}') + + # PYCBC-1177 remove deprecated heuristic from PYCBC-948: + seconds = int(duration.total_seconds()) + if seconds < 0: + raise ValueError(f'Expected expiry seconds of zero (for no expiry) or greater, got {seconds}.') + + if seconds < THIRTY_DAYS_IN_SECONDS: + return seconds + + return seconds + int(time()) diff --git a/couchbase_analytics/protocol/database.py b/couchbase_analytics/protocol/database.py new file mode 100644 index 0000000..fea661e --- /dev/null +++ b/couchbase_analytics/protocol/database.py @@ -0,0 +1,55 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from concurrent.futures import ThreadPoolExecutor +from typing import TYPE_CHECKING + +from couchbase_analytics.protocol.core.client_adapter import _ClientAdapter +from couchbase_analytics.protocol.scope import Scope + +if TYPE_CHECKING: + from couchbase_analytics.protocol.cluster import Cluster + + +class Database: + def __init__(self, cluster: Cluster, database_name: str) -> None: + self._database_name = database_name + self._cluster = cluster + + @property + def client_adapter(self) -> _ClientAdapter: + """ + **INTERNAL** + """ + return self._cluster.client_adapter + + @property + def name(self) -> str: + """ + str: The name of this :class:`~couchbase_analytics.protocol.database.Database` instance. + """ + return self._database_name + + @property + def threadpool_executor(self) -> ThreadPoolExecutor: + """ + **INTERNAL** + """ + return self._cluster.threadpool_executor + + def scope(self, scope_name: str) -> Scope: + return Scope(self, scope_name) diff --git a/couchbase_analytics/protocol/database.pyi b/couchbase_analytics/protocol/database.pyi new file mode 100644 index 0000000..7bbccc8 --- /dev/null +++ b/couchbase_analytics/protocol/database.pyi @@ -0,0 +1,34 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from concurrent.futures import ThreadPoolExecutor + +from couchbase_analytics.protocol.cluster import Cluster as Cluster +from couchbase_analytics.protocol.core.client_adapter import _ClientAdapter +from couchbase_analytics.protocol.scope import Scope + +class Database: + def __init__(self, cluster: Cluster, database_name: str) -> None: ... + + @property + def client_adapter(self) -> _ClientAdapter: ... + + @property + def name(self) -> str: ... + + @property + def threadpool_executor(self) -> ThreadPoolExecutor: ... + + def scope(self, scope_name: str) -> Scope: ... diff --git a/couchbase_analytics/protocol/errors.py b/couchbase_analytics/protocol/errors.py new file mode 100644 index 0000000..1a8d707 --- /dev/null +++ b/couchbase_analytics/protocol/errors.py @@ -0,0 +1,92 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import sys +from enum import Enum +from typing import (Any, + Dict, + List, + Optional, + Union, + cast) + +if sys.version_info < (3, 10): + from typing_extensions import TypeAlias +else: + from typing import TypeAlias + +from couchbase_analytics.common.errors import (AnalyticsError, + InternalSDKError, + InvalidCredentialError, + QueryError) + +AnalyticsClientError: TypeAlias = Union[InternalSDKError, + QueryError, + RuntimeError, + ValueError] + +# class CoreErrorMap(Enum): +# AnalyticsError = 1 +# InvalidCredentialError = 2 +# TimeoutError = 3 +# QueryError = 4 + + +# class ClientErrorMap(Enum): +# ValueError = 1 +# RuntimeError = 2 +# QueryOperationCanceledError = 3 +# InternalSDKError = 4 + + +# PYCBAC_CORE_ERROR_MAP: Dict[int, type[AnalyticsError]] = { +# e.value: getattr(sys.modules['couchbase_analytics.common.errors'], e.name) for e in CoreErrorMap +# } + +# PYCBAC_CLIENT_ERROR_MAP: Dict[int, type[AnalyticsClientError]] = { +# 1: ValueError, +# 2: RuntimeError, +# 3: QueryOperationCanceledError, +# 4: InternalSDKError +# } + + +class ErrorMapper: + @staticmethod # noqa: C901 + def build_error(base_error: Exception, + mapping: Optional[Dict[str, type[AnalyticsError]]] = None + ) -> AnalyticsClientError: + # TODO: exceptions + return AnalyticsError(base=base_error) + + + @staticmethod # noqa: C901 + def build_error_from_json(json_data: List[Dict[str, Any]], + status_code: Optional[int]=None) -> AnalyticsClientError: + context = {'errors': json_data, + 'http_status': status_code} + if status_code is None: + status_code = json_data.get('status', 500) + elif status_code == 401: + return InvalidCredentialError(context, message='Invalid credentials provided.') + else: + first_error = json_data[0] + code = first_error.get('code', 0) + server_message = first_error.get('msg', 'Unknown error occurred.') + return QueryError(code, server_message, context) + + diff --git a/couchbase_analytics/protocol/options.py b/couchbase_analytics/protocol/options.py new file mode 100644 index 0000000..814081f --- /dev/null +++ b/couchbase_analytics/protocol/options.py @@ -0,0 +1,311 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from copy import copy +from typing import (Any, + Callable, + Dict, + List, + Literal, + Optional, + Tuple, + TypedDict, + TypeVar, + Union) + +from couchbase_analytics.common.core.utils import (VALIDATE_BOOL, + VALIDATE_DESERIALIZER, + VALIDATE_STR, + VALIDATE_STR_LIST, + EnumToStr, + timedelta_as_seconds, + to_microseconds, + validate_path, + validate_raw_dict) +from couchbase_analytics.common.core import JsonStreamConfig +from couchbase_analytics.common.deserializer import Deserializer +from couchbase_analytics.common.enums import QueryScanConsistency +from couchbase_analytics.common.options import (ClusterOptions, + OptionsClass, + QueryOptions, + SecurityOptions, + TimeoutOptions) +from couchbase_analytics.common.options_base import (ClusterOptionsValidKeys, + QueryOptionsValidKeys, + SecurityOptionsValidKeys, + TimeoutOptionsValidKeys) + +QUERY_CONSISTENCY_TO_STR = EnumToStr[QueryScanConsistency]() + +QueryStrVal = Union[List[str], str, bool, int] + + +class ClusterOptionsTransforms(TypedDict): + deserializer: Dict[Literal['deserializer'], Callable[[Any], Deserializer]] + security_options: Dict[Literal['security_options'], Callable[[Any], Any]] + timeout_options: Dict[Literal['timeout_options'], Callable[[Any], Any]] + + +CLUSTER_OPTIONS_TRANSFORMS: ClusterOptionsTransforms = { + 'deserializer': {'deserializer': VALIDATE_DESERIALIZER}, + 'security_options': {'security_options': lambda x: x}, + 'timeout_options': {'timeout_options': lambda x: x}, +} + + +class ClusterOptionsTransformedKwargs(TypedDict, total=False): + deserializer: Optional[Deserializer] + security_options: Optional[SecurityOptionsTransformedKwargs] + timeout_options: Optional[TimeoutOptionsTransformedKwargs] + + +class SecurityOptionsTransforms(TypedDict): + trust_only_capella: Dict[Literal['trust_only_capella'], Callable[[Any], bool]] + trust_only_pem_file: Dict[Literal['trust_only_pem_file'], Callable[[Any], str]] + trust_only_pem_str: Dict[Literal['trust_only_pem_str'], Callable[[Any], str]] + trust_only_certificates: Dict[Literal['trust_only_certificates'], Callable[[Any], List[str]]] + disable_server_certificate_verification: Dict[Literal['disable_server_certificate_verification'], + Callable[[Any], bool]] + + +SECURITY_OPTIONS_TRANSFORMS: SecurityOptionsTransforms = { + 'trust_only_capella': {'trust_only_capella': VALIDATE_BOOL}, + 'trust_only_pem_file': {'trust_only_pem_file': validate_path}, + 'trust_only_pem_str': {'trust_only_pem_str': VALIDATE_STR}, + 'trust_only_certificates': {'trust_only_certificates': VALIDATE_STR_LIST}, + 'disable_server_certificate_verification': {'disable_server_certificate_verification': VALIDATE_BOOL}, +} + + +class SecurityOptionsTransformedKwargs(TypedDict, total=False): + trust_only_capella: Optional[bool] + trust_only_pem_file: Optional[str] + trust_only_pem_str: Optional[str] + trust_only_certificates: Optional[List[str]] + disable_server_certificate_verification: Optional[bool] + + +class TimeoutOptionsTransforms(TypedDict): + connect_timeout: Dict[Literal['bootstrap_timeout'], Callable[[Any], int]] + dispatch_timeout: Dict[Literal['dispatch_timeout'], Callable[[Any], int]] + query_timeout: Dict[Literal['query_timeout'], Callable[[Any], int]] + + +TIMEOUT_OPTIONS_TRANSFORMS: TimeoutOptionsTransforms = { + 'connect_timeout': {'bootstrap_timeout': timedelta_as_seconds}, + 'dispatch_timeout': {'dispatch_timeout': timedelta_as_seconds}, + 'query_timeout': {'query_timeout': timedelta_as_seconds}, +} + + +class TimeoutOptionsTransformedKwargs(TypedDict, total=False): + connect_timeout: Optional[int] + dispatch_timeout: Optional[int] + query_timeout: Optional[int] + + +class QueryOptionsTransforms(TypedDict): + client_context_id: Dict[Literal['client_context_id'], Callable[[Any], str]] + deserializer: Dict[Literal['deserializer'], Callable[[Any], Deserializer]] + lazy_execute: Dict[Literal['lazy_execute'], Callable[[Any], bool]] + named_parameters: Dict[Literal['named_parameters'], Callable[[Any], Any]] + positional_parameters: Dict[Literal['positional_parameters'], Callable[[Any], Any]] + priority: Dict[Literal['priority'], Callable[[Any], bool]] + query_context: Dict[Literal['query_context'], Callable[[Any], str]] + raw: Dict[Literal['raw'], Callable[[Any], Dict[str, Any]]] + read_only: Dict[Literal['readonly'], Callable[[Any], bool]] + scan_consistency: Dict[Literal['scan_consistency'], Callable[[Any], str]] + stream_config: Dict[Literal['stream_config'], Callable[[Any], JsonStreamConfig]] + timeout: Dict[Literal['timeout'], Callable[[Any], int]] + + +QUERY_OPTIONS_TRANSFORMS: QueryOptionsTransforms = { + 'client_context_id': {'client_context_id': VALIDATE_STR}, + 'deserializer': {'deserializer': VALIDATE_DESERIALIZER}, + 'lazy_execute': {'lazy_execute': VALIDATE_BOOL}, + 'named_parameters': {'named_parameters': lambda x: x}, + 'positional_parameters': {'positional_parameters': lambda x: x}, + 'priority': {'priority': VALIDATE_BOOL}, + 'query_context': {'query_context': VALIDATE_STR}, + 'raw': {'raw': validate_raw_dict}, + 'read_only': {'readonly': VALIDATE_BOOL}, + 'scan_consistency': {'scan_consistency': QUERY_CONSISTENCY_TO_STR}, + 'stream_config': {'stream_config': lambda x: x}, + 'timeout': {'timeout': to_microseconds} +} + + +class QueryOptionsTransformedKwargs(TypedDict, total=False): + client_context_id: Optional[str] + deserializer: Optional[Deserializer] + lazy_execute: Optional[bool] + named_parameters: Optional[Any] + positional_parameters: Optional[Any] + priority: Optional[bool] + query_context: Optional[str] + raw: Optional[Dict[str, Any]] + readonly: Optional[bool] + scan_consistency: Optional[str] + stream_config: Optional[JsonStreamConfig] + timeout: Optional[int] + + +TransformedOptionKwargs = TypeVar('TransformedOptionKwargs', + QueryOptionsTransformedKwargs, + ClusterOptionsTransformedKwargs, + SecurityOptionsTransformedKwargs, + TimeoutOptionsTransformedKwargs) + +TransformedClusterOptionKwargs = TypeVar('TransformedClusterOptionKwargs', + ClusterOptionsTransformedKwargs, + SecurityOptionsTransformedKwargs, + TimeoutOptionsTransformedKwargs) + +TransformDetailsPair = Union[Tuple[List[QueryOptionsValidKeys], QueryOptionsTransforms], + Tuple[List[ClusterOptionsValidKeys], ClusterOptionsTransforms], + Tuple[List[SecurityOptionsValidKeys], SecurityOptionsTransforms], + Tuple[List[TimeoutOptionsValidKeys], TimeoutOptionsTransforms], + ] + + +class OptionsBuilder: + """ + **INTERNAL** + """ + + def _get_options_copy(self, + options_class: type[OptionsClass], + orig_kwargs: Dict[str, object], + options: Optional[object] = None) -> Dict[str, object]: + orig_kwargs = copy(orig_kwargs) if orig_kwargs else dict() + # set our options base dict() + temp_options: Dict[str, object] = {} + if options and isinstance(options, (options_class, dict)): + # mypy cannot recognize that all our options classes are dicts + temp_options = options_class(**options) + else: + temp_options = dict() + temp_options.update(orig_kwargs) + + return temp_options + + def _get_transform_details(self, option_type: str) -> TransformDetailsPair: # noqa: C901 + + if option_type == 'ClusterOptions': + return ClusterOptions.VALID_OPTION_KEYS, CLUSTER_OPTIONS_TRANSFORMS + elif option_type == 'SecurityOptions': + return SecurityOptions.VALID_OPTION_KEYS, SECURITY_OPTIONS_TRANSFORMS + elif option_type == 'TimeoutOptions': + return TimeoutOptions.VALID_OPTION_KEYS, TIMEOUT_OPTIONS_TRANSFORMS + elif option_type == 'QueryOptions': + return QueryOptions.VALID_OPTION_KEYS, QUERY_OPTIONS_TRANSFORMS + else: + raise ValueError('Invalid OptionType.') + + def build_cluster_options(self, # noqa: C901 + option_type: type[OptionsClass], + output_type: type[TransformedClusterOptionKwargs], + orig_kwargs: Dict[str, object], + options: Optional[object] = None, + query_str_opts: Optional[Dict[str, QueryStrVal]] = None + ) -> TransformedClusterOptionKwargs: + temp_options = self._get_options_copy(option_type, orig_kwargs, options) + + # we flatten all the nested options (timeout_options & security_options) + # so that we can combine the nested options w/ potential query string options + # when parsing the various nested options we pass in keys that are okay to be ignored as + # we know they are included in the overall "cluster options" umbrella (mainly due to handling + # the query string options). + + security_opts = temp_options.pop('security_options', {}) + if security_opts and isinstance(security_opts, dict): + for k, v in security_opts.items(): + if k not in temp_options: + temp_options[k] = v + + timeout_opts = temp_options.pop('timeout_options', {}) + if timeout_opts and isinstance(timeout_opts, dict): + for k, v in timeout_opts.items(): + if k not in temp_options: + temp_options[k] = v + + if query_str_opts: + # query string options override the options passed in via ClusterOptions + for k, v in query_str_opts.items(): + temp_options[k] = v + + keys_to_ignore: List[str] = [*ClusterOptions.VALID_OPTION_KEYS, + *TimeoutOptions.VALID_OPTION_KEYS] + + # not going to be able to make mypy happy w/ keys_to_ignore :/ + transformed_security_opts = self.build_options(SecurityOptions, + SecurityOptionsTransformedKwargs, + temp_options, + keys_to_ignore=keys_to_ignore) + if transformed_security_opts: + temp_options['security_options'] = transformed_security_opts + + keys_to_ignore = [*ClusterOptions.VALID_OPTION_KEYS, + *SecurityOptions.VALID_OPTION_KEYS] + + # not going to be able to make mypy happy w/ keys_to_ignore :/ + transformed_timeout_opts = self.build_options(TimeoutOptions, + TimeoutOptionsTransformedKwargs, + temp_options, + keys_to_ignore=keys_to_ignore) + if transformed_timeout_opts: + temp_options['timeout_options'] = transformed_timeout_opts + + # transform final ClusterOptions + transformed_opts = self.build_options(option_type, output_type, temp_options) + + return transformed_opts + + def build_options(self, + option_type: type[OptionsClass], + output_type: type[TransformedOptionKwargs], + orig_kwargs: Dict[str, object], + options: Optional[object] = None, + keys_to_ignore: Optional[List[str]] = None + ) -> TransformedOptionKwargs: + + temp_options = self._get_options_copy(option_type, orig_kwargs, options) + transformed_opts: TransformedOptionKwargs = {} + # Option 1 satisfies mypy, but we want temp_options to be the limiting factor for the loop. + # Option 2. Also makes providing warnings/exceptions for users not using static type checking easier, + # but unfortunately we need to use some type: ignore comments + + # Option 1: + # for k in option_type.VALID_OPTION_KEYS: + # if k in ALLOWED_TRANSFORM_KEYS and k in temp_options: + # for nk, cfn in tf_dict[k].items(): + # conv = cfn(temp_options[k]) + # transformed_opts[nk] = conv # type: ignore + + # Option 2: + allowed_keys, option_transforms = self._get_transform_details(option_type.__name__) + for k, v in temp_options.items(): + if k in allowed_keys: + transforms = option_transforms[k] # type: ignore[literal-required] + for nk, cfn in transforms.items(): + conv = cfn(v) + if conv is not None: + transformed_opts[nk] = conv # type: ignore[literal-required] + elif keys_to_ignore and k not in keys_to_ignore: + raise ValueError(f'Invalid key provided (key={k}).') + + return transformed_opts diff --git a/couchbase_analytics/protocol/result.py b/couchbase_analytics/protocol/result.py new file mode 100644 index 0000000..6837555 --- /dev/null +++ b/couchbase_analytics/protocol/result.py @@ -0,0 +1,16 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations diff --git a/couchbase_analytics/protocol/scope.py b/couchbase_analytics/protocol/scope.py new file mode 100644 index 0000000..ca5c9d8 --- /dev/null +++ b/couchbase_analytics/protocol/scope.py @@ -0,0 +1,86 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from concurrent.futures import Future, ThreadPoolExecutor +from typing import TYPE_CHECKING, Union + +from couchbase_analytics.common.result import BlockingQueryResult +from couchbase_analytics.protocol.core._request_context import RequestContext +from couchbase_analytics.protocol.core.client_adapter import _ClientAdapter +from couchbase_analytics.protocol.core.request import _RequestBuilder +from couchbase_analytics.protocol.streaming import HttpStreamingResponse + + +if TYPE_CHECKING: + from couchbase_analytics.protocol.database import Database + + +class Scope: + + def __init__(self, database: Database, scope_name: str) -> None: + self._database = database + self._scope_name = scope_name + self._request_builder = _RequestBuilder(self.client_adapter, self._database.name, self.name) + + @property + def client_adapter(self) -> _ClientAdapter: + """ + **INTERNAL** + """ + return self._database.client_adapter + + @property + def name(self) -> str: + """ + str: The name of this :class:`~couchbase_analytics.protocol.scope.Scope` instance. + """ + return self._scope_name + + @property + def threadpool_executor(self) -> ThreadPoolExecutor: + """ + **INTERNAL** + """ + return self._database.threadpool_executor + + def execute_query(self, + statement: str, + *args: object, + **kwargs: object) -> Union[BlockingQueryResult, Future[BlockingQueryResult]]: + base_req = self._request_builder.build_base_query_request(statement, *args, **kwargs) + lazy_execute = base_req.options.pop('lazy_execute', None) + stream_config = base_req.options.pop('stream_config', None) + request_context = RequestContext(self.client_adapter, + base_req, + self.threadpool_executor) + resp = HttpStreamingResponse(request_context, + lazy_execute=lazy_execute, + stream_config=stream_config) + + def _execute_query(http_response: HttpStreamingResponse) -> BlockingQueryResult: + http_response.send_request() + return BlockingQueryResult(http_response) + + if request_context.cancel_enabled is True: + if lazy_execute is True: + raise RuntimeError(('Cannot cancel, via cancel token, a query that is executed lazily.' + ' Queries executed lazily can be cancelled only after iteration begins.')) + return request_context.send_request_in_background(_execute_query, resp) + else: + if lazy_execute is not True: + resp.send_request() + return BlockingQueryResult(resp) diff --git a/couchbase_analytics/protocol/scope.pyi b/couchbase_analytics/protocol/scope.pyi new file mode 100644 index 0000000..7f10e93 --- /dev/null +++ b/couchbase_analytics/protocol/scope.pyi @@ -0,0 +1,158 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import sys +from concurrent.futures import Future, ThreadPoolExecutor +from typing import overload + +if sys.version_info < (3, 11): + from typing_extensions import Unpack +else: + from typing import Unpack + +from couchbase_analytics import JSONType +from couchbase_analytics.common.result import BlockingQueryResult +from couchbase_analytics.options import QueryOptions, QueryOptionsKwargs +from couchbase_analytics.protocol.core.client_adapter import _ClientAdapter +from couchbase_analytics.protocol.database import Database as Database + +class Scope: + def __init__(self, database: Database, scope_name: str) -> None: ... + + @property + def client_adapter(self) -> _ClientAdapter: ... + + @property + def name(self) -> str: ... + + @property + def threadpool_executor(self) -> ThreadPoolExecutor: ... + + @overload + def execute_query(self, statement: str) -> BlockingQueryResult: ... + + @overload + def execute_query(self, + statement: str, + options: QueryOptions) -> BlockingQueryResult: ... + + @overload + def execute_query(self, + statement: str, + **kwargs: Unpack[QueryOptionsKwargs]) -> BlockingQueryResult: ... + + @overload + def execute_query(self, + statement: str, + options: QueryOptions, + **kwargs: Unpack[QueryOptionsKwargs] + ) -> BlockingQueryResult: ... + + @overload + def execute_query(self, + statement: str, + options: QueryOptions, + *args: JSONType, + **kwargs: Unpack[QueryOptionsKwargs]) -> BlockingQueryResult: ... + + @overload + def execute_query(self, + statement: str, + options: QueryOptions, + *args: JSONType, + **kwargs: str) -> BlockingQueryResult: ... + + @overload + def execute_query(self, + statement: str, + *args: JSONType, + **kwargs: str) -> BlockingQueryResult: ... + + @overload + def execute_query(self, + statement: str, + enable_cancel: bool) -> Future[BlockingQueryResult]: ... + + @overload + def execute_query(self, + statement: str, + enable_cancel: bool, + *args: JSONType) -> Future[BlockingQueryResult]: ... + + @overload + def execute_query(self, + statement: str, + options: QueryOptions, + enable_cancel: bool) -> Future[BlockingQueryResult]: ... + + @overload + def execute_query(self, + statement: str, + enable_cancel: bool, + **kwargs: Unpack[QueryOptionsKwargs]) -> Future[BlockingQueryResult]: ... + + @overload + def execute_query(self, + statement: str, + options: QueryOptions, + enable_cancel: bool, + **kwargs: Unpack[QueryOptionsKwargs]) -> Future[BlockingQueryResult]: ... + + @overload + def execute_query(self, + statement: str, + options: QueryOptions, + enable_cancel: bool, + *args: JSONType, + **kwargs: Unpack[QueryOptionsKwargs]) -> Future[BlockingQueryResult]: ... + + @overload + def execute_query(self, + statement: str, + options: QueryOptions, + *args: JSONType, + enable_cancel: bool, + **kwargs: Unpack[QueryOptionsKwargs]) -> Future[BlockingQueryResult]: ... + + + @overload + def execute_query(self, + statement: str, + options: QueryOptions, + enable_cancel: bool, + *args: JSONType, + **kwargs: str) -> Future[BlockingQueryResult]: ... + + @overload + def execute_query(self, + statement: str, + options: QueryOptions, + *args: JSONType, + enable_cancel: bool, + **kwargs: str) -> Future[BlockingQueryResult]: ... + + @overload + def execute_query(self, + statement: str, + enable_cancel: bool, + *args: JSONType, + **kwargs: str) -> Future[BlockingQueryResult]: ... + + @overload + def execute_query(self, + statement: str, + *args: JSONType, + enable_cancel: bool, + **kwargs: str) -> Future[BlockingQueryResult]: ... diff --git a/couchbase_analytics/protocol/streaming.py b/couchbase_analytics/protocol/streaming.py new file mode 100644 index 0000000..aa43b4c --- /dev/null +++ b/couchbase_analytics/protocol/streaming.py @@ -0,0 +1,223 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import json + +from concurrent.futures import CancelledError +from functools import wraps +from typing import (Any, + Callable, + Optional) + +from httpx import Response as HttpCoreResponse + +# TODO: errors? +from couchbase_analytics.common.errors import AnalyticsError, InternalSDKError +from couchbase_analytics.common.core import (JsonStreamConfig, + ParsedResult, + ParsedResultType) +from couchbase_analytics.common.core.json_stream import JsonStream +from couchbase_analytics.common.core.query import build_query_metadata +from couchbase_analytics.common.query import QueryMetadata +from couchbase_analytics.common.streaming import StreamingState +from couchbase_analytics.protocol.core._request_context import RequestContext, ThreadSafeBytesIterator + +class RequestWrapper: + """ + **INTERNAL** + """ + + @classmethod + def handle_retries(cls) -> Callable[[Callable[[], None]], Callable[[HttpStreamingResponse], None]]: + """ + **INTERNAL** + """ + + def decorator(fn: Callable[[], None]) -> Callable[[HttpStreamingResponse], None]: + @wraps(fn) + def wrapped_fn(self: HttpStreamingResponse) -> None: + try: + fn(self) + except AnalyticsError: + # if an AnalyticsError is raised, we have already shut down the request context + raise + except RuntimeError as ex: + self._request_context.shutdown(ex) + raise ex + except BaseException as ex: + self._request_context.shutdown(ex) + if self._request_context.request_error is not None: + raise self._request_context.request_error from None + if self._request_context.timed_out: + raise TimeoutError() from None + if self._request_context.cancelled: + raise CancelledError('Request was cancelled.') from None + raise InternalSDKError(ex) from None + finally: + if not StreamingState.is_okay(self._request_context.request_state): + self.close() + + return wrapped_fn + return decorator + +class HttpStreamingResponse: + def __init__(self, + request_context: RequestContext, + lazy_execute: Optional[bool] = None, + stream_config: Optional[JsonStreamConfig]=None) -> None: + self._request_context = request_context + if lazy_execute is not None: + self._lazy_execute = lazy_execute + else: + self._lazy_execute = False + self._metadata: Optional[QueryMetadata] = None + self._core_response: HttpCoreResponse + self._stream_config = stream_config or JsonStreamConfig() + self._json_stream = None + + @property + def lazy_execute(self) -> bool: + """ + **INTERNAL** + """ + return self._lazy_execute + + def _finish_processing_stream(self) -> None: + if not self._request_context.has_stage_completed: + self._process_ft.result() + + if self._request_context.cancelled: + return + + while not self._json_stream.token_stream_exhausted: + self._json_stream.continue_parsing() + + def _maybe_continue_to_process_stream(self) -> None: + if not self._request_context.has_stage_completed: + return + + if self._json_stream.token_stream_exhausted: + return + + if self._request_context.cancelled: + return + + # NOTE: start_next_stage injects the request context into args + self._request_context.start_next_stage(self._json_stream.continue_parsing, reset_previous_stage=True) + + def _process_response(self, raw_response: Optional[ParsedResult]=None) -> None: + if raw_response is None: + raw_response = self._json_stream.get_result(self._stream_config.queue_timeout) + if raw_response is None: + # TODO: logging?? + # TODO: exception?? + raise RuntimeError('No result from JsonStream') + + json_response = json.loads(raw_response.value) + if 'errors' in json_response: + self._request_context.process_error(json_response['errors']) + self.set_metadata(json_data=json_response) + # we have all the data, close the core response/stream + self.close() + + def _start(self) -> None: + """ + **INTERNAL** + """ + if self._json_stream is not None: + # TODO: logging; I don't think this is an error... + return + + # TODO: need to confirm if the httpx Response iterator is thread-safe + self._json_stream = JsonStream(self._core_response.iter_bytes(), stream_config=self._stream_config) + # NOTE: start_next_stage injects the request context into args + self._request_context.start_next_stage(self._json_stream.start_parsing, create_notification=True) + + def close(self) -> None: + """ + **INTERNAL** + """ + if hasattr(self,'_core_response'): + self._core_response.close() + del self._core_response + + def cancel(self) -> None: + """ + **INTERNAL** + """ + self.close() + + def get_metadata(self) -> QueryMetadata: + if self._metadata is None: + raise RuntimeError('Query metadata is only available after all rows have been iterated.') + return self._metadata + + def set_metadata(self, + json_data: Optional[Any]=None, + raw_metadata: Optional[bytes]=None) -> None: + try: + self._metadata = QueryMetadata(build_query_metadata(json_data=json_data, raw_metadata=raw_metadata)) + except AnalyticsError as err: + raise err + except Exception as ex: + raise InternalSDKError(str(ex)) + + def get_next_row(self) -> Any: + """ + **INTERNAL** + """ + if not (hasattr(self, '_core_response') + and self._core_response is not None + and self._request_context.okay_to_iterate): + self.close() + raise StopIteration + + self._maybe_continue_to_process_stream() + while True: + if self._request_context.cancelled: + self.close() + raise StopIteration + # TODO: handle timeout + raw_response = self._json_stream.get_result(self._stream_config.queue_timeout) + if raw_response.result_type == ParsedResultType.ROW: + return self._request_context.deserializer.deserialize(raw_response.value) + elif raw_response.result_type in [ParsedResultType.ERROR, ParsedResultType.UNKNOWN]: + self._process_response(raw_response=raw_response) + elif raw_response.result_type == ParsedResultType.END: + self.set_metadata(raw_metadata=raw_response.value) + self.close() + raise StopIteration + + @RequestWrapper.handle_retries() + def send_request(self) -> None: + if not self._request_context.okay_to_stream: + raise RuntimeError('Query has been canceled or previously executed.') + + self._request_context.initialize() + # TODO: do we need to use the tracing? + self._core_response = self._request_context.send_request() + if self._request_context.cancelled: + raise CancelledError('Request was cancelled.') + self._start() + # block until we either know we have rows or errors + res = self._request_context.wait_for_stage_notification() + if res == ParsedResultType.ROW: + # we move to iterating rows + self._request_context.set_state_to_streaming() + else: + self._finish_processing_stream() + self._process_response() diff --git a/couchbase_analytics/query.py b/couchbase_analytics/query.py new file mode 100644 index 0000000..6d6520e --- /dev/null +++ b/couchbase_analytics/query.py @@ -0,0 +1,19 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from couchbase_analytics.common.enums import QueryScanConsistency as QueryScanConsistency # noqa: F401 +from couchbase_analytics.common.query import QueryMetadata as QueryMetadata # noqa: F401 +from couchbase_analytics.common.query import QueryMetrics as QueryMetrics # noqa: F401 +from couchbase_analytics.common.query import QueryWarning as QueryWarning # noqa: F401 diff --git a/couchbase_analytics/result.py b/couchbase_analytics/result.py new file mode 100644 index 0000000..78712e1 --- /dev/null +++ b/couchbase_analytics/result.py @@ -0,0 +1,18 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from couchbase_analytics.common.result import AsyncQueryResult as AsyncQueryResult # noqa: F401 +from couchbase_analytics.common.result import BlockingQueryResult as BlockingQueryResult # noqa: F401 +from couchbase_analytics.common.result import QueryResult as QueryResult # noqa: F401 diff --git a/couchbase_analytics/scope.py b/couchbase_analytics/scope.py new file mode 100644 index 0000000..a2a8970 --- /dev/null +++ b/couchbase_analytics/scope.py @@ -0,0 +1,115 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from concurrent.futures import Future +from typing import TYPE_CHECKING, Union + +from couchbase_analytics.result import BlockingQueryResult + +if TYPE_CHECKING: + from couchbase_analytics.protocol.database import Database + + +class Scope: + """Create a Scope instance. + + The Scope instance exposes the operations which are available to be performed against an Analytics scope. + + Args: + database (:class:`~couchbase_analytics.database.Database`): A :class:`~couchbase_analytics.database.Database` instance. + scope_name (str): The scope name. + + """ # noqa: E501 + + def __init__(self, database: Database, scope_name: str) -> None: + from couchbase_analytics.protocol.scope import Scope as _Scope + self._impl = _Scope(database, scope_name) + + @property + def name(self) -> str: + """ + str: The name of this :class:`~couchbase_analytics.scope.Scope` instance. + """ + return self._impl.name + + def execute_query(self, + statement: str, + *args: object, + **kwargs: object) -> Union[Future[BlockingQueryResult], BlockingQueryResult]: + """Executes a query against an Analytics scope. + + .. note:: + A departure from the operational SDK, the query is *NOT* executed lazily. + + .. seealso:: + * :meth:`couchbase_analytics.Cluster.execute_query`: For how to execute cluster-level queries. + + Args: + statement (str): The N1QL statement to execute. + options (:class:`~couchbase_analytics.options.QueryOptions`): Optional parameters for the query operation. + **kwargs (Dict[str, Any]): keyword arguments that can be used in place or to override provided :class:`~couchbase_analytics.options.QueryOptions` + + Returns: + :class:`~couchbase_analytics.result.BlockingQueryResult`: An instance of a :class:`~couchbase_analytics.result.BlockingQueryResult` which + provides access to iterate over the query results and access metadata and metrics about the query. + + Examples: + Simple query:: + + q_str = 'SELECT * FROM airline WHERE country LIKE 'United%' LIMIT 2;' + q_res = scope.execute_query(q_str) + for row in q_res.rows(): + print(f'Found row: {row}') + + Simple query with positional parameters:: + + from couchbase_analytics.options import QueryOptions + + # ... other code ... + + q_str = 'SELECT * FROM airline WHERE country LIKE $1 LIMIT $2;' + q_res = scope.execute_query(q_str, QueryOptions(positional_parameters=['United%', 5])) + for row in q_res.rows(): + print(f'Found row: {row}') + + Simple query with named parameters:: + + from couchbase_analytics.options import QueryOptions + + # ... other code ... + + q_str = 'SELECT * FROM airline WHERE country LIKE $country LIMIT $lim;' + q_res = scope.execute_query(q_str, QueryOptions(named_parameters={'country': 'United%', 'lim':2})) + for row in q_res.rows(): + print(f'Found row: {row}') + + Retrieve metadata and/or metrics from query:: + + from couchbase_analytics.options import QueryOptions + + # ... other code ... + + q_str = 'SELECT * FROM `travel-sample` WHERE country LIKE $country LIMIT $lim;' + q_res = scope.execute_query(q_str, QueryOptions(named_parameters={'country': 'United%', 'lim':2})) + for row in q_res.rows(): + print(f'Found row: {row}') + + print(f'Query metadata: {q_res.metadata()}') + print(f'Query metrics: {q_res.metadata().metrics()}') + + """ # noqa: E501 + return self._impl.execute_query(statement, *args, **kwargs) diff --git a/couchbase_analytics/scope.pyi b/couchbase_analytics/scope.pyi new file mode 100644 index 0000000..913b6d8 --- /dev/null +++ b/couchbase_analytics/scope.pyi @@ -0,0 +1,151 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import sys +from concurrent.futures import Future +from typing import overload + +if sys.version_info < (3, 11): + from typing_extensions import Unpack +else: + from typing import Unpack + +from couchbase_analytics import JSONType +from couchbase_analytics.options import QueryOptions, QueryOptionsKwargs +from couchbase_analytics.protocol.database import Database as Database +from couchbase_analytics.result import BlockingQueryResult + +class Scope: + def __init__(self, database: Database, scope_name: str) -> None: ... + + @property + def name(self) -> str: ... + + @overload + def execute_query(self, statement: str) -> BlockingQueryResult: ... + + @overload + def execute_query(self, + statement: str, + options: QueryOptions) -> BlockingQueryResult: ... + + @overload + def execute_query(self, + statement: str, + **kwargs: Unpack[QueryOptionsKwargs]) -> BlockingQueryResult: ... + + @overload + def execute_query(self, + statement: str, + options: QueryOptions, + **kwargs: Unpack[QueryOptionsKwargs] + ) -> BlockingQueryResult: ... + + @overload + def execute_query(self, + statement: str, + options: QueryOptions, + *args: JSONType, + **kwargs: Unpack[QueryOptionsKwargs]) -> BlockingQueryResult: ... + + @overload + def execute_query(self, + statement: str, + options: QueryOptions, + *args: JSONType, + **kwargs: str) -> BlockingQueryResult: ... + + @overload + def execute_query(self, + statement: str, + *args: JSONType, + **kwargs: str) -> BlockingQueryResult: ... + + @overload + def execute_query(self, + statement: str, + enable_cancel: bool) -> Future[BlockingQueryResult]: ... + + @overload + def execute_query(self, + statement: str, + enable_cancel: bool, + *args: JSONType) -> Future[BlockingQueryResult]: ... + + @overload + def execute_query(self, + statement: str, + options: QueryOptions, + enable_cancel: bool) -> Future[BlockingQueryResult]: ... + + @overload + def execute_query(self, + statement: str, + enable_cancel: bool, + **kwargs: Unpack[QueryOptionsKwargs]) -> Future[BlockingQueryResult]: ... + + @overload + def execute_query(self, + statement: str, + options: QueryOptions, + enable_cancel: bool, + **kwargs: Unpack[QueryOptionsKwargs]) -> Future[BlockingQueryResult]: ... + + @overload + def execute_query(self, + statement: str, + options: QueryOptions, + enable_cancel: bool, + *args: JSONType, + **kwargs: Unpack[QueryOptionsKwargs]) -> Future[BlockingQueryResult]: ... + + @overload + def execute_query(self, + statement: str, + options: QueryOptions, + *args: JSONType, + enable_cancel: bool, + **kwargs: Unpack[QueryOptionsKwargs]) -> Future[BlockingQueryResult]: ... + + + @overload + def execute_query(self, + statement: str, + options: QueryOptions, + enable_cancel: bool, + *args: JSONType, + **kwargs: str) -> Future[BlockingQueryResult]: ... + + @overload + def execute_query(self, + statement: str, + options: QueryOptions, + *args: JSONType, + enable_cancel: bool, + **kwargs: str) -> Future[BlockingQueryResult]: ... + + @overload + def execute_query(self, + statement: str, + enable_cancel: bool, + *args: JSONType, + **kwargs: str) -> Future[BlockingQueryResult]: ... + + @overload + def execute_query(self, + statement: str, + *args: JSONType, + enable_cancel: bool, + **kwargs: str) -> Future[BlockingQueryResult]: ... diff --git a/couchbase_analytics/tests/__init__.py b/couchbase_analytics/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/couchbase_analytics/tests/duration_parsing_t.py b/couchbase_analytics/tests/duration_parsing_t.py new file mode 100644 index 0000000..2b7badf --- /dev/null +++ b/couchbase_analytics/tests/duration_parsing_t.py @@ -0,0 +1,97 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING + +import pytest + +from couchbase_analytics.common.core.duration_str_utils import parse_duration_str + + +class DurationParsingTestSuite: + + TEST_MANIFEST = [ + 'test_invalid_durations', + 'test_valid_durations', + ] + + @pytest.mark.parametrize('duration', + [ + '', + '10', + '10Gs', + 'abc', + '-', + '+', + '1h-', + '1h 30m', + '1h_30m', + 'h1', + '-.5s', + '1.2.3s', + ]) + def test_invalid_durations(self, duration: str) -> None: + with pytest.raises(ValueError): + parse_duration_str(duration) + + @pytest.mark.parametrize('duration, expected_millis', + [('0', 0), + ('0s', 0), + ('1h', 3.6e6), + ('+1h', 3.6e6), + ('1h10m', 4.2e6), + ('1.h10m', 4.2e6), + ('1.234h', 1.234 * 3.6e6), + ('1h30m0s', 5.4e6), + ('0.1h10m', 9.6e5), + ('.1h10m', 9.6e5), # TODO: apparently this is invalid in Go, but was okay w/ C++ implementation + ('0001h00010m', 4.2e6), + ('100ns', 1e-4), + ('100us', 0.1), + ('100μs', 0.1), + ('100µs', 0.1), + ('1000000ns', 1), + ('1000us', 1), + ('1000μs', 1), + ('1000µs', 1), + ('3h15m10s500ms', 11710.5 * 1e3), + ('1h1m1s1ms1us1ns', 3.6e6 + 60e3 + 1e3 + 1 + 0.001 + 0.000001), + ('2m3s4ms', 123004), + ('4ms3s2m', 123004), + ('4ms3s2m5s', 128004), + ('2m3.125s', 123125), + ]) + def test_valid_durations(self, duration: str, expected_millis: float) -> None: + actual = parse_duration_str(duration, in_millis=True) + # if we don't allow for a tolerance, we will have issues with float precision + # examples: + # 100us yields 0.09999999999999999 != 0.1 + # 4ms3s2m5s yields 128004.00000000001 != 128004 + assert abs(actual - expected_millis) < 1e-9 + +class DurationParsingTests(DurationParsingTestSuite): + + @pytest.fixture(scope='class', autouse=True) + def validate_test_manifest(self) -> None: + def valid_test_method(meth: str) -> bool: + attr = getattr(DurationParsingTests, meth) + return callable(attr) and not meth.startswith('__') and meth.startswith('test') + method_list = [meth for meth in dir(DurationParsingTests) if valid_test_method(meth)] + test_list = set(DurationParsingTestSuite.TEST_MANIFEST).symmetric_difference(method_list) + if test_list: + pytest.fail(f'Test manifest invalid. Missing/extra tests: {test_list}.') \ No newline at end of file diff --git a/couchbase_analytics/tests/json_parsing_t.py b/couchbase_analytics/tests/json_parsing_t.py new file mode 100644 index 0000000..65a9826 --- /dev/null +++ b/couchbase_analytics/tests/json_parsing_t.py @@ -0,0 +1,351 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING + +import pytest + +from couchbase_analytics.common.core import (JsonParsingError, + ParsedResult, + ParsedResultType) +from couchbase_analytics.common.core.json_stream import JsonStream, JsonStreamConfig +from tests.environments.simple_environment import JsonDataType +from tests.utils import BytesIterator + +if TYPE_CHECKING: + from tests.environments.simple_environment import SimpleEnvironment + + +class JsonParsingTestSuite: + + TEST_MANIFEST = [ + 'test_analytics_error', + 'test_analytics_many_rows', + 'test_analytics_simple_result', + + 'test_array', + 'test_array_empty', + 'test_array_mixed_types', + 'test_array_of_objects', + + 'test_invalid_empty', + 'test_invalid_garbage_between_objects', + 'test_invalid_leading_garbage', + 'test_invalid_trailing_garbage', + 'test_invalid_whitespace_only', + + 'test_object', + 'test_object_complex_nested_structure', + 'test_object_empty', + 'test_object_simple_nested', + 'test_object_with_empty_key_and_value', + 'test_object_with_unicode', + + 'test_value_bool', + 'test_value_null', + ] + + @pytest.mark.parametrize('buffered_result', [True, False]) + def test_analytics_error(self, + test_env: SimpleEnvironment, + buffered_result: bool) -> None: + json_object, bytes_data = test_env.get_json_data(JsonDataType.FAILED_REQUEST) + if buffered_result: + parser = JsonStream(BytesIterator(bytes_data), + stream_config=JsonStreamConfig(buffer_entire_result=True)) + else: + parser = JsonStream(BytesIterator(bytes_data)) + parser.start_parsing() + result = parser.get_result(0.01) + assert isinstance(result, ParsedResult) + assert result.result_type == ParsedResultType.ERROR + assert isinstance(result.value, bytes) + assert json.loads(result.value.decode('utf-8')) == json_object + assert parser.get_result(0.01) is None + + def test_analytics_many_rows(self, test_env: SimpleEnvironment) -> None: + json_object, bytes_data = test_env.get_json_data(JsonDataType.MULTIPLE_RESULTS) + parser = JsonStream(BytesIterator(bytes_data)) + parser.start_parsing() + row_idx = 0 + while row_idx < 36: + result = parser.get_result(0.01) + if result is None and not parser.token_stream_exhausted: + parser.continue_parsing() + continue + assert isinstance(result, ParsedResult) + assert result.result_type == ParsedResultType.ROW + assert isinstance(result.value, bytes) + assert json.loads(result.value.decode('utf-8')) == json_object['results'][row_idx] + row_idx += 1 + + final_result = parser.get_result(0.01) + assert isinstance(final_result, ParsedResult) + assert final_result.result_type == ParsedResultType.END + assert isinstance(final_result.value, bytes) + # if we are not buffering the entire result, the final result will exclude the results key + json_object.pop('results') + assert json.loads(final_result.value.decode('utf-8')) == json_object + assert parser.get_result(0.01) is None + + @pytest.mark.parametrize('buffered_result', [True, False]) + def test_analytics_simple_result(self, + test_env: SimpleEnvironment, + buffered_result: bool) -> None: + json_object, bytes_data = test_env.get_json_data(JsonDataType.SIMPLE_REQUEST) + if buffered_result: + parser = JsonStream(BytesIterator(bytes_data), + stream_config=JsonStreamConfig(buffer_entire_result=True)) + else: + parser = JsonStream(BytesIterator(bytes_data)) + parser.start_parsing() + # check for individual rows when not buffering the result + if not buffered_result: + result = parser.get_result(0.01) + assert isinstance(result, ParsedResult) + assert result.result_type == ParsedResultType.ROW + assert isinstance(result.value, bytes) + assert json.loads(result.value.decode('utf-8')) == json_object['results'][0] + + final_result = parser.get_result(0.01) + assert isinstance(final_result, ParsedResult) + assert final_result.result_type == ParsedResultType.END + assert isinstance(final_result.value, bytes) + # we don't store the 'results' if buffering is not enabled + if not buffered_result: + json_object.pop('results') + assert json.loads(final_result.value.decode('utf-8')) == json_object + assert parser.get_result(0.01) is None + + def test_array(self): + data = '[1,2,"three"]' + parser = JsonStream(BytesIterator(bytes(data, 'utf-8')), + stream_config=JsonStreamConfig(buffer_entire_result=True)) + parser.start_parsing() + result = parser.get_result(0.01) + assert isinstance(result, ParsedResult) + assert result.result_type == ParsedResultType.END + assert isinstance(result.value, bytes) + assert result.value.decode('utf-8') == data + assert parser.get_result(0.01) is None + + def test_array_empty(self): + data = '[]' + parser = JsonStream(BytesIterator(bytes(data, 'utf-8')), + stream_config=JsonStreamConfig(buffer_entire_result=True)) + parser.start_parsing() + result = parser.get_result(0.01) + assert isinstance(result, ParsedResult) + assert result.result_type == ParsedResultType.END + assert isinstance(result.value, bytes) + assert result.value.decode('utf-8') == data + assert parser.get_result(0.01) is None + + def test_array_mixed_types(self): + data = '[123,"text",true,null,{"key":"value"}]' + parser = JsonStream(BytesIterator(bytes(data, 'utf-8')), + stream_config=JsonStreamConfig(buffer_entire_result=True)) + parser.start_parsing() + result = parser.get_result(0.01) + assert isinstance(result, ParsedResult) + assert result.result_type == ParsedResultType.END + assert isinstance(result.value, bytes) + assert result.value.decode('utf-8') == data + assert parser.get_result(0.01) is None + + def test_array_of_objects(self): + data = '[{"id":1,"name":"Alice"},{"id":2,"name":"Bob"}]' + parser = JsonStream(BytesIterator(bytes(data, 'utf-8')), + stream_config=JsonStreamConfig(buffer_entire_result=True)) + parser.start_parsing() + result = parser.get_result(0.01) + assert isinstance(result, ParsedResult) + assert result.result_type == ParsedResultType.END + assert isinstance(result.value, bytes) + assert result.value.decode('utf-8') == data + assert parser.get_result(0.01) is None + + def test_invalid_empty(self): + try: + data = '' + parser = JsonStream(BytesIterator(bytes(data, 'utf-8')), + stream_config=JsonStreamConfig(buffer_entire_result=True)) + parser.start_parsing() + except JsonParsingError as err: + assert isinstance(err, JsonParsingError) + assert err.cause is not None + assert 'parse error' in str(err.cause) + + def test_invalid_garbage_between_objects(self): + try: + data = '[{"id":1,"name":"Alice"},garbage,{"id":2,"name":"Bob"}]' + parser = JsonStream(BytesIterator(bytes(data, 'utf-8')), + stream_config=JsonStreamConfig(buffer_entire_result=True)) + parser.start_parsing() + except JsonParsingError as err: + assert isinstance(err, JsonParsingError) + assert err.cause is not None + assert 'lexical error' in str(err.cause) + + def test_invalid_leading_garbage(self): + try: + data = 'garbage{"key":"value"}' + parser = JsonStream(BytesIterator(bytes(data, 'utf-8')), + stream_config=JsonStreamConfig(buffer_entire_result=True)) + parser.start_parsing() + except JsonParsingError as err: + assert isinstance(err, JsonParsingError) + assert err.cause is not None + assert 'lexical error' in str(err.cause) + + def test_invalid_trailing_garbage(self): + try: + data = '{"key":"value"}garbage' + parser = JsonStream(BytesIterator(bytes(data, 'utf-8')), + stream_config=JsonStreamConfig(buffer_entire_result=True)) + parser.start_parsing() + except JsonParsingError as err: + assert isinstance(err, JsonParsingError) + assert err.cause is not None + assert 'parse error' in str(err.cause) + + def test_invalid_whitespace_only(self): + try: + data = ' \n\t ' + parser = JsonStream(BytesIterator(bytes(data, 'utf-8')), + stream_config=JsonStreamConfig(buffer_entire_result=True)) + parser.start_parsing() + except JsonParsingError as err: + assert isinstance(err, JsonParsingError) + assert err.cause is not None + assert 'parse error' in str(err.cause) + + def test_object(self): + data = '{"name":"John","age":30,"city":"New York"}' + parser = JsonStream(BytesIterator(bytes(data, 'utf-8')), + stream_config=JsonStreamConfig(buffer_entire_result=True)) + parser.start_parsing() + result = parser.get_result(0.01) + assert isinstance(result, ParsedResult) + assert result.result_type == ParsedResultType.END + assert isinstance(result.value, bytes) + assert result.value.decode('utf-8') == data + assert parser.get_result(0.01) is None + + def test_object_complex_nested_structure(self): + data_list = ['{"users":[{"id":1,"name":"Alice","roles":["admin","editor"]},' + '{"id":2,"name":"Bob","roles":["viewer"]}],', + '"meta":{"count":2,"status":"success"}}'] + data = ''.join(data_list) + parser = JsonStream(BytesIterator(bytes(data, 'utf-8')), + stream_config=JsonStreamConfig(buffer_entire_result=True)) + parser.start_parsing() + result = parser.get_result(0.01) + assert isinstance(result, ParsedResult) + assert result.result_type == ParsedResultType.END + assert isinstance(result.value, bytes) + assert result.value.decode('utf-8') == data + assert parser.get_result(0.01) is None + + def test_object_empty(self): + data = '{}' + parser = JsonStream(BytesIterator(bytes(data, 'utf-8')), + stream_config=JsonStreamConfig(buffer_entire_result=True)) + parser.start_parsing() + result = parser.get_result(0.01) + assert isinstance(result, ParsedResult) + assert result.result_type == ParsedResultType.END + assert isinstance(result.value, bytes) + assert result.value.decode('utf-8') == data + assert parser.get_result(0.01) is None + + def test_object_simple_nested(self): + data = '{"outer":{"inner":{"key":"value"}}}' + parser = JsonStream(BytesIterator(bytes(data, 'utf-8')), + stream_config=JsonStreamConfig(buffer_entire_result=True)) + parser.start_parsing() + result = parser.get_result(0.01) + assert isinstance(result, ParsedResult) + assert result.result_type == ParsedResultType.END + assert isinstance(result.value, bytes) + assert result.value.decode('utf-8') == data + assert parser.get_result(0.01) is None + + def test_object_with_empty_key_and_value(self): + data = '{"":""}' + parser = JsonStream(BytesIterator(bytes(data, 'utf-8')), + stream_config=JsonStreamConfig(buffer_entire_result=True)) + parser.start_parsing() + result = parser.get_result(0.01) + assert isinstance(result, ParsedResult) + assert result.result_type == ParsedResultType.END + assert isinstance(result.value, bytes) + assert result.value.decode('utf-8') == data + assert parser.get_result(0.01) is None + + def test_object_with_unicode(self): + data = '{"name":"你好","city":"Denver"}' + parser = JsonStream(BytesIterator(bytes(data, 'utf-8')), + stream_config=JsonStreamConfig(buffer_entire_result=True)) + parser.start_parsing() + result = parser.get_result(0.01) + assert isinstance(result, ParsedResult) + assert result.result_type == ParsedResultType.END + assert isinstance(result.value, bytes) + assert result.value.decode('utf-8') == data + assert parser.get_result(0.01) is None + + def test_value_bool(self) -> None: + data = 'true' + parser = JsonStream(BytesIterator(bytes(data, 'utf-8')), + stream_config=JsonStreamConfig(buffer_entire_result=True)) + parser.start_parsing() + result = parser.get_result(0.01) + assert isinstance(result, ParsedResult) + assert result.result_type == ParsedResultType.END + assert isinstance(result.value, bytes) + assert result.value.decode('utf-8') == data + assert parser.get_result(0.01) is None + + def test_value_null(self): + data = 'null' + parser = JsonStream(BytesIterator(bytes(data, 'utf-8')), + stream_config=JsonStreamConfig(buffer_entire_result=True)) + parser.start_parsing() + result = parser.get_result(0.01) + assert isinstance(result, ParsedResult) + assert result.result_type == ParsedResultType.END + assert isinstance(result.value, bytes) + assert result.value.decode('utf-8') == data + assert parser.get_result(0.01) is None + +class JsonParsingTests(JsonParsingTestSuite): + + @pytest.fixture(scope='class', autouse=True) + def validate_test_manifest(self) -> None: + def valid_test_method(meth: str) -> bool: + attr = getattr(JsonParsingTests, meth) + return callable(attr) and not meth.startswith('__') and meth.startswith('test') + method_list = [meth for meth in dir(JsonParsingTests) if valid_test_method(meth)] + test_list = set(JsonParsingTestSuite.TEST_MANIFEST).symmetric_difference(method_list) + if test_list: + pytest.fail(f'Test manifest invalid. Missing/extra tests: {test_list}.') + + @pytest.fixture(scope='class', name='test_env') + def couchbase_test_environment(self, simple_test_env: SimpleEnvironment) -> SimpleEnvironment: + return simple_test_env \ No newline at end of file diff --git a/couchbase_analytics/tests/test_server_t.py b/couchbase_analytics/tests/test_server_t.py new file mode 100644 index 0000000..9649e06 --- /dev/null +++ b/couchbase_analytics/tests/test_server_t.py @@ -0,0 +1,65 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import json +from concurrent.futures import Future +from typing import TYPE_CHECKING + +import pytest + +from tests import YieldFixture + +if TYPE_CHECKING: + from tests.environments.base_environment import BlockingTestEnvironment + + +class TestServerTestSuite: + + TEST_MANIFEST = [ + 'test_simple', + ] + + def test_simple(self, test_env: BlockingTestEnvironment) -> None: + test_env.set_url_path('/test_post') + test_env.update_request_json({'test_timeout': 10}) + test_env.update_request_extensions({'timeout': {'pool': 5, + 'test_pool_timeout': 5, + 'test_connect_timeout': 5}}) + statement = 'SELECT "Hello, data!" AS greeting' + res = test_env.cluster.execute_query(statement) + print(f'Have result: {res=}') + if isinstance(res, Future): + print('Result is a Future') + res = res.result() + +class TestServerTests(TestServerTestSuite): + + @pytest.fixture(scope='class', autouse=True) + def validate_test_manifest(self) -> None: + def valid_test_method(meth: str) -> bool: + attr = getattr(TestServerTests, meth) + return callable(attr) and not meth.startswith('__') and meth.startswith('test') + method_list = [meth for meth in dir(TestServerTests) if valid_test_method(meth)] + test_list = set(TestServerTestSuite.TEST_MANIFEST).symmetric_difference(method_list) + if test_list: + pytest.fail(f'Test manifest invalid. Missing/extra tests: {test_list}.') + + @pytest.fixture(scope='class', name='test_env') + def couchbase_test_environment(self, sync_test_env_with_server: BlockingTestEnvironment) -> YieldFixture[BlockingTestEnvironment]: + test_env = sync_test_env_with_server.enable_test_server() + yield test_env + test_env.disable_test_server() \ No newline at end of file diff --git a/couchbase_analytics_version.py b/couchbase_analytics_version.py new file mode 100644 index 0000000..55e9245 --- /dev/null +++ b/couchbase_analytics_version.py @@ -0,0 +1,206 @@ +#!/usr/bin/env python + +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import print_function + +import datetime +import os.path +import re +import subprocess +import warnings +from typing import Optional + + +class CantInvokeGit(Exception): + pass + + +class VersionNotFound(Exception): + pass + + +class MalformedGitTag(Exception): + pass + + +RE_XYZ = re.compile(r'(\d+)\.(\d+)\.(\d+)(?:-(.*))?') + +VERSION_FILE = os.path.join(os.path.dirname(__file__), 'couchbase_analytics', '_version.py') + + +class VersionInfo: + def __init__(self, rawtext: str): + self.rawtext = rawtext + t = self.rawtext.rsplit('-', 2) + if len(t) != 3: + raise MalformedGitTag(self.rawtext) + + vinfo, ncommits, self.sha = t + self.ncommits = int(ncommits) + + # Split up the X.Y.Z + match = RE_XYZ.match(vinfo) + if match is not None: + (self.ver_maj, self.ver_min, self.ver_patch, self.ver_extra) = match.groups() + + # Per PEP-440, replace any 'DP' with an 'a', and any beta with 'b' + if self.ver_extra: + self.ver_extra = re.sub(r'^dp', 'dev', self.ver_extra, count=1) + self.ver_extra = re.sub(r'^alpha', 'a', self.ver_extra, count=1) + self.ver_extra = re.sub(r'^beta', 'b', self.ver_extra, count=1) + m = re.search(r'^([ab]|dev|rc|post)\.?(\d+)?', self.ver_extra) + if m is not None: + if m.group(1) in ["dev", "post"]: + self.ver_extra = "." + self.ver_extra.replace('.', '') + if m.group(2) is None: + # No suffix, then add the number + first = self.ver_extra[0] + self.ver_extra = first + '0' + self.ver_extra[1:] + + @property + def is_final(self) -> bool: + return self.ncommits == 0 + + @property + def is_prerelease(self) -> bool: + return self.ver_extra is not None and not self.ver_extra.isspace() + + @property + def xyz_version(self) -> str: + return '.'.join((self.ver_maj, self.ver_min, self.ver_patch)) + + @property + def base_version(self) -> str: + """Returns the actual upstream version (without dev info)""" + components = [self.xyz_version] + if self.ver_extra: + components.append(self.ver_extra) + return ''.join(components) + + @property + def package_version(self) -> str: + """Returns the well formed PEP-440 version""" + vbase = self.base_version + if self.ncommits: + if self.ver_extra: + vbase += f'+{self.sha}' + else: + vbase += f'.dev{self.ncommits}+{self.sha}' + return vbase + + +def get_version() -> str: + """ + Returns the version from the generated version file without actually + loading it (and thus trying to load the extension module). + """ + if not os.path.exists(VERSION_FILE): + raise VersionNotFound(VERSION_FILE + " does not exist") + fp = open(VERSION_FILE, "r") + vline = None + for x in fp.readlines(): + x = x.rstrip() + if not x: + continue + if not x.startswith("__version__"): + continue + + vline = x.split('=')[1] + break + if not vline: + raise VersionNotFound("version file present but has no contents") + + return vline.strip().rstrip().replace("'", '') + + +def get_git_describe() -> str: + if not os.path.exists(os.path.join(os.path.dirname(__file__), ".git")): + raise CantInvokeGit("Not a git build") + + try: + po = subprocess.Popen( + ("git", "describe", "--tags", "--long", "--always"), + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + except OSError as e: + raise CantInvokeGit(e) + + stdout, stderr = po.communicate() + if po.returncode != 0: + raise CantInvokeGit("Couldn't invoke git describe", stderr) + + return stdout.decode('utf-8').rstrip() + + +def gen_version(do_write: Optional[bool] = True, txt: Optional[str] = None) -> None: + """ + Generate a version based on git tag info. This will write the + couchbase_analytics/_version.py file. If not inside a git tree it will + raise a CantInvokeGit exception - which is normal + (and squashed by setup.py) if we are running from a tarball + """ + + if txt is None: + txt = get_git_describe() + + t = txt.rsplit('-', 2) + if len(t) != 3: + only_sha = re.match('[a-z0-9]+', txt) + if only_sha is not None and only_sha.group(): + txt = f'0.0.1-0-{txt}' + + try: + info = VersionInfo(txt) + vstr = info.package_version + except MalformedGitTag: + warnings.warn("Malformed input '{0}'".format(txt)) + vstr = '0.0.0' + txt + + if not do_write: + print(vstr) + return + + lines = ( + '# This file automatically generated by', + '# {0}'.format(__file__), + '# at', + '# {0}'.format(datetime.datetime.now().isoformat(' ')), + "__version__ = '{0}'".format(vstr), + "" + ) + with open(VERSION_FILE, "w") as fp: + fp.write("\n".join(lines)) + + +if __name__ == '__main__': + from argparse import ArgumentParser + ap = ArgumentParser(description='Parse git version to PEP-440 version') + ap.add_argument('-c', '--mode', choices=('show', 'make', 'parse')) + ap.add_argument('-i', '--input', + help='Sample input string (instead of git)') + options = ap.parse_args() + + cmd = options.mode + if cmd == 'show': + print(get_version()) + elif cmd == 'make': + gen_version(do_write=True, txt=options.input) + print(get_version()) + elif cmd == 'parse': + gen_version(do_write=False, txt=options.input) + + else: + raise Exception("Command must be 'show' or 'make'") diff --git a/dev_requirements.txt b/dev_requirements.txt new file mode 100644 index 0000000..a3de3d8 --- /dev/null +++ b/dev_requirements.txt @@ -0,0 +1,2 @@ +aiohttp~=3.11.10 +pytest~=8.3.5 \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c9fddbe --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,28 @@ +[build-system] +requires = [ + "setuptools>=42", + "wheel", +] +build-backend = "setuptools.build_meta" + +[tool.pytest.ini_options] +minversion = "8.0" +log_cli = true +#addopts = "-ra -q" +testpaths = [ + "tests", + "acouchbase_analytics/tests", + "couchbase_analytics/tests", +] +python_classes = [ + "*Tests" +] +python_files = [ + "*_t.py" +] +markers = [ + "pycbac_couchbase: marks a test for the couchbase API (deselect with '-m \"not pycbac_couchbase\"')", + "pycbac_acouchbase: marks a test for the acouchbase API (deselect with '-m \"not pycbac_acouchbase\"')", + "pycbac_unit: marks a test as a unit test", + "pycbac_integration: marks a test as an integration test", +] \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..165a783 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +anyio~=4.9.0 +sniffio~=1.3.1 +httpx~=0.28.1 +ijson~=3.3.0 +# Typing support +typing-extensions~=4.11; python_version<"3.11" diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..124e846 --- /dev/null +++ b/setup.py @@ -0,0 +1,66 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +import sys + +from setuptools import find_packages, setup + +sys.path.append('.') +import couchbase_analytics_version # nopep8 # isort:skip # noqa: E402 + +try: + couchbase_analytics_version.gen_version() +except couchbase_analytics_version.CantInvokeGit: + pass + +PYCBAC_README = os.path.join(os.path.dirname(__file__), 'README.md') +PYCBAC_VERSION = couchbase_analytics_version.get_version() + + +package_data = {'couchbase_analytics.common.core.nonprod_certificates': ['*.pem']} + +print(f'Python Analytics SDK version: {PYCBAC_VERSION}') + +setup(name='couchbase-analytics', + version=PYCBAC_VERSION, + python_requires='>=3.9', + install_requires=[ + 'typing-extensions~=4.11; python_version<"3.11"' + ], + packages=find_packages( + include=['acouchbase_analytics', 'couchbase_analytics', 'acouchbase_analytics.*', 'couchbase_analytics.*'], + exclude=['acouchbase_analytics.tests', 'couchbase_analytics.tests']), + package_data=package_data, + url="https://github.com/couchbase/analytics-python-client", + author="Couchbase, Inc.", + author_email="PythonPackage@couchbase.com", + license="Apache License 2.0", + description="Python Client for Couchbase Analytics", + long_description=open(PYCBAC_README, "r").read(), + long_description_content_type='text/markdown', + keywords=["couchbase", "nosql", "pycouchbase", "couchbase++", "analytics"], + classifiers=[ + "Development Status :: 5 - Production/Stable", + "License :: OSI Approved :: Apache Software License", + "Intended Audience :: Developers", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: Implementation :: CPython", + "Topic :: Database", + "Topic :: Software Development :: Libraries", + "Topic :: Software Development :: Libraries :: Python Modules"], + ) diff --git a/sphinx_requirements.txt b/sphinx_requirements.txt new file mode 100644 index 0000000..e69de29 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..14e7d51 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,36 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from typing import (AsyncGenerator, + Generator, + Optional, + TypeVar) + +T = TypeVar('T') +AsyncYieldFixture = AsyncGenerator[T, None] +YieldFixture = Generator[T, None, None] + + +class AnalyticsTestEnvironmentError(Exception): + """Raised when something with the test environment is incorrect.""" + + def __init__(self, message: Optional[str] = None) -> None: + super().__init__(message) + + def __repr__(self) -> str: + return f"{type(self).__name__}({super().__repr__()})" + + def __str__(self) -> str: + return self.__repr__() diff --git a/tests/analytics_config.py b/tests/analytics_config.py new file mode 100644 index 0000000..63d46ed --- /dev/null +++ b/tests/analytics_config.py @@ -0,0 +1,142 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import os +import pathlib +from configparser import ConfigParser +from typing import Tuple +from uuid import uuid4 + +import pytest + +from tests import AnalyticsTestEnvironmentError + +BASEDIR = pathlib.Path(__file__).parent.parent +CONFIG_FILE = os.path.join(pathlib.Path(__file__).parent, "test_config.ini") +ENV_TRUE = ['true', '1', 'y', 'yes', 'on'] + + +class AnalyticsConfig: + def __init__(self) -> None: + self._scheme = 'http' + self._host = 'localhost' + self._port = 8095 + self._username = 'Administrator' + self._password = 'password' + self._nonprod = False + self._database_name = '' + self._scope_name = '' + self._collection_name = '' + self._disable_server_certificate_verification = False + self._create_keyspace = True + + @property + def database_name(self) -> str: + return self._database_name + + @property + def collection_name(self) -> str: + return self._collection_name + + @property + def create_keyspace(self) -> bool: + return self._create_keyspace + + @property + def fqdn(self) -> str: + return f'`{self._database_name}`.`{self._scope_name}`.`{self._collection_name}`' + + @property + def nonprod(self) -> bool: + return self._nonprod + + @property + def disable_server_certificate_verification(self) -> bool: + return self._disable_server_certificate_verification + + @property + def scope_name(self) -> str: + return self._scope_name + + def get_connection_string(self) -> str: + return f'{self._scheme}://{self._host}' + + def get_username_and_pw(self) -> Tuple[str, str]: + return self._username, self._password + + @classmethod + def load_config(cls) -> AnalyticsConfig: + analytics_config = cls() + try: + test_config = ConfigParser() + test_config.read(CONFIG_FILE) + test_config_analytics = test_config['analytics'] + analytics_config._scheme = os.environ.get('PYCBAC_SCHEME', + test_config_analytics.get('scheme', fallback='httpss')) + analytics_config._host = os.environ.get('PYCBAC_HOST', + test_config_analytics.get('host', fallback='localhost')) + port = os.environ.get('PYCBAC_PORT', test_config_analytics.get('port', fallback='8095')) + analytics_config._port = int(port) + analytics_config._username = os.environ.get('PYCBAC_USERNAME', + test_config_analytics.get('username', fallback='Administrator')) + analytics_config._password = os.environ.get('PYCBAC_PASSWORD', + test_config_analytics.get('password', fallback='password')) + use_nonprod = os.environ.get('PYCBAC_NONPROD', test_config_analytics.get('nonprod', fallback='OFF')) + if use_nonprod.lower() in ENV_TRUE: + analytics_config._nonprod = True + else: + analytics_config._nonprod = False + analytics_config._database_name = os.environ.get('PYCBAC_DATABASE', + test_config_analytics.get('database_name', + fallback='travel-sample')) + analytics_config._scope_name = os.environ.get('PYCBAC_SCOPE', + test_config_analytics.get('scope_name', fallback='inventory')) + analytics_config._collection_name = os.environ.get('PYCBAC_COLLECTION', + test_config_analytics.get('collection_name', + fallback='airline')) + disable_cert_verification = os.environ.get('PYCBAC_DISABLE_SERVER_CERT_VERIFICATION', + test_config_analytics.get('disable_server_cert_verification', + fallback='ON')) + if disable_cert_verification.lower() in ENV_TRUE: + analytics_config._disable_server_certificate_verification = True + fqdn = os.environ.get('PYCBAC_FQDN', test_config_analytics.get('fqdn', fallback=None)) + if fqdn is not None: + fqdn_tokens = fqdn.split('.') + if len(fqdn_tokens) != 3: + raise AnalyticsTestEnvironmentError(('Invalid FQDN provided. Expected database.scope.collection. ' + f'FQDN provide={fqdn}')) + + analytics_config._database_name = f'{fqdn_tokens[0]}' + analytics_config._scope_name = f'{fqdn_tokens[1]}' + analytics_config._collection_name = f'{fqdn_tokens[2]}' + analytics_config._create_keyspace = False + else: + # lets make the database unique (enough) + analytics_config._database_name = f'travel-sample-{str(uuid4())[:8]}' + analytics_config._scope_name = 'inventory' + analytics_config._collection_name = 'airline' + + except Exception as ex: + raise AnalyticsTestEnvironmentError(f'Problem trying read/load test configuration:\n{ex}') + + return analytics_config + + +@pytest.fixture(name='analytics_config', scope='session') +def analytics_test_config() -> AnalyticsConfig: + config = AnalyticsConfig.load_config() + return config diff --git a/tests/environments/__init__.py b/tests/environments/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/environments/base_environment.py b/tests/environments/base_environment.py new file mode 100644 index 0000000..2cf0439 --- /dev/null +++ b/tests/environments/base_environment.py @@ -0,0 +1,260 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import sys +from typing import (TYPE_CHECKING, + Dict, + Optional, + TypedDict) + +if sys.version_info < (3, 11): + from typing_extensions import Unpack +else: + from typing import Unpack + +import pytest + +from acouchbase_analytics.cluster import AsyncCluster +from couchbase_analytics.cluster import Cluster +from couchbase_analytics.credential import Credential +from couchbase_analytics.options import ClusterOptions, SecurityOptions +from tests import AnalyticsTestEnvironmentError +from tests.utils._run_web_server import WebServerHandler + + +if TYPE_CHECKING: + from tests.analytics_config import AnalyticsConfig + + + +class TestEnvironmentOptionsKwargs(TypedDict, total=False): + async_cluster: Optional[AsyncCluster] + cluster: Optional[Cluster] + database_name: Optional[str] + scope_name: Optional[str] + collection_name: Optional[str] + server_handler: Optional[WebServerHandler] + backend: Optional[str] + +class TestEnvironment: + + def __init__(self, config: AnalyticsConfig, **kwargs: Unpack[TestEnvironmentOptionsKwargs]) -> None: + self._config = config + self._async_cluster = kwargs.pop('async_cluster', None) + self._cluster = kwargs.pop('cluster', None) + self._database_name = kwargs.pop('database_name', None) + self._scope_name = kwargs.pop('scope_name', None) + self._collection_name = kwargs.pop('collection_name', None) + # self._async_scope: Optional[AsyncScope] = None + # self._scope: Optional[Scope] = None + # self._use_scope = False + self._server_handler = kwargs.pop('server_handler', None) + + @property + def config(self) -> AnalyticsConfig: + return self._config + +class BlockingTestEnvironment(TestEnvironment): + def __init__(self, config: AnalyticsConfig, **kwargs: Unpack[TestEnvironmentOptionsKwargs]) -> None: + super().__init__(config, **kwargs) + + @property + def cluster(self) -> Cluster: + if self._cluster is None: + raise AnalyticsTestEnvironmentError('No cluster available.') + return self._cluster + + def enable_test_server(self) -> BlockingTestEnvironment: + if self._server_handler is None: + raise AnalyticsTestEnvironmentError('No server handler provided, cannot enable test server.') + from tests.utils._client_adapter import _ClientAdapter + from tests.utils._test_httpx import HTTPTransport + new_adapter = _ClientAdapter(adapter=self._cluster._impl._client_adapter, + http_transport_cls=HTTPTransport) + new_adapter.create_client() + self._cluster._impl._client_adapter = new_adapter + scheme, host, port = self._cluster._impl.client_adapter.connection_details.get_scheme_host_and_port() + print(f"Connecting to test server at {scheme}://{host}:{port}") + self._server_handler.start_server() + return self + + def disable_test_server(self) -> BlockingTestEnvironment: + if self._server_handler is not None: + self._server_handler.stop_server() + self._server_handler = None + return self + + def set_url_path(self, url_path: str) -> None: + if self._server_handler is None: + raise AnalyticsTestEnvironmentError('No server handler provided, cannot set URL path.') + self._cluster._impl._client_adapter.set_request_path(url_path) + + def update_request_extensions(self, extensions: Dict[str, object]) -> None: + if self._server_handler is None: + raise AnalyticsTestEnvironmentError('No server handler provided, cannot update request extensions.') + self._cluster._impl._client_adapter.update_request_extensions(extensions) + + def update_request_json(self, json: Dict[str, object]) -> None: + if self._server_handler is None: + raise AnalyticsTestEnvironmentError('No server handler provided, cannot update request JSON.') + self._cluster._impl._client_adapter.update_request_json(json) + + @classmethod + def get_environment(cls, + config: AnalyticsConfig, + server_handler: Optional[WebServerHandler]=None) -> BlockingTestEnvironment: + if config is None: + raise AnalyticsTestEnvironmentError('No test config provided.') + + env_opts: TestEnvironmentOptionsKwargs = {} + if server_handler is not None: + connstr = server_handler.connstr + env_opts['server_handler'] = server_handler + else: + connstr = config.get_connection_string() + username, pw = config.get_username_and_pw() + cred = Credential.from_username_and_password(username, pw) + sec_opts: Optional[SecurityOptions] = None + if config.nonprod is True: + from couchbase_analytics.common.core._certificates import _Certificates + sec_opts = SecurityOptions.trust_only_certificates(_Certificates.get_nonprod_certificates()) + + if config.disable_server_certificate_verification is True: + if sec_opts is not None: + sec_opts['disable_server_certificate_verification'] = True + else: + sec_opts = SecurityOptions(disable_server_certificate_verification=True) + + if sec_opts is not None: + opts = ClusterOptions(security_options=sec_opts) + env_opts['cluster'] = Cluster.create_instance(connstr, cred, opts) + else: + env_opts['cluster'] = Cluster.create_instance(connstr, cred) + + return cls(config, **env_opts) + + + +class AsyncTestEnvironment(TestEnvironment): + def __init__(self, config: AnalyticsConfig, **kwargs: Unpack[TestEnvironmentOptionsKwargs]) -> None: + self._backend = kwargs.pop('backend', None) + super().__init__(config, **kwargs) + + @property + def cluster(self) -> AsyncCluster: + if self._async_cluster is None: + raise AnalyticsTestEnvironmentError('No async cluster available.') + return self._async_cluster + + async def enable_test_server(self) -> AsyncTestEnvironment: + if self._server_handler is None: + raise AnalyticsTestEnvironmentError('No server handler provided, cannot enable test server.') + from tests.utils._async_client_adapter import _AsyncClientAdapter + from tests.utils._test_async_httpx import AsyncHTTPTransport + # close the adapter here b/c we need to await + await self._async_cluster._impl._client_adapter.close_client() + new_adapter = _AsyncClientAdapter(adapter=self._async_cluster._impl._client_adapter, + http_transport_cls=AsyncHTTPTransport) + await new_adapter.create_client() + self._async_cluster._impl._client_adapter = new_adapter + scheme, host, port = self._async_cluster._impl.client_adapter.connection_details.get_scheme_host_and_port() + print(f"Connecting to test server at {scheme}://{host}:{port}") + self._server_handler.start_server() + return self + + def disable_test_server(self) -> AsyncTestEnvironment: + if self._server_handler is not None: + self._server_handler.stop_server() + self._server_handler = None + return self + + def set_url_path(self, url_path: str) -> None: + if self._server_handler is None: + raise AnalyticsTestEnvironmentError('No server handler provided, cannot set URL path.') + self._async_cluster._impl._client_adapter.set_request_path(url_path) + + def update_request_extensions(self, extensions: Dict[str, object]) -> None: + if self._server_handler is None: + raise AnalyticsTestEnvironmentError('No server handler provided, cannot update request extensions.') + self._async_cluster._impl._client_adapter.update_request_extensions(extensions) + + def update_request_json(self, json: Dict[str, object]) -> None: + if self._server_handler is None: + raise AnalyticsTestEnvironmentError('No server handler provided, cannot update request JSON.') + self._async_cluster._impl._client_adapter.update_request_json(json) + + @classmethod + def get_environment(cls, + config: AnalyticsConfig, + server_handler: Optional[WebServerHandler]=None, + backend: Optional[str]=None) -> AsyncTestEnvironment: + if config is None: + raise AnalyticsTestEnvironmentError('No test config provided.') + + env_opts: TestEnvironmentOptionsKwargs = {} + if server_handler is not None: + connstr = server_handler.connstr + env_opts['server_handler'] = server_handler + else: + connstr = config.get_connection_string() + if backend is not None: + env_opts['backend'] = backend + username, pw = config.get_username_and_pw() + cred = Credential.from_username_and_password(username, pw) + sec_opts: Optional[SecurityOptions] = None + if config.nonprod is True: + from couchbase_analytics.common.core._certificates import _Certificates + sec_opts = SecurityOptions.trust_only_certificates(_Certificates.get_nonprod_certificates()) + + if config.disable_server_certificate_verification is True: + if sec_opts is not None: + sec_opts['disable_server_certificate_verification'] = True + else: + sec_opts = SecurityOptions(disable_server_certificate_verification=True) + + print(f'{env_opts=}') + if sec_opts is not None: + opts = ClusterOptions(security_options=sec_opts) + env_opts['async_cluster'] = AsyncCluster.create_instance(connstr, cred, opts) + else: + env_opts['async_cluster'] = AsyncCluster.create_instance(connstr, cred) + + return cls(config, **env_opts) + +@pytest.fixture(scope='class', name='sync_test_env') +def base_test_environment(analytics_config: AnalyticsConfig) -> BlockingTestEnvironment: + print("Creating sync test environment") + return BlockingTestEnvironment.get_environment(analytics_config) + +@pytest.fixture(scope='class', name='sync_test_env_with_server') +def base_test_environment_with_server(analytics_config: AnalyticsConfig) -> BlockingTestEnvironment: + print("Creating sync test environment w/ test server") + server_handler = WebServerHandler() + return BlockingTestEnvironment.get_environment(analytics_config, server_handler=server_handler) + +@pytest.fixture(scope='class', name='async_test_env') +def base_async_test_environment(analytics_config: AnalyticsConfig, anyio_backend: str) -> AsyncTestEnvironment: + print("Creating async test environment") + return AsyncTestEnvironment.get_environment(analytics_config, backend=anyio_backend) + +@pytest.fixture(scope='class', name='async_test_env_with_server') +def base_async_test_environment_with_server(analytics_config: AnalyticsConfig, anyio_backend:str) -> AsyncTestEnvironment: + print("Creating async test environment w/ test server") + server_handler = WebServerHandler() + return AsyncTestEnvironment.get_environment(analytics_config, + server_handler=server_handler, + backend=anyio_backend) \ No newline at end of file diff --git a/tests/environments/simple_environment.py b/tests/environments/simple_environment.py new file mode 100644 index 0000000..0ba1814 --- /dev/null +++ b/tests/environments/simple_environment.py @@ -0,0 +1,146 @@ +import json +from enum import Enum +from typing import Any, Tuple + +import pytest + +class JsonDataType(Enum): + SIMPLE_REQUEST = 'simple_request' + MULTIPLE_RESULTS = 'multiple_results' + FAILED_REQUEST = 'failed_request' + +JSON_DATA = { + 'simple_request':""" +{ + "requestID": "98f69cf0-6d00-4a61-b8b6-e3b29fb6061b", + "signature": { + "*": "*" + }, + "results": [ + { + "greeting": "Hello, data!" + } + ], + "plans": {}, + "status": "success", + "metrics": { + "elapsedTime": "60.13982ms", + "executionTime": "56.765081ms", + "compileTime": "3.244949ms", + "queueWaitTime": "0ns", + "resultCount": 1, + "resultSize": 27, + "processedObjects": 0 + } +}""".strip(), + 'multiple_results':""" +{ + "requestID": "94c7f89f-92b6-4aba-a90d-be715ca47309", + "signature": { + "*": "*" + }, + "results": [ + {"id": 1, "name": "John Doe", "age": 30, "city": "New York"}, + {"id": 2, "name": "Jane Smith", "age": 25, "city": "Los Angeles"}, + {"id": 3, "name": "Sam Brown", "age": 22, "city": "Chicago"}, + {"id": 4, "name": "Lisa White", "age": 28, "city": "Houston"}, + {"id": 5, "name": "Tom Green", "age": 35, "city": "Phoenix"}, + {"id": 6, "name": "Anna Blue", "age": 27, "city": "Philadelphia"}, + {"id": 7, "name": "Mike Black", "age": 32, "city": "San Antonio"}, + {"id": 8, "name": "Sara Yellow", "age": 29, "city": "San Diego"}, + {"id": 9, "name": "Chris Red", "age": 31, "city": "Dallas"}, + {"id": 10, "name": "Kate Purple", "age": 26, "city": "San Jose"}, + {"id": 11, "name": "Paul Orange", "age": 33, "city": "Austin"}, + {"id": 12, "name": "Nina Pink", "age": 24, "city": "Jacksonville"}, + {"id": 13, "name": "Leo Grey", "age": 36, "city": "Fort Worth"}, + {"id": 14, "name": "Eva Cyan", "age": 23, "city": "Columbus"}, + {"id": 15, "name": "Zoe Brown", "age": 34, "city": "Charlotte"}, + {"id": 16, "name": "Liam Gold", "age": 21, "city": "San Francisco"}, + {"id": 17, "name": "Mia Silver", "age": 30, "city": "Indianapolis"}, + {"id": 18, "name": "Noah Bronze", "age": 25, "city": "Seattle"}, + {"id": 19, "name": "Olivia Copper", "age": 22, "city": "Denver"}, + {"id": 20, "name": "Ethan Steel", "age": 28, "city": "Washington"}, + {"id": 21, "name": "Sophia Iron", "age": 35, "city": "Boston"}, + {"id": 22, "name": "James Wood", "age": 27, "city": "El Paso"}, + {"id": 23, "name": "Ava Stone", "age": 32, "city": "Detroit"}, + {"id": 24, "name": "Lucas Clay", "age": 29, "city": "Nashville"}, + {"id": 25, "name": "Charlotte Brick", "age": 31, "city": "Baltimore"}, + {"id": 26, "name": "Benjamin Marble", "age": 26, "city": "Milwaukee"}, + {"id": 27, "name": "Amelia Slate", "age": 33, "city": "Albuquerque"}, + {"id": 28, "name": "Oliver Quartz", "age": 24, "city": "Tucson"}, + {"id": 29, "name": "Isabella Granite", "age": 36, "city": "Fresno"}, + {"id": 30, "name": "Elijah Onyx", "age": 23, "city": "Sacramento"}, + {"id": 31, "name": "Mason Jade", "age": 34, "city": "Long Beach"}, + {"id": 32, "name": "Charlotte Ruby", "age": 21, "city": "Kansas City"}, + {"id": 33, "name": "Aiden Sapphire", "age": 30, "city": "Mesa"}, + {"id": 34, "name": "Harper Emerald", "age": 25, "city": "Virginia Beach"}, + {"id": 35, "name": "Ella Amethyst", "age": 22, "city": "Atlanta"}, + {"id": 36, "name": "Liam Diamond", "age": 28, "city": "Colorado Springs"} + ], + "plans": {}, + "status": "success", + "metrics": { + "elapsedTime": "14.927542ms", + "executionTime": "12.875792ms", + "compileTime": "4.178042ms", + "queueWaitTime": "0ns", + "resultCount": 2, + "resultSize": 300, + "processedObjects": 2, + "bufferCacheHitRatio": "100.00%" + } +}""".strip(), + 'failed_request':""" +{ + "requestID": "c5f50c58-c044-481f-a26a-357a29f7446e", + "errors": [ + { + "code": 24000, + "msg": "Syntax error: TokenMgrError: Lexical error at line 1, column 14. Encountered: after : \\"'m not N1QL;\\"" + } + ], + "status": "fatal", + "metrics": { + "elapsedTime": "3.146092ms", + "executionTime": "1.907313ms", + "compileTime": "0ns", + "queueWaitTime": "0ns", + "resultCount": 0, + "resultSize": 0, + "processedObjects": 0, + "bufferCacheHitRatio": "0.00%", + "bufferCachePageReadCount": 0, + "errorCount": 1 + } +}""".strip() +} + +class BaseSimpleEnvironment: + def __init__(self, backend) -> None: + self._backend = backend + + def get_json_data(self, json_type: JsonDataType) -> Tuple[Any, bytes]: + """ + Retrieve JSON data by key. + """ + key = json_type.value if isinstance(json_type, JsonDataType) else json_type + if key not in JSON_DATA: + raise KeyError(f"Key '{key}' not found in JSON data.") + data = JSON_DATA[key] + return json.loads(data), bytes(data, 'utf-8') + +class AsyncSimpleEnvironment(BaseSimpleEnvironment): + def __init__(self, backend) -> None: + super().__init__(backend) + +class SimpleEnvironment(BaseSimpleEnvironment): + def __init__(self, backend) -> None: + super().__init__(backend) + +@pytest.fixture(scope='class', name='simple_async_test_env') +def simple_async_test_environment(anyio_backend) -> AsyncSimpleEnvironment: + return AsyncSimpleEnvironment(anyio_backend) + +@pytest.fixture(scope='class', name='simple_test_env') +def simple_test_environment(anyio_backend) -> SimpleEnvironment: + return SimpleEnvironment(anyio_backend) \ No newline at end of file diff --git a/tests/test_config.ini b/tests/test_config.ini new file mode 100644 index 0000000..2cbb886 --- /dev/null +++ b/tests/test_config.ini @@ -0,0 +1,8 @@ +[analytics] +scheme = https +host = localhost +username = Administrator +password = password +nonprod = False +disable_server_cert_verification = False +fqdn = travel-sample.inventory.airline diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py new file mode 100644 index 0000000..5679f6b --- /dev/null +++ b/tests/utils/__init__.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python + +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import print_function + +import random +from typing import Optional, Union + +import anyio + +class AsyncBytesIterator: + + def __init__(self, + data: Union[bytes, str], + chunk_size: Optional[int] = 100, + simulate_delay: Optional[bool] = False, + simulate_delay_range: Optional[tuple] = (0.01, 0.1)): + self._data = data if isinstance(data, bytes) else bytes(data, 'utf-8') + self._chunk_size = chunk_size + self._simulate_delay = simulate_delay + self._simulate_delay_range = simulate_delay_range + self._start = 0 + self._stop = self._chunk_size + + def __aiter__(self): + return self + + async def __anext__(self): + if self._simulate_delay: + delay = random.uniform(*self._simulate_delay_range) + await anyio.sleep(delay) + if not self._data: + raise StopAsyncIteration + while True: + if len(self._data) == 0: + raise StopAsyncIteration + + if self._start >= len(self._data): + raise StopAsyncIteration + + if self._stop >= len(self._data): + self._stop = len(self._data) + + chunk = self._data[self._start:self._stop] + self._start = self._stop + self._stop += self._chunk_size + return chunk + +class BytesIterator: + + def __init__(self, data: Union[bytes, str], chunk_size: Optional[int] = 100): + self._data = data if isinstance(data, bytes) else bytes(data, 'utf-8') + self._chunk_size = chunk_size + self._start = 0 + self._stop = self._chunk_size + + def __iter__(self): + return self + + def __next__(self): + if not self._data: + raise StopIteration + while True: + if len(self._data) == 0: + raise StopIteration + + if self._start >= len(self._data): + raise StopIteration + + if self._stop >= len(self._data): + self._stop = len(self._data) + + chunk = self._data[self._start:self._stop] + self._start = self._stop + self._stop += self._chunk_size + return chunk \ No newline at end of file diff --git a/tests/utils/_async_client_adapter.py b/tests/utils/_async_client_adapter.py new file mode 100644 index 0000000..c371e96 --- /dev/null +++ b/tests/utils/_async_client_adapter.py @@ -0,0 +1,84 @@ +import socket +from typing import Dict + +from httpx import BasicAuth, AsyncClient, Response + +from acouchbase_analytics.protocol.core.client_adapter import _AsyncClientAdapter +from couchbase_analytics.protocol.core.request import QueryRequest + + +def client_adapter_init_override(self, *args, **kwargs) -> None: + if not hasattr(self, 'PYCBAC_TESTING'): + raise RuntimeError('This is a testing only adapter') + self._http_transport_cls = kwargs.pop('http_transport_cls', None) + if self._http_transport_cls is not None and not hasattr(self._http_transport_cls, 'PYCBAC_TESTING'): + raise RuntimeError('http_transport_cls must be a test transport') + adapter: _AsyncClientAdapter = kwargs.pop('adapter', None) + # adapter.close_client() + self._client_id = adapter._client_id + self._opts_builder = adapter._opts_builder + self._conn_details = adapter._conn_details + if self._http_transport_cls is None: + self._http_transport_cls = adapter._http_transport_cls + +async def create_client_override(self) -> None: + if not hasattr(self, '_client'): + auth = BasicAuth(*self._conn_details.credential) + if self._conn_details.is_secure(): + transport = None + if self._http_transport_cls is not None: + transport = self._http_transport_cls(verify=self._conn_details.ssl_context) + self._client = AsyncClient(verify=self._conn_details.ssl_context, + auth=auth, + transport=transport) + else: + transport = None + if self._http_transport_cls is not None: + transport = self._http_transport_cls() + self._client = AsyncClient(auth=auth, transport=transport) + +async def send_request_override(self, request: QueryRequest) -> Response: + if not hasattr(self, '_client'): + raise RuntimeError('Client not created yet') + + print(f'Sending request: {request.method} {request.url}') + request_json = request.body + if hasattr(self, '_request_json') and self._request_json is not None: + request_json.update(self._request_json) + + request_extensions = request.extensions + if hasattr(self, '_request_extensions') and self._request_extensions is not None: + if request_extensions is None: + request_extensions = self._request_extensions + else: + if 'timeout' in self._request_extensions: + request_extensions['timeout'].update(self._request_extensions['timeout']) + + print(f'{request_extensions=}') + + req = self._client.build_request(request.method, + request.url, + json=request_json, + extensions=request_extensions) + try: + return await self._client.send(req, stream=True) + except socket.gaierror as err: + raise RuntimeError(f'Unable to connect to {self._conn_details.get_scheme_host_and_port()}') from err + + +def set_request_path(self, path: str) -> None: + self._ANALYTICS_PATH = path + +def update_request_json(self, json: Dict[str, object]) -> None: + self._request_json = json + +def update_request_extensions(self, extensions: Dict[str, str]) -> None: + self._request_extensions = extensions + +_AsyncClientAdapter.__init__ = client_adapter_init_override +_AsyncClientAdapter.create_client = create_client_override +_AsyncClientAdapter.send_request = send_request_override +setattr(_AsyncClientAdapter, 'set_request_path', set_request_path) +setattr(_AsyncClientAdapter, 'update_request_json', update_request_json) +setattr(_AsyncClientAdapter, 'update_request_extensions', update_request_extensions) +setattr(_AsyncClientAdapter, 'PYCBAC_TESTING', True) \ No newline at end of file diff --git a/tests/utils/_async_utils.py b/tests/utils/_async_utils.py new file mode 100644 index 0000000..a0b8e4e --- /dev/null +++ b/tests/utils/_async_utils.py @@ -0,0 +1,33 @@ + +from typing import (Any, + List) + +import anyio + +class TaskGroupResultCollector: + + def __init__(self): + self._results = [] + + @property + def results(self) -> List[Any]: + return self._results + + async def _execute(self, fn, *args): + result = await fn(*args) + self._results.append(result) + + def start_soon(self, fn, *args): + self._taskgroup.start_soon(self._execute, fn, *args) + + async def __aenter__(self): + self._taskgroup = anyio.create_task_group() + await self._taskgroup.__aenter__() + return self + + async def __aexit__(self, *tb): + try: + res = await self._taskgroup.__aexit__(*tb) + return res + finally: + del self._taskgroup \ No newline at end of file diff --git a/tests/utils/_async_web_server.py b/tests/utils/_async_web_server.py new file mode 100644 index 0000000..f2d19d3 --- /dev/null +++ b/tests/utils/_async_web_server.py @@ -0,0 +1,112 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import asyncio +import logging +import json +import sys + +from typing import Optional +import urllib.parse + +from aiohttp import web + + +logging.basicConfig(level=logging.INFO, + stream=sys.stderr, + format='%(asctime)s - %(levelname)s - (PID:%(process)d) - %(message)s') +logger = logging.getLogger(__name__) + +class AsyncWebServer: + def __init__(self, host: Optional[str]='0.0.0.0', port:Optional[int]=8080) -> None: + self._app = web.Application() + self._host = host + self._port = port + self._app.add_routes([web.get('/test_get', self.handle_get_request), + web.post('/test_post', self.handle_post_request)]) + + async def handle_get_request(self, request): + path = request.match_info['path'] + query_params = request.query_string + response_data = { + 'path': path, + 'query': urllib.parse.parse_qs(query_params) + } + return web.json_response(response_data) + + async def handle_post_request(self, request): + try: + received_json = await request.json() + logger.info(f"Received JSON: {received_json}") + return web.json_response({ + 'status': 'success', + 'data': received_json + }) + except json.JSONDecodeError: + received_text = await request.text() + msg = "POST request received, but data is not valid JSON. Showing as plain text." + logger.error(msg) + logger.error(f'Received text: {received_text}') + except Exception as e: + logger.error(f'An error occurred: {e}', exc_info=True) + return web.Response(status=400, text="Bad Request") + + async def start(self): + runner = web.AppRunner(self._app) + await runner.setup() + site = web.TCPSite(runner, self._host, self._port) + await site.start() + logger.info(f'Server running on http://{self._host}:{self._port}') + + async def stop(self): + await self._app.shutdown() + await self._app.cleanup() + +async def run_server(host: str, port: int) -> None: + server = AsyncWebServer(host=host, port=port) + logger.info(f'Attempting to start server on {host}:{port}...') + await server.start() + logger.info('Server started. Listening for requests...') + try: + while True: + await asyncio.sleep(300) + except asyncio.CancelledError: + logger.info('asyncio task cancelled (e.g., from SIGTERM). Shutting down.') + except Exception as e: + logger.error(f'Unexpected error: {e}', exc_info=True) + finally: + logger.info('Stopping server...') + await server.stop() + logger.info('Server stopped.') + + +if __name__ == '__main__': + from argparse import ArgumentParser + ap = ArgumentParser(description='Run Async Web Server') + ap.add_argument('--host', + type=str, + default='127.0.0.1', + help='Host address to bind to (e.g., 127.0.0.1 for localhost only)') + ap.add_argument('--port', + type=int, + default=8000, + help='Port number to listen on') + options = ap.parse_args() + try: + asyncio.run(run_server(host=options.host, port=options.port)) + except KeyboardInterrupt: + pass + except Exception as e: + logger.critical(f'Critical error: {e}', exc_info=True) \ No newline at end of file diff --git a/tests/utils/_client_adapter.py b/tests/utils/_client_adapter.py new file mode 100644 index 0000000..948efe7 --- /dev/null +++ b/tests/utils/_client_adapter.py @@ -0,0 +1,85 @@ +import socket + +from typing import Dict + +from httpx import Response + +from couchbase_analytics.protocol.core.client_adapter import _ClientAdapter +from couchbase_analytics.protocol.core.request import QueryRequest + + +def client_adapter_init_override(self, *args, **kwargs) -> None: + if not hasattr(self, 'PYCBAC_TESTING'): + raise RuntimeError('This is a testing only adapter') + self._http_transport_cls = kwargs.pop('http_transport_cls', None) + if self._http_transport_cls is not None and not hasattr(self._http_transport_cls, 'PYCBAC_TESTING'): + raise RuntimeError('http_transport_cls must be a test transport') + adapter: _ClientAdapter = kwargs.pop('adapter', None) + adapter.close_client() + self._client_id = adapter._client_id + self._opts_builder = adapter._opts_builder + self._conn_details = adapter._conn_details + if self._http_transport_cls is None: + self._http_transport_cls = adapter._http_transport_cls + + +# def create_client_override(self) -> None: +# if not hasattr(self, '_client'): +# if self._conn_details.is_secure(): +# transport = None +# if self._http_transport_cls is not None: +# transport = self._http_transport_cls(verify=self._conn_details.ssl_context) +# self._client = Client(verify=self._conn_details.ssl_context, +# auth=BasicAuth(*self._conn_details.credential), +# transport=transport) +# else: +# transport = None +# if self._http_transport_cls is not None: +# transport = self._http_transport_cls() +# self._client = Client(auth=BasicAuth(*self._conn_details.credential), +# transport=transport) + +def send_request_override(self, request: QueryRequest) -> Response: + if not hasattr(self, '_client'): + raise RuntimeError('Client not created yet') + + print(f'Sending request: {request.method} {request.url}') + request_json = request.body + if hasattr(self, '_request_json') and self._request_json is not None: + request_json.update(self._request_json) + + request_extensions = request.extensions + if hasattr(self, '_request_extensions') and self._request_extensions is not None: + if request_extensions is None: + request_extensions = self._request_extensions + else: + if 'timeout' in self._request_extensions: + request_extensions['timeout'].update(self._request_extensions['timeout']) + + print(f'{request_extensions=}') + + req = self._client.build_request(request.method, + request.url, + json=request_json, + extensions=request_extensions) + try: + return self._client.send(req, stream=True) + except socket.gaierror as err: + raise RuntimeError(f'Unable to connect to {self._conn_details.get_scheme_host_and_port()}') from err + +def set_request_path(self, path: str) -> None: + self._ANALYTICS_PATH = path + +def update_request_json(self, json: Dict[str, object]) -> None: + self._request_json = json + +def update_request_extensions(self, extensions: Dict[str, str]) -> None: + self._request_extensions = extensions + +_ClientAdapter.__init__ = client_adapter_init_override +# _ClientAdapter.create_client = create_client_override +_ClientAdapter.send_request = send_request_override +setattr(_ClientAdapter, 'set_request_path', set_request_path) +setattr(_ClientAdapter, 'update_request_json', update_request_json) +setattr(_ClientAdapter, 'update_request_extensions', update_request_extensions) +setattr(_ClientAdapter, 'PYCBAC_TESTING', True) \ No newline at end of file diff --git a/tests/utils/_run_web_server.py b/tests/utils/_run_web_server.py new file mode 100644 index 0000000..92e717a --- /dev/null +++ b/tests/utils/_run_web_server.py @@ -0,0 +1,101 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import atexit +import logging +import pathlib +import subprocess +import sys +import time + +from os import path +from typing import Optional + +WEB_SERVER_PATH = path.join(pathlib.Path(__file__).parent, '_async_web_server.py') + +print(f'Web server script path: {WEB_SERVER_PATH}') + +logging.basicConfig(level=logging.INFO, + stream=sys.stderr, + format='%(asctime)s - %(levelname)s - (PID:%(process)d) - %(message)s') +logger = logging.getLogger(__name__) + +class WebServerHandler: + def __init__(self, host: Optional[str]='0.0.0.0', port:Optional[int]=8080) -> None: + self._host = host + self._port = port + self._server_process = None + atexit.register(self.stop_server) + + @property + def connstr(self) -> str: + host = self._host if self._host != '0.0.0.0' else 'localhost' + return f'http://{host}:{self._port}' + + def start_server(self) -> None: + if self._server_process and self._server_process.poll() is None: + logger.info(f'Web server is already running (PID: {self._server_process.pid}).') + return + + if not path.exists(WEB_SERVER_PATH): + msg = f'Web server script not found at {WEB_SERVER_PATH}.' + logger.error(msg) + raise FileNotFoundError(msg) + + try: + cmd = [sys.executable, + WEB_SERVER_PATH, + '--host', + self._host, + '--port', + str(self._port)] + self._server_process = subprocess.Popen(cmd, stdout=sys.stdout, stderr=sys.stderr) + time.sleep(1) + + # Check if the server process unexpectedly exited during startup + if self._server_process.poll() is not None: + logger.error((f'Server process (PID: {self._server_process.pid}) exited immediately after launch. ' + f'Exit code: {self._server_process.returncode}.')) + self._server_process = None + else: + logger.info('Server should be running at http://%s:%d/', self._host, self._port) + except Exception as e: + logger.error(f'Failed to start web server: {e}', exc_info=True) + self._server_process = None + raise + + def stop_server(self) -> None: + if self._server_process is None: + self._server_process = None + return + + if self._server_process.poll() is not None: + self._server_process = None + return + + try: + self._server_process.terminate() + try: + self._server_process.wait(timeout=5) + logger.info(f'Web server stopped (PID: {self._server_process.pid}).') + except subprocess.TimeoutExpired: + logger.warning(f'Web server (PID: {self._server_process.pid}) did not terminate in time, killing it.') + self._server_process.kill() + self._server_process.wait() + except Exception as e: + logger.error(f'Error stopping web server: {e}', exc_info=True) + raise + finally: + self._server_process = None \ No newline at end of file diff --git a/tests/utils/_test_async_httpx.py b/tests/utils/_test_async_httpx.py new file mode 100644 index 0000000..2de6c65 --- /dev/null +++ b/tests/utils/_test_async_httpx.py @@ -0,0 +1,253 @@ +import typing + +from httpx import AsyncHTTPTransport, create_ssl_context, Limits +from httpcore import (AsyncConnectionPool, + Origin, + Request, + Response) +from httpcore._async.connection import (AsyncHTTPConnection, + exponential_backoff, + RETRIES_BACKOFF_FACTOR, + logger) +from httpcore._async.connection_pool import AsyncPoolRequest, PoolByteStream +from httpcore._async.interfaces import AsyncConnectionInterface +from httpcore._backends.base import AsyncNetworkStream +from httpcore._exceptions import (ConnectError, + ConnectionNotAvailable, + ConnectTimeout, + UnsupportedProtocol) + +from httpcore._ssl import default_ssl_context +from httpcore._trace import Trace + +class TestAsyncHTTPConnection(AsyncHTTPConnection): + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + + async def _connect(self, request: Request) -> AsyncNetworkStream: + timeouts = request.extensions.get("timeout", {}) + sni_hostname = request.extensions.get("sni_hostname", None) + timeout = timeouts.get("connect", None) + # TESTING_OVERRIDE + test_connect_timeout = timeouts.get("test_connect_timeout", None) + print(f"PYCBAC OVERRIDE: connect timeout: {timeout}, test_connect_timeout: {test_connect_timeout}") + + retries_left = self._retries + delays = exponential_backoff(factor=RETRIES_BACKOFF_FACTOR) + + while True: + try: + if self._uds is None: + kwargs = { + "host": self._origin.host.decode("ascii"), + "port": self._origin.port, + "local_address": self._local_address, + "timeout": timeout, + "socket_options": self._socket_options, + } + async with Trace("connect_tcp", logger, request, kwargs) as trace: + stream = await self._network_backend.connect_tcp(**kwargs) + trace.return_value = stream + else: + kwargs = { + "path": self._uds, + "timeout": timeout, + "socket_options": self._socket_options, + } + async with Trace( + "connect_unix_socket", logger, request, kwargs + ) as trace: + stream = await self._network_backend.connect_unix_socket( + **kwargs + ) + trace.return_value = stream + + if self._origin.scheme in (b"https", b"wss"): + ssl_context = ( + default_ssl_context() + if self._ssl_context is None + else self._ssl_context + ) + alpn_protocols = ["http/1.1", "h2"] if self._http2 else ["http/1.1"] + ssl_context.set_alpn_protocols(alpn_protocols) + + kwargs = { + "ssl_context": ssl_context, + "server_hostname": sni_hostname + or self._origin.host.decode("ascii"), + "timeout": timeout, + } + async with Trace("start_tls", logger, request, kwargs) as trace: + stream = await stream.start_tls(**kwargs) + trace.return_value = stream + return stream + except (ConnectError, ConnectTimeout): + if retries_left <= 0: + raise + retries_left -= 1 + delay = next(delays) + async with Trace("retry", logger, request, kwargs) as trace: + await self._network_backend.sleep(delay) + +class TestAsyncConnectionPool(AsyncConnectionPool): + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + + def create_connection(self, origin: Origin) -> AsyncConnectionInterface: + if self._proxy is not None: + if self._proxy.url.scheme in (b"socks5", b"socks5h"): + from httpcore._async.socks_proxy import AsyncSocks5Connection + + return AsyncSocks5Connection( + proxy_origin=self._proxy.url.origin, + proxy_auth=self._proxy.auth, + remote_origin=origin, + ssl_context=self._ssl_context, + keepalive_expiry=self._keepalive_expiry, + http1=self._http1, + http2=self._http2, + network_backend=self._network_backend, + ) + elif origin.scheme == b"http": + from httpcore._async.http_proxy import AsyncForwardHTTPConnection + + return AsyncForwardHTTPConnection( + proxy_origin=self._proxy.url.origin, + proxy_headers=self._proxy.headers, + proxy_ssl_context=self._proxy.ssl_context, + remote_origin=origin, + keepalive_expiry=self._keepalive_expiry, + network_backend=self._network_backend, + ) + from httpcore._async.http_proxy import AsyncTunnelHTTPConnection + + return AsyncTunnelHTTPConnection( + proxy_origin=self._proxy.url.origin, + proxy_headers=self._proxy.headers, + proxy_ssl_context=self._proxy.ssl_context, + remote_origin=origin, + ssl_context=self._ssl_context, + keepalive_expiry=self._keepalive_expiry, + http1=self._http1, + http2=self._http2, + network_backend=self._network_backend, + ) + + # TESTING_OVERRIDE + return TestAsyncHTTPConnection( + origin=origin, + ssl_context=self._ssl_context, + keepalive_expiry=self._keepalive_expiry, + http1=self._http1, + http2=self._http2, + retries=self._retries, + local_address=self._local_address, + uds=self._uds, + network_backend=self._network_backend, + socket_options=self._socket_options, + ) + + async def handle_async_request(self, request: Request) -> Response: + """ + Send an HTTP request, and return an HTTP response. + + This is the core implementation that is called into by `.request()` or `.stream()`. + """ + scheme = request.url.scheme.decode() + if scheme == "": + raise UnsupportedProtocol( + "Request URL is missing an 'http://' or 'https://' protocol." + ) + if scheme not in ("http", "https", "ws", "wss"): + raise UnsupportedProtocol( + f"Request URL has an unsupported protocol '{scheme}://'." + ) + + timeouts = request.extensions.get("timeout", {}) + timeout = timeouts.get("pool", None) + # TESTING_OVERRIDE + test_pool_timeout = timeouts.get("test_pool_timeout", None) + print(f"PYCBAC OVERRIDE: pool timeout: {timeout}, test_pool_timeout: {test_pool_timeout}") + + with self._optional_thread_lock: + # Add the incoming request to our request queue. + pool_request = AsyncPoolRequest(request) + self._requests.append(pool_request) + + try: + while True: + with self._optional_thread_lock: + # Assign incoming requests to available connections, + # closing or creating new connections as required. + closing = self._assign_requests_to_connections() + await self._close_connections(closing) + + # Wait until this request has an assigned connection. + connection = await pool_request.wait_for_connection(timeout=timeout) + + try: + # Send the request on the assigned connection. + response = await connection.handle_async_request( + pool_request.request + ) + except ConnectionNotAvailable: + # In some cases a connection may initially be available to + # handle a request, but then become unavailable. + # + # In this case we clear the connection and try again. + pool_request.clear_connection() + else: + break # pragma: nocover + + except BaseException as exc: + with self._optional_thread_lock: + # For any exception or cancellation we remove the request from + # the queue, and then re-assign requests to connections. + self._requests.remove(pool_request) + closing = self._assign_requests_to_connections() + + await self._close_connections(closing) + raise exc from None + + # Return the response. Note that in this case we still have to manage + # the point at which the response is closed. + assert isinstance(response.stream, typing.AsyncIterable) + return Response( + status=response.status, + headers=response.headers, + content=PoolByteStream( + stream=response.stream, pool_request=pool_request, pool=self + ), + extensions=response.extensions, + ) + +def async_http_transport_init_override(self, *args, **kwargs) -> None: + verify = kwargs.get('verify') + cert = kwargs.get('cert') + trust_env = kwargs.get('trust_env') + ssl_context = create_ssl_context(verify=verify, cert=cert, trust_env=trust_env) + + # See https://github.com/encode/httpx/blob/master/httpx/_config.py for defaults + # default keepalive_expiry is 5 seconds + limits = kwargs.get('limits', Limits(max_connections=100, max_keepalive_connections=20)) + http1 = kwargs.get('http1') + http2 = kwargs.get('http2') + uds = kwargs.get('uds') + local_address = kwargs.get('local_address') + retries = kwargs.get('retries') + socket_options = kwargs.get('socket_options') + self._pool = TestAsyncConnectionPool( + ssl_context=ssl_context, + max_connections=limits.max_connections, + max_keepalive_connections=limits.max_keepalive_connections, + keepalive_expiry=limits.keepalive_expiry, + http1=http1, + http2=http2, + uds=uds, + local_address=local_address, + retries=retries, + socket_options=socket_options, + ) + +AsyncHTTPTransport.__init__ = async_http_transport_init_override +setattr(AsyncHTTPTransport, 'PYCBAC_TESTING', True) \ No newline at end of file diff --git a/tests/utils/_test_httpx.py b/tests/utils/_test_httpx.py new file mode 100644 index 0000000..e80629e --- /dev/null +++ b/tests/utils/_test_httpx.py @@ -0,0 +1,281 @@ +import time +import typing + +from httpx import HTTPTransport, create_ssl_context, Limits +from httpcore import (ConnectionPool, + Origin, + Request, + Response) +from httpcore._backends.base import NetworkStream +from httpcore._exceptions import (ConnectError, + ConnectionNotAvailable, + ConnectTimeout, + PoolTimeout, + UnsupportedProtocol) +from httpcore._ssl import default_ssl_context + +from httpcore._sync.connection import (HTTPConnection, + exponential_backoff, + RETRIES_BACKOFF_FACTOR, + logger) +from httpcore._sync.connection_pool import PoolRequest, PoolByteStream +from httpcore._sync.interfaces import ConnectionInterface +from httpcore._trace import Trace + +class TestHTTPConnection(HTTPConnection): + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + + def _connect(self, request: Request) -> NetworkStream: + timeouts = request.extensions.get("timeout", {}) + sni_hostname = request.extensions.get("sni_hostname", None) + timeout = timeouts.get("connect", None) + # -- START PYCBAC TESTING -- + test_connect_timeout = timeouts.get("test_connect_timeout", None) + print(f"PYCBAC OVERRIDE: connect timeout: {timeout}, test_connect_timeout: {test_connect_timeout}") + # -- END PYCBAC TESTING -- + + retries_left = self._retries + delays = exponential_backoff(factor=RETRIES_BACKOFF_FACTOR) + + # -- START PYCBAC TESTING -- + deadline = time.monotonic() + timeout + # -- END PYCBAC TESTING -- + while True: + try: + if self._uds is None: + kwargs = { + "host": self._origin.host.decode("ascii"), + "port": self._origin.port, + "local_address": self._local_address, + "timeout": timeout, + "socket_options": self._socket_options, + } + with Trace("connect_tcp", logger, request, kwargs) as trace: + # -- START PYCBAC TESTING -- + if test_connect_timeout is not None: + time.sleep(test_connect_timeout) + current_time = time.monotonic() + if current_time > deadline: + raise ConnectTimeout(f"Connection timed out after {timeout} seconds") + # -- END PYCBAC TESTING -- + stream = self._network_backend.connect_tcp(**kwargs) + trace.return_value = stream + else: + kwargs = { + "path": self._uds, + "timeout": timeout, + "socket_options": self._socket_options, + } + with Trace( + "connect_unix_socket", logger, request, kwargs + ) as trace: + stream = self._network_backend.connect_unix_socket( + **kwargs + ) + trace.return_value = stream + + if self._origin.scheme in (b"https", b"wss"): + ssl_context = ( + default_ssl_context() + if self._ssl_context is None + else self._ssl_context + ) + alpn_protocols = ["http/1.1", "h2"] if self._http2 else ["http/1.1"] + ssl_context.set_alpn_protocols(alpn_protocols) + + kwargs = { + "ssl_context": ssl_context, + "server_hostname": sni_hostname + or self._origin.host.decode("ascii"), + "timeout": timeout, + } + with Trace("start_tls", logger, request, kwargs) as trace: + stream = stream.start_tls(**kwargs) + trace.return_value = stream + return stream + except (ConnectError, ConnectTimeout): + if retries_left <= 0: + raise + retries_left -= 1 + delay = next(delays) + with Trace("retry", logger, request, kwargs) as trace: + self._network_backend.sleep(delay) + +class TestConnectionPool(ConnectionPool): + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + + def create_connection(self, origin: Origin) -> ConnectionInterface: + if self._proxy is not None: + if self._proxy.url.scheme in (b"socks5", b"socks5h"): + from httpcore._sync.socks_proxy import Socks5Connection + + return Socks5Connection( + proxy_origin=self._proxy.url.origin, + proxy_auth=self._proxy.auth, + remote_origin=origin, + ssl_context=self._ssl_context, + keepalive_expiry=self._keepalive_expiry, + http1=self._http1, + http2=self._http2, + network_backend=self._network_backend, + ) + elif origin.scheme == b"http": + from httpcore._sync.http_proxy import ForwardHTTPConnection + + return ForwardHTTPConnection( + proxy_origin=self._proxy.url.origin, + proxy_headers=self._proxy.headers, + proxy_ssl_context=self._proxy.ssl_context, + remote_origin=origin, + keepalive_expiry=self._keepalive_expiry, + network_backend=self._network_backend, + ) + from httpcore._sync.http_proxy import TunnelHTTPConnection + + return TunnelHTTPConnection( + proxy_origin=self._proxy.url.origin, + proxy_headers=self._proxy.headers, + proxy_ssl_context=self._proxy.ssl_context, + remote_origin=origin, + ssl_context=self._ssl_context, + keepalive_expiry=self._keepalive_expiry, + http1=self._http1, + http2=self._http2, + network_backend=self._network_backend, + ) + + # TESTING_OVERRIDE + return TestHTTPConnection( + origin=origin, + ssl_context=self._ssl_context, + keepalive_expiry=self._keepalive_expiry, + http1=self._http1, + http2=self._http2, + retries=self._retries, + local_address=self._local_address, + uds=self._uds, + network_backend=self._network_backend, + socket_options=self._socket_options, + ) + + def handle_request(self, request: Request) -> Response: + """ + Send an HTTP request, and return an HTTP response. + + This is the core implementation that is called into by `.request()` or `.stream()`. + """ + scheme = request.url.scheme.decode() + if scheme == "": + raise UnsupportedProtocol( + "Request URL is missing an 'http://' or 'https://' protocol." + ) + if scheme not in ("http", "https", "ws", "wss"): + raise UnsupportedProtocol( + f"Request URL has an unsupported protocol '{scheme}://'." + ) + + timeouts = request.extensions.get("timeout", {}) + timeout = timeouts.get("pool", None) + # -- START PYCBAC TESTING -- + test_pool_timeout = timeouts.get("test_pool_timeout", None) + print(f"PYCBAC OVERRIDE: pool timeout: {timeout}, test_pool_timeout: {test_pool_timeout}") + # -- END PYCBAC TESTING -- + + with self._optional_thread_lock: + # Add the incoming request to our request queue. + pool_request = PoolRequest(request) + self._requests.append(pool_request) + + # PYCBAC Addition: track the deadline + deadline = time.monotonic() + timeout + try: + while True: + with self._optional_thread_lock: + # Assign incoming requests to available connections, + # closing or creating new connections as required. + closing = self._assign_requests_to_connections() + self._close_connections(closing) + + # -- START PYCBAC TESTING -- + if test_pool_timeout is not None: + time.sleep(test_pool_timeout) + current_time = time.monotonic() + if current_time > deadline: + raise PoolTimeout(f"Connection timed out after {timeout} seconds") + # -- END PYCBAC TESTING -- + # Wait until this request has an assigned connection. + connection = pool_request.wait_for_connection(timeout=timeout) + # PYCBAC Addition: We _always_ set the request timeouts, so no need to validate keys + connect_timeout = round(deadline - time.monotonic(), 6) # round to microseconds + pool_request.request.extensions["timeout"]["connect"] = connect_timeout + + try: + # Send the request on the assigned connection. + response = connection.handle_request( + pool_request.request + ) + except ConnectionNotAvailable: + # In some cases a connection may initially be available to + # handle a request, but then become unavailable. + # + # In this case we clear the connection and try again. + pool_request.clear_connection() + # PYCBAC Addition: We update the timeout for the next attempt + timeout = round(deadline - time.monotonic(), 6) # round to microseconds + else: + break # pragma: nocover + + except BaseException as exc: + with self._optional_thread_lock: + # For any exception or cancellation we remove the request from + # the queue, and then re-assign requests to connections. + self._requests.remove(pool_request) + closing = self._assign_requests_to_connections() + + self._close_connections(closing) + raise exc from None + + # Return the response. Note that in this case we still have to manage + # the point at which the response is closed. + assert isinstance(response.stream, typing.Iterable) + return Response( + status=response.status, + headers=response.headers, + content=PoolByteStream( + stream=response.stream, pool_request=pool_request, pool=self + ), + extensions=response.extensions, + ) + +def http_transport_init_override(self, *args, **kwargs) -> None: + verify = kwargs.get('verify') + cert = kwargs.get('cert') + trust_env = kwargs.get('trust_env') + ssl_context = create_ssl_context(verify=verify, cert=cert, trust_env=trust_env) + + # See https://github.com/encode/httpx/blob/master/httpx/_config.py for defaults + # default keepalive_expiry is 5 seconds + limits = kwargs.get('limits', Limits(max_connections=100, max_keepalive_connections=20)) + http1 = kwargs.get('http1') + http2 = kwargs.get('http2') + uds = kwargs.get('uds') + local_address = kwargs.get('local_address') + retries = kwargs.get('retries', 0) + socket_options = kwargs.get('socket_options') + self._pool = TestConnectionPool( + ssl_context=ssl_context, + max_connections=limits.max_connections, + max_keepalive_connections=limits.max_keepalive_connections, + keepalive_expiry=limits.keepalive_expiry, + http1=http1, + http2=http2, + uds=uds, + local_address=local_address, + retries=retries, + socket_options=socket_options, + ) + +HTTPTransport.__init__ = http_transport_init_override +setattr(HTTPTransport, 'PYCBAC_TESTING', True) \ No newline at end of file From 0b6df6535bca6e5ce5f54ea682cc91edc7b0c7fe Mon Sep 17 00:00:00 2001 From: Jared Casey Date: Thu, 19 Jun 2025 12:55:31 -0600 Subject: [PATCH 02/18] Updates to clean up baseline project Changes ======= * Static type checking in place (via mypy) * Baseline unit tests available and passing * Baseline integration query tests available and passing --- MANIFEST.in | 8 + acouchbase_analytics/options.py | 1 - acouchbase_analytics/protocol/cluster.pyi | 4 +- .../protocol/core/_request_context.py | 59 ++-- .../protocol/core/client_adapter.py | 5 + acouchbase_analytics/protocol/scope.pyi | 4 +- acouchbase_analytics/protocol/streaming.py | 35 +- acouchbase_analytics/tests/connection_t.py | 229 +++++++++++++ acouchbase_analytics/tests/json_parsing_t.py | 49 ++- acouchbase_analytics/tests/options_t.py | 227 +++++++++++++ .../tests/query_integration_t.py | 255 +++++++++++++++ acouchbase_analytics/tests/query_options_t.py | 301 ++++++++++++++++++ conftest.py | 16 +- couchbase_analytics/_version.py | 5 + couchbase_analytics/common/core/__init__.py | 8 +- .../core/_capella_certificates/_capella.pem | 19 ++ .../common/core/_certificates.py | 36 ++- .../core/_nonprod_certificates/_nonprod.pem | 19 ++ .../common/core/async_json_stream.py | 12 +- .../common/core/async_json_token_parser.py | 18 +- .../common/core/json_parsing.py | 4 +- .../common/core/json_stream.py | 12 +- .../common/core/json_token_parser.py | 17 +- .../common/core/json_token_parser_base.py | 19 +- couchbase_analytics/common/core/net_utils.py | 25 +- couchbase_analytics/common/core/query.py | 16 +- couchbase_analytics/common/core/result.py | 2 +- couchbase_analytics/common/core/utils.py | 16 + couchbase_analytics/common/credential.py | 17 +- couchbase_analytics/common/errors.py | 8 +- couchbase_analytics/common/options.py | 6 +- couchbase_analytics/common/options_base.py | 17 +- couchbase_analytics/common/query.py | 4 +- couchbase_analytics/protocol/connection.py | 118 ++++--- .../protocol/core/_http_transport.py | 20 +- .../protocol/core/_request_context.py | 47 +-- .../protocol/core/client_adapter.py | 10 +- couchbase_analytics/protocol/core/request.py | 56 +++- couchbase_analytics/protocol/errors.py | 22 +- couchbase_analytics/protocol/options.py | 31 +- couchbase_analytics/protocol/streaming.py | 36 ++- couchbase_analytics/tests/connection_t.py | 229 +++++++++++++ couchbase_analytics/tests/json_parsing_t.py | 80 ++++- couchbase_analytics/tests/options_t.py | 226 +++++++++++++ .../tests/query_integration_t.py | 275 ++++++++++++++++ couchbase_analytics/tests/query_options_t.py | 301 ++++++++++++++++++ mypy.ini | 14 + pyproject.toml | 60 +++- requirements.txt | 2 +- run-mypy | 7 + setup.py | 22 +- sphinx_requirements.txt | 5 + tests/analytics_config.py | 6 +- tests/environments/base_environment.py | 291 +++++++++++++++-- tests/environments/simple_environment.py | 66 +++- tests/test_config.ini | 5 +- tests/test_data/airline.json | 189 +++++++++++ tests/utils/__init__.py | 64 +++- tests/utils/_async_client_adapter.py | 59 ++-- tests/utils/_async_utils.py | 42 ++- tests/utils/_async_web_server.py | 9 +- tests/utils/_client_adapter.py | 27 +- tests/utils/_run_web_server.py | 6 +- tests/utils/_test_async_httpx.py | 16 +- tests/utils/_test_httpx.py | 16 +- tests/utils/certs/dinoca.pem | 31 ++ tests/utils/certs/dinocluster.pem | 31 ++ 67 files changed, 3472 insertions(+), 420 deletions(-) create mode 100644 MANIFEST.in create mode 100644 acouchbase_analytics/tests/connection_t.py create mode 100644 acouchbase_analytics/tests/options_t.py create mode 100644 acouchbase_analytics/tests/query_integration_t.py create mode 100644 acouchbase_analytics/tests/query_options_t.py create mode 100644 couchbase_analytics/_version.py create mode 100644 couchbase_analytics/common/core/_capella_certificates/_capella.pem create mode 100644 couchbase_analytics/common/core/_nonprod_certificates/_nonprod.pem create mode 100644 couchbase_analytics/tests/connection_t.py create mode 100644 couchbase_analytics/tests/options_t.py create mode 100644 couchbase_analytics/tests/query_integration_t.py create mode 100644 couchbase_analytics/tests/query_options_t.py create mode 100644 mypy.ini create mode 100755 run-mypy create mode 100644 tests/test_data/airline.json create mode 100644 tests/utils/certs/dinoca.pem create mode 100644 tests/utils/certs/dinocluster.pem diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..8f48798 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,8 @@ +include *.txt LICENSE CONTRIBUTING.md pyproject.toml couchbase_analytics_version.py +include couchbase-sdk-analytics-python-black-duck-manifest.yaml +include couchbase_analytics/common/core/_nonprod_certificates/*.pem +include couchbase_analytics/common/core/_capella_certificates/*.pem +recursive-include couchbase_analytics *.py +recursive-include acouchbase_analytics *.py +global-exclude *.py[cod] *.DS_Store +exclude .git .gitignore .gitmodules gocaves* *.jar .clang* .cmake* .pre* .flake* MANIFEST.in \ No newline at end of file diff --git a/acouchbase_analytics/options.py b/acouchbase_analytics/options.py index ce34875..ef2074d 100644 --- a/acouchbase_analytics/options.py +++ b/acouchbase_analytics/options.py @@ -13,7 +13,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -from couchbase_analytics.common.enums import IpProtocol as IpProtocol # noqa: F401 from couchbase_analytics.common.options import ClusterOptions as ClusterOptions # noqa: F401 from couchbase_analytics.common.options import ClusterOptionsKwargs as ClusterOptionsKwargs # noqa: F401 from couchbase_analytics.common.options import QueryOptions as QueryOptions # noqa: F401 diff --git a/acouchbase_analytics/protocol/cluster.pyi b/acouchbase_analytics/protocol/cluster.pyi index 4e5c6ec..7fce7dd 100644 --- a/acouchbase_analytics/protocol/cluster.pyi +++ b/acouchbase_analytics/protocol/cluster.pyi @@ -21,7 +21,7 @@ if sys.version_info < (3, 11): else: from typing import Unpack -from acouchbase_analytics.protocol.core.client_adapter import _ClientAdapter +from acouchbase_analytics.protocol.core.client_adapter import _AsyncClientAdapter from acouchbase_analytics.protocol.database import AsyncDatabase from couchbase_analytics.common.credential import Credential from couchbase_analytics.common.result import AsyncQueryResult @@ -54,7 +54,7 @@ class AsyncCluster: **kwargs: Unpack[ClusterOptionsKwargs]) -> None: ... @property - def client_adapter(self) -> _ClientAdapter: ... + def client_adapter(self) -> _AsyncClientAdapter: ... @property def connected(self) -> bool: ... diff --git a/acouchbase_analytics/protocol/core/_request_context.py b/acouchbase_analytics/protocol/core/_request_context.py index 0d98bc3..f2e5876 100644 --- a/acouchbase_analytics/protocol/core/_request_context.py +++ b/acouchbase_analytics/protocol/core/_request_context.py @@ -5,6 +5,7 @@ from typing import (Any, Awaitable, Callable, + Coroutine, Dict, List, Optional, @@ -20,7 +21,7 @@ get_time) from couchbase_analytics.common.core.net_utils import get_request_ip_async from couchbase_analytics.common.deserializer import Deserializer -from couchbase_analytics.common.errors import AnalyticsError +from couchbase_analytics.common.errors import AnalyticsError, InvalidCredentialError from couchbase_analytics.common.streaming import StreamingState from couchbase_analytics.protocol.connection import DEFAULT_TIMEOUTS from couchbase_analytics.protocol.errors import ErrorMapper @@ -41,7 +42,7 @@ def __init__(self, self._client_adapter = client_adapter self._request = request self._backend = backend or current_async_library() - self._response_task: Optional[Task] = None + # self._response_task: Optional[Task] = None self._request_state = StreamingState.NotStarted self._stage_completed: Optional[anyio.Event] = None self._request_error: Optional[Exception] = None @@ -80,9 +81,9 @@ def request_state(self, state: StreamingState) -> None: raise TypeError('request_state must be an instance of StreamingState') self._request_state = state - @property - def stage_completed(self) -> anyio.Event: - return self._stage_completed + # @property + # def stage_completed(self) -> Optional[anyio.Event]: + # return self._stage_completed @property def timed_out(self) -> bool: @@ -94,9 +95,10 @@ def cancelled(self) -> bool: async def _execute(self, fn: Callable[..., Awaitable[Any]], *args: object) -> None: await fn(*args) - self._stage_completed.set() + if self._stage_completed is not None: + self._stage_completed.set() - async def _trace_handler(self, event_name, _) -> None: + async def _trace_handler(self, event_name: str, _: str) -> None: if event_name == 'connection.connect_tcp.complete': # after connection is established, we need to update the cancel_scope deadline to match the query_timeout self._update_cancel_scope_deadline(self._request_deadline, is_absolute=True) @@ -115,24 +117,31 @@ async def initialize(self) -> None: self._request_state = StreamingState.Started # we set the request timeout once the context is initialized in order to create the deadline # closer to when the upstream logic will begin to use the request context - timeouts = self._request.get_request_timeouts() - self._request_deadline = get_time() + timeouts.get('read', DEFAULT_TIMEOUTS['query_timeout']) + timeouts = self._request.get_request_timeouts() or {} + self._request_deadline = get_time() + (timeouts.get('read', None) or DEFAULT_TIMEOUTS['query_timeout']) self._update_cancel_scope_deadline(self._connect_timeout) async def send_request(self, enable_trace_handling: Optional[bool]=False) -> HttpCoreResponse: ip = await get_request_ip_async(self._request.host, self._request.port, self._request.previous_ips) if ip is None: attempted_ips = ', '.join(self._request.previous_ips or []) - raise AnalyticsError(f'Connect failure. Attempted to connect to resolved IPs: {attempted_ips}.') + raise AnalyticsError(message=f'Connect failure. Attempted to connect to resolved IPs: {attempted_ips}.') if enable_trace_handling is True: (self._request.update_url(ip, self._client_adapter.analytics_path) - .update_extensions({'trace': self._trace_handler}) + .add_trace_to_extensions(self._trace_handler) .update_previous_ips(ip)) else: self._request.update_url(ip, self._client_adapter.analytics_path).update_previous_ips(ip) response = await self._client_adapter.send_request(self._request) self._request.set_client_server_addrs(response) + if response.status_code == 401: + context = { + 'client_addr': self._request.client_addr, + 'server_addr': self._request.server_addr, + 'http_status': response.status_code, + } + raise InvalidCredentialError(str(context)) return response async def shutdown(self, @@ -149,14 +158,16 @@ async def shutdown(self, if StreamingState.is_okay(self._request_state): self._request_state = StreamingState.Completed - def create_response_task(self, fn: Callable[..., Awaitable[Any]], *args: object) -> Task: + def create_response_task(self, fn: Callable[..., Coroutine[Any, Any, Any]], *args: object) -> Task[Any]: if self._backend is None or self._backend.backend_lib != 'asyncio': raise RuntimeError('Must use the asyncio backend to create a response task.') + if self._backend.loop is None: + raise RuntimeError('Async backend loop is not initialized.') task_name = f'{self._id}-response-task' print(f'Creating response task: {task_name}') - task = self._backend.loop.create_task(fn(*args), name=task_name) + task: Task[Any] = self._backend.loop.create_task(fn(*args), name=task_name) # TODO: I don't think this callback is necessary...need to add more tests to confirm - def task_done(t: Task) -> None: + def task_done(t: Task[Any]) -> None: print(f'Task ({t.get_name()}) done: {t.done()}, cancelled: {t.cancelled()}') task.add_done_callback(task_done) @@ -170,15 +181,23 @@ def start_next_stage(self, fn: Callable[..., Awaitable[Any]], *args: object, reset_previous_stage: Optional[bool]=False) -> None: - if reset_previous_stage is True: - if self._stage_completed is not None: + # if reset_previous_stage is True: + # if self._stage_completed is not None: + # self._stage_completed = None + if self._stage_completed is not None: + if reset_previous_stage is True: self._stage_completed = None - elif self._stage_completed is not None: - raise RuntimeError('Task already running in this context.') + else: + raise RuntimeError('Task already running in this context.') self._stage_completed = anyio.Event() self._taskgroup.start_soon(self._execute, fn, *args) + async def wait_for_stage_to_complete(self) -> None: + if self._stage_completed is None: + return + await self._stage_completed.wait() + async def process_error(self, json_data: List[Dict[str, Any]]) -> None: self._request_state = StreamingState.Error if not isinstance(json_data, list): @@ -209,4 +228,6 @@ async def __aexit__(self, self._request_state = StreamingState.Cancelled elif exc_val is not None: self._request_state = StreamingState.Error - del self._taskgroup \ No newline at end of file + del self._taskgroup + # TODO: should we suppress here (e.g., return True) + return None \ No newline at end of file diff --git a/acouchbase_analytics/protocol/core/client_adapter.py b/acouchbase_analytics/protocol/core/client_adapter.py index 81fb3e3..f591490 100644 --- a/acouchbase_analytics/protocol/core/client_adapter.py +++ b/acouchbase_analytics/protocol/core/client_adapter.py @@ -115,6 +115,8 @@ async def create_client(self) -> None: """ if not hasattr(self, '_client'): if self._conn_details.is_secure(): + if self._conn_details.ssl_context is None: + raise ValueError('SSL context is required for secure connections.') transport = None if self._http_transport_cls is not None: transport = self._http_transport_cls(verify=self._conn_details.ssl_context) @@ -137,6 +139,9 @@ async def send_request(self, request: QueryRequest) -> Response: if not hasattr(self, '_client'): raise RuntimeError('Client not created yet') + if request.url is None: + raise ValueError('Request URL cannot be None') + req = self._client.build_request(request.method, request.url, json=request.body, diff --git a/acouchbase_analytics/protocol/scope.pyi b/acouchbase_analytics/protocol/scope.pyi index 1c4f2d5..2817523 100644 --- a/acouchbase_analytics/protocol/scope.pyi +++ b/acouchbase_analytics/protocol/scope.pyi @@ -21,7 +21,7 @@ if sys.version_info < (3, 11): else: from typing import Unpack -from acouchbase_analytics.protocol.core.client_adapter import _ClientAdapter +from acouchbase_analytics.protocol.core.client_adapter import _AsyncClientAdapter from acouchbase_analytics.protocol.database import AsyncDatabase as AsyncDatabase from couchbase_analytics.options import QueryOptions, QueryOptionsKwargs from couchbase_analytics.result import AsyncQueryResult @@ -30,7 +30,7 @@ class AsyncScope: def __init__(self, database: AsyncDatabase, scope_name: str) -> None: ... @property - def client_adapter(self) -> _ClientAdapter: ... + def client_adapter(self) -> _AsyncClientAdapter: ... @property def name(self) -> str: ... diff --git a/acouchbase_analytics/protocol/streaming.py b/acouchbase_analytics/protocol/streaming.py index c74bfe2..881143d 100644 --- a/acouchbase_analytics/protocol/streaming.py +++ b/acouchbase_analytics/protocol/streaming.py @@ -16,6 +16,8 @@ from __future__ import annotations import json +import sys + from asyncio import CancelledError from functools import wraps from typing import (Any, @@ -23,6 +25,11 @@ Coroutine, Optional) +if sys.version_info < (3, 10): + from typing_extensions import TypeAlias +else: + from typing import TypeAlias + from httpx import Response as HttpCoreResponse # TODO: errors? @@ -39,20 +46,20 @@ from couchbase_analytics.common.streaming import StreamingState + + class RequestWrapper: """ **INTERNAL** """ @classmethod - def handle_retries(cls, # noqa: C901 - ) -> Callable[[Callable[[], None]], Callable[[AsyncHttpStreamingResponse], Coroutine[Any, Any, None]]]: + def handle_retries(cls) -> Callable[[SendRequestFunc], WrappedSendRequestFunc]: # noqa: C901 """ **INTERNAL** """ - def decorator(fn: Callable[[], None] # noqa: C901 - ) -> Callable[[AsyncHttpStreamingResponse], Coroutine[Any, Any, None]]: + def decorator(fn: SendRequestFunc) -> WrappedSendRequestFunc: # noqa: C901 @wraps(fn) async def wrapped_fn(self: AsyncHttpStreamingResponse) -> None: try: @@ -93,11 +100,11 @@ def __init__(self, async def _finish_processing_stream(self) -> None: if not self._request_context.has_stage_completed: - await self._request_context.stage_completed.wait() + await self._request_context.wait_for_stage_to_complete() while not self._json_stream.token_stream_exhausted: self._request_context.start_next_stage(self._json_stream.continue_parsing, reset_previous_stage=True) - await self._request_context.stage_completed.wait() + await self._request_context.wait_for_stage_to_complete() def _maybe_continue_to_process_stream(self) -> None: if not self._request_context.has_stage_completed: @@ -112,10 +119,11 @@ async def _process_response(self, raw_response: Optional[ParsedResult]=None) -> if raw_response is None: raw_response = await self._json_stream.get_result() if raw_response is None: - # TODO: logging?? - # TODO: exception?? - raise RuntimeError('No result from JsonStream') - + raise AnalyticsError(message='Received unexpected empty result from JsonStream.') + + if raw_response.value is None: + raise AnalyticsError(message='Received unexpected empty result from JsonStream.') + json_response = json.loads(raw_response.value) if 'errors' in json_response: await self._request_context.process_error(json_response['errors']) @@ -173,6 +181,8 @@ async def get_next_row(self) -> Any: self._maybe_continue_to_process_stream() raw_response = await self._json_stream.get_result() if raw_response.result_type == ParsedResultType.ROW: + if raw_response.value is None: + raise AnalyticsError(message='Unexpected empty row response while streaming.') return self._request_context.deserializer.deserialize(raw_response.value) elif raw_response.result_type in [ParsedResultType.ERROR, ParsedResultType.UNKNOWN]: await self._process_response(raw_response=raw_response) @@ -200,3 +210,8 @@ async def send_request(self) -> None: else: await self._finish_processing_stream() await self._process_response() + +SendRequestFunc: TypeAlias = Callable[[AsyncHttpStreamingResponse], Coroutine[Any, Any, None]] +# Although, SendRequestFunc is the same type as WrappedSendRequestFunc, keep separate for clarity and indicate +# WrappedSendRequestFunc is a decorator +WrappedSendRequestFunc: TypeAlias = Callable[[AsyncHttpStreamingResponse], Coroutine[Any, Any, None]] \ No newline at end of file diff --git a/acouchbase_analytics/tests/connection_t.py b/acouchbase_analytics/tests/connection_t.py new file mode 100644 index 0000000..e482f97 --- /dev/null +++ b/acouchbase_analytics/tests/connection_t.py @@ -0,0 +1,229 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from typing import Dict +from urllib.parse import urlparse + +import pytest + +from acouchbase_analytics.cluster import AsyncCluster +from acouchbase_analytics.protocol.core.client_adapter import _AsyncClientAdapter +from couchbase_analytics.credential import Credential +from couchbase_analytics.protocol.core.request import _RequestBuilder +from tests.utils import get_test_cert_path, to_query_str + +TEST_CERT_PATH = get_test_cert_path() + + +class ConnectionTestSuite: + TEST_MANIFEST = [ + 'test_connstr_options_fail', + 'test_connstr_options_timeout', + 'test_connstr_options_timeout_fail', + 'test_connstr_options_timeout_invalid_duration', + 'test_connstr_options_security', + 'test_connstr_options_security_fail', + 'test_invalid_connection_strings', + 'test_valid_connection_strings', + ] + + @pytest.mark.parametrize('connstr_opt', + ['invalid_op=10', + 'connect_timeout=2500ms', + 'dispatch_timeout=2500ms', + 'query_timeout=2500ms', + 'socket_connect_timeout=2500ms', + 'trust_only_pem_file=/path/to/file', + 'disable_server_certificate_verification=True' + ]) + def test_connstr_options_fail(self, + connstr_opt: str) -> None: + cred = Credential.from_username_and_password('Administrator', 'password') + connstr = f'https://localhost?{connstr_opt}' + with pytest.raises(ValueError): + _AsyncClientAdapter(connstr, cred) + + @pytest.mark.parametrize('duration, expected_seconds', + [('1h', '3600'), + ('+1h', '3600'), + ('+1h', '3600'), + ('1h10m', '4200'), + ('1.h10m', '4200'), + ('.1h10m', '960'), + ('0001h00010m', '4200'), + ('2m3s4ms', '123.004'), + (('100ns', '1e-7')), + (('100us', '1e-4')), + (('100μs', '1e-4')), + (('1000000ns', '.001')), + (('1000us', '.001')), + (('1000μs', '.001')), + ('4ms3s2m', '123.004'), + ('4ms3s2m5s', '128.004'), + ('2m3.125s', '123.125'), + ]) + def test_connstr_options_timeout(self, + duration: str, + expected_seconds: str) -> None: + opt_keys = ['timeout.connect_timeout', + 'timeout.query_timeout'] + opts = {k: duration for k in opt_keys} + cred = Credential.from_username_and_password('Administrator', 'password') + connstr = f'https://localhost?{to_query_str(opts)}' + client = _AsyncClientAdapter(connstr, cred) + req_builder = _RequestBuilder(client) + req = req_builder.build_base_query_request('SELECT 1=1') + expected = float(expected_seconds) + returned_timeout_opts = req.get_request_timeouts() + assert isinstance(returned_timeout_opts, dict) + for k in opts.keys(): + opt_key = k.split('.')[1] + if opt_key.startswith('connect'): + pool_timeout = returned_timeout_opts.get('pool') + assert pool_timeout is not None + assert abs(pool_timeout - expected) < 1e-9 + connect_timeout = returned_timeout_opts.get('connect') + assert connect_timeout is not None + assert abs(connect_timeout - expected) < 1e-9 + else: + read_timeout = returned_timeout_opts.get('read') + assert read_timeout is not None + assert abs(read_timeout - expected) < 1e-9 + + @pytest.mark.parametrize('invalid_opt_name', + ['connect_timeout', + 'dispatch_timeout', + 'query_timeout', + 'resolve_timeout', + 'socket_connect_timeout']) + def test_connstr_options_timeout_fail(self, invalid_opt_name: str) -> None: + opts = {invalid_opt_name: '2500s'} + cred = Credential.from_username_and_password('Administrator', 'password') + connstr = f'https://localhost?{to_query_str(opts)}' + with pytest.raises(ValueError): + _AsyncClientAdapter(connstr, cred) + + @pytest.mark.parametrize('bad_duration', + ['123', + '00', + ' 1h', + '1h ', + '1h 2m' + '+-3h', + '-+3h', + '-', + '-.', + '.', + '.h', + '2.3.4h', + '3x', + '3', + '3h4x', + '1H', + '1h-2m', + '-1h', + '-1m', + '-1s' + ]) + def test_connstr_options_timeout_invalid_duration(self, + bad_duration: str) -> None: + opt_keys = ['timeout.connect_timeout', 'timeout.query_timeout'] + for key in opt_keys: + opts = {key: bad_duration} + cred = Credential.from_username_and_password('Administrator', 'password') + connstr = f'https://localhost?{to_query_str(opts)}' + with pytest.raises(ValueError): + _AsyncClientAdapter(connstr, cred) + + @pytest.mark.parametrize('connstr_opts, expected_opts', + [({'security.trust_only_pem_file': TEST_CERT_PATH}, + {'trust_only_pem_file': TEST_CERT_PATH, + 'trust_only_capella': False}), + ({'security.disable_server_certificate_verification': 'true'}, + {'disable_server_certificate_verification': True}), + ]) + def test_connstr_options_security(self, + connstr_opts: Dict[str, object], + expected_opts: Dict[str, object]) -> None: + cred = Credential.from_username_and_password('Administrator', 'password') + connstr = f'https://localhost?{to_query_str(connstr_opts)}' + client = _AsyncClientAdapter(connstr, cred) + sec_opts = client.connection_details.cluster_options.get('security_options', {}) + assert sec_opts == expected_opts + + @pytest.mark.parametrize('invalid_opt_name', + ['trust_only_capella', + 'trust_only_pem_file', + 'trust_only_pem_str', + 'trust_only_certificates', + 'disable_server_certificate_verification']) + def test_connstr_options_security_fail(self, invalid_opt_name: str) -> None: + opts = {invalid_opt_name: 'True'} + cred = Credential.from_username_and_password('Administrator', 'password') + connstr = f'https://localhost?{to_query_str(opts)}' + with pytest.raises(ValueError): + _AsyncClientAdapter(connstr, cred) + + @pytest.mark.parametrize('connstr', ['10.0.0.1:8091', + 'http://10.0.0.1:11222,10.0.0.2,10.0.0.3:11207', + 'http://10.0.0.1;10.0.0.2:11210;10.0.0.3', + 'http://[::ffff:192.168.0.1]:11207,[::ffff:192.168.0.2]:11207', + 'https://10.0.0.1:11222,10.0.0.2,10.0.0.3:11207', + 'https://10.0.0.1;10.0.0.2:11210;10.0.0.3', + 'https://[::ffff:192.168.0.1]:11207,[::ffff:192.168.0.2]:11207', + 'couchbase://10.0.0.1', + 'couchbases://10.0.0.1']) + def test_invalid_connection_strings(self, connstr: str) -> None: + cred = Credential.from_username_and_password('Administrator', 'password') + with pytest.raises(ValueError): + AsyncCluster.create_instance(connstr, cred) + + @pytest.mark.parametrize('connstr', ['http://10.0.0.1', + 'http://10.0.0.1:11222', + 'http://[3ffe:2a00:100:7031::1]', + 'http://[::ffff:192.168.0.1]:11207', + 'http://test.local:11210', + 'http://fqdn', + 'https://10.0.0.1', + 'https://10.0.0.1:11222', + 'https://[3ffe:2a00:100:7031::1]', + 'https://[::ffff:192.168.0.1]:11207', + 'https://test.local:11210', + 'https://fqdn' + ]) + def test_valid_connection_strings(self, connstr: str) -> None: + cred = Credential.from_username_and_password('Administrator', 'password') + client = _AsyncClientAdapter(connstr, cred) + # options should be empty + assert {} == client.connection_details.cluster_options + parsed_connstr = urlparse(connstr) + parsed_port = parsed_connstr.port or (80 if parsed_connstr.scheme == 'http' else 443) + scheme, host, port = client.connection_details.get_scheme_host_and_port() + assert f'{parsed_connstr.scheme}://{parsed_connstr.hostname}:{parsed_port}' == f'{scheme}://{host}:{port}' + + +class ConnectionTests(ConnectionTestSuite): + + @pytest.fixture(scope='class', autouse=True) + def validate_test_manifest(self) -> None: + def valid_test_method(meth: str) -> bool: + attr = getattr(ConnectionTests, meth) + return callable(attr) and not meth.startswith('__') and meth.startswith('test') + method_list = [meth for meth in dir(ConnectionTests) if valid_test_method(meth)] + test_list = set(ConnectionTestSuite.TEST_MANIFEST).symmetric_difference(method_list) + if test_list: + pytest.fail(f'Test manifest invalid. Missing/extra tests: {test_list}.') diff --git a/acouchbase_analytics/tests/json_parsing_t.py b/acouchbase_analytics/tests/json_parsing_t.py index dc05a95..70e622e 100644 --- a/acouchbase_analytics/tests/json_parsing_t.py +++ b/acouchbase_analytics/tests/json_parsing_t.py @@ -22,11 +22,11 @@ import pytest from couchbase_analytics.common.core import (JsonParsingError, + JsonStreamConfig, ParsedResult, ParsedResultType) from couchbase_analytics.common.core.async_json_stream import AsyncJsonStream -from couchbase_analytics.common.core.json_stream import JsonStreamConfig from couchbase_analytics.common.errors import AnalyticsError from tests.environments.simple_environment import JsonDataType from tests.utils import AsyncBytesIterator @@ -39,7 +39,9 @@ class JsonParsingTestSuite: TEST_MANIFEST = [ 'test_analytics_error', + 'test_analytics_error_mid_stream', 'test_analytics_many_rows', + 'test_analytics_multiple_errors', 'test_analytics_parses_async', 'test_analytics_simple_result', @@ -84,6 +86,32 @@ async def test_analytics_error(self, with pytest.raises(AnalyticsError): await parser.get_result() + async def test_analytics_error_mid_stream(self, async_test_env: AsyncSimpleEnvironment) -> None: + json_object, bytes_data = async_test_env.get_json_data(JsonDataType.FAILED_REQUEST_MID_STREAM) + parser = AsyncJsonStream(AsyncBytesIterator(bytes_data)) + await parser.start_parsing() + row_idx = 0 + while True: + result = await parser.get_result() + if result is None and not parser.token_stream_exhausted: + await parser.continue_parsing() + continue + assert isinstance(result, ParsedResult) + assert result.result_type in [ParsedResultType.ROW, ParsedResultType.ERROR] + assert isinstance(result.value, bytes) + if result.result_type == ParsedResultType.ROW: + assert json.loads(result.value.decode('utf-8')) == json_object['results'][row_idx] + row_idx += 1 + else: + final_result = result.value.decode('utf-8') + break + + # if we are not buffering the entire result, the final result will exclude the results key + json_object.pop('results') + assert json.loads(final_result) == json_object + with pytest.raises(AnalyticsError): + await parser.get_result() + async def test_analytics_many_rows(self, async_test_env: AsyncSimpleEnvironment) -> None: json_object, bytes_data = async_test_env.get_json_data(JsonDataType.MULTIPLE_RESULTS) parser = AsyncJsonStream(AsyncBytesIterator(bytes_data)) @@ -110,6 +138,25 @@ async def test_analytics_many_rows(self, async_test_env: AsyncSimpleEnvironment) with pytest.raises(AnalyticsError): await parser.get_result() + @pytest.mark.parametrize('buffered_result', [True, False]) + async def test_analytics_multiple_errors(self, + async_test_env: AsyncSimpleEnvironment, + buffered_result: bool) -> None: + json_object, bytes_data = async_test_env.get_json_data(JsonDataType.FAILED_REQUEST_MULTI_ERRORS) + if buffered_result: + parser = AsyncJsonStream(AsyncBytesIterator(bytes_data), + stream_config=JsonStreamConfig(buffer_entire_result=True)) + else: + parser = AsyncJsonStream(AsyncBytesIterator(bytes_data)) + await parser.start_parsing() + result = await parser.get_result() + assert isinstance(result, ParsedResult) + assert result.result_type == ParsedResultType.ERROR + assert isinstance(result.value, bytes) + assert json.loads(result.value.decode('utf-8')) == json_object + with pytest.raises(AnalyticsError): + await parser.get_result() + async def test_analytics_parses_async(self, async_test_env: AsyncSimpleEnvironment) -> None: json_object, bytes_data = async_test_env.get_json_data(JsonDataType.MULTIPLE_RESULTS) async def _run_async(idx: int) -> Dict[float, int]: diff --git a/acouchbase_analytics/tests/options_t.py b/acouchbase_analytics/tests/options_t.py new file mode 100644 index 0000000..ba18d6d --- /dev/null +++ b/acouchbase_analytics/tests/options_t.py @@ -0,0 +1,227 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from datetime import timedelta +from typing import Dict, Type + +import pytest + +from acouchbase_analytics.protocol.core.client_adapter import _AsyncClientAdapter +from couchbase_analytics.credential import Credential +from couchbase_analytics.deserializer import (Deserializer, + DefaultJsonDeserializer, + PassthroughDeserializer) +from couchbase_analytics.options import (ClusterOptions, + SecurityOptions, + SecurityOptionsKwargs, + TimeoutOptions, + TimeoutOptionsKwargs) + +from tests.utils import (get_test_cert_path, + get_test_cert_list, + get_test_cert_str) + +TEST_CERT_PATH = get_test_cert_path() +TEST_CERT_LIST = get_test_cert_list() +TEST_CERT_STR = get_test_cert_str() + + +class ClusterOptionsTestSuite: + + TEST_MANIFEST = [ + 'test_options_deserializer', + 'test_options_deserializer_kwargs', + 'test_security_options', + 'test_security_options_classmethods', + 'test_security_options_kwargs', + 'test_security_options_invalid', + 'test_security_options_invalid_kwargs', + 'test_timeout_options', + 'test_timeout_options_kwargs', + 'test_timeout_options_must_be_positive', + 'test_timeout_options_must_be_positive_kwargs', + ] + + @pytest.mark.parametrize('deserializer_cls', [DefaultJsonDeserializer, PassthroughDeserializer]) + def test_options_deserializer(self, deserializer_cls:Type[Deserializer]) -> None: + cred = Credential.from_username_and_password('Administrator', 'password') + deserializer_instance = deserializer_cls() + client = _AsyncClientAdapter('https://localhost', cred, ClusterOptions(deserializer=deserializer_instance)) + assert isinstance(client.connection_details.default_deserializer, deserializer_cls) + + @pytest.mark.parametrize('deserializer_cls', [DefaultJsonDeserializer, PassthroughDeserializer]) + def test_options_deserializer_kwargs(self, deserializer_cls:Type[Deserializer]) -> None: + cred = Credential.from_username_and_password('Administrator', 'password') + deserializer_instance = deserializer_cls() + client = _AsyncClientAdapter('https://localhost', cred, **{'deserializer': deserializer_instance}) + assert isinstance(client.connection_details.default_deserializer, deserializer_cls) + + @pytest.mark.parametrize('opts, expected_opts', + [({}, None), + ({'trust_only_capella': True}, + {'trust_only_capella': True}), + ({'trust_only_pem_file': TEST_CERT_PATH}, + {'trust_only_pem_file': TEST_CERT_PATH, + 'trust_only_capella': False}), + ({'trust_only_pem_str': TEST_CERT_STR}, + {'trust_only_pem_str': TEST_CERT_STR, + 'trust_only_capella': False}), + ({'trust_only_certificates': TEST_CERT_LIST}, + {'trust_only_certificates': TEST_CERT_LIST, + 'trust_only_capella': False}), + ({'disable_server_certificate_verification': True}, + {'disable_server_certificate_verification': True}), + ]) + def test_security_options(self, opts: SecurityOptionsKwargs, expected_opts: SecurityOptionsKwargs) -> None: + cred = Credential.from_username_and_password('Administrator', 'password') + client = _AsyncClientAdapter('https://localhost', + cred, + ClusterOptions(security_options=SecurityOptions(**opts))) + assert expected_opts == client.connection_details.cluster_options.get('security_options') + + @pytest.mark.parametrize('opts, expected_opts', + [(SecurityOptions.trust_only_capella(), + {'trust_only_capella': True}), + (SecurityOptions.trust_only_pem_file(TEST_CERT_PATH), + {'trust_only_pem_file': TEST_CERT_PATH, + 'trust_only_capella': False}), + (SecurityOptions.trust_only_pem_str(TEST_CERT_STR), + {'trust_only_pem_str': TEST_CERT_STR, + 'trust_only_capella': False}), + (SecurityOptions.trust_only_certificates(TEST_CERT_LIST), + {'trust_only_certificates': TEST_CERT_LIST, + 'trust_only_capella': False}), + ]) + def test_security_options_classmethods(self, opts: SecurityOptions, expected_opts: Dict[str, object]) -> None: + cred = Credential.from_username_and_password('Administrator', 'password') + client = _AsyncClientAdapter('https://localhost', + cred, + ClusterOptions(security_options=opts)) + assert expected_opts == client.connection_details.cluster_options.get('security_options') + + @pytest.mark.parametrize('opts, expected_opts', + [({}, None), + ({'trust_only_capella': True}, + {'trust_only_capella': True}), + ({'trust_only_pem_file': TEST_CERT_PATH}, + {'trust_only_pem_file': TEST_CERT_PATH, + 'trust_only_capella': False}), + ({'trust_only_pem_str': TEST_CERT_STR}, + {'trust_only_pem_str': TEST_CERT_STR, + 'trust_only_capella': False}), + ({'trust_only_certificates': TEST_CERT_LIST}, + {'trust_only_certificates': TEST_CERT_LIST, + 'trust_only_capella': False}), + ({'disable_server_certificate_verification': True}, + {'disable_server_certificate_verification': True}), + ]) + def test_security_options_kwargs(self, opts: Dict[str, object], expected_opts: Dict[str, object]) -> None: + cred = Credential.from_username_and_password('Administrator', 'password') + client = _AsyncClientAdapter('https://localhost', cred, **opts) + assert expected_opts == client.connection_details.cluster_options.get('security_options') + + @pytest.mark.parametrize('opts', + [{'trust_only_capella': True, + 'trust_only_pem_file': TEST_CERT_PATH}, + {'trust_only_capella': True, + 'trust_only_pem_str': TEST_CERT_STR}, + {'trust_only_capella': True, + 'trust_only_certificates': TEST_CERT_LIST}, + ]) + def test_security_options_invalid(self, opts: SecurityOptionsKwargs) -> None: + cred = Credential.from_username_and_password('Administrator', 'password') + with pytest.raises(ValueError): + _AsyncClientAdapter('https://localhost', + cred, + ClusterOptions(security_options=SecurityOptions(**opts))) + + @pytest.mark.parametrize('opts', + [{'trust_only_capella': True, + 'trust_only_pem_file': TEST_CERT_PATH}, + {'trust_only_capella': True, + 'trust_only_pem_str': TEST_CERT_STR}, + {'trust_only_capella': True, + 'trust_only_certificates': TEST_CERT_LIST}, + ]) + def test_security_options_invalid_kwargs(self, opts: Dict[str, object]) -> None: + cred = Credential.from_username_and_password('Administrator', 'password') + with pytest.raises(ValueError): + _AsyncClientAdapter('https://localhost', cred, **opts) + + @pytest.mark.parametrize('opts, expected_opts', + [({}, None), + ({'connect_timeout': timedelta(seconds=30)}, + {'connect_timeout': 30}), + ({'query_timeout': timedelta(seconds=30)}, + {'query_timeout': 30}), + ({'connect_timeout': timedelta(seconds=60), + 'query_timeout': timedelta(seconds=30)}, + {'connect_timeout': 60, + 'query_timeout': 30}), + ]) + def test_timeout_options(self, opts: TimeoutOptionsKwargs, expected_opts: TimeoutOptionsKwargs) -> None: + cred = Credential.from_username_and_password('Administrator', 'password') + client = _AsyncClientAdapter('https://localhost', + cred, + ClusterOptions(timeout_options=TimeoutOptions(**opts))) + assert expected_opts == client.connection_details.cluster_options.get('timeout_options') + + @pytest.mark.parametrize('opts, expected_opts', + [({'connect_timeout': timedelta(seconds=30)}, + {'connect_timeout': 30}), + ({'query_timeout': timedelta(seconds=30)}, + {'query_timeout': 30}), + ({'connect_timeout': timedelta(seconds=60), + 'query_timeout': timedelta(seconds=30)}, + {'connect_timeout': 60, + 'query_timeout': 30}), + ]) + def test_timeout_options_kwargs(self, opts: Dict[str, object], expected_opts: Dict[str, object]) -> None: + cred = Credential.from_username_and_password('Administrator', 'password') + client = _AsyncClientAdapter('https://localhost', cred, **opts) + assert expected_opts == client.connection_details.cluster_options.get('timeout_options') + + @pytest.mark.parametrize('opts', + [{'connect_timeout': timedelta(seconds=-1)}, + {'query_timeout': timedelta(seconds=-1)}]) + def test_timeout_options_must_be_positive(self, opts: TimeoutOptionsKwargs) -> None: + cred = Credential.from_username_and_password('Administrator', 'password') + with pytest.raises(ValueError): + _AsyncClientAdapter('https://localhost', + cred, + ClusterOptions(timeout_options=TimeoutOptions(**opts))) + + @pytest.mark.parametrize('opts', + [{'connect_timeout': timedelta(seconds=-1)}, + {'query_timeout': timedelta(seconds=-1)}]) + def test_timeout_options_must_be_positive_kwargs(self, opts: Dict[str, object]) -> None: + cred = Credential.from_username_and_password('Administrator', 'password') + with pytest.raises(ValueError): + _AsyncClientAdapter('https://localhost', cred, **opts) + + +class ClusterOptionsTests(ClusterOptionsTestSuite): + + @pytest.fixture(scope='class', autouse=True) + def validate_test_manifest(self) -> None: + def valid_test_method(meth: str) -> bool: + attr = getattr(ClusterOptionsTests, meth) + return callable(attr) and not meth.startswith('__') and meth.startswith('test') + method_list = [meth for meth in dir(ClusterOptionsTests) if valid_test_method(meth)] + test_list = set(ClusterOptionsTestSuite.TEST_MANIFEST).symmetric_difference(method_list) + if test_list: + pytest.fail(f'Test manifest invalid. Missing/extra tests: {test_list}.') \ No newline at end of file diff --git a/acouchbase_analytics/tests/query_integration_t.py b/acouchbase_analytics/tests/query_integration_t.py new file mode 100644 index 0000000..b6fafb7 --- /dev/null +++ b/acouchbase_analytics/tests/query_integration_t.py @@ -0,0 +1,255 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import json +from asyncio import CancelledError, Future +from datetime import timedelta +from typing import TYPE_CHECKING + +import pytest + +from acouchbase_analytics.deserializer import PassthroughDeserializer +from acouchbase_analytics.errors import QueryError +from acouchbase_analytics.options import QueryOptions +from acouchbase_analytics.result import AsyncQueryResult +from couchbase_analytics.common.streaming import StreamingState +from tests import AsyncYieldFixture + +if TYPE_CHECKING: + from tests.environments.base_environment import AsyncTestEnvironment + + +class QueryTestSuite: + + TEST_MANIFEST = [ + # 'test_query_cancel_prior_iterating', + # 'test_query_cancel_while_iterating', + 'test_query_metadata', + 'test_query_metadata_not_available', + 'test_query_named_parameters', + 'test_query_named_parameters_no_options', + 'test_query_named_parameters_override', + 'test_query_positional_params', + 'test_query_positional_params_no_option', + 'test_query_positional_params_override', + 'test_query_raises_exception_prior_to_iterating', + 'test_query_raw_options', + 'test_simple_query', + 'test_query_passthrough_deserializer', + ] + + @pytest.fixture(scope='class') + def query_statement_limit2(self, test_env: AsyncTestEnvironment) -> str: + if test_env.use_scope: + return f'SELECT * FROM {test_env.collection_name} LIMIT 2;' + else: + return f'SELECT * FROM {test_env.fqdn} LIMIT 2;' + + @pytest.fixture(scope='class') + def query_statement_pos_params_limit2(self, test_env: AsyncTestEnvironment) -> str: + if test_env.use_scope: + return f'SELECT * FROM {test_env.collection_name} WHERE country = $1 LIMIT 2;' + else: + return f'SELECT * FROM {test_env.fqdn} WHERE country = $1 LIMIT 2;' + + @pytest.fixture(scope='class') + def query_statement_named_params_limit2(self, test_env: AsyncTestEnvironment) -> str: + if test_env.use_scope: + return f'SELECT * FROM {test_env.collection_name} WHERE country = $country LIMIT 2;' + else: + return f'SELECT * FROM {test_env.fqdn} WHERE country = $country LIMIT 2;' + + @pytest.fixture(scope='class') + def query_statement_limit5(self, test_env: AsyncTestEnvironment) -> str: + if test_env.use_scope: + return f'SELECT * FROM {test_env.collection_name} LIMIT 5;' + else: + return f'SELECT * FROM {test_env.fqdn} LIMIT 5;' + + async def test_query_metadata(self, + test_env: AsyncTestEnvironment, + query_statement_limit5: str) -> None: + result = await test_env.cluster_or_scope.execute_query(query_statement_limit5) + expected_count = 5 + await test_env.assert_rows(result, expected_count) + + metadata = result.metadata() + + assert len(metadata.warnings()) == 0 + assert len(metadata.request_id()) > 0 + + metrics = metadata.metrics() + + assert metrics.result_size() > 0 + assert metrics.result_count() == expected_count + assert metrics.processed_objects() > 0 + assert metrics.elapsed_time() > timedelta(0) + assert metrics.execution_time() > timedelta(0) + + async def test_query_metadata_not_available(self, + test_env: AsyncTestEnvironment, + query_statement_limit5: str) -> None: + result = await test_env.cluster_or_scope.execute_query(query_statement_limit5) + + with pytest.raises(RuntimeError): + result.metadata() + + # Read one row -- NOTE: anext()/aiter() add in Python 3.10 + aiter = result.rows() + row = await aiter.__anext__() + assert row is not None + assert isinstance(row, dict) + + with pytest.raises(RuntimeError): + result.metadata() + + # Iterate the rest of the rows + rows = [r async for r in result.rows()] + assert len(rows) == 4 + + metadata = result.metadata() + assert len(metadata.warnings()) == 0 + assert len(metadata.request_id()) > 0 + + async def test_query_named_parameters(self, + test_env: AsyncTestEnvironment, + query_statement_named_params_limit2: str,) -> None: + q_opts = QueryOptions(named_parameters={'country': 'United States'}) + result = await test_env.cluster_or_scope.execute_query(query_statement_named_params_limit2, q_opts) + await test_env.assert_rows(result, 2) + + async def test_query_named_parameters_no_options(self, + test_env: AsyncTestEnvironment, + query_statement_named_params_limit2: str) -> None: + result = await test_env.cluster_or_scope.execute_query(query_statement_named_params_limit2, + country='United States') + await test_env.assert_rows(result, 2) + + async def test_query_named_parameters_override(self, + test_env: AsyncTestEnvironment, + query_statement_named_params_limit2: str) -> None: + q_opts = QueryOptions(named_parameters={'country': 'abcdefg'}) + result = await test_env.cluster_or_scope.execute_query(query_statement_named_params_limit2, + q_opts, + country='United States') + await test_env.assert_rows(result, 2) + + async def test_query_positional_params(self, + test_env: AsyncTestEnvironment, + query_statement_pos_params_limit2: str) -> None: + q_opts = QueryOptions(positional_parameters=['United States']) + result = await test_env.cluster_or_scope.execute_query(query_statement_pos_params_limit2, q_opts) + await test_env.assert_rows(result, 2) + + async def test_query_positional_params_no_option(self, + test_env: AsyncTestEnvironment, + query_statement_pos_params_limit2: str) -> None: + result = await test_env.cluster_or_scope.execute_query(query_statement_pos_params_limit2, 'United States') + await test_env.assert_rows(result, 2) + + async def test_query_positional_params_override(self, + test_env: AsyncTestEnvironment, + query_statement_pos_params_limit2: str) -> None: + q_opts = QueryOptions(positional_parameters=['abcdefg']) + result = await test_env.cluster_or_scope.execute_query(query_statement_pos_params_limit2, + q_opts, + 'United States') + await test_env.assert_rows(result, 2) + + async def test_query_raises_exception_prior_to_iterating(self, test_env: AsyncTestEnvironment) -> None: + statement = "I'm not N1QL!" + with pytest.raises(QueryError): + await test_env.cluster_or_scope.execute_query(statement) + + async def test_query_raw_options(self, + test_env: AsyncTestEnvironment, + query_statement_pos_params_limit2: str) -> None: + # via raw, we should be able to pass any option + # if using named params, need to match full name param in query + # which is different for when we pass in name_parameters via their specific + # query option (i.e. include the $ when using raw) + if test_env.use_scope: + statement = f'SELECT * FROM {test_env.collection_name} WHERE country = $country LIMIT $1;' + else: + statement = f'SELECT * FROM {test_env.fqdn} WHERE country = $country LIMIT $1;' + + q_opts = QueryOptions(raw={'$country': 'United States', 'args': [2]}) + result = await test_env.cluster_or_scope.execute_query(statement, q_opts) + await test_env.assert_rows(result, 2) + + result = await test_env.cluster_or_scope.execute_query(query_statement_pos_params_limit2, + QueryOptions(raw={'args': ['United States']})) + await test_env.assert_rows(result, 2) + + async def test_simple_query(self, + test_env: AsyncTestEnvironment, + query_statement_limit2: str) -> None: + result = await test_env.cluster_or_scope.execute_query(query_statement_limit2) + await test_env.assert_rows(result, 2) + + async def test_query_passthrough_deserializer(self, test_env: AsyncTestEnvironment) -> None: + statement = 'FROM range(0, 10) AS num SELECT *' + result = await test_env.cluster_or_scope.execute_query(statement, + QueryOptions(deserializer=PassthroughDeserializer())) + idx = 0 + async for row in result.rows(): + assert isinstance(row, bytes) + assert json.loads(row) == {'num': idx} + idx += 1 + +class ClusterQueryTests(QueryTestSuite): + + @pytest.fixture(scope='class', autouse=True) + def validate_test_manifest(self) -> None: + def valid_test_method(meth: str) -> bool: + attr = getattr(ClusterQueryTests, meth) + return callable(attr) and not meth.startswith('__') and meth.startswith('test') + method_list = [meth for meth in dir(ClusterQueryTests) if valid_test_method(meth)] + test_list = set(QueryTestSuite.TEST_MANIFEST).symmetric_difference(method_list) + if test_list: + pytest.fail(f'Test manifest invalid. Missing/extra tests: {test_list}.') + + @pytest.fixture(scope='class', name='test_env') + async def couchbase_test_environment(self, + async_test_env: AsyncTestEnvironment + ) -> AsyncYieldFixture[AsyncTestEnvironment]: + await async_test_env.setup() + yield async_test_env + await async_test_env.teardown() + + +class ScopeQueryTests(QueryTestSuite): + + @pytest.fixture(scope='class', autouse=True) + def validate_test_manifest(self) -> None: + def valid_test_method(meth: str) -> bool: + attr = getattr(ScopeQueryTests, meth) + return callable(attr) and not meth.startswith('__') and meth.startswith('test') + method_list = [meth for meth in dir(ScopeQueryTests) if valid_test_method(meth)] + test_list = set(QueryTestSuite.TEST_MANIFEST).symmetric_difference(method_list) + if test_list: + pytest.fail(f'Test manifest invalid. Missing/extra tests: {test_list}.') + + @pytest.fixture(scope='class', name='test_env') + async def couchbase_test_environment(self, + async_test_env: AsyncTestEnvironment + ) -> AsyncYieldFixture[AsyncTestEnvironment]: + await async_test_env.setup() + test_env = async_test_env.enable_scope() + yield test_env + test_env.disable_scope() + await test_env.teardown() \ No newline at end of file diff --git a/acouchbase_analytics/tests/query_options_t.py b/acouchbase_analytics/tests/query_options_t.py new file mode 100644 index 0000000..0ec4d5d --- /dev/null +++ b/acouchbase_analytics/tests/query_options_t.py @@ -0,0 +1,301 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import timedelta +from typing import (Any, + Dict, + List, + Optional, + Union) + +import pytest + +from acouchbase_analytics.protocol.core.client_adapter import _AsyncClientAdapter +from couchbase_analytics import JSONType +from couchbase_analytics.credential import Credential +from couchbase_analytics.options import QueryOptions, QueryOptionsKwargs +from couchbase_analytics.protocol.options import QueryOptionsTransformedKwargs +from couchbase_analytics.protocol.core.request import _RequestBuilder + + +@dataclass +class QueryContext: + database_name: Optional[str] = None + scope_name: Optional[str] = None + + def validate_query_context(self, body: Dict[str, Union[str, object]]) -> None: + if self.database_name is None or self.scope_name is None: + with pytest.raises(KeyError): + body['query_context'] + else: + assert body['query_context'] == f'default:`{self.database_name}`.`{self.scope_name}`' + + +class QueryOptionsTestSuite: + TEST_MANIFEST = [ + 'test_options_deserializer', + 'test_options_deserializer_kwargs', + 'test_options_named_parameters', + 'test_options_named_parameters_kwargs', + 'test_options_positional_parameters', + 'test_options_positional_parameters_kwargs', + 'test_options_raw', + 'test_options_raw_kwargs', + 'test_options_readonly', + 'test_options_readonly_kwargs', + 'test_options_scan_consistency', + 'test_options_scan_consistency_kwargs', + 'test_options_timeout', + 'test_options_timeout_kwargs', + 'test_options_timeout_must_be_positive', + 'test_options_timeout_must_be_positive_kwargs' + ] + + @pytest.fixture(scope='class') + def query_statment(self) -> str: + return 'SELECT * FROM default' + + def test_options_deserializer(self, + query_statment: str, + request_builder: _RequestBuilder, + query_ctx: QueryContext) -> None: + from couchbase_analytics.deserializer import DefaultJsonDeserializer + deserializer = DefaultJsonDeserializer() + q_opts = QueryOptions(deserializer=deserializer) + req = request_builder.build_base_query_request(query_statment, q_opts) + exp_opts: QueryOptionsTransformedKwargs = {} + assert req.options == exp_opts + assert req.deserializer == deserializer + query_ctx.validate_query_context(req.body) + + def test_options_deserializer_kwargs(self, + query_statment: str, + request_builder: _RequestBuilder, + query_ctx: QueryContext) -> None: + from couchbase_analytics.deserializer import DefaultJsonDeserializer + deserializer = DefaultJsonDeserializer() + kwargs: QueryOptionsKwargs = {'deserializer': deserializer} + req = request_builder.build_base_query_request(query_statment, **kwargs) + exp_opts: QueryOptionsTransformedKwargs = {} + assert req.options == exp_opts + assert req.deserializer == deserializer + query_ctx.validate_query_context(req.body) + + def test_options_named_parameters(self, + query_statment: str, + request_builder: _RequestBuilder, + query_ctx: QueryContext) -> None: + params: Dict[str, JSONType] = {'foo': 'bar', 'baz': 1, 'quz': False} + q_opts = QueryOptions(named_parameters=params) + req = request_builder.build_base_query_request(query_statment, q_opts) + exp_opts: QueryOptionsTransformedKwargs = {'named_parameters': params} + assert req.options == exp_opts + query_ctx.validate_query_context(req.body) + + def test_options_named_parameters_kwargs(self, + query_statment: str, + request_builder: _RequestBuilder, + query_ctx: QueryContext) -> None: + params: Dict[str, JSONType] = {'foo': 'bar', 'baz': 1, 'quz': False} + kwargs: QueryOptionsKwargs = {'named_parameters': params} + req = request_builder.build_base_query_request(query_statment, **kwargs) + exp_opts: QueryOptionsTransformedKwargs = {'named_parameters': params} + assert req.options == exp_opts + query_ctx.validate_query_context(req.body) + + def test_options_positional_parameters(self, + query_statment: str, + request_builder: _RequestBuilder, + query_ctx: QueryContext) -> None: + params: List[JSONType] = ['foo', 'bar', 1, False] + q_opts = QueryOptions(positional_parameters=params) + req = request_builder.build_base_query_request(query_statment, q_opts) + exp_opts: QueryOptionsTransformedKwargs = {'positional_parameters': params} + assert req.options == exp_opts + query_ctx.validate_query_context(req.body) + + def test_options_positional_parameters_kwargs(self, + query_statment: str, + request_builder: _RequestBuilder, + query_ctx: QueryContext) -> None: + params: List[JSONType] = ['foo', 'bar', 1, False] + kwargs: QueryOptionsKwargs = {'positional_parameters': params} + req = request_builder.build_base_query_request(query_statment, **kwargs) + exp_opts: QueryOptionsTransformedKwargs = {'positional_parameters': params} + assert req.options == exp_opts + query_ctx.validate_query_context(req.body) + + def test_options_raw(self, + query_statment: str, + request_builder: _RequestBuilder, + query_ctx: QueryContext) -> None: + pos_params: List[JSONType] = ['foo', 'bar', 1, False] + params: Dict[str, Any] = {'readonly': True, 'positional_params': pos_params} + q_opts = QueryOptions(raw=params) + req = request_builder.build_base_query_request(query_statment, q_opts) + exp_opts: QueryOptionsTransformedKwargs = {'raw': params} + assert req.options == exp_opts + query_ctx.validate_query_context(req.body) + + def test_options_raw_kwargs(self, + query_statment: str, + request_builder: _RequestBuilder, + query_ctx: QueryContext) -> None: + pos_params: List[JSONType] = ['foo', 'bar', 1, False] + params: Dict[str, Any] = {'readonly': True, 'positional_params': pos_params} + kwargs: QueryOptionsKwargs = {'raw': params} + req = request_builder.build_base_query_request(query_statment, **kwargs) + exp_opts: QueryOptionsTransformedKwargs = {'raw': params} + assert req.options == exp_opts + query_ctx.validate_query_context(req.body) + + def test_options_readonly(self, + query_statment: str, + request_builder: _RequestBuilder, + query_ctx: QueryContext) -> None: + q_opts = QueryOptions(readonly=True) + req = request_builder.build_base_query_request(query_statment, q_opts) + exp_opts: QueryOptionsTransformedKwargs = {'readonly': True} + assert req.options == exp_opts + query_ctx.validate_query_context(req.body) + + def test_options_readonly_kwargs(self, + query_statment: str, + request_builder: _RequestBuilder, + query_ctx: QueryContext) -> None: + kwargs: QueryOptionsKwargs = {'readonly': True} + req = request_builder.build_base_query_request(query_statment, **kwargs) + exp_opts: QueryOptionsTransformedKwargs = {'readonly': True} + assert req.options == exp_opts + query_ctx.validate_query_context(req.body) + + def test_options_scan_consistency(self, + query_statment: str, + request_builder: _RequestBuilder, + query_ctx: QueryContext) -> None: + from couchbase_analytics.query import QueryScanConsistency + q_opts = QueryOptions(scan_consistency=QueryScanConsistency.REQUEST_PLUS) + req = request_builder.build_base_query_request(query_statment, q_opts) + exp_opts: QueryOptionsTransformedKwargs = { + 'scan_consistency': QueryScanConsistency.REQUEST_PLUS.value + } + assert req.options == exp_opts + query_ctx.validate_query_context(req.body) + + def test_options_scan_consistency_kwargs(self, + query_statment: str, + request_builder: _RequestBuilder, + query_ctx: QueryContext) -> None: + from couchbase_analytics.query import QueryScanConsistency + kwargs: QueryOptionsKwargs = {'scan_consistency': QueryScanConsistency.REQUEST_PLUS} + req = request_builder.build_base_query_request(query_statment, **kwargs) + exp_opts: QueryOptionsTransformedKwargs = { + 'scan_consistency': QueryScanConsistency.REQUEST_PLUS.value + } + assert req.options == exp_opts + query_ctx.validate_query_context(req.body) + + def test_options_timeout(self, + query_statment: str, + request_builder: _RequestBuilder, + query_ctx: QueryContext) -> None: + q_opts = QueryOptions(timeout=timedelta(seconds=20)) + req = request_builder.build_base_query_request(query_statment, q_opts) + exp_opts: QueryOptionsTransformedKwargs = { + 'timeout': 20.0 + } + assert req.options == exp_opts + # NOTE: we add time to the server timeout to ensure a client side timeout + assert req.body['timeout'] == '25000.0ms' + query_ctx.validate_query_context(req.body) + + def test_options_timeout_kwargs(self, + query_statment: str, + request_builder: _RequestBuilder, + query_ctx: QueryContext) -> None: + kwargs: QueryOptionsKwargs = {'timeout': timedelta(seconds=20)} + req = request_builder.build_base_query_request(query_statment, **kwargs) + exp_opts: QueryOptionsTransformedKwargs = { + 'timeout': 20.0 + } + assert req.options == exp_opts + # NOTE: we add time to the server timeout to ensure a client side timeout + assert req.body['timeout'] == '25000.0ms' + query_ctx.validate_query_context(req.body) + + def test_options_timeout_must_be_positive(self, + query_statment: str, + request_builder: _RequestBuilder + ) -> None: + q_opts = QueryOptions(timeout=timedelta(seconds=-1)) + with pytest.raises(ValueError): + request_builder.build_base_query_request(query_statment, q_opts) + + def test_options_timeout_must_be_positive_kwargs(self, + query_statment: str, + request_builder: _RequestBuilder + ) -> None: + kwargs: QueryOptionsKwargs = {'timeout': timedelta(seconds=-1)} + with pytest.raises(ValueError): + request_builder.build_base_query_request(query_statment, **kwargs) + + +class ClusterQueryOptionsTests(QueryOptionsTestSuite): + + @pytest.fixture(scope='class', autouse=True) + def validate_test_manifest(self) -> None: + def valid_test_method(meth: str) -> bool: + attr = getattr(ClusterQueryOptionsTests, meth) + return callable(attr) and not meth.startswith('__') and meth.startswith('test') + method_list = [meth for meth in dir(ClusterQueryOptionsTests) if valid_test_method(meth)] + test_list = set(QueryOptionsTestSuite.TEST_MANIFEST).symmetric_difference(method_list) + if test_list: + pytest.fail(f'Test manifest invalid. Missing/extra tests: {test_list}.') + + @pytest.fixture(scope='class', name='query_ctx') + def query_context(self) -> QueryContext: + return QueryContext() + + @pytest.fixture(scope='class') + def request_builder(self) -> _RequestBuilder: + cred = Credential.from_username_and_password('Administrator', 'password') + return _RequestBuilder(_AsyncClientAdapter('https://localhost', cred)) + + +class ScopeQueryOptionsTests(QueryOptionsTestSuite): + + @pytest.fixture(scope='class', autouse=True) + def validate_test_manifest(self) -> None: + def valid_test_method(meth: str) -> bool: + attr = getattr(ScopeQueryOptionsTests, meth) + return callable(attr) and not meth.startswith('__') and meth.startswith('test') + method_list = [meth for meth in dir(ScopeQueryOptionsTests) if valid_test_method(meth)] + test_list = set(QueryOptionsTestSuite.TEST_MANIFEST).symmetric_difference(method_list) + if test_list: + pytest.fail(f'Test manifest invalid. Missing/extra tests: {test_list}.') + + @pytest.fixture(scope='class', name='query_ctx') + def query_context(self) -> QueryContext: + return QueryContext('test-database', 'test-scope') + + @pytest.fixture(scope='class') + def request_builder(self) -> _RequestBuilder: + cred = Credential.from_username_and_password('Administrator', 'password') + return _RequestBuilder(_AsyncClientAdapter('https://localhost', cred), + 'test-database', + 'test-scope') \ No newline at end of file diff --git a/conftest.py b/conftest.py index e51f3b5..e5120c3 100644 --- a/conftest.py +++ b/conftest.py @@ -24,14 +24,28 @@ ] _UNIT_TESTS = [ + 'acouchbase_analytics/tests/connection_t.py::ConnectionTests', + 'acouchbase_analytics/tests/json_parsing_t.py::JsonParsingTests', + 'acouchbase_analytics/tests/options_t.py::ClusterOptionsTests', + 'acouchbase_analytics/tests/query_options_t.py::ClusterQueryOptionsTests', + 'acouchbase_analytics/tests/query_options_t.py::ScopeQueryOptionsTests', + 'couchbase_analytics/tests/connection_t.py::ConnectionTests', + 'couchbase_analytics/tests/duration_parsing_t.py::DurationParsingTests', 'couchbase_analytics/tests/json_parsing_t.py::JsonParsingTests', + 'couchbase_analytics/tests/options_t.py::ClusterOptionsTests', + 'couchbase_analytics/tests/query_options_t.py::ClusterQueryOptionsTests', + 'couchbase_analytics/tests/query_options_t.py::ScopeQueryOptionsTests', ] _INTEGRATRION_TESTS = [ + 'acouchbase_analytics/tests/query_integration_t.py::ClusterQueryTests', + 'acouchbase_analytics/tests/query_integration_t.py::ScopeQueryTests', + 'couchbase_analytics/tests/query_integration_t.py::ClusterQueryTests', + 'couchbase_analytics/tests/query_integration_t.py::ScopeQueryTests', ] @pytest.fixture(scope='class') -def anyio_backend(): +def anyio_backend() -> str: return 'asyncio' # https://docs.pytest.org/en/stable/reference/reference.html#std-hook-pytest_collection_modifyitems diff --git a/couchbase_analytics/_version.py b/couchbase_analytics/_version.py new file mode 100644 index 0000000..f2665a0 --- /dev/null +++ b/couchbase_analytics/_version.py @@ -0,0 +1,5 @@ +# This file automatically generated by +# /Users/jaredcasey/GIT/couchbase/clients/python/analytics-python-client/couchbase_analytics_version.py +# at +# 2025-06-13 16:27:15.151489 +__version__ = '0.0.1' diff --git a/couchbase_analytics/common/core/__init__.py b/couchbase_analytics/common/core/__init__.py index 1385a99..e2303b3 100644 --- a/couchbase_analytics/common/core/__init__.py +++ b/couchbase_analytics/common/core/__init__.py @@ -13,7 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -from .json_parsing import JsonParsingError -from .json_parsing import JsonStreamConfig -from .json_parsing import ParsedResult -from .json_parsing import ParsedResultType \ No newline at end of file +from .json_parsing import JsonParsingError as JsonParsingError # noqa: F401 +from .json_parsing import JsonStreamConfig as JsonStreamConfig # noqa: F401 +from .json_parsing import ParsedResult as ParsedResult # noqa: F401 +from .json_parsing import ParsedResultType as ParsedResultType # noqa: F401 \ No newline at end of file diff --git a/couchbase_analytics/common/core/_capella_certificates/_capella.pem b/couchbase_analytics/common/core/_capella_certificates/_capella.pem new file mode 100644 index 0000000..32d3977 --- /dev/null +++ b/couchbase_analytics/common/core/_capella_certificates/_capella.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDFTCCAf2gAwIBAgIRANLVkgOvtaXiQJi0V6qeNtswDQYJKoZIhvcNAQELBQAw +JDESMBAGA1UECgwJQ291Y2hiYXNlMQ4wDAYDVQQLDAVDbG91ZDAeFw0xOTEyMDYy +MjEyNTlaFw0yOTEyMDYyMzEyNTlaMCQxEjAQBgNVBAoMCUNvdWNoYmFzZTEOMAwG +A1UECwwFQ2xvdWQwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCfvOIi +enG4Dp+hJu9asdxEMRmH70hDyMXv5ZjBhbo39a42QwR59y/rC/sahLLQuNwqif85 +Fod1DkqgO6Ng3vecSAwyYVkj5NKdycQu5tzsZkghlpSDAyI0xlIPSQjoORA/pCOU +WOpymA9dOjC1bo6rDyw0yWP2nFAI/KA4Z806XeqLREuB7292UnSsgFs4/5lqeil6 +rL3ooAw/i0uxr/TQSaxi1l8t4iMt4/gU+W52+8Yol0JbXBTFX6itg62ppb/Eugmn +mQRMgL67ccZs7cJ9/A0wlXencX2ohZQOR3mtknfol3FH4+glQFn27Q4xBCzVkY9j +KQ20T1LgmGSngBInAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYE +FJQOBPvrkU2In1Sjoxt97Xy8+cKNMA4GA1UdDwEB/wQEAwIBhjANBgkqhkiG9w0B +AQsFAAOCAQEARgM6XwcXPLSpFdSf0w8PtpNGehmdWijPM3wHb7WZiS47iNen3oq8 +m2mm6V3Z57wbboPpfI+VEzbhiDcFfVnK1CXMC0tkF3fnOG1BDDvwt4jU95vBiNjY +xdzlTP/Z+qr0cnVbGBSZ+fbXstSiRaaAVcqQyv3BRvBadKBkCyPwo+7svQnScQ5P +Js7HEHKVms5tZTgKIw1fbmgR2XHleah1AcANB+MAPBCcTgqurqr5G7W2aPSBLLGA +fRIiVzm7VFLc7kWbp7ENH39HVG6TZzKnfl9zJYeiklo5vQQhGSMhzBsO70z4RRzi +DPFAN/4qZAgD5q3AFNIq2WWADFQGSwVJhg== +-----END CERTIFICATE----- \ No newline at end of file diff --git a/couchbase_analytics/common/core/_certificates.py b/couchbase_analytics/common/core/_certificates.py index a5ff4dd..069148c 100644 --- a/couchbase_analytics/common/core/_certificates.py +++ b/couchbase_analytics/common/core/_certificates.py @@ -16,11 +16,43 @@ from __future__ import annotations +import os + +from pathlib import Path from typing import List class _Certificates: """**INTERNAL**""" + + @staticmethod + def get_certificate_from_file(certpath: str) -> str: + """ + **INTERNAL** Convenience method for access to the certificate file. NOT part of the public API. + + Returns: + str: The contents of the certificate file. + """ + cert_file = Path(certpath) + if not cert_file.exists(): + raise FileNotFoundError(f'Certificate file not found: {cert_file}') + return cert_file.read_text() + + @staticmethod + def get_capella_certificates() -> List[str]: + """ + **INTERNAL** Convenience method for access to Capella certificates. NOT part of the public API. + Returns: + List[str]: List of Capella certificates. + """ + nonprod_cert_dir = Path(Path(__file__).resolve().parent, '_capella_certificates') + nonprod_certs: List[str] = [] + for cert in nonprod_cert_dir.iterdir(): + if os.path.isdir(cert) or cert.suffix != '.pem': + continue + nonprod_certs.append(cert.read_text()) + return nonprod_certs + @staticmethod def get_nonprod_certificates() -> List[str]: """ @@ -30,11 +62,9 @@ def get_nonprod_certificates() -> List[str]: Returns: List[str]: List of nonprod Capella certificates. """ - import os import warnings - from pathlib import Path warnings.warn('Only use non-prod certificate in DEVELOPMENT environments.', ResourceWarning) - nonprod_cert_dir = Path(Path(__file__).resolve().parent, 'nonprod_certificates') + nonprod_cert_dir = Path(Path(__file__).resolve().parent, '_nonprod_certificates') nonprod_certs: List[str] = [] for cert in nonprod_cert_dir.iterdir(): if os.path.isdir(cert) or cert.suffix != '.pem': diff --git a/couchbase_analytics/common/core/_nonprod_certificates/_nonprod.pem b/couchbase_analytics/common/core/_nonprod_certificates/_nonprod.pem new file mode 100644 index 0000000..57fc342 --- /dev/null +++ b/couchbase_analytics/common/core/_nonprod_certificates/_nonprod.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDFTCCAf2gAwIBAgIRANguFcFZ7eVLTF2mnPqkkhYwDQYJKoZIhvcNAQELBQAw +JDESMBAGA1UECgwJQ291Y2hiYXNlMQ4wDAYDVQQLDAVDbG91ZDAeFw0xOTEwMTgx +NDUzMzRaFw0yOTEwMTgxNTUzMzRaMCQxEjAQBgNVBAoMCUNvdWNoYmFzZTEOMAwG +A1UECwwFQ2xvdWQwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDMoL2G +1yR4XKOL5KrAZbgJI11NkcooxqCSqoibr5nSM+GNARlou42XbopRhkLQlSMlmH7U +ZreI7xq2MqmCaQvP1jdS5al/GwuwAP+2kU2nz4IHzliCVV6YvYqNy0fygNpYky9/ +wjCu32n8Ae0AZuxcsAzPUtJBvIIGHum08WlLYS3gNrYkfyds6LfvZvqMk703RL5X +Ny/RXWmbbBXAXh0chsavEK7EsDLI4t4WI2Iv8+lwS7Wo7Vh6NnEmJLPAAp7udNK4 +U3nwjkL5p/yINROT7CxUE9x0IB2l2rZwZiJhgHCpee77J8QesDut+jZu38ZYY3le +PS38S81T6I6bSSgtAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYE +FLlocLdzgAeibrlCmEO4OH5Buf3vMA4GA1UdDwEB/wQEAwIBhjANBgkqhkiG9w0B +AQsFAAOCAQEAkoVX5CJ7rGx2ALfzy5C7Z+tmEmrZ6jdHjDtw4XwWNhlrsgMuuboU +Y9XMinSSm1TVfvIz4ru82MVMRxq4v1tPwPdZabbzKYclHkwSMxK5BkyEKWzF1Hoq +UcinTaT68lVzkTc0D8T+gkRzwXIqxjML2ZdruD1foHNzCgeGHzKzdsjYqrnHv17b +J+f5tqoa5CKbnyWl3HP0k7r3HHQP0GQequoqXcL3XlERX3Ne20Chck9mftNnHhKw +Dby7ylZaP97sphqOZQ/W/gza7x1JYylrLXvjfdv3Nmu7oSMKO/2cDyWwcbVGkpbk +8JOQtFENWmr9u2S0cQfwoCSYBWaK0ofivA== +-----END CERTIFICATE----- \ No newline at end of file diff --git a/couchbase_analytics/common/core/async_json_stream.py b/couchbase_analytics/common/core/async_json_stream.py index a4c1a55..7c4b209 100644 --- a/couchbase_analytics/common/core/async_json_stream.py +++ b/couchbase_analytics/common/core/async_json_stream.py @@ -35,9 +35,11 @@ class AsyncJsonStream: def __init__(self, http_stream_iter: AsyncIterator[bytes], *, - stream_config: Optional[JsonStreamConfig]=JsonStreamConfig(), + stream_config: Optional[JsonStreamConfig]=None, ) -> None: # HTTP stream handling + if stream_config is None: + stream_config = JsonStreamConfig() self._http_stream_iter = http_stream_iter self._http_stream_buffer_size = stream_config.http_stream_buffer_size self._http_response_buffer = bytearray() @@ -96,7 +98,7 @@ async def _send_to_stream(self, result: ParsedResult, close: Optional[bool]=Fals if close is True: await self._send_stream.aclose() - async def _handle_json_result(self, row: str) -> None: + async def _handle_json_result(self, row: bytes) -> None: """ **INTERNAL** """ @@ -125,7 +127,7 @@ async def _process_token_stream(self) -> None: while self._continue_processing(): try: - _, event, value = await self._json_stream_parser.__anext__() + _, event, value = await self._json_stream_parser.__anext__() # type: ignore[attr-defined] # this is a hack b/c the ijson.parse_async iterator does not yield to the event loop # TODO: create PYCO to either build custom JSON parsing, or dig into ijson root cause await self._json_token_parser.parse_token(event, value) @@ -140,11 +142,11 @@ async def _process_token_stream(self) -> None: await self._send_to_stream(ParsedResult(self._json_token_parser.get_result(), result_type), close=True) self._handle_notification(result_type) - async def read(self, size=-1) -> bytes: + async def read(self, size: Optional[int]=-1) -> bytes: """ **INTERNAL** """ - if size == 0 or self._http_stream_exhausted: + if size is None or size == 0 or self._http_stream_exhausted: return b'' while not self._http_stream_exhausted: diff --git a/couchbase_analytics/common/core/async_json_token_parser.py b/couchbase_analytics/common/core/async_json_token_parser.py index b81720b..ec542ab 100644 --- a/couchbase_analytics/common/core/async_json_token_parser.py +++ b/couchbase_analytics/common/core/async_json_token_parser.py @@ -16,9 +16,10 @@ from __future__ import annotations from typing import (Any, + Callable, Coroutine, - Optional, - Tuple) + List, + Optional) from couchbase_analytics.common.core.json_token_parser_base import (JsonTokenParserBase, ParsingState, @@ -29,22 +30,21 @@ class AsyncJsonTokenParser(JsonTokenParserBase): def __init__(self, - results_handler: Optional[Coroutine[Any, Any, None]]=None) -> None: + results_handler: Optional[Callable[[bytes], Coroutine[Any, Any, None]]]=None) -> None: self._results_handler = results_handler super().__init__(emit_results_enabled=results_handler is not None) - async def _handle_obj_emit(self, obj: str) -> None: - should_emit_result = (self._emit_results_enabled - and self._results_handler is not None - and self._state == ParsingState.PROCESSING_RESULTS) - if should_emit_result: + async def _handle_obj_emit(self, obj: str) -> bool: + if (self._emit_results_enabled + and self._results_handler is not None + and self._state == ParsingState.PROCESSING_RESULTS): await self._results_handler(bytes(obj, 'utf-8')) return True return False async def _handle_pop_event(self, token_type: TokenType) -> None: matching_token = self._get_matching_token(token_type) - obj_pairs = [] + obj_pairs: List[str] = [] while self._stack: next_token = self._pop() if next_token.type == matching_token.type: diff --git a/couchbase_analytics/common/core/json_parsing.py b/couchbase_analytics/common/core/json_parsing.py index 2faa506..840a717 100644 --- a/couchbase_analytics/common/core/json_parsing.py +++ b/couchbase_analytics/common/core/json_parsing.py @@ -28,7 +28,7 @@ def __init__(self, cause: Optional[Exception]=None) -> None: def cause(self) -> Optional[Exception]: return self._cause - def __repr__(self): + def __repr__(self) -> str: return f'JsonParsingError(cause={self._cause})' def __str__(self) -> str: @@ -61,5 +61,5 @@ class ParsedResult(NamedTuple): """ **INTERNAL** """ - value: bytes + value: Optional[bytes] result_type: ParsedResultType \ No newline at end of file diff --git a/couchbase_analytics/common/core/json_stream.py b/couchbase_analytics/common/core/json_stream.py index ffb1e7b..44af4ee 100644 --- a/couchbase_analytics/common/core/json_stream.py +++ b/couchbase_analytics/common/core/json_stream.py @@ -40,9 +40,11 @@ class JsonStream: def __init__(self, http_stream_iter: Iterator[bytes], *, - stream_config: Optional[JsonStreamConfig]=JsonStreamConfig(), + stream_config: Optional[JsonStreamConfig]=None, ) -> None: # HTTP stream handling + if stream_config is None: + stream_config = JsonStreamConfig() self._http_stream_iter = http_stream_iter self._http_stream_buffer_size = stream_config.http_stream_buffer_size self._http_response_buffer = bytearray() @@ -101,7 +103,7 @@ def _put(self, result: ParsedResult) -> None: pass - def _handle_json_result(self, row: str) -> None: + def _handle_json_result(self, row: bytes) -> None: """ **INTERNAL** """ @@ -124,7 +126,7 @@ def _process_token_stream(self, request_context: Optional[RequestContext]=None) while self._continue_processing(request_context=request_context): try: - _, event, value = next(self._json_stream_parser) + _, event, value = next(self._json_stream_parser) # type: ignore[call-overload] self._json_token_parser.parse_token(event, value) except StopIteration as ex: self._token_stream_exhausted = True @@ -136,11 +138,11 @@ def _process_token_stream(self, request_context: Optional[RequestContext]=None) self._put(ParsedResult(self._json_token_parser.get_result(), result_type)) self._handle_notification(result_type) - def read(self, size=-1) -> bytes: + def read(self, size: Optional[int]=-1) -> bytes: """ **INTERNAL** """ - if size == 0 or self._http_stream_exhausted: + if size is None or size == 0 or self._http_stream_exhausted: return b'' while not self._http_stream_exhausted: diff --git a/couchbase_analytics/common/core/json_token_parser.py b/couchbase_analytics/common/core/json_token_parser.py index 98199b2..245484f 100644 --- a/couchbase_analytics/common/core/json_token_parser.py +++ b/couchbase_analytics/common/core/json_token_parser.py @@ -15,7 +15,9 @@ from __future__ import annotations -from typing import Callable, Optional +from typing import (Callable, + List, + Optional) from couchbase_analytics.common.core.json_token_parser_base import (JsonTokenParserBase, ParsingState, @@ -26,23 +28,22 @@ class JsonTokenParser(JsonTokenParserBase): def __init__(self, - result_handler: Optional[Callable[[str], None]]=None) -> None: + result_handler: Optional[Callable[[bytes], None]]=None) -> None: self._result_handler = result_handler super().__init__(emit_results_enabled=result_handler is not None) - def _handle_obj_emit(self, obj: str) -> None: - should_emit_result = (self._emit_results_enabled - and self._result_handler is not None - and self._state == ParsingState.PROCESSING_RESULTS) - if should_emit_result: + def _handle_obj_emit(self, obj: str) -> bool: + if (self._emit_results_enabled + and self._result_handler is not None + and self._state == ParsingState.PROCESSING_RESULTS): self._result_handler(bytes(obj, 'utf-8')) return True return False def _handle_pop_event(self, token_type: TokenType) -> None: matching_token = self._get_matching_token(token_type) - obj_pairs = [] + obj_pairs: List[str] = [] while self._stack: next_token = self._pop() if next_token.type == matching_token.type: diff --git a/couchbase_analytics/common/core/json_token_parser_base.py b/couchbase_analytics/common/core/json_token_parser_base.py index a711010..671afd2 100644 --- a/couchbase_analytics/common/core/json_token_parser_base.py +++ b/couchbase_analytics/common/core/json_token_parser_base.py @@ -29,31 +29,20 @@ class ParsingState(Enum): START_ERRORS_PROCESSING = 'start_errors_processing' PROCESSING_ERRORS = 'processing_errors' PROCESSING_ERROR = 'processing_error' - # RESULTS_START = 'results_start' - # RESULT_START = 'result_start' - # ERRORS_START = 'errors_start' - # ERROR_START = 'error_start' UNDEFINED = 'undefined' - def __str__(self): + def __str__(self) -> str: return self.value class TokenState(Enum): - # PROCESSING = 'processing' - # START_RESULTS_PROCESSING = 'start_results_processing' - # PROCESSING_RESULTS = 'processing_results' - # PROCESSING_RESULT = 'processing_result' - # START_ERRORS_PROCESSING = 'start_errors_processing' - # PROCESSING_ERRORS = 'processing_errors' - # PROCESSING_ERROR = 'processing_error' RESULTS_START = 'results_start' RESULT_START = 'result_start' ERRORS_START = 'errors_start' ERROR_START = 'error_start' UNDEFINED = 'undefined' - def __str__(self): + def __str__(self) -> str: return self.value class TokenType(Enum): @@ -79,7 +68,7 @@ def from_str(cls, value: str) -> TokenType: except KeyError: raise ValueError(f'Invalid token type: {value}') - def __str__(self): + def __str__(self) -> str: return self.value class Token(NamedTuple): @@ -171,7 +160,7 @@ def _handle_push_transition(self) -> Optional[TokenState]: self._previous_state = self._state self._state = ParsingState.PROCESSING_ERROR return TokenState.ERROR_START - # TODO: Handle other states?? or error? + raise ValueError(f'Invalid state for push transition: {self._state}') def _handle_start_event(self, token_type: TokenType) -> None: transition = False diff --git a/couchbase_analytics/common/core/net_utils.py b/couchbase_analytics/common/core/net_utils.py index 1441057..85b8f82 100644 --- a/couchbase_analytics/common/core/net_utils.py +++ b/couchbase_analytics/common/core/net_utils.py @@ -17,22 +17,23 @@ import socket -from ipaddress import ip_address +from ipaddress import (IPv4Address, IPv6Address, ip_address) from random import choice from typing import (Any, Dict, - List, - Optional) + Optional, + Set, + Union) from urllib.parse import quote import anyio def get_request_ip(host: str, port: int, - previous_ips: Optional[List[str]]=None) -> Optional[str]: + previous_ips: Optional[Set[str]]=None) -> Optional[str]: # Lets not call getaddrinfo, if the host is already an IP address try: - ip = ip_address(host) + ip: Optional[Union[IPv4Address, IPv6Address, str]] = ip_address(host) except ValueError: ip = None @@ -42,14 +43,15 @@ def get_request_ip(host: str, ip = '127.0.0.1' if previous_ips is None: - previous_ips = [] + previous_ips = set() if not ip: # TODO: getaddrinfo() will raise an exception if name resolution fails result = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM, family=socket.AF_UNSPEC) # TODO: Handle IPv4 vs IPv6; with or without port? # ips = [f'{addr[4][0]}:{addr[4][1]}' for addr in result] try: - ip = choice([addr[4][0] for addr in result if addr[4][0] not in previous_ips]) + res_ip = choice([addr[4][0] for addr in result if addr[4][0] not in previous_ips]) + ip = str(res_ip) except IndexError: ip = None else: @@ -61,10 +63,10 @@ def get_request_ip(host: str, async def get_request_ip_async(host: str, port: int, - previous_ips: Optional[List[str]]=None) -> Optional[str]: + previous_ips: Optional[Set[str]]=None) -> Optional[str]: # Lets not call getaddrinfo, if the host is already an IP address try: - ip = ip_address(host) + ip: Optional[Union[IPv4Address, IPv6Address, str]] = ip_address(host) except ValueError: ip = None @@ -74,7 +76,7 @@ async def get_request_ip_async(host: str, ip = '127.0.0.1' if previous_ips is None: - previous_ips = [] + previous_ips = set() if not ip: # TODO: getaddrinfo() will raise an exception if name resolution fails @@ -82,7 +84,8 @@ async def get_request_ip_async(host: str, # TODO: Handle IPv4 vs IPv6; with or without port? # ips = [f'{addr[4][0]}:{addr[4][1]}' for addr in result] try: - ip = choice([addr[4][0] for addr in result if addr[4][0] not in previous_ips]) + res_ip = choice([addr[4][0] for addr in result if addr[4][0] not in previous_ips]) + ip = str(res_ip) except IndexError: ip = None else: diff --git a/couchbase_analytics/common/core/query.py b/couchbase_analytics/common/core/query.py index 261154a..d233a1f 100644 --- a/couchbase_analytics/common/core/query.py +++ b/couchbase_analytics/common/core/query.py @@ -73,17 +73,21 @@ def build_query_metadata(json_data: Optional[Any]=None, QueryMetadataCore: The parsed query metadata. """ if json_data is None and raw_metadata is None: - raise ValueError("Either json_data or raw_metadata must be provided") + raise ValueError('No metadata provided.') - if json_data is None: + if json_data is None and raw_metadata is not None: json_data = json.loads(raw_metadata.decode('utf-8')) - warnings = [] + + if json_data is None or not isinstance(json_data, dict): + raise ValueError('Invalid query metadata format. Expected a JSON object.') + + warnings: List[QueryWarningCore] = [] for warning in json_data.get('warnings', []): warnings.append({'code':warning.get('code', 0), 'message': warning.get('msg', '')}) - metadata = {'request_id':json_data.get('requestID', ''), - 'client_context_id':json_data.get('clientContextID', ''), - 'warnings':warnings} + metadata: QueryMetadataCore = {'request_id':json_data.get('requestID', ''), + 'client_context_id':json_data.get('clientContextID', ''), + 'warnings':warnings} # TODO: include status in metadata?? Seems to only be populated in error scenario if 'status' in json_data: diff --git a/couchbase_analytics/common/core/result.py b/couchbase_analytics/common/core/result.py index a3b0ef3..8cbd521 100644 --- a/couchbase_analytics/common/core/result.py +++ b/couchbase_analytics/common/core/result.py @@ -37,7 +37,7 @@ class QueryResult(ABC): """Abstract base class for query results.""" @abstractmethod - def cancel(self) -> None: + def cancel(self) -> Union[Coroutine[Any, Any, None], None]: """ Cancel streaming the query results. diff --git a/couchbase_analytics/common/core/utils.py b/couchbase_analytics/common/core/utils.py index aea529d..6117c4f 100644 --- a/couchbase_analytics/common/core/utils.py +++ b/couchbase_analytics/common/core/utils.py @@ -60,6 +60,22 @@ def to_microseconds(value: Union[timedelta, float, int]) -> int: return total_us +def to_seconds(value: Union[timedelta, float, int]) -> float: + if value and not isinstance(value, (timedelta, float, int)): + raise ValueError(f'Excepted value to be of type Union[timedelta, float, int] instead of {type(value)}') + if not value: + total_secs = float(0) + elif isinstance(value, timedelta): + if value.total_seconds() < 0: + raise ValueError('Timeout must be non-negative.') + total_secs = float(value.total_seconds()) + else: + if value < 0: + raise ValueError('Timeout must be non-negative.') + total_secs = float(value) + + return total_secs + def validate_raw_dict(value: Dict[str, Any]) -> Dict[str, Any]: if not isinstance(value, dict): diff --git a/couchbase_analytics/common/credential.py b/couchbase_analytics/common/credential.py index 4fcc08c..4d8719b 100644 --- a/couchbase_analytics/common/credential.py +++ b/couchbase_analytics/common/credential.py @@ -15,7 +15,9 @@ from __future__ import annotations -from typing import Callable, Tuple +from typing import (Callable, + Dict, + Tuple) class Credential: @@ -45,6 +47,15 @@ def __init__(self, **kwargs: str) -> None: self._username = username self._password = password + def asdict(self) -> Dict[str, str]: + """ + **INTERNAL** + """ + return { + 'username': self._username, + 'password': self._password + } + def astuple(self) -> Tuple[bytes, bytes]: """ **INTERNAL** @@ -89,8 +100,8 @@ def _cred_from_env() -> Credential: """ return Credential(**callback().asdict()) - def __repr__(self): + def __repr__(self) -> str: return f'Credential(username={self._username}, password=****)' - def __str__(self): + def __str__(self) -> str: return self.__repr__() diff --git a/couchbase_analytics/common/errors.py b/couchbase_analytics/common/errors.py index f7a4553..3d80950 100644 --- a/couchbase_analytics/common/errors.py +++ b/couchbase_analytics/common/errors.py @@ -16,9 +16,7 @@ from __future__ import annotations from typing import (Dict, - Optional, - Union, - cast) + Optional) """ @@ -40,7 +38,7 @@ def __init__(self, cause: Optional[Exception] = None, message: Optional[str] = N def __repr__(self) -> str: details: Dict[str, str] = {} if self._cause is not None: - details['cause'] = self._base.__repr__() + details['cause'] = self._cause.__repr__() if self._message is not None and not self._message.isspace(): details['message'] = self._message @@ -67,7 +65,7 @@ def __repr__(self) -> str: 'context': self._context } if self._cause is not None: - details['cause'] = self._base.__repr__() + details['cause'] = self._cause.__repr__() if self._message is not None and not self._message.isspace(): details['message'] = self._message diff --git a/couchbase_analytics/common/options.py b/couchbase_analytics/common/options.py index b2d352a..c689891 100644 --- a/couchbase_analytics/common/options.py +++ b/couchbase_analytics/common/options.py @@ -130,8 +130,7 @@ class TimeoutOptions(TimeoutOptionsBase): Options marked **VOLATILE** are subject to change at any time. Args: - connect_timeout (Optional[timedelta]): Set to configure the period of time allowed to complete bootstrap connection. Defaults to `None` (10s). - dispatch_timeout (Optional[timedelta]): Set to configure the period of time allowed to complete HTTP connection prior to sending request. Defaults to `None` (30s). + connect_timeout (Optional[timedelta]): Set to configure the period of time allowed to make a connection. Defaults to `None` (10s). query_timeout (Optional[timedelta]): Set to configure the period of time allowed for query operations. Defaults to `None` (10m). """ # noqa: E501 @@ -150,10 +149,9 @@ class QueryOptions(QueryOptionsBase): lazy_execute (Optional[bool]): **VOLATILE** If enabled, the query will not execute until the application begins to iterate over results. Defaulst to `None` (disabled). named_parameters (Optional[Dict[str, :py:type:`~couchbase_analytics.JSONType`]]): Values to use for positional placeholders in query. positional_parameters (Optional[List[:py:type:`~couchbase_analytics.JSONType`]]):, optional): Values to use for named placeholders in query. - priority (Optional[bool]): Indicates whether this query should be executed with a specific priority level. query_context (Optional[str]): Specifies the context within which this query should be executed. raw (Optional[Dict[str, Any]]): Specifies any additional parameters which should be passed to the Analytics engine when executing the query. - read_only (Optional[bool]): Specifies that this query should be executed in read-only mode, disabling the ability for the query to make any changes to the data. + readonly (Optional[bool]): Specifies that this query should be executed in read-only mode, disabling the ability for the query to make any changes to the data. scan_consistency (Optional[QueryScanConsistency]): Specifies the consistency requirements when executing the query. timeout (Optional[timedelta]): Set to configure allowed time for operation to complete. Defaults to `None` (75s). stream_config (Optional[JsonStreamConfig]): **VOLATILE** Configuration for JSON stream processing. Defaults to `None` (default configuration). See :class:`~couchbase_analytics.common.json_parsing.JsonStreamConfig` for details. diff --git a/couchbase_analytics/common/options_base.py b/couchbase_analytics/common/options_base.py index 1c31b56..c0799ed 100644 --- a/couchbase_analytics/common/options_base.py +++ b/couchbase_analytics/common/options_base.py @@ -24,7 +24,8 @@ List, Literal, Optional, - TypedDict) + TypedDict, + Union) if sys.version_info < (3, 10): from typing_extensions import TypeAlias, Unpack @@ -112,13 +113,11 @@ def __init__(self, **kwargs: Unpack[SecurityOptionsKwargs]) -> None: class TimeoutOptionsKwargs(TypedDict, total=False): connect_timeout: Optional[timedelta] - dispatch_timeout: Optional[timedelta] query_timeout: Optional[timedelta] TimeoutOptionsValidKeys: TypeAlias = Literal[ 'connect_timeout', - 'dispatch_timeout', 'query_timeout', ] @@ -130,7 +129,6 @@ class TimeoutOptionsBase(Dict[str, object]): VALID_OPTION_KEYS: List[TimeoutOptionsValidKeys] = [ 'connect_timeout', - 'dispatch_timeout', 'query_timeout', ] @@ -145,11 +143,10 @@ class QueryOptionsKwargs(TypedDict, total=False): lazy_execute: Optional[bool] named_parameters: Optional[Dict[str, JSONType]] positional_parameters: Optional[Iterable[JSONType]] - priority: Optional[bool] query_context: Optional[str] raw: Optional[Dict[str, Any]] - read_only: Optional[bool] - scan_consistency: Optional[QueryScanConsistency] + readonly: Optional[bool] + scan_consistency: Optional[Union[QueryScanConsistency, str]] stream_config: Optional[JsonStreamConfig] timeout: Optional[timedelta] @@ -160,10 +157,9 @@ class QueryOptionsKwargs(TypedDict, total=False): 'lazy_execute', 'named_parameters', 'positional_parameters', - 'priority', 'query_context', 'raw', - 'read_only', + 'readonly', 'scan_consistency', 'stream_config', 'timeout', @@ -178,10 +174,9 @@ class QueryOptionsBase(Dict[str, object]): 'lazy_execute', 'named_parameters', 'positional_parameters', - 'priority', 'query_context', 'raw', - 'read_only', + 'readonly', 'scan_consistency', 'stream_config', 'timeout', diff --git a/couchbase_analytics/common/query.py b/couchbase_analytics/common/query.py index fe67952..0ec8b30 100644 --- a/couchbase_analytics/common/query.py +++ b/couchbase_analytics/common/query.py @@ -54,7 +54,7 @@ def elapsed_time(self) -> timedelta: Returns: The total amount of time spent running the query. """ - us = (self._raw.get('elapsed_time') or 0) / 1000 + us = (self._raw.get('elapsed_time') or 0) * 1000 return timedelta(microseconds=us) def execution_time(self) -> timedelta: @@ -63,7 +63,7 @@ def execution_time(self) -> timedelta: Returns: The total amount of time spent executing the query. """ - us = (self._raw.get('execution_time') or 0) / 1000 + us = (self._raw.get('execution_time') or 0) * 1000 return timedelta(microseconds=us) def result_count(self) -> int: diff --git a/couchbase_analytics/protocol/connection.py b/couchbase_analytics/protocol/connection.py index 9ac36ae..0d87393 100644 --- a/couchbase_analytics/protocol/connection.py +++ b/couchbase_analytics/protocol/connection.py @@ -23,42 +23,46 @@ List, Optional, Tuple, - TypedDict) + TypedDict, + cast) from urllib.parse import parse_qs, urlparse +from couchbase_analytics.common.core._certificates import _Certificates +from couchbase_analytics.common.core.duration_str_utils import parse_duration_str from couchbase_analytics.common.credential import Credential from couchbase_analytics.common.deserializer import DefaultJsonDeserializer, Deserializer -from couchbase_analytics.common.options import ClusterOptions +from couchbase_analytics.common.options import (ClusterOptions, + SecurityOptions, + TimeoutOptions) + from couchbase_analytics.protocol import PYCBAC_VERSION from couchbase_analytics.protocol.options import (ClusterOptionsTransformedKwargs, QueryStrVal, SecurityOptionsTransformedKwargs, TimeoutOptionsTransformedKwargs) -from httpcore import (Origin, URL) +from httpcore import URL if TYPE_CHECKING: from couchbase_analytics.protocol.options import OptionsBuilder class StreamingTimeouts(TypedDict, total=False): - query_timeout: Optional[int] - + query_timeout: Optional[float] class DefaultTimeouts(TypedDict): - connect_timeout: int - dispatch_timeout: int - query_timeout: int + connect_timeout: float + query_timeout: float + DEFAULT_TIMEOUTS: DefaultTimeouts = { 'connect_timeout': 10, - 'dispatch_timeout': 30, 'query_timeout': 60 * 10, } -def parse_http_endpoint(http_endpoint: str) -> Tuple[URL, Dict[str, QueryStrVal]]: +def parse_http_endpoint(http_endpoint: str) -> Tuple[URL, Dict[str, List[str]]]: """ **INTERNAL** Parse the provided HTTP endpoint @@ -77,41 +81,27 @@ def parse_http_endpoint(http_endpoint: str) -> Tuple[URL, Dict[str, QueryStrVal] if parsed_endpoint.scheme is None or parsed_endpoint.scheme not in ['http', 'https']: raise ValueError(f"The endpoint scheme must be 'http[s]'. Found: {parsed_endpoint.scheme}.") + host = parsed_endpoint.hostname + if host is None: + host = '' + + if len(host.split(',')) > 1: + raise ValueError('The endpoint must not contain multiple hosts.') + port = parsed_endpoint.port if parsed_endpoint.port is None: port = 80 if parsed_endpoint.scheme == 'http' else 443 url = URL(scheme=parsed_endpoint.scheme, - host=parsed_endpoint.hostname, + host=host, port=port, target=parsed_endpoint.path or '/') - return url, parse_query_string_options(parsed_endpoint.query) + return url, parse_qs(parsed_endpoint.query) -def parse_query_string_options(query_str: str) -> Dict[str, QueryStrVal]: - """Parse the query string options - - Query options will be split into legacy options and 'current' options. The values for the - 'current' options are cast to integers or booleans where applicable - - Args: - query_str (str): The query string. - - Returns: - Tuple[Dict[str, QueryStrVal], Dict[str, QueryStrVal]]: The parsed current options and legacy options. - """ - options = parse_qs(query_str) - - query_str_opts: Dict[str, QueryStrVal] = {} - for k, v in options.items(): - query_str_opts[k] = parse_query_string_value(v) - - return query_str_opts - - -def parse_query_string_value(value: List[str]) -> QueryStrVal: +def parse_query_string_value(value: List[str], enforce_str: Optional[bool]=False) -> QueryStrVal: """Parse a query string value The provided value is a list of at least one element. Returns either a list of strings or a single element @@ -127,25 +117,36 @@ def parse_query_string_value(value: List[str]) -> QueryStrVal: if len(value) > 1: return value v = value[0] - if v.isnumeric(): + if v.isnumeric() and not enforce_str: return int(v) elif v.lower() in ['true', 'false']: return v.lower() == 'true' return v -def parse_query_str_options(query_str_opts: Dict[str, QueryStrVal]) -> Dict[str, QueryStrVal]: +def parse_query_str_options(query_str_opts: Dict[str, List[str]]) -> Dict[str, QueryStrVal]: final_opts: Dict[str, QueryStrVal] = {} for k, v in query_str_opts.items(): tokens = k.split('.') if len(tokens) == 2: - if tokens[0] in ['timeout', 'security']: - final_opts[tokens[1]] = v + if tokens[0] == 'security': + final_opts[tokens[1]] = parse_query_string_value(v) + elif tokens[0] == 'timeout': + val = parse_query_string_value(v, enforce_str=True) + final_opts[tokens[1]] = parse_duration_str(cast(str, val)) else: + print('Warning: Unrecognized query string option:', k) # TODO: exceptions -- this means the user passed in an invalid option pass else: - final_opts[k] = v + if k in SecurityOptions.VALID_OPTION_KEYS: + msg = f'Invalid query string option: {k}.' + if k not in ['trust_only_pem_str', 'trust_only_certificates']: + msg += f' Use "security.{k}" instead.' + raise ValueError(msg) + elif k in TimeoutOptions.VALID_OPTION_KEYS: + raise ValueError(f'Invalid query string option: {k}. Use "timeout.{k}" instead.') + final_opts[k] = parse_query_string_value(v) return final_opts @@ -162,7 +163,7 @@ class _ConnectionDetails: ssl_context: Optional[ssl.SSLContext] = None sni_hostname: Optional[str] = None - def get_connect_timeout(self) -> int: + def get_connect_timeout(self) -> float: timeout_opts: Optional[TimeoutOptionsTransformedKwargs] = self.cluster_options.get('timeout_options') if timeout_opts is not None: connect_timeout = timeout_opts.get('connect_timeout', None) @@ -170,7 +171,7 @@ def get_connect_timeout(self) -> int: return connect_timeout return DEFAULT_TIMEOUTS['connect_timeout'] - def get_query_timeout(self) -> int: + def get_query_timeout(self) -> float: timeout_opts: Optional[TimeoutOptionsTransformedKwargs] = self.cluster_options.get('timeout_options') if timeout_opts is not None: query_timeout = timeout_opts.get('query_timeout', None) @@ -179,6 +180,8 @@ def get_query_timeout(self) -> int: return DEFAULT_TIMEOUTS['query_timeout'] def get_scheme_host_and_port(self) -> Tuple[str, str, int]: + if self.url.port is None: + raise ValueError('The URL must have a port specified.') return self.url.scheme.decode(), self.url.host.decode(), self.url.port def is_secure(self) -> bool: @@ -189,23 +192,36 @@ def validate_security_options(self) -> None: # TODO: security settings if security_opts is not None: # separate between value options and boolean option (trust_only_capella) - solo_security_opts = ['trust_only_pem_file', - 'trust_only_pem_str', - 'trust_only_certificates'] + solo_security_opts = ['trust_only_pem_file', 'trust_only_pem_str', 'trust_only_certificates'] trust_capella = security_opts.get('trust_only_capella', None) security_opt_count = sum(map(lambda k: 1 if security_opts.get(k, None) is not None else 0, solo_security_opts)) if security_opt_count > 1 or (security_opt_count == 1 and trust_capella is True): raise ValueError(('Can only set one of the following options: ' f'[{", ".join(["trust_only_capella"] + solo_security_opts)}]')) - if self.is_secure(): - self.ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + if not self.is_secure(): + return + + self.ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + self.sni_hostname = self.url.host.decode() + + if security_opts is None: self.ssl_context.set_default_verify_paths() - # ssl_context.load_verify_locations(cafile='.vscode/tls/cluster_ca.pem') - self.ssl_context.load_verify_locations(cafile='.vscode/tls/capella.pem') - self.ssl_context.load_verify_locations(cafile='.vscode/tls/dinocluster.pem') - self.ssl_context.load_verify_locations(cafile='.vscode/tls/dinoca.pem') - self.sni_hostname = self.url.host.decode() + capalla_certs = _Certificates.get_capella_certificates() + self.ssl_context.load_verify_locations(cadata='\n'.join(capalla_certs)) + elif security_opts.get('trust_only_capella', False): + capalla_certs = _Certificates.get_capella_certificates() + self.ssl_context.load_verify_locations(cadata='\n'.join(capalla_certs)) + elif (certpath := security_opts.get('trust_only_pem_file', None)) is not None: + self.ssl_context.load_verify_locations(cafile=certpath) + security_opts['trust_only_capella'] = False + elif (certstr := security_opts.get('trust_only_pem_str', None)) is not None: + self.ssl_context.load_verify_locations(cadata=certstr) + security_opts['trust_only_capella'] = False + elif (certificates := security_opts.get('trust_only_certificates', None)) is not None: + self.ssl_context.load_verify_locations(cadata='\n'.join(certificates)) + security_opts['trust_only_capella'] = False + @classmethod def create(cls, diff --git a/couchbase_analytics/protocol/core/_http_transport.py b/couchbase_analytics/protocol/core/_http_transport.py index 36c17ac..f26b684 100644 --- a/couchbase_analytics/protocol/core/_http_transport.py +++ b/couchbase_analytics/protocol/core/_http_transport.py @@ -39,13 +39,13 @@ # CertTypes = Union[str, Tuple[str, str], Tuple[str, str, str]] class AnalyticsHTTPConnection(HTTPConnection): - def __init__(self, *args, **kwargs) -> None: + def __init__(self, *args, **kwargs) -> None: # type: ignore super().__init__(*args, **kwargs) # The logic is the exact same as httpcore's Connection.handle_request, with the following additions: # - We update the request's read timeout to remove the time taken to establish a connection # 2025-06-05: https://github.com/encode/httpcore/blob/98209758cc14e1a5f966fe1dfdc1064b94055d8c/httpcore/_sync/connection.py#L69 - def handle_request(self, request: Request) -> Response: + def handle_request(self, request: Request) -> CoreResponse: if not self.can_handle_request(request.url.origin): raise RuntimeError( f"Attempted to send request to {request.url.origin} on connection to {self._origin}" @@ -88,7 +88,7 @@ def handle_request(self, request: Request) -> Response: return self._connection.handle_request(request) class AnalyticsConnectionPool(ConnectionPool): - def __init__(self, *args, **kwargs) -> None: + def __init__(self, *args, **kwargs) -> None: # type: ignore super().__init__(*args, **kwargs) # The logic is the exact same as httpcore's ConnectionPool.handle_request, with the following additions: @@ -192,7 +192,7 @@ def create_connection(self, origin: Origin) -> ConnectionInterface: class AnalyticsHTTPTransport(BaseTransport): def __init__( self, - verify: Optional[Union[ssl.SSLContext, str, bool]] = True, + verify: Union[ssl.SSLContext, str, bool] = True, cert: Optional[CertTypes] = None, trust_env: bool = True, http1: bool = True, @@ -221,7 +221,7 @@ def __init__( socket_options=socket_options, ) - def __enter__(self: T) -> T: # Use generics for subclass support. + def __enter__(self: T) -> T: # type: ignore self._pool.__enter__() return self @@ -236,7 +236,7 @@ def __exit__( def handle_request( self, - request: Request, + request: Request, # type: ignore ) -> Response: assert isinstance(request.stream, SyncByteStream) import httpcore @@ -244,12 +244,12 @@ def handle_request( req = httpcore.Request( method=request.method, url=httpcore.URL( - scheme=request.url.raw_scheme, - host=request.url.raw_host, + scheme=request.url.raw_scheme, # type: ignore + host=request.url.raw_host, # type: ignore port=request.url.port, - target=request.url.raw_path, + target=request.url.raw_path, # type: ignore ), - headers=request.headers.raw, + headers=request.headers.raw, # type: ignore content=request.stream, extensions=request.extensions, ) diff --git a/couchbase_analytics/protocol/core/_request_context.py b/couchbase_analytics/protocol/core/_request_context.py index 77f4894..180453b 100644 --- a/couchbase_analytics/protocol/core/_request_context.py +++ b/couchbase_analytics/protocol/core/_request_context.py @@ -15,12 +15,13 @@ Iterator, List, Optional, + Union, TYPE_CHECKING) from uuid import uuid4 from httpx import Response as HttpCoreResponse -from couchbase_analytics.common.core import ParsedResult +from couchbase_analytics.common.core import ParsedResult, ParsedResultType from couchbase_analytics.common.core.net_utils import get_request_ip from couchbase_analytics.common.deserializer import Deserializer from couchbase_analytics.common.errors import AnalyticsError, InvalidCredentialError @@ -109,8 +110,8 @@ def __init__(self, self._cancel_event = Event() self._request_error: Optional[Exception] = None self._tp_executor = tp_executor - self._stage_completed_ft: Optional[Future] = None - self._stage_notification_ft: Optional[Future[ParsedResult]] = None + self._stage_completed_ft: Optional[Future[Any]] = None + self._stage_notification_ft: Optional[Future[ParsedResultType]] = None self._request_deadline = math.inf self._background_request: Optional[BackgroundRequest] = None @@ -121,12 +122,12 @@ def __init__(self, # return self._stage_notification_ft @property - def cancel_enabled(self) -> bool: + def cancel_enabled(self) -> Optional[bool]: return self._request.enable_cancel - @property - def cancel_event(self) -> Event: - return self._request._cancel_event + # @property + # def cancel_event(self) -> Event: + # return self._request._cancel_event @property def deserializer(self) -> Deserializer: @@ -201,34 +202,35 @@ def _create_stage_notification_future(self) -> None: # TODO: custom ThreadPoolExecutor, to get a "plain" future if self._stage_notification_ft is not None: raise RuntimeError('Stage notification future already created for this context.') - self._stage_notification_ft = Future[ParsedResult]() + self._stage_notification_ft = Future[ParsedResultType]() - def _trace_handler(self, event_name, _) -> None: + def _trace_handler(self, event_name: str, _: str) -> None: if event_name == 'connection.connect_tcp.complete': print('Connection established, updating cancel scope deadline') def initialize(self) -> None: self._request_state = StreamingState.Started - timeouts = self._request.get_request_timeouts() - self._request_deadline = time.monotonic() + timeouts.get('read', DEFAULT_TIMEOUTS['query_timeout']) + timeouts = self._request.get_request_timeouts() or {} + self._request_deadline = time.monotonic() + (timeouts.get('read', None) or DEFAULT_TIMEOUTS['query_timeout']) def process_error(self, json_data: List[Dict[str, Any]]) -> None: self._request_state = StreamingState.Error if not isinstance(json_data, list): - self._request_error = AnalyticsError('Cannot parse error response; expected JSON array') + self._request_error = AnalyticsError(message='Cannot parse error response; expected JSON array') self._request_error = ErrorMapper.build_error_from_json(json_data, status_code=self._request.response_status_code) raise self._request_error def send_request(self, enable_trace_handling: Optional[bool]=False) -> HttpCoreResponse: + # TODO: handle if lookup fails ip = get_request_ip(self._request.host, self._request.port, self._request.previous_ips) if ip is None: attempted_ips = ', '.join(self._request.previous_ips or []) - raise AnalyticsError(f'Connect failure. Attempted to connect to resolved IPs: {attempted_ips}.') + raise AnalyticsError(message=f'Connect failure. Attempted to connect to resolved IPs: {attempted_ips}.') if enable_trace_handling is True: (self._request.update_url(ip, self._client_adapter.analytics_path) - .update_extensions({'trace': self._trace_handler}) + .add_trace_to_extensions(self._trace_handler) .update_previous_ips(ip)) else: self._request.update_url(ip, self._client_adapter.analytics_path).update_previous_ips(ip) @@ -240,7 +242,7 @@ def send_request(self, enable_trace_handling: Optional[bool]=False) -> HttpCoreR 'server_addr': self._request.server_addr, 'http_status': response.status_code, } - raise InvalidCredentialError(context) + raise InvalidCredentialError(str(context)) return response @@ -283,14 +285,23 @@ def start_next_stage(self, elif self._stage_completed_ft is not None and not self._stage_completed_ft.done(): raise RuntimeError('Future already running in this context.') - kwargs = {'request_context': self} + kwargs: Dict[str, Union[RequestContext, Future[ParsedResultType]]] = {'request_context': self} if create_notification is True: self._create_stage_notification_future() + if self._stage_notification_ft is None: + raise RuntimeError('Unable to create stage notification future.') kwargs['notify_on_results_or_error'] = self._stage_notification_ft self._stage_completed_ft = self._tp_executor.submit(fn, *args, **kwargs) - - def wait_for_stage_notification(self) -> ParsedResult: + + def wait_for_stage_completed(self) -> None: + if self._stage_completed_ft is None: + raise RuntimeError('Stage completed future not created for this context.') + self._stage_completed_ft.result() + + def wait_for_stage_notification(self) -> ParsedResultType: + if self._stage_notification_ft is None: + raise RuntimeError('Stage notification future not created for this context.') # TODO: what if the deadline is already passed? deadline = round(self._request_deadline - time.monotonic(), 6) # round to microseconds res = self._stage_notification_ft.result(timeout=deadline) diff --git a/couchbase_analytics/protocol/core/client_adapter.py b/couchbase_analytics/protocol/core/client_adapter.py index 5fe0009..34e75f9 100644 --- a/couchbase_analytics/protocol/core/client_adapter.py +++ b/couchbase_analytics/protocol/core/client_adapter.py @@ -17,8 +17,7 @@ import socket -from random import choice -from typing import Dict, Optional, TYPE_CHECKING +from typing import Optional, TYPE_CHECKING from uuid import uuid4 from httpx import BasicAuth, Client, Response @@ -27,7 +26,7 @@ from couchbase_analytics.common.deserializer import Deserializer from couchbase_analytics.protocol.connection import _ConnectionDetails from couchbase_analytics.protocol.options import OptionsBuilder -from couchbase_analytics.protocol.core._http_transport import AnalyticsHTTPTransport +# from couchbase_analytics.protocol.core._http_transport import AnalyticsHTTPTransport if TYPE_CHECKING: from couchbase_analytics.protocol.core.request import QueryRequest @@ -121,6 +120,8 @@ def create_client(self) -> None: if not hasattr(self, '_client'): auth = BasicAuth(*self._conn_details.credential) if self._conn_details.is_secure(): + if self._conn_details.ssl_context is None: + raise ValueError('SSL context is required for secure connections.') transport = None if self._http_transport_cls is not None: transport = self._http_transport_cls(verify=self._conn_details.ssl_context) @@ -141,6 +142,9 @@ def send_request(self, request: QueryRequest) -> Response: if not hasattr(self, '_client'): raise RuntimeError('Client not created yet') + if request.url is None: + raise ValueError('Request URL cannot be None') + req = self._client.build_request(request.method, request.url, json=request.body, diff --git a/couchbase_analytics/protocol/core/request.py b/couchbase_analytics/protocol/core/request.py index 347ce25..2f83a1d 100644 --- a/couchbase_analytics/protocol/core/request.py +++ b/couchbase_analytics/protocol/core/request.py @@ -19,16 +19,21 @@ from dataclasses import dataclass from typing import (TYPE_CHECKING, + Any, + Callable, + Coroutine, Dict, Optional, Set, Tuple, + TypedDict, Union) from uuid import uuid4 from couchbase_analytics.common.deserializer import Deserializer from couchbase_analytics.common.options import QueryOptions from couchbase_analytics.protocol.options import QueryOptionsTransformedKwargs +from couchbase_analytics.query import QueryScanConsistency if TYPE_CHECKING: from httpx import Response as HttpCoreResponse @@ -36,7 +41,16 @@ from acouchbase_analytics.protocol.core.client_adapter import _AsyncClientAdapter as AsyncClientAdapter from couchbase_analytics.protocol.core.client_adapter import _ClientAdapter as BlockingClientAdapter +class RequestTimeoutExtensions(TypedDict, total=False): + pool: Optional[float] # Timeout for acquiring a connection from the pool + connect: Optional[float] # Timeout for establishing a socket connection + read: Optional[float] # Timeout for reading data from the socket connection + write: Optional[float] # Timeout for writing data to the socket connection +class RequestExtensions(TypedDict, total=False): + timeout: RequestTimeoutExtensions + sni_hostname: Optional[str] + trace: Optional[Callable[[str, str], Union[None, Coroutine[Any, Any, None]]]] @dataclass class QueryRequest: @@ -44,10 +58,10 @@ class QueryRequest: host: str port: int deserializer: Deserializer - body: Dict[str, object] - extensions: Dict[str, str] + body: Dict[str, Union[str, object]] + extensions: RequestExtensions + method: str = 'POST' url: Optional[str] = None - method: Optional[str] = 'POST' options: Optional[QueryOptionsTransformedKwargs] = None client_addr: Optional[Tuple[str, int]] = None @@ -56,7 +70,7 @@ class QueryRequest: response_status_code: Optional[int] = None enable_cancel: Optional[bool] = None - def get_request_timeouts(self) -> Dict[str, int]: + def get_request_timeouts(self) -> Optional[RequestTimeoutExtensions]: """ **INTERNAL** Get the request timeouts from the extensions. @@ -77,7 +91,7 @@ def set_client_server_addrs(self, response: HttpCoreResponse) -> None: self.response_status_code = response.status_code - def update_extensions(self, new_extensions: Dict[str, str]) -> QueryRequest: + def add_trace_to_extensions(self, handler: Callable[[str, str], Union[None, Coroutine[Any, Any, None]]]) -> QueryRequest: """ **INTERNAL** Update the extensions of the request. @@ -86,7 +100,7 @@ def update_extensions(self, new_extensions: Dict[str, str]) -> QueryRequest: """ if self.extensions is None: self.extensions = {} - self.extensions.update(new_extensions) + self.extensions['trace'] = handler return self def update_previous_ips(self, ip: str) -> QueryRequest: @@ -126,7 +140,7 @@ def __init__(self, connect_timeout = self._conn_details.get_connect_timeout() self._default_query_timeout = self._conn_details.get_query_timeout() - self._extensions = { + self._extensions: RequestExtensions = { 'timeout': { 'pool': connect_timeout, 'connect': connect_timeout, @@ -137,6 +151,7 @@ def __init__(self, if self._conn_details.is_secure() and self._conn_details.sni_hostname is not None: self._extensions['sni_hostname'] = self._conn_details.sni_hostname + def build_base_query_request(self, # noqa: C901 statement: str, *args: object, @@ -178,16 +193,19 @@ def build_base_query_request(self, # noqa: C901 # add the default serializer if one does not exist deserializer = q_opts.pop('deserializer', None) or self._conn_details.default_deserializer - body = {'statement': statement, - 'client_context_id': q_opts.get('client_context_id', str(uuid4()))} + body: Dict[str, Union[str, object]] = { + 'statement': statement, + 'client_context_id': q_opts.get('client_context_id', None) or str(uuid4()) + } if self._database_name is not None and self._scope_name is not None: body['query_context'] = f'default:`{self._database_name}`.`{self._scope_name}`' # handle timeouts - timeout = q_opts.get('timeout', self._default_query_timeout) + timeout = q_opts.get('timeout', None) or self._default_query_timeout extensions = deepcopy(self._extensions) - if timeout != self._default_query_timeout: + if (timeout is not None + and timeout != self._default_query_timeout): extensions['timeout']['read'] = timeout # in the async world we have our own cancel scope that handles the connect timeout if is_async: @@ -201,17 +219,21 @@ def build_base_query_request(self, # noqa: C901 if opt_key == 'deserializer': continue elif opt_key == 'raw': - for k, v in opt_val.items(): + for k, v in opt_val.items(): # type: ignore[attr-defined] body[k] = v elif opt_key == 'positional_parameters': - body['args'] = [arg for arg in opt_val] + body['args'] = [arg for arg in opt_val] # type: ignore[attr-defined] elif opt_key == 'named_parameters': - for k, v in opt_val.items(): + for k, v in opt_val.items(): # type: ignore[attr-defined] key = f'${k}' if not k.startswith('$') else k body[key] = v - else: - # TODO: readonly, priority & scan_consistency - pass + elif opt_key == 'readonly': + body['readonly'] = opt_val + elif opt_key == 'scan_consistency': + if isinstance(opt_val, QueryScanConsistency): + body['scan_consistency'] = opt_val.value + else: + body['scan_consistency'] = opt_val scheme, host, port = self._conn_details.get_scheme_host_and_port() return QueryRequest(scheme, diff --git a/couchbase_analytics/protocol/errors.py b/couchbase_analytics/protocol/errors.py index 1a8d707..ba16229 100644 --- a/couchbase_analytics/protocol/errors.py +++ b/couchbase_analytics/protocol/errors.py @@ -16,13 +16,11 @@ from __future__ import annotations import sys -from enum import Enum from typing import (Any, Dict, List, Optional, - Union, - cast) + Union) if sys.version_info < (3, 10): from typing_extensions import TypeAlias @@ -34,7 +32,8 @@ InvalidCredentialError, QueryError) -AnalyticsClientError: TypeAlias = Union[InternalSDKError, +AnalyticsClientError: TypeAlias = Union[AnalyticsError, + InternalSDKError, QueryError, RuntimeError, ValueError] @@ -66,27 +65,22 @@ class ErrorMapper: - @staticmethod # noqa: C901 - def build_error(base_error: Exception, - mapping: Optional[Dict[str, type[AnalyticsError]]] = None - ) -> AnalyticsClientError: - # TODO: exceptions - return AnalyticsError(base=base_error) - @staticmethod # noqa: C901 def build_error_from_json(json_data: List[Dict[str, Any]], status_code: Optional[int]=None) -> AnalyticsClientError: context = {'errors': json_data, 'http_status': status_code} + # TODO: error handling needs to be more robust if status_code is None: - status_code = json_data.get('status', 500) + status_code = json_data[0].get('status', 500) + return AnalyticsError(message='Unknown error occurred.') elif status_code == 401: - return InvalidCredentialError(context, message='Invalid credentials provided.') + return InvalidCredentialError(str(context), message='Invalid credentials provided.') else: first_error = json_data[0] code = first_error.get('code', 0) server_message = first_error.get('msg', 'Unknown error occurred.') - return QueryError(code, server_message, context) + return QueryError(code, server_message, str(context)) diff --git a/couchbase_analytics/protocol/options.py b/couchbase_analytics/protocol/options.py index 814081f..98986ce 100644 --- a/couchbase_analytics/protocol/options.py +++ b/couchbase_analytics/protocol/options.py @@ -32,7 +32,7 @@ VALIDATE_STR, VALIDATE_STR_LIST, EnumToStr, - timedelta_as_seconds, + to_seconds, to_microseconds, validate_path, validate_raw_dict) @@ -51,7 +51,7 @@ QUERY_CONSISTENCY_TO_STR = EnumToStr[QueryScanConsistency]() -QueryStrVal = Union[List[str], str, bool, int] +QueryStrVal = Union[List[str], str, bool, int, float] class ClusterOptionsTransforms(TypedDict): @@ -100,21 +100,18 @@ class SecurityOptionsTransformedKwargs(TypedDict, total=False): class TimeoutOptionsTransforms(TypedDict): - connect_timeout: Dict[Literal['bootstrap_timeout'], Callable[[Any], int]] - dispatch_timeout: Dict[Literal['dispatch_timeout'], Callable[[Any], int]] - query_timeout: Dict[Literal['query_timeout'], Callable[[Any], int]] + connect_timeout: Dict[Literal['connect_timeout'], Callable[[Any], float]] + query_timeout: Dict[Literal['query_timeout'], Callable[[Any], float]] TIMEOUT_OPTIONS_TRANSFORMS: TimeoutOptionsTransforms = { - 'connect_timeout': {'bootstrap_timeout': timedelta_as_seconds}, - 'dispatch_timeout': {'dispatch_timeout': timedelta_as_seconds}, - 'query_timeout': {'query_timeout': timedelta_as_seconds}, + 'connect_timeout': {'connect_timeout': to_seconds}, + 'query_timeout': {'query_timeout': to_seconds}, } class TimeoutOptionsTransformedKwargs(TypedDict, total=False): connect_timeout: Optional[int] - dispatch_timeout: Optional[int] query_timeout: Optional[int] @@ -124,13 +121,12 @@ class QueryOptionsTransforms(TypedDict): lazy_execute: Dict[Literal['lazy_execute'], Callable[[Any], bool]] named_parameters: Dict[Literal['named_parameters'], Callable[[Any], Any]] positional_parameters: Dict[Literal['positional_parameters'], Callable[[Any], Any]] - priority: Dict[Literal['priority'], Callable[[Any], bool]] query_context: Dict[Literal['query_context'], Callable[[Any], str]] raw: Dict[Literal['raw'], Callable[[Any], Dict[str, Any]]] - read_only: Dict[Literal['readonly'], Callable[[Any], bool]] + readonly: Dict[Literal['readonly'], Callable[[Any], bool]] scan_consistency: Dict[Literal['scan_consistency'], Callable[[Any], str]] stream_config: Dict[Literal['stream_config'], Callable[[Any], JsonStreamConfig]] - timeout: Dict[Literal['timeout'], Callable[[Any], int]] + timeout: Dict[Literal['timeout'], Callable[[Any], float]] QUERY_OPTIONS_TRANSFORMS: QueryOptionsTransforms = { @@ -139,13 +135,12 @@ class QueryOptionsTransforms(TypedDict): 'lazy_execute': {'lazy_execute': VALIDATE_BOOL}, 'named_parameters': {'named_parameters': lambda x: x}, 'positional_parameters': {'positional_parameters': lambda x: x}, - 'priority': {'priority': VALIDATE_BOOL}, 'query_context': {'query_context': VALIDATE_STR}, 'raw': {'raw': validate_raw_dict}, - 'read_only': {'readonly': VALIDATE_BOOL}, + 'readonly': {'readonly': VALIDATE_BOOL}, 'scan_consistency': {'scan_consistency': QUERY_CONSISTENCY_TO_STR}, 'stream_config': {'stream_config': lambda x: x}, - 'timeout': {'timeout': to_microseconds} + 'timeout': {'timeout': to_seconds} } @@ -161,7 +156,7 @@ class QueryOptionsTransformedKwargs(TypedDict, total=False): readonly: Optional[bool] scan_consistency: Optional[str] stream_config: Optional[JsonStreamConfig] - timeout: Optional[int] + timeout: Optional[float] TransformedOptionKwargs = TypeVar('TransformedOptionKwargs', @@ -196,7 +191,7 @@ def _get_options_copy(self, temp_options: Dict[str, object] = {} if options and isinstance(options, (options_class, dict)): # mypy cannot recognize that all our options classes are dicts - temp_options = options_class(**options) + temp_options = options_class(**options) # type: ignore[arg-type] else: temp_options = dict() temp_options.update(orig_kwargs) @@ -246,7 +241,7 @@ def build_cluster_options(self, # noqa: C901 if query_str_opts: # query string options override the options passed in via ClusterOptions for k, v in query_str_opts.items(): - temp_options[k] = v + temp_options[k] = v keys_to_ignore: List[str] = [*ClusterOptions.VALID_OPTION_KEYS, *TimeoutOptions.VALID_OPTION_KEYS] diff --git a/couchbase_analytics/protocol/streaming.py b/couchbase_analytics/protocol/streaming.py index aa43b4c..6f68c88 100644 --- a/couchbase_analytics/protocol/streaming.py +++ b/couchbase_analytics/protocol/streaming.py @@ -16,6 +16,7 @@ from __future__ import annotations import json +import sys from concurrent.futures import CancelledError from functools import wraps @@ -23,6 +24,11 @@ Callable, Optional) +if sys.version_info < (3, 10): + from typing_extensions import TypeAlias +else: + from typing import TypeAlias + from httpx import Response as HttpCoreResponse # TODO: errors? @@ -42,12 +48,12 @@ class RequestWrapper: """ @classmethod - def handle_retries(cls) -> Callable[[Callable[[], None]], Callable[[HttpStreamingResponse], None]]: + def handle_retries(cls) -> Callable[[SendRequestFunc], WrappedSendRequestFunc]: """ **INTERNAL** """ - def decorator(fn: Callable[[], None]) -> Callable[[HttpStreamingResponse], None]: + def decorator(fn: SendRequestFunc) -> WrappedSendRequestFunc: @wraps(fn) def wrapped_fn(self: HttpStreamingResponse) -> None: try: @@ -87,7 +93,7 @@ def __init__(self, self._metadata: Optional[QueryMetadata] = None self._core_response: HttpCoreResponse self._stream_config = stream_config or JsonStreamConfig() - self._json_stream = None + self._json_stream: JsonStream @property def lazy_execute(self) -> bool: @@ -98,7 +104,7 @@ def lazy_execute(self) -> bool: def _finish_processing_stream(self) -> None: if not self._request_context.has_stage_completed: - self._process_ft.result() + self._request_context.wait_for_stage_completed() if self._request_context.cancelled: return @@ -123,9 +129,10 @@ def _process_response(self, raw_response: Optional[ParsedResult]=None) -> None: if raw_response is None: raw_response = self._json_stream.get_result(self._stream_config.queue_timeout) if raw_response is None: - # TODO: logging?? - # TODO: exception?? - raise RuntimeError('No result from JsonStream') + raise AnalyticsError(message='Received unexpected empty result from JsonStream.') + + if raw_response.value is None: + raise AnalyticsError(message='Received unexpected empty result from JsonStream.') json_response = json.loads(raw_response.value) if 'errors' in json_response: @@ -138,7 +145,7 @@ def _start(self) -> None: """ **INTERNAL** """ - if self._json_stream is not None: + if hasattr(self, '_json_stream'): # TODO: logging; I don't think this is an error... return @@ -193,7 +200,11 @@ def get_next_row(self) -> Any: raise StopIteration # TODO: handle timeout raw_response = self._json_stream.get_result(self._stream_config.queue_timeout) + if raw_response is None: + continue if raw_response.result_type == ParsedResultType.ROW: + if raw_response.value is None: + raise AnalyticsError(message='Unexpected empty row response while streaming.') return self._request_context.deserializer.deserialize(raw_response.value) elif raw_response.result_type in [ParsedResultType.ERROR, ParsedResultType.UNKNOWN]: self._process_response(raw_response=raw_response) @@ -214,10 +225,15 @@ def send_request(self) -> None: raise CancelledError('Request was cancelled.') self._start() # block until we either know we have rows or errors - res = self._request_context.wait_for_stage_notification() - if res == ParsedResultType.ROW: + result_type = self._request_context.wait_for_stage_notification() + if result_type == ParsedResultType.ROW: # we move to iterating rows self._request_context.set_state_to_streaming() else: self._finish_processing_stream() self._process_response() + +SendRequestFunc: TypeAlias = Callable[[HttpStreamingResponse], None] +# Although, SendRequestFunc is the same type as WrappedSendRequestFunc, keep separate for clarity and indicate +# WrappedSendRequestFunc is a decorator +WrappedSendRequestFunc: TypeAlias = Callable[[HttpStreamingResponse], None] \ No newline at end of file diff --git a/couchbase_analytics/tests/connection_t.py b/couchbase_analytics/tests/connection_t.py new file mode 100644 index 0000000..60bc122 --- /dev/null +++ b/couchbase_analytics/tests/connection_t.py @@ -0,0 +1,229 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from typing import Dict, Optional +from urllib.parse import urlparse + +import pytest + +from couchbase_analytics.cluster import Cluster +from couchbase_analytics.credential import Credential +from couchbase_analytics.protocol.core.client_adapter import _ClientAdapter +from couchbase_analytics.protocol.core.request import _RequestBuilder +from tests.utils import get_test_cert_path, to_query_str + +TEST_CERT_PATH = get_test_cert_path() + + +class ConnectionTestSuite: + TEST_MANIFEST = [ + 'test_connstr_options_fail', + 'test_connstr_options_timeout', + 'test_connstr_options_timeout_fail', + 'test_connstr_options_timeout_invalid_duration', + 'test_connstr_options_security', + 'test_connstr_options_security_fail', + 'test_invalid_connection_strings', + 'test_valid_connection_strings', + ] + + @pytest.mark.parametrize('connstr_opt', + ['invalid_op=10', + 'connect_timeout=2500ms', + 'dispatch_timeout=2500ms', + 'query_timeout=2500ms', + 'socket_connect_timeout=2500ms', + 'trust_only_pem_file=/path/to/file', + 'disable_server_certificate_verification=True' + ]) + def test_connstr_options_fail(self, + connstr_opt: str) -> None: + cred = Credential.from_username_and_password('Administrator', 'password') + connstr = f'https://localhost?{connstr_opt}' + with pytest.raises(ValueError): + _ClientAdapter(connstr, cred) + + @pytest.mark.parametrize('duration, expected_seconds', + [('1h', '3600'), + ('+1h', '3600'), + ('+1h', '3600'), + ('1h10m', '4200'), + ('1.h10m', '4200'), + ('.1h10m', '960'), + ('0001h00010m', '4200'), + ('2m3s4ms', '123.004'), + (('100ns', '1e-7')), + (('100us', '1e-4')), + (('100μs', '1e-4')), + (('1000000ns', '.001')), + (('1000us', '.001')), + (('1000μs', '.001')), + ('4ms3s2m', '123.004'), + ('4ms3s2m5s', '128.004'), + ('2m3.125s', '123.125'), + ]) + def test_connstr_options_timeout(self, + duration: str, + expected_seconds: str) -> None: + opt_keys = ['timeout.connect_timeout', + 'timeout.query_timeout'] + opts = {k: duration for k in opt_keys} + cred = Credential.from_username_and_password('Administrator', 'password') + connstr = f'https://localhost?{to_query_str(opts)}' + client = _ClientAdapter(connstr, cred) + req_builder = _RequestBuilder(client) + req = req_builder.build_base_query_request('SELECT 1=1') + expected = float(expected_seconds) + returned_timeout_opts = req.get_request_timeouts() + assert isinstance(returned_timeout_opts, dict) + for k in opts.keys(): + opt_key = k.split('.')[1] + if opt_key.startswith('connect'): + pool_timeout = returned_timeout_opts.get('pool') + assert pool_timeout is not None + assert abs(pool_timeout - expected) < 1e-9 + connect_timeout = returned_timeout_opts.get('connect') + assert connect_timeout is not None + assert abs(connect_timeout - expected) < 1e-9 + else: + read_timeout = returned_timeout_opts.get('read') + assert read_timeout is not None + assert abs(read_timeout - expected) < 1e-9 + + @pytest.mark.parametrize('invalid_opt_name', + ['connect_timeout', + 'dispatch_timeout', + 'query_timeout', + 'resolve_timeout', + 'socket_connect_timeout']) + def test_connstr_options_timeout_fail(self, invalid_opt_name: str) -> None: + opts = {invalid_opt_name: '2500s'} + cred = Credential.from_username_and_password('Administrator', 'password') + connstr = f'https://localhost?{to_query_str(opts)}' + with pytest.raises(ValueError): + _ClientAdapter(connstr, cred) + + @pytest.mark.parametrize('bad_duration', + ['123', + '00', + ' 1h', + '1h ', + '1h 2m' + '+-3h', + '-+3h', + '-', + '-.', + '.', + '.h', + '2.3.4h', + '3x', + '3', + '3h4x', + '1H', + '1h-2m', + '-1h', + '-1m', + '-1s' + ]) + def test_connstr_options_timeout_invalid_duration(self, + bad_duration: str) -> None: + opt_keys = ['timeout.connect_timeout', 'timeout.query_timeout'] + for key in opt_keys: + opts = {key: bad_duration} + cred = Credential.from_username_and_password('Administrator', 'password') + connstr = f'https://localhost?{to_query_str(opts)}' + with pytest.raises(ValueError): + _ClientAdapter(connstr, cred) + + @pytest.mark.parametrize('connstr_opts, expected_opts', + [({'security.trust_only_pem_file': TEST_CERT_PATH}, + {'trust_only_pem_file': TEST_CERT_PATH, + 'trust_only_capella': False}), + ({'security.disable_server_certificate_verification': 'true'}, + {'disable_server_certificate_verification': True}), + ]) + def test_connstr_options_security(self, + connstr_opts: Dict[str, object], + expected_opts: Dict[str, object]) -> None: + cred = Credential.from_username_and_password('Administrator', 'password') + connstr = f'https://localhost?{to_query_str(connstr_opts)}' + client = _ClientAdapter(connstr, cred) + sec_opts = client.connection_details.cluster_options.get('security_options', {}) + assert sec_opts == expected_opts + + @pytest.mark.parametrize('invalid_opt_name', + ['trust_only_capella', + 'trust_only_pem_file', + 'trust_only_pem_str', + 'trust_only_certificates', + 'disable_server_certificate_verification']) + def test_connstr_options_security_fail(self, invalid_opt_name: str) -> None: + opts = {invalid_opt_name: 'True'} + cred = Credential.from_username_and_password('Administrator', 'password') + connstr = f'https://localhost?{to_query_str(opts)}' + with pytest.raises(ValueError): + _ClientAdapter(connstr, cred) + + @pytest.mark.parametrize('connstr', ['10.0.0.1:8091', + 'http://10.0.0.1:11222,10.0.0.2,10.0.0.3:11207', + 'http://10.0.0.1;10.0.0.2:11210;10.0.0.3', + 'http://[::ffff:192.168.0.1]:11207,[::ffff:192.168.0.2]:11207', + 'https://10.0.0.1:11222,10.0.0.2,10.0.0.3:11207', + 'https://10.0.0.1;10.0.0.2:11210;10.0.0.3', + 'https://[::ffff:192.168.0.1]:11207,[::ffff:192.168.0.2]:11207', + 'couchbase://10.0.0.1', + 'couchbases://10.0.0.1']) + def test_invalid_connection_strings(self, connstr: str) -> None: + cred = Credential.from_username_and_password('Administrator', 'password') + with pytest.raises(ValueError): + Cluster.create_instance(connstr, cred) + + @pytest.mark.parametrize('connstr', ['http://10.0.0.1', + 'http://10.0.0.1:11222', + 'http://[3ffe:2a00:100:7031::1]', + 'http://[::ffff:192.168.0.1]:11207', + 'http://test.local:11210', + 'http://fqdn', + 'https://10.0.0.1', + 'https://10.0.0.1:11222', + 'https://[3ffe:2a00:100:7031::1]', + 'https://[::ffff:192.168.0.1]:11207', + 'https://test.local:11210', + 'https://fqdn' + ]) + def test_valid_connection_strings(self, connstr: str) -> None: + cred = Credential.from_username_and_password('Administrator', 'password') + client = _ClientAdapter(connstr, cred) + # options should be empty + assert {} == client.connection_details.cluster_options + parsed_connstr = urlparse(connstr) + parsed_port = parsed_connstr.port or (80 if parsed_connstr.scheme == 'http' else 443) + scheme, host, port = client.connection_details.get_scheme_host_and_port() + assert f'{parsed_connstr.scheme}://{parsed_connstr.hostname}:{parsed_port}' == f'{scheme}://{host}:{port}' + + +class ConnectionTests(ConnectionTestSuite): + + @pytest.fixture(scope='class', autouse=True) + def validate_test_manifest(self) -> None: + def valid_test_method(meth: str) -> bool: + attr = getattr(ConnectionTests, meth) + return callable(attr) and not meth.startswith('__') and meth.startswith('test') + method_list = [meth for meth in dir(ConnectionTests) if valid_test_method(meth)] + test_list = set(ConnectionTestSuite.TEST_MANIFEST).symmetric_difference(method_list) + if test_list: + pytest.fail(f'Test manifest invalid. Missing/extra tests: {test_list}.') diff --git a/couchbase_analytics/tests/json_parsing_t.py b/couchbase_analytics/tests/json_parsing_t.py index 65a9826..11278c2 100644 --- a/couchbase_analytics/tests/json_parsing_t.py +++ b/couchbase_analytics/tests/json_parsing_t.py @@ -21,9 +21,10 @@ import pytest from couchbase_analytics.common.core import (JsonParsingError, + JsonStreamConfig, ParsedResult, ParsedResultType) -from couchbase_analytics.common.core.json_stream import JsonStream, JsonStreamConfig +from couchbase_analytics.common.core.json_stream import JsonStream from tests.environments.simple_environment import JsonDataType from tests.utils import BytesIterator @@ -35,7 +36,9 @@ class JsonParsingTestSuite: TEST_MANIFEST = [ 'test_analytics_error', + 'test_analytics_error_mid_stream', 'test_analytics_many_rows', + 'test_analytics_multiple_errors', 'test_analytics_simple_result', 'test_array', @@ -78,6 +81,31 @@ def test_analytics_error(self, assert json.loads(result.value.decode('utf-8')) == json_object assert parser.get_result(0.01) is None + def test_analytics_error_mid_stream(self, test_env: SimpleEnvironment) -> None: + json_object, bytes_data = test_env.get_json_data(JsonDataType.FAILED_REQUEST_MID_STREAM) + parser = JsonStream(BytesIterator(bytes_data)) + parser.start_parsing() + row_idx = 0 + while True: + result = parser.get_result(0.01) + if result is None and not parser.token_stream_exhausted: + parser.continue_parsing() + continue + assert isinstance(result, ParsedResult) + assert result.result_type in [ParsedResultType.ROW, ParsedResultType.ERROR] + assert isinstance(result.value, bytes) + if result.result_type == ParsedResultType.ROW: + assert json.loads(result.value.decode('utf-8')) == json_object['results'][row_idx] + row_idx += 1 + else: + final_result = result.value.decode('utf-8') + break + + # if we are not buffering the entire result, the final result will exclude the results key + json_object.pop('results') + assert json.loads(final_result) == json_object + assert parser.get_result(0.01) is None + def test_analytics_many_rows(self, test_env: SimpleEnvironment) -> None: json_object, bytes_data = test_env.get_json_data(JsonDataType.MULTIPLE_RESULTS) parser = JsonStream(BytesIterator(bytes_data)) @@ -103,6 +131,24 @@ def test_analytics_many_rows(self, test_env: SimpleEnvironment) -> None: assert json.loads(final_result.value.decode('utf-8')) == json_object assert parser.get_result(0.01) is None + @pytest.mark.parametrize('buffered_result', [True, False]) + def test_analytics_multiple_errors(self, + test_env: SimpleEnvironment, + buffered_result: bool) -> None: + json_object, bytes_data = test_env.get_json_data(JsonDataType.FAILED_REQUEST_MULTI_ERRORS) + if buffered_result: + parser = JsonStream(BytesIterator(bytes_data), + stream_config=JsonStreamConfig(buffer_entire_result=True)) + else: + parser = JsonStream(BytesIterator(bytes_data)) + parser.start_parsing() + result = parser.get_result(0.01) + assert isinstance(result, ParsedResult) + assert result.result_type == ParsedResultType.ERROR + assert isinstance(result.value, bytes) + assert json.loads(result.value.decode('utf-8')) == json_object + assert parser.get_result(0.01) is None + @pytest.mark.parametrize('buffered_result', [True, False]) def test_analytics_simple_result(self, test_env: SimpleEnvironment, @@ -132,7 +178,7 @@ def test_analytics_simple_result(self, assert json.loads(final_result.value.decode('utf-8')) == json_object assert parser.get_result(0.01) is None - def test_array(self): + def test_array(self) -> None: data = '[1,2,"three"]' parser = JsonStream(BytesIterator(bytes(data, 'utf-8')), stream_config=JsonStreamConfig(buffer_entire_result=True)) @@ -144,7 +190,7 @@ def test_array(self): assert result.value.decode('utf-8') == data assert parser.get_result(0.01) is None - def test_array_empty(self): + def test_array_empty(self) -> None: data = '[]' parser = JsonStream(BytesIterator(bytes(data, 'utf-8')), stream_config=JsonStreamConfig(buffer_entire_result=True)) @@ -156,7 +202,7 @@ def test_array_empty(self): assert result.value.decode('utf-8') == data assert parser.get_result(0.01) is None - def test_array_mixed_types(self): + def test_array_mixed_types(self) -> None: data = '[123,"text",true,null,{"key":"value"}]' parser = JsonStream(BytesIterator(bytes(data, 'utf-8')), stream_config=JsonStreamConfig(buffer_entire_result=True)) @@ -168,7 +214,7 @@ def test_array_mixed_types(self): assert result.value.decode('utf-8') == data assert parser.get_result(0.01) is None - def test_array_of_objects(self): + def test_array_of_objects(self) -> None: data = '[{"id":1,"name":"Alice"},{"id":2,"name":"Bob"}]' parser = JsonStream(BytesIterator(bytes(data, 'utf-8')), stream_config=JsonStreamConfig(buffer_entire_result=True)) @@ -180,7 +226,7 @@ def test_array_of_objects(self): assert result.value.decode('utf-8') == data assert parser.get_result(0.01) is None - def test_invalid_empty(self): + def test_invalid_empty(self) -> None: try: data = '' parser = JsonStream(BytesIterator(bytes(data, 'utf-8')), @@ -191,7 +237,7 @@ def test_invalid_empty(self): assert err.cause is not None assert 'parse error' in str(err.cause) - def test_invalid_garbage_between_objects(self): + def test_invalid_garbage_between_objects(self) -> None: try: data = '[{"id":1,"name":"Alice"},garbage,{"id":2,"name":"Bob"}]' parser = JsonStream(BytesIterator(bytes(data, 'utf-8')), @@ -202,7 +248,7 @@ def test_invalid_garbage_between_objects(self): assert err.cause is not None assert 'lexical error' in str(err.cause) - def test_invalid_leading_garbage(self): + def test_invalid_leading_garbage(self) -> None: try: data = 'garbage{"key":"value"}' parser = JsonStream(BytesIterator(bytes(data, 'utf-8')), @@ -213,7 +259,7 @@ def test_invalid_leading_garbage(self): assert err.cause is not None assert 'lexical error' in str(err.cause) - def test_invalid_trailing_garbage(self): + def test_invalid_trailing_garbage(self) -> None: try: data = '{"key":"value"}garbage' parser = JsonStream(BytesIterator(bytes(data, 'utf-8')), @@ -224,7 +270,7 @@ def test_invalid_trailing_garbage(self): assert err.cause is not None assert 'parse error' in str(err.cause) - def test_invalid_whitespace_only(self): + def test_invalid_whitespace_only(self) -> None: try: data = ' \n\t ' parser = JsonStream(BytesIterator(bytes(data, 'utf-8')), @@ -235,7 +281,7 @@ def test_invalid_whitespace_only(self): assert err.cause is not None assert 'parse error' in str(err.cause) - def test_object(self): + def test_object(self) -> None: data = '{"name":"John","age":30,"city":"New York"}' parser = JsonStream(BytesIterator(bytes(data, 'utf-8')), stream_config=JsonStreamConfig(buffer_entire_result=True)) @@ -247,7 +293,7 @@ def test_object(self): assert result.value.decode('utf-8') == data assert parser.get_result(0.01) is None - def test_object_complex_nested_structure(self): + def test_object_complex_nested_structure(self) -> None: data_list = ['{"users":[{"id":1,"name":"Alice","roles":["admin","editor"]},' '{"id":2,"name":"Bob","roles":["viewer"]}],', '"meta":{"count":2,"status":"success"}}'] @@ -262,7 +308,7 @@ def test_object_complex_nested_structure(self): assert result.value.decode('utf-8') == data assert parser.get_result(0.01) is None - def test_object_empty(self): + def test_object_empty(self) -> None: data = '{}' parser = JsonStream(BytesIterator(bytes(data, 'utf-8')), stream_config=JsonStreamConfig(buffer_entire_result=True)) @@ -274,7 +320,7 @@ def test_object_empty(self): assert result.value.decode('utf-8') == data assert parser.get_result(0.01) is None - def test_object_simple_nested(self): + def test_object_simple_nested(self) -> None: data = '{"outer":{"inner":{"key":"value"}}}' parser = JsonStream(BytesIterator(bytes(data, 'utf-8')), stream_config=JsonStreamConfig(buffer_entire_result=True)) @@ -286,7 +332,7 @@ def test_object_simple_nested(self): assert result.value.decode('utf-8') == data assert parser.get_result(0.01) is None - def test_object_with_empty_key_and_value(self): + def test_object_with_empty_key_and_value(self) -> None: data = '{"":""}' parser = JsonStream(BytesIterator(bytes(data, 'utf-8')), stream_config=JsonStreamConfig(buffer_entire_result=True)) @@ -298,7 +344,7 @@ def test_object_with_empty_key_and_value(self): assert result.value.decode('utf-8') == data assert parser.get_result(0.01) is None - def test_object_with_unicode(self): + def test_object_with_unicode(self) -> None: data = '{"name":"你好","city":"Denver"}' parser = JsonStream(BytesIterator(bytes(data, 'utf-8')), stream_config=JsonStreamConfig(buffer_entire_result=True)) @@ -322,7 +368,7 @@ def test_value_bool(self) -> None: assert result.value.decode('utf-8') == data assert parser.get_result(0.01) is None - def test_value_null(self): + def test_value_null(self) -> None: data = 'null' parser = JsonStream(BytesIterator(bytes(data, 'utf-8')), stream_config=JsonStreamConfig(buffer_entire_result=True)) diff --git a/couchbase_analytics/tests/options_t.py b/couchbase_analytics/tests/options_t.py new file mode 100644 index 0000000..14797ee --- /dev/null +++ b/couchbase_analytics/tests/options_t.py @@ -0,0 +1,226 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from datetime import timedelta +from typing import Dict, Type + +import pytest + +from couchbase_analytics.credential import Credential +from couchbase_analytics.deserializer import (Deserializer, + DefaultJsonDeserializer, + PassthroughDeserializer) +from couchbase_analytics.options import (ClusterOptions, + SecurityOptions, + SecurityOptionsKwargs, + TimeoutOptions, + TimeoutOptionsKwargs) +from couchbase_analytics.protocol.core.client_adapter import _ClientAdapter +from tests.utils import (get_test_cert_path, + get_test_cert_list, + get_test_cert_str) + +TEST_CERT_PATH = get_test_cert_path() +TEST_CERT_LIST = get_test_cert_list() +TEST_CERT_STR = get_test_cert_str() + + +class ClusterOptionsTestSuite: + + TEST_MANIFEST = [ + 'test_options_deserializer', + 'test_options_deserializer_kwargs', + 'test_security_options', + 'test_security_options_classmethods', + 'test_security_options_kwargs', + 'test_security_options_invalid', + 'test_security_options_invalid_kwargs', + 'test_timeout_options', + 'test_timeout_options_kwargs', + 'test_timeout_options_must_be_positive', + 'test_timeout_options_must_be_positive_kwargs', + ] + + @pytest.mark.parametrize('deserializer_cls', [DefaultJsonDeserializer, PassthroughDeserializer]) + def test_options_deserializer(self, deserializer_cls:Type[Deserializer]) -> None: + cred = Credential.from_username_and_password('Administrator', 'password') + deserializer_instance = deserializer_cls() + client = _ClientAdapter('https://localhost', cred, ClusterOptions(deserializer=deserializer_instance)) + assert isinstance(client.connection_details.default_deserializer, deserializer_cls) + + @pytest.mark.parametrize('deserializer_cls', [DefaultJsonDeserializer, PassthroughDeserializer]) + def test_options_deserializer_kwargs(self, deserializer_cls:Type[Deserializer]) -> None: + cred = Credential.from_username_and_password('Administrator', 'password') + deserializer_instance = deserializer_cls() + client = _ClientAdapter('https://localhost', cred, **{'deserializer': deserializer_instance}) + assert isinstance(client.connection_details.default_deserializer, deserializer_cls) + + @pytest.mark.parametrize('opts, expected_opts', + [({}, None), + ({'trust_only_capella': True}, + {'trust_only_capella': True}), + ({'trust_only_pem_file': TEST_CERT_PATH}, + {'trust_only_pem_file': TEST_CERT_PATH, + 'trust_only_capella': False}), + ({'trust_only_pem_str': TEST_CERT_STR}, + {'trust_only_pem_str': TEST_CERT_STR, + 'trust_only_capella': False}), + ({'trust_only_certificates': TEST_CERT_LIST}, + {'trust_only_certificates': TEST_CERT_LIST, + 'trust_only_capella': False}), + ({'disable_server_certificate_verification': True}, + {'disable_server_certificate_verification': True}), + ]) + def test_security_options(self, opts: SecurityOptionsKwargs, expected_opts: SecurityOptionsKwargs) -> None: + cred = Credential.from_username_and_password('Administrator', 'password') + client = _ClientAdapter('https://localhost', + cred, + ClusterOptions(security_options=SecurityOptions(**opts))) + assert expected_opts == client.connection_details.cluster_options.get('security_options') + + @pytest.mark.parametrize('opts, expected_opts', + [(SecurityOptions.trust_only_capella(), + {'trust_only_capella': True}), + (SecurityOptions.trust_only_pem_file(TEST_CERT_PATH), + {'trust_only_pem_file': TEST_CERT_PATH, + 'trust_only_capella': False}), + (SecurityOptions.trust_only_pem_str(TEST_CERT_STR), + {'trust_only_pem_str': TEST_CERT_STR, + 'trust_only_capella': False}), + (SecurityOptions.trust_only_certificates(TEST_CERT_LIST), + {'trust_only_certificates': TEST_CERT_LIST, + 'trust_only_capella': False}), + ]) + def test_security_options_classmethods(self, opts: SecurityOptions, expected_opts: Dict[str, object]) -> None: + cred = Credential.from_username_and_password('Administrator', 'password') + client = _ClientAdapter('https://localhost', + cred, + ClusterOptions(security_options=opts)) + assert expected_opts == client.connection_details.cluster_options.get('security_options') + + @pytest.mark.parametrize('opts, expected_opts', + [({}, None), + ({'trust_only_capella': True}, + {'trust_only_capella': True}), + ({'trust_only_pem_file': TEST_CERT_PATH}, + {'trust_only_pem_file': TEST_CERT_PATH, + 'trust_only_capella': False}), + ({'trust_only_pem_str': TEST_CERT_STR}, + {'trust_only_pem_str': TEST_CERT_STR, + 'trust_only_capella': False}), + ({'trust_only_certificates': TEST_CERT_LIST}, + {'trust_only_certificates': TEST_CERT_LIST, + 'trust_only_capella': False}), + ({'disable_server_certificate_verification': True}, + {'disable_server_certificate_verification': True}), + ]) + def test_security_options_kwargs(self, opts: Dict[str, object], expected_opts: Dict[str, object]) -> None: + cred = Credential.from_username_and_password('Administrator', 'password') + client = _ClientAdapter('https://localhost', cred, **opts) + assert expected_opts == client.connection_details.cluster_options.get('security_options') + + @pytest.mark.parametrize('opts', + [{'trust_only_capella': True, + 'trust_only_pem_file': TEST_CERT_PATH}, + {'trust_only_capella': True, + 'trust_only_pem_str': TEST_CERT_STR}, + {'trust_only_capella': True, + 'trust_only_certificates': TEST_CERT_LIST}, + ]) + def test_security_options_invalid(self, opts: SecurityOptionsKwargs) -> None: + cred = Credential.from_username_and_password('Administrator', 'password') + with pytest.raises(ValueError): + _ClientAdapter('https://localhost', + cred, + ClusterOptions(security_options=SecurityOptions(**opts))) + + @pytest.mark.parametrize('opts', + [{'trust_only_capella': True, + 'trust_only_pem_file': TEST_CERT_PATH}, + {'trust_only_capella': True, + 'trust_only_pem_str': TEST_CERT_STR}, + {'trust_only_capella': True, + 'trust_only_certificates': TEST_CERT_LIST}, + ]) + def test_security_options_invalid_kwargs(self, opts: Dict[str, object]) -> None: + cred = Credential.from_username_and_password('Administrator', 'password') + with pytest.raises(ValueError): + _ClientAdapter('https://localhost', cred, **opts) + + @pytest.mark.parametrize('opts, expected_opts', + [({}, None), + ({'connect_timeout': timedelta(seconds=30)}, + {'connect_timeout': 30}), + ({'query_timeout': timedelta(seconds=30)}, + {'query_timeout': 30}), + ({'connect_timeout': timedelta(seconds=60), + 'query_timeout': timedelta(seconds=30)}, + {'connect_timeout': 60, + 'query_timeout': 30}), + ]) + def test_timeout_options(self, opts: TimeoutOptionsKwargs, expected_opts: TimeoutOptionsKwargs) -> None: + cred = Credential.from_username_and_password('Administrator', 'password') + client = _ClientAdapter('https://localhost', + cred, + ClusterOptions(timeout_options=TimeoutOptions(**opts))) + assert expected_opts == client.connection_details.cluster_options.get('timeout_options') + + @pytest.mark.parametrize('opts, expected_opts', + [({'connect_timeout': timedelta(seconds=30)}, + {'connect_timeout': 30}), + ({'query_timeout': timedelta(seconds=30)}, + {'query_timeout': 30}), + ({'connect_timeout': timedelta(seconds=60), + 'query_timeout': timedelta(seconds=30)}, + {'connect_timeout': 60, + 'query_timeout': 30}), + ]) + def test_timeout_options_kwargs(self, opts: Dict[str, object], expected_opts: Dict[str, object]) -> None: + cred = Credential.from_username_and_password('Administrator', 'password') + client = _ClientAdapter('https://localhost', cred, **opts) + assert expected_opts == client.connection_details.cluster_options.get('timeout_options') + + @pytest.mark.parametrize('opts', + [{'connect_timeout': timedelta(seconds=-1)}, + {'query_timeout': timedelta(seconds=-1)}]) + def test_timeout_options_must_be_positive(self, opts: TimeoutOptionsKwargs) -> None: + cred = Credential.from_username_and_password('Administrator', 'password') + with pytest.raises(ValueError): + _ClientAdapter('https://localhost', + cred, + ClusterOptions(timeout_options=TimeoutOptions(**opts))) + + @pytest.mark.parametrize('opts', + [{'connect_timeout': timedelta(seconds=-1)}, + {'query_timeout': timedelta(seconds=-1)}]) + def test_timeout_options_must_be_positive_kwargs(self, opts: Dict[str, object]) -> None: + cred = Credential.from_username_and_password('Administrator', 'password') + with pytest.raises(ValueError): + _ClientAdapter('https://localhost', cred, **opts) + + +class ClusterOptionsTests(ClusterOptionsTestSuite): + + @pytest.fixture(scope='class', autouse=True) + def validate_test_manifest(self) -> None: + def valid_test_method(meth: str) -> bool: + attr = getattr(ClusterOptionsTests, meth) + return callable(attr) and not meth.startswith('__') and meth.startswith('test') + method_list = [meth for meth in dir(ClusterOptionsTests) if valid_test_method(meth)] + test_list = set(ClusterOptionsTestSuite.TEST_MANIFEST).symmetric_difference(method_list) + if test_list: + pytest.fail(f'Test manifest invalid. Missing/extra tests: {test_list}.') \ No newline at end of file diff --git a/couchbase_analytics/tests/query_integration_t.py b/couchbase_analytics/tests/query_integration_t.py new file mode 100644 index 0000000..63f49f1 --- /dev/null +++ b/couchbase_analytics/tests/query_integration_t.py @@ -0,0 +1,275 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import json +from concurrent.futures import Future +from datetime import timedelta +from typing import TYPE_CHECKING + +import pytest + +from couchbase_analytics.common.streaming import StreamingState +from couchbase_analytics.deserializer import PassthroughDeserializer +from couchbase_analytics.errors import QueryError +from couchbase_analytics.options import QueryOptions +from couchbase_analytics.query import QueryScanConsistency +from couchbase_analytics.result import BlockingQueryResult +from tests import YieldFixture + +if TYPE_CHECKING: + from tests.environments.base_environment import BlockingTestEnvironment + + +class QueryTestSuite: + TEST_MANIFEST = [ + # 'test_cancel_positional_params_override', + # 'test_cancel_positional_params_override_token_in_kwargs', + # 'test_cancel_prior_iterating', + # 'test_cancel_prior_iterating_positional_params', + # 'test_cancel_prior_iterating_with_kwargs', + # 'test_cancel_prior_iterating_with_options', + # 'test_cancel_prior_iterating_with_opts_and_kwargs', + # 'test_cancel_while_iterating', + 'test_query_metadata', + 'test_query_metadata_not_available', + 'test_query_named_parameters', + 'test_query_named_parameters_no_options', + 'test_query_named_parameters_override', + 'test_query_positional_params', + 'test_query_positional_params_no_option', + 'test_query_positional_params_override', + 'test_query_raises_exception_prior_to_iterating', + 'test_query_raw_options', + 'test_simple_query', + 'test_query_with_lazy_execution', + 'test_query_with_lazy_execution_raises_exception', + 'test_query_passthrough_deserializer', + ] + + @pytest.fixture(scope='class') + def query_statement_limit2(self, test_env: BlockingTestEnvironment) -> str: + if test_env.use_scope: + return f'SELECT * FROM {test_env.collection_name} LIMIT 2;' + else: + return f'SELECT * FROM {test_env.fqdn} LIMIT 2;' + + @pytest.fixture(scope='class') + def query_statement_pos_params_limit2(self, test_env: BlockingTestEnvironment) -> str: + if test_env.use_scope: + return f'SELECT * FROM {test_env.collection_name} WHERE country = $1 LIMIT 2;' + else: + return f'SELECT * FROM {test_env.fqdn} WHERE country = $1 LIMIT 2;' + + @pytest.fixture(scope='class') + def query_statement_named_params_limit2(self, test_env: BlockingTestEnvironment) -> str: + if test_env.use_scope: + return f'SELECT * FROM {test_env.collection_name} WHERE country = $country LIMIT 2;' + else: + return f'SELECT * FROM {test_env.fqdn} WHERE country = $country LIMIT 2;' + + @pytest.fixture(scope='class') + def query_statement_limit5(self, test_env: BlockingTestEnvironment) -> str: + if test_env.use_scope: + return f'SELECT * FROM {test_env.collection_name} LIMIT 5;' + else: + return f'SELECT * FROM {test_env.fqdn} LIMIT 5;' + + def test_query_metadata(self, + test_env: BlockingTestEnvironment, + query_statement_limit5: str) -> None: + result = test_env.cluster_or_scope.execute_query(query_statement_limit5) + expected_count = 5 + test_env.assert_rows(result, expected_count) + + metadata = result.metadata() + + assert len(metadata.warnings()) == 0 + assert len(metadata.request_id()) > 0 + + metrics = metadata.metrics() + + assert metrics.result_size() > 0 + assert metrics.result_count() == expected_count + assert metrics.processed_objects() > 0 + assert metrics.elapsed_time() > timedelta(0) + assert metrics.execution_time() > timedelta(0) + + def test_query_metadata_not_available(self, + test_env: BlockingTestEnvironment, + query_statement_limit5: str) -> None: + result = test_env.cluster_or_scope.execute_query(query_statement_limit5) + + with pytest.raises(RuntimeError): + result.metadata() + + # Read one row + next(iter(result.rows())) + + with pytest.raises(RuntimeError): + result.metadata() + + # Iterate the rest of the rows + rows = list(result.rows()) + assert len(rows) == 4 + + metadata = result.metadata() + assert len(metadata.warnings()) == 0 + assert len(metadata.request_id()) > 0 + + def test_query_named_parameters(self, + test_env: BlockingTestEnvironment, + query_statement_named_params_limit2: str) -> None: + result = test_env.cluster_or_scope.execute_query(query_statement_named_params_limit2, + QueryOptions(named_parameters={'country': 'United States'})) + test_env.assert_rows(result, 2) + + def test_query_named_parameters_no_options(self, + test_env: BlockingTestEnvironment, + query_statement_named_params_limit2: str) -> None: + result = test_env.cluster_or_scope.execute_query(query_statement_named_params_limit2, country='United States') + test_env.assert_rows(result, 2) + + def test_query_named_parameters_override(self, + test_env: BlockingTestEnvironment, + query_statement_named_params_limit2: str) -> None: + result = test_env.cluster_or_scope.execute_query(query_statement_named_params_limit2, + QueryOptions(named_parameters={'country': 'abcdefg'}), + country='United States') + test_env.assert_rows(result, 2) + + def test_query_positional_params(self, + test_env: BlockingTestEnvironment, + query_statement_pos_params_limit2: str) -> None: + result = test_env.cluster_or_scope.execute_query(query_statement_pos_params_limit2, + QueryOptions(positional_parameters=['United States'])) + test_env.assert_rows(result, 2) + + def test_query_positional_params_no_option(self, + test_env: BlockingTestEnvironment, + query_statement_pos_params_limit2: str) -> None: + result = test_env.cluster_or_scope.execute_query(query_statement_pos_params_limit2, 'United States') + test_env.assert_rows(result, 2) + + def test_query_positional_params_override(self, + test_env: BlockingTestEnvironment, + query_statement_pos_params_limit2: str) -> None: + result = test_env.cluster_or_scope.execute_query(query_statement_pos_params_limit2, + QueryOptions(positional_parameters=['abcdefg']), + 'United States') + test_env.assert_rows(result, 2) + + def test_query_raises_exception_prior_to_iterating(self, test_env: BlockingTestEnvironment) -> None: + statement = "I'm not N1QL!" + with pytest.raises(QueryError): + test_env.cluster_or_scope.execute_query(statement) + + def test_query_raw_options(self, + test_env: BlockingTestEnvironment, + query_statement_pos_params_limit2: str) -> None: + # via raw, we should be able to pass any option + # if using named params, need to match full name param in query + # which is different for when we pass in name_parameters via their specific + # query option (i.e. include the $ when using raw) + if test_env.use_scope: + statement = f'SELECT * FROM {test_env.collection_name} WHERE country = $country LIMIT $1;' + else: + statement = f'SELECT * FROM {test_env.fqdn} WHERE country = $country LIMIT $1;' + result = test_env.cluster_or_scope.execute_query(statement, + QueryOptions(raw={'$country': 'United States', + 'args': [2]})) + test_env.assert_rows(result, 2) + + result = test_env.cluster_or_scope.execute_query(query_statement_pos_params_limit2, + QueryOptions(raw={'args': ['United States']})) + test_env.assert_rows(result, 2) + + def test_simple_query(self, + test_env: BlockingTestEnvironment, + query_statement_limit2: str) -> None: + result = test_env.cluster_or_scope.execute_query(query_statement_limit2) + test_env.assert_rows(result, 2) + + def test_query_with_lazy_execution(self, + test_env: BlockingTestEnvironment, + query_statement_limit2: str) -> None: + result = test_env.cluster_or_scope.execute_query(query_statement_limit2, + QueryOptions(lazy_execute=True)) + expected_state = StreamingState.NotStarted + assert result._http_response._request_context.request_state == expected_state + expected_state = StreamingState.StreamingResults + count = 0 + for row in result.rows(): + assert result._http_response._request_context.request_state == expected_state + assert row is not None + count += 1 + assert count == 2 + + def test_query_with_lazy_execution_raises_exception(self, test_env: BlockingTestEnvironment) -> None: + statement = "I'm not N1QL!" + result = test_env.cluster_or_scope.execute_query(statement, QueryOptions(lazy_execute=True)) + expected_state = StreamingState.NotStarted + assert result._http_response._request_context.request_state == expected_state + with pytest.raises(QueryError): + [r for r in result.rows()] + + def test_query_passthrough_deserializer(self, test_env: BlockingTestEnvironment) -> None: + statement = 'FROM range(0, 10) AS num SELECT *' + result = test_env.cluster_or_scope.execute_query(statement, + QueryOptions(deserializer=PassthroughDeserializer())) + for idx, row in enumerate(result.rows()): + assert isinstance(row, bytes) + assert json.loads(row) == {'num': idx} + +class ClusterQueryTests(QueryTestSuite): + + @pytest.fixture(scope='class', autouse=True) + def validate_test_manifest(self) -> None: + def valid_test_method(meth: str) -> bool: + attr = getattr(ClusterQueryTests, meth) + return callable(attr) and not meth.startswith('__') and meth.startswith('test') + method_list = [meth for meth in dir(ClusterQueryTests) if valid_test_method(meth)] + test_list = set(QueryTestSuite.TEST_MANIFEST).symmetric_difference(method_list) + if test_list: + pytest.fail(f'Test manifest invalid. Missing/extra tests: {test_list}.') + + @pytest.fixture(scope='class', name='test_env') + def couchbase_test_environment(self, + sync_test_env: BlockingTestEnvironment) -> YieldFixture[BlockingTestEnvironment]: + sync_test_env.setup() + yield sync_test_env + sync_test_env.teardown() + +class ScopeQueryTests(QueryTestSuite): + + @pytest.fixture(scope='class', autouse=True) + def validate_test_manifest(self) -> None: + def valid_test_method(meth: str) -> bool: + attr = getattr(ScopeQueryTests, meth) + return callable(attr) and not meth.startswith('__') and meth.startswith('test') + method_list = [meth for meth in dir(ScopeQueryTests) if valid_test_method(meth)] + test_list = set(QueryTestSuite.TEST_MANIFEST).symmetric_difference(method_list) + if test_list: + pytest.fail(f'Test manifest invalid. Missing/extra tests: {test_list}.') + + @pytest.fixture(scope='class', name='test_env') + def couchbase_test_environment(self, + sync_test_env: BlockingTestEnvironment) -> YieldFixture[BlockingTestEnvironment]: + sync_test_env.setup() + test_env = sync_test_env.enable_scope() + yield test_env + test_env.disable_scope() + test_env.teardown() \ No newline at end of file diff --git a/couchbase_analytics/tests/query_options_t.py b/couchbase_analytics/tests/query_options_t.py new file mode 100644 index 0000000..cf436e2 --- /dev/null +++ b/couchbase_analytics/tests/query_options_t.py @@ -0,0 +1,301 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import timedelta +from typing import (Any, + Dict, + List, + Optional, + Union) + +import pytest + +from couchbase_analytics import JSONType +from couchbase_analytics.credential import Credential +from couchbase_analytics.options import QueryOptions, QueryOptionsKwargs +from couchbase_analytics.protocol.core.client_adapter import _ClientAdapter +from couchbase_analytics.protocol.options import QueryOptionsTransformedKwargs +from couchbase_analytics.protocol.core.request import _RequestBuilder + + +@dataclass +class QueryContext: + database_name: Optional[str] = None + scope_name: Optional[str] = None + + def validate_query_context(self, body: Dict[str, Union[str, object]]) -> None: + if self.database_name is None or self.scope_name is None: + with pytest.raises(KeyError): + body['query_context'] + else: + assert body['query_context'] == f'default:`{self.database_name}`.`{self.scope_name}`' + + +class QueryOptionsTestSuite: + TEST_MANIFEST = [ + 'test_options_deserializer', + 'test_options_deserializer_kwargs', + 'test_options_named_parameters', + 'test_options_named_parameters_kwargs', + 'test_options_positional_parameters', + 'test_options_positional_parameters_kwargs', + 'test_options_raw', + 'test_options_raw_kwargs', + 'test_options_readonly', + 'test_options_readonly_kwargs', + 'test_options_scan_consistency', + 'test_options_scan_consistency_kwargs', + 'test_options_timeout', + 'test_options_timeout_kwargs', + 'test_options_timeout_must_be_positive', + 'test_options_timeout_must_be_positive_kwargs' + ] + + @pytest.fixture(scope='class') + def query_statment(self) -> str: + return 'SELECT * FROM default' + + def test_options_deserializer(self, + query_statment: str, + request_builder: _RequestBuilder, + query_ctx: QueryContext) -> None: + from couchbase_analytics.deserializer import DefaultJsonDeserializer + deserializer = DefaultJsonDeserializer() + q_opts = QueryOptions(deserializer=deserializer) + req = request_builder.build_base_query_request(query_statment, q_opts) + exp_opts: QueryOptionsTransformedKwargs = {} + assert req.options == exp_opts + assert req.deserializer == deserializer + query_ctx.validate_query_context(req.body) + + def test_options_deserializer_kwargs(self, + query_statment: str, + request_builder: _RequestBuilder, + query_ctx: QueryContext) -> None: + from couchbase_analytics.deserializer import DefaultJsonDeserializer + deserializer = DefaultJsonDeserializer() + kwargs: QueryOptionsKwargs = {'deserializer': deserializer} + req = request_builder.build_base_query_request(query_statment, **kwargs) + exp_opts: QueryOptionsTransformedKwargs = {} + assert req.options == exp_opts + assert req.deserializer == deserializer + query_ctx.validate_query_context(req.body) + + def test_options_named_parameters(self, + query_statment: str, + request_builder: _RequestBuilder, + query_ctx: QueryContext) -> None: + params: Dict[str, JSONType] = {'foo': 'bar', 'baz': 1, 'quz': False} + q_opts = QueryOptions(named_parameters=params) + req = request_builder.build_base_query_request(query_statment, q_opts) + exp_opts: QueryOptionsTransformedKwargs = {'named_parameters': params} + assert req.options == exp_opts + query_ctx.validate_query_context(req.body) + + def test_options_named_parameters_kwargs(self, + query_statment: str, + request_builder: _RequestBuilder, + query_ctx: QueryContext) -> None: + params: Dict[str, JSONType] = {'foo': 'bar', 'baz': 1, 'quz': False} + kwargs: QueryOptionsKwargs = {'named_parameters': params} + req = request_builder.build_base_query_request(query_statment, **kwargs) + exp_opts: QueryOptionsTransformedKwargs = {'named_parameters': params} + assert req.options == exp_opts + query_ctx.validate_query_context(req.body) + + def test_options_positional_parameters(self, + query_statment: str, + request_builder: _RequestBuilder, + query_ctx: QueryContext) -> None: + params: List[JSONType] = ['foo', 'bar', 1, False] + q_opts = QueryOptions(positional_parameters=params) + req = request_builder.build_base_query_request(query_statment, q_opts) + exp_opts: QueryOptionsTransformedKwargs = {'positional_parameters': params} + assert req.options == exp_opts + query_ctx.validate_query_context(req.body) + + def test_options_positional_parameters_kwargs(self, + query_statment: str, + request_builder: _RequestBuilder, + query_ctx: QueryContext) -> None: + params: List[JSONType] = ['foo', 'bar', 1, False] + kwargs: QueryOptionsKwargs = {'positional_parameters': params} + req = request_builder.build_base_query_request(query_statment, **kwargs) + exp_opts: QueryOptionsTransformedKwargs = {'positional_parameters': params} + assert req.options == exp_opts + query_ctx.validate_query_context(req.body) + + def test_options_raw(self, + query_statment: str, + request_builder: _RequestBuilder, + query_ctx: QueryContext) -> None: + pos_params: List[JSONType] = ['foo', 'bar', 1, False] + params: Dict[str, Any] = {'readonly': True, 'positional_params': pos_params} + q_opts = QueryOptions(raw=params) + req = request_builder.build_base_query_request(query_statment, q_opts) + exp_opts: QueryOptionsTransformedKwargs = {'raw': params} + assert req.options == exp_opts + query_ctx.validate_query_context(req.body) + + def test_options_raw_kwargs(self, + query_statment: str, + request_builder: _RequestBuilder, + query_ctx: QueryContext) -> None: + pos_params: List[JSONType] = ['foo', 'bar', 1, False] + params: Dict[str, Any] = {'readonly': True, 'positional_params': pos_params} + kwargs: QueryOptionsKwargs = {'raw': params} + req = request_builder.build_base_query_request(query_statment, **kwargs) + exp_opts: QueryOptionsTransformedKwargs = {'raw': params} + assert req.options == exp_opts + query_ctx.validate_query_context(req.body) + + def test_options_readonly(self, + query_statment: str, + request_builder: _RequestBuilder, + query_ctx: QueryContext) -> None: + q_opts = QueryOptions(readonly=True) + req = request_builder.build_base_query_request(query_statment, q_opts) + exp_opts: QueryOptionsTransformedKwargs = {'readonly': True} + assert req.options == exp_opts + query_ctx.validate_query_context(req.body) + + def test_options_readonly_kwargs(self, + query_statment: str, + request_builder: _RequestBuilder, + query_ctx: QueryContext) -> None: + kwargs: QueryOptionsKwargs = {'readonly': True} + req = request_builder.build_base_query_request(query_statment, **kwargs) + exp_opts: QueryOptionsTransformedKwargs = {'readonly': True} + assert req.options == exp_opts + query_ctx.validate_query_context(req.body) + + def test_options_scan_consistency(self, + query_statment: str, + request_builder: _RequestBuilder, + query_ctx: QueryContext) -> None: + from couchbase_analytics.query import QueryScanConsistency + q_opts = QueryOptions(scan_consistency=QueryScanConsistency.REQUEST_PLUS) + req = request_builder.build_base_query_request(query_statment, q_opts) + exp_opts: QueryOptionsTransformedKwargs = { + 'scan_consistency': QueryScanConsistency.REQUEST_PLUS.value + } + assert req.options == exp_opts + query_ctx.validate_query_context(req.body) + + def test_options_scan_consistency_kwargs(self, + query_statment: str, + request_builder: _RequestBuilder, + query_ctx: QueryContext) -> None: + from couchbase_analytics.query import QueryScanConsistency + kwargs: QueryOptionsKwargs = {'scan_consistency': QueryScanConsistency.REQUEST_PLUS} + req = request_builder.build_base_query_request(query_statment, **kwargs) + exp_opts: QueryOptionsTransformedKwargs = { + 'scan_consistency': QueryScanConsistency.REQUEST_PLUS.value + } + assert req.options == exp_opts + query_ctx.validate_query_context(req.body) + + def test_options_timeout(self, + query_statment: str, + request_builder: _RequestBuilder, + query_ctx: QueryContext) -> None: + q_opts = QueryOptions(timeout=timedelta(seconds=20)) + req = request_builder.build_base_query_request(query_statment, q_opts) + exp_opts: QueryOptionsTransformedKwargs = { + 'timeout': 20.0 + } + assert req.options == exp_opts + # NOTE: we add time to the server timeout to ensure a client side timeout + assert req.body['timeout'] == '25000.0ms' + query_ctx.validate_query_context(req.body) + + def test_options_timeout_kwargs(self, + query_statment: str, + request_builder: _RequestBuilder, + query_ctx: QueryContext) -> None: + kwargs: QueryOptionsKwargs = {'timeout': timedelta(seconds=20)} + req = request_builder.build_base_query_request(query_statment, **kwargs) + exp_opts: QueryOptionsTransformedKwargs = { + 'timeout': 20.0 + } + assert req.options == exp_opts + # NOTE: we add time to the server timeout to ensure a client side timeout + assert req.body['timeout'] == '25000.0ms' + query_ctx.validate_query_context(req.body) + + def test_options_timeout_must_be_positive(self, + query_statment: str, + request_builder: _RequestBuilder + ) -> None: + q_opts = QueryOptions(timeout=timedelta(seconds=-1)) + with pytest.raises(ValueError): + request_builder.build_base_query_request(query_statment, q_opts) + + def test_options_timeout_must_be_positive_kwargs(self, + query_statment: str, + request_builder: _RequestBuilder + ) -> None: + kwargs: QueryOptionsKwargs = {'timeout': timedelta(seconds=-1)} + with pytest.raises(ValueError): + request_builder.build_base_query_request(query_statment, **kwargs) + + +class ClusterQueryOptionsTests(QueryOptionsTestSuite): + + @pytest.fixture(scope='class', autouse=True) + def validate_test_manifest(self) -> None: + def valid_test_method(meth: str) -> bool: + attr = getattr(ClusterQueryOptionsTests, meth) + return callable(attr) and not meth.startswith('__') and meth.startswith('test') + method_list = [meth for meth in dir(ClusterQueryOptionsTests) if valid_test_method(meth)] + test_list = set(QueryOptionsTestSuite.TEST_MANIFEST).symmetric_difference(method_list) + if test_list: + pytest.fail(f'Test manifest invalid. Missing/extra tests: {test_list}.') + + @pytest.fixture(scope='class', name='query_ctx') + def query_context(self) -> QueryContext: + return QueryContext() + + @pytest.fixture(scope='class') + def request_builder(self) -> _RequestBuilder: + cred = Credential.from_username_and_password('Administrator', 'password') + return _RequestBuilder(_ClientAdapter('https://localhost', cred)) + + +class ScopeQueryOptionsTests(QueryOptionsTestSuite): + + @pytest.fixture(scope='class', autouse=True) + def validate_test_manifest(self) -> None: + def valid_test_method(meth: str) -> bool: + attr = getattr(ScopeQueryOptionsTests, meth) + return callable(attr) and not meth.startswith('__') and meth.startswith('test') + method_list = [meth for meth in dir(ScopeQueryOptionsTests) if valid_test_method(meth)] + test_list = set(QueryOptionsTestSuite.TEST_MANIFEST).symmetric_difference(method_list) + if test_list: + pytest.fail(f'Test manifest invalid. Missing/extra tests: {test_list}.') + + @pytest.fixture(scope='class', name='query_ctx') + def query_context(self) -> QueryContext: + return QueryContext('test-database', 'test-scope') + + @pytest.fixture(scope='class') + def request_builder(self) -> _RequestBuilder: + cred = Credential.from_username_and_password('Administrator', 'password') + return _RequestBuilder(_ClientAdapter('https://localhost', cred), + 'test-database', + 'test-scope') \ No newline at end of file diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 0000000..670a12b --- /dev/null +++ b/mypy.ini @@ -0,0 +1,14 @@ +[mypy] +exclude = (?x)( + setup\.py$ + | docs/ + ) + +[mypy-ijson.*] +ignore_missing_imports = True + +[mypy-pytest.*] +ignore_missing_imports = True + +[mypy-setuptools.*] +ignore_missing_imports = True diff --git a/pyproject.toml b/pyproject.toml index c9fddbe..8f8867b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,68 @@ [build-system] requires = [ - "setuptools>=42", + "setuptools>=65", "wheel", ] build-backend = "setuptools.build_meta" +[project] +name = "couchbase-analytics" +dependencies = [ + "anyio~=4.9.0", + "httpx~=0.28.1", + "ijson~=3.3.0", + "sniffio~=1.3.1", + "typing-extensions~=4.11; python_version<'3.11'" +] +requires-python = ">=3.9" +authors = [ + {name = "Couchbase, Inc.", email = "PythonPackage@couchbase.com"} +] +maintainers = [ + {name = "Couchbase, Inc.", email = "PythonPackage@couchbase.com"} +] +description = "Python Client for Couchbase Analytics" +readme = "README.md" +license = {file = "LICENSE"} +keywords = ["couchbase", "nosql", "pycouchbase", "couchbase++", "analytics"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: Implementation :: CPython", + "Topic :: Database", + "Topic :: Software Development :: Libraries", + "Topic :: Software Development :: Libraries :: Python Modules" +] + +[project.urls] +Homepage = "https://couchbase.com" +Documentation = "https://docs.couchbase.com/python-analytics-sdk/current/hello-world/overview.html" +"API Reference" = "https://docs.couchbase.com/sdk-api/analytics-python-client/" +Repository = "https://github.com/couchbase/analytics-python-client" +"Bug Tracker" = "https://issues.couchbase.com/projects/PYCO/issues/" +"Release Notes" = "https://docs.couchbase.com/python-analytics-sdk/current/project-docs/analytics-sdk-release-notes.html" + +[dependency-groups] +dev = [ + "aiohttp~=3.11.10", + "pytest~=8.3.5" +] +sphinx = [ + "Sphinx~=7.4.7", + "sphinx-rtd-theme~=2.0", + "sphinx-copybutton~=0.5", + "enum-tools~=0.12", + "sphinx-toolbox~=3.7" +] + [tool.pytest.ini_options] minversion = "8.0" log_cli = true diff --git a/requirements.txt b/requirements.txt index 165a783..68d5447 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ anyio~=4.9.0 -sniffio~=1.3.1 +sniffio~=1.3.1 httpx~=0.28.1 ijson~=3.3.0 # Typing support diff --git a/run-mypy b/run-mypy new file mode 100755 index 0000000..48288a9 --- /dev/null +++ b/run-mypy @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +set -o errexit + +cd "$(dirname "$0")" + +mypy . --strict diff --git a/setup.py b/setup.py index 124e846..d849054 100644 --- a/setup.py +++ b/setup.py @@ -30,7 +30,8 @@ PYCBAC_VERSION = couchbase_analytics_version.get_version() -package_data = {'couchbase_analytics.common.core.nonprod_certificates': ['*.pem']} +package_data = {'couchbase_analytics.common.core._nonprod_certificates': ['*.pem'], + 'couchbase_analytics.common.core._capella_certificates': ['*.pem']} print(f'Python Analytics SDK version: {PYCBAC_VERSION}') @@ -38,29 +39,14 @@ version=PYCBAC_VERSION, python_requires='>=3.9', install_requires=[ + 'httpx~=0.28.1', + 'ijson~=3.3.0', 'typing-extensions~=4.11; python_version<"3.11"' ], packages=find_packages( include=['acouchbase_analytics', 'couchbase_analytics', 'acouchbase_analytics.*', 'couchbase_analytics.*'], exclude=['acouchbase_analytics.tests', 'couchbase_analytics.tests']), package_data=package_data, - url="https://github.com/couchbase/analytics-python-client", - author="Couchbase, Inc.", - author_email="PythonPackage@couchbase.com", - license="Apache License 2.0", - description="Python Client for Couchbase Analytics", long_description=open(PYCBAC_README, "r").read(), long_description_content_type='text/markdown', - keywords=["couchbase", "nosql", "pycouchbase", "couchbase++", "analytics"], - classifiers=[ - "Development Status :: 5 - Production/Stable", - "License :: OSI Approved :: Apache Software License", - "Intended Audience :: Developers", - "Operating System :: OS Independent", - "Programming Language :: Python", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: Implementation :: CPython", - "Topic :: Database", - "Topic :: Software Development :: Libraries", - "Topic :: Software Development :: Libraries :: Python Modules"], ) diff --git a/sphinx_requirements.txt b/sphinx_requirements.txt index e69de29..f394b7b 100644 --- a/sphinx_requirements.txt +++ b/sphinx_requirements.txt @@ -0,0 +1,5 @@ +Sphinx~=7.4.7 +sphinx-rtd-theme~=2.0 +sphinx-copybutton~=0.5 +enum-tools~=0.12 +sphinx-toolbox~=3.7 \ No newline at end of file diff --git a/tests/analytics_config.py b/tests/analytics_config.py index 63d46ed..3e521ec 100644 --- a/tests/analytics_config.py +++ b/tests/analytics_config.py @@ -42,7 +42,7 @@ def __init__(self) -> None: self._scope_name = '' self._collection_name = '' self._disable_server_certificate_verification = False - self._create_keyspace = True + self._create_keyspace = False @property def database_name(self) -> str: @@ -73,6 +73,8 @@ def scope_name(self) -> str: return self._scope_name def get_connection_string(self) -> str: + if self._port is not None: + return f'{self._scheme}://{self._host}:{self._port}' return f'{self._scheme}://{self._host}' def get_username_and_pw(self) -> Tuple[str, str]: @@ -86,7 +88,7 @@ def load_config(cls) -> AnalyticsConfig: test_config.read(CONFIG_FILE) test_config_analytics = test_config['analytics'] analytics_config._scheme = os.environ.get('PYCBAC_SCHEME', - test_config_analytics.get('scheme', fallback='httpss')) + test_config_analytics.get('scheme', fallback='https')) analytics_config._host = os.environ.get('PYCBAC_HOST', test_config_analytics.get('host', fallback='localhost')) port = os.environ.get('PYCBAC_PORT', test_config_analytics.get('port', fallback='8095')) diff --git a/tests/environments/base_environment.py b/tests/environments/base_environment.py index 2cf0439..1058166 100644 --- a/tests/environments/base_environment.py +++ b/tests/environments/base_environment.py @@ -15,23 +15,35 @@ from __future__ import annotations +import json +import pathlib import sys + +from os import path from typing import (TYPE_CHECKING, + Any, Dict, + List, Optional, - TypedDict) + TypedDict, + Union) if sys.version_info < (3, 11): from typing_extensions import Unpack else: - from typing import Unpack + from typing import Unpack import pytest from acouchbase_analytics.cluster import AsyncCluster +from acouchbase_analytics.result import AsyncQueryResult +from acouchbase_analytics.scope import AsyncScope from couchbase_analytics.cluster import Cluster from couchbase_analytics.credential import Credential from couchbase_analytics.options import ClusterOptions, SecurityOptions +from couchbase_analytics.result import BlockingQueryResult +from couchbase_analytics.scope import Scope + from tests import AnalyticsTestEnvironmentError from tests.utils._run_web_server import WebServerHandler @@ -40,6 +52,9 @@ from tests.analytics_config import AnalyticsConfig +TEST_AIRLINE_DATA_PATH = path.join(pathlib.Path(__file__).parent.parent, + 'test_data', + 'airline.json') class TestEnvironmentOptionsKwargs(TypedDict, total=False): async_cluster: Optional[AsyncCluster] @@ -59,15 +74,35 @@ def __init__(self, config: AnalyticsConfig, **kwargs: Unpack[TestEnvironmentOpti self._database_name = kwargs.pop('database_name', None) self._scope_name = kwargs.pop('scope_name', None) self._collection_name = kwargs.pop('collection_name', None) - # self._async_scope: Optional[AsyncScope] = None - # self._scope: Optional[Scope] = None - # self._use_scope = False + self._async_scope: Optional[AsyncScope] = None + self._scope: Optional[Scope] = None + self._use_scope = False self._server_handler = kwargs.pop('server_handler', None) @property def config(self) -> AnalyticsConfig: return self._config + @property + def fqdn(self) -> str: + return self.config.fqdn + + @property + def collection_name(self) -> Optional[str]: + return self._collection_name + + @property + def use_scope(self) -> bool: + return self._use_scope + + def load_collection_data_from_file(self, file_path: str, limit: Optional[int] = 100) -> List[Dict[str, Any]]: + with open(file_path, mode='+r') as json_file: + json_data: List[Dict[str, Any]] = json.load(json_file) + + if limit is not None and len(json_data) > limit: + return json_data[:limit] + return json_data + class BlockingTestEnvironment(TestEnvironment): def __init__(self, config: AnalyticsConfig, **kwargs: Unpack[TestEnvironmentOptionsKwargs]) -> None: super().__init__(config, **kwargs) @@ -77,14 +112,63 @@ def cluster(self) -> Cluster: if self._cluster is None: raise AnalyticsTestEnvironmentError('No cluster available.') return self._cluster + + @property + def scope(self) -> Scope: + if self._scope is None: + raise AnalyticsTestEnvironmentError('No scope available.') + return self._scope + + @property + def cluster_or_scope(self) -> Union[Cluster, Scope]: + if self._scope is not None: + return self.scope + return self.cluster + + def assert_rows(self, result: BlockingQueryResult, expected_count: int) -> None: + count = 0 + assert isinstance(result, (BlockingQueryResult,)) + for row in result.rows(): + assert row is not None + count += 1 + assert count >= expected_count + + def disable_scope(self) -> BlockingTestEnvironment: + self._scope = None + self._use_scope = False + return self + + def disable_test_server(self) -> BlockingTestEnvironment: + if self._server_handler is not None: + self._server_handler.stop_server() + self._server_handler = None + return self + + def enable_scope(self, + database_name: Optional[str] = None, + scope_name: Optional[str] = None) -> BlockingTestEnvironment: + + if self._cluster is None: + raise AnalyticsTestEnvironmentError('No cluster available.') + db_name = database_name if database_name is not None else self._database_name + if db_name is None: + raise AnalyticsTestEnvironmentError('Cannot create scope without a database name.') + scope_name = scope_name if scope_name is not None else self._scope_name + if scope_name is None: + raise AnalyticsTestEnvironmentError('Cannot create scope without a scope name.') + self._scope = self._cluster.database(db_name).scope(scope_name) + self._use_scope = True + return self def enable_test_server(self) -> BlockingTestEnvironment: if self._server_handler is None: raise AnalyticsTestEnvironmentError('No server handler provided, cannot enable test server.') - from tests.utils._client_adapter import _ClientAdapter - from tests.utils._test_httpx import HTTPTransport - new_adapter = _ClientAdapter(adapter=self._cluster._impl._client_adapter, - http_transport_cls=HTTPTransport) + if self._cluster is None or not hasattr(self._cluster, '_impl'): + raise AnalyticsTestEnvironmentError('No cluster available, cannot enable test server.') + from tests.utils._client_adapter import _TestClientAdapter + from tests.utils._test_httpx import TestHTTPTransport + new_adapter = _TestClientAdapter(adapter=self._cluster._impl._client_adapter, # type: ignore[call-arg] + http_transport_cls=TestHTTPTransport) new_adapter.create_client() self._cluster._impl._client_adapter = new_adapter scheme, host, port = self._cluster._impl.client_adapter.connection_details.get_scheme_host_and_port() @@ -92,25 +176,77 @@ def enable_test_server(self) -> BlockingTestEnvironment: self._server_handler.start_server() return self - def disable_test_server(self) -> BlockingTestEnvironment: - if self._server_handler is not None: - self._server_handler.stop_server() - self._server_handler = None - return self - + def setup(self) -> None: + if self.config.create_keyspace is False: + return + + setup_statements = [ + f'CREATE DATABASE `{self.config.database_name}` IF NOT EXISTS;', + f'CREATE SCOPE `{self.config.database_name}`.`{self.config.scope_name}` IF NOT EXISTS;', + ('CREATE COLLECTION ' + f'`{self.config.database_name}`.`{self.config.scope_name}`.`{self.config.collection_name}`' + ' IF NOT EXISTS PRIMARY KEY (pk: UUID) AUTOGENERATED;') + ] + + for statement in setup_statements: + try: + self.cluster.execute_query(statement) + except Exception as ex: + raise AnalyticsTestEnvironmentError(f'Unable to execute statement={statement}. Error: {ex}') + + json_data = self.load_collection_data_from_file(TEST_AIRLINE_DATA_PATH) + docs = [] + for d in json_data: + if 'collection' in d: + d['collection'] = self.config.collection_name + if 'scope' in d: + d['scope'] = self.config.scope_name + docs.append(json.dumps(d)) + statement = (f'USE `{self.config.database_name}`.`{self.config.scope_name}`; ' + f'UPSERT INTO `{self.config.collection_name}` ({",".join(docs)})') + + try: + self.cluster.execute_query(statement) + except Exception as ex: + raise AnalyticsTestEnvironmentError(f'Unable to load collection data. Error: {ex}') + def set_url_path(self, url_path: str) -> None: if self._server_handler is None: raise AnalyticsTestEnvironmentError('No server handler provided, cannot set URL path.') + if self._cluster is None or not hasattr(self._cluster, '_impl'): + raise AnalyticsTestEnvironmentError('No cluster available, cannot enable test server.') self._cluster._impl._client_adapter.set_request_path(url_path) + def teardown(self) -> None: + if self.config.create_keyspace is False: + return + + teardown_statements = [ + f'DROP DATABASE `{self.config.database_name}` IF EXISTS;', + f'DROP SCOPE `{self.config.database_name}`.`{self.config.scope_name}` IF EXISTS;', + ('DROP COLLECTION ' + f'`{self.config.database_name}`.`{self.config.scope_name}`.`{self.config.collection_name}`' + ' IF EXISTS;') + ] + + for statement in teardown_statements: + try: + self.cluster.execute_query(statement) + except Exception as ex: + raise AnalyticsTestEnvironmentError(f'Unable to execute statement={statement}. Error: {ex}') + def update_request_extensions(self, extensions: Dict[str, object]) -> None: if self._server_handler is None: raise AnalyticsTestEnvironmentError('No server handler provided, cannot update request extensions.') + if self._cluster is None or not hasattr(self._cluster, '_impl'): + raise AnalyticsTestEnvironmentError('No cluster available, cannot enable test server.') self._cluster._impl._client_adapter.update_request_extensions(extensions) def update_request_json(self, json: Dict[str, object]) -> None: if self._server_handler is None: raise AnalyticsTestEnvironmentError('No server handler provided, cannot update request JSON.') + if self._cluster is None or not hasattr(self._cluster, '_impl'): + raise AnalyticsTestEnvironmentError('No cluster available, cannot enable test server.') self._cluster._impl._client_adapter.update_request_json(json) @classmethod @@ -145,6 +281,9 @@ def get_environment(cls, else: env_opts['cluster'] = Cluster.create_instance(connstr, cred) + env_opts['database_name'] = config.database_name + env_opts['scope_name'] = config.scope_name + env_opts['collection_name'] = config.collection_name return cls(config, **env_opts) @@ -160,15 +299,64 @@ def cluster(self) -> AsyncCluster: raise AnalyticsTestEnvironmentError('No async cluster available.') return self._async_cluster + @property + def cluster_or_scope(self) -> Union[AsyncCluster, AsyncScope]: + if self._async_scope is not None: + return self.scope + return self.cluster + + @property + def scope(self) -> AsyncScope: + if self._async_scope is None: + raise AnalyticsTestEnvironmentError('No scope available.') + return self._async_scope + + async def assert_rows(self, result: AsyncQueryResult, expected_count: int) -> None: + count = 0 + assert isinstance(result, (AsyncQueryResult,)) + async for row in result.rows(): + assert row is not None + count += 1 + assert count >= expected_count + + def disable_scope(self) -> AsyncTestEnvironment: + self._async_scope = None + self._use_scope = False + return self + + def disable_test_server(self) -> AsyncTestEnvironment: + if self._server_handler is not None: + self._server_handler.stop_server() + self._server_handler = None + return self + + def enable_scope(self, + database_name: Optional[str] = None, + scope_name: Optional[str] = None) -> AsyncTestEnvironment: + + if self._async_cluster is None: + raise AnalyticsTestEnvironmentError('No cluster available.') + db_name = database_name if database_name is not None else self._database_name + if db_name is None: + raise AnalyticsTestEnvironmentError('Cannot create scope without a database name.') + scope_name = scope_name if scope_name is not None else self._scope_name + if scope_name is None: + raise AnalyticsTestEnvironmentError('Cannot create scope without a scope name.') + self._async_scope = self._async_cluster.database(db_name).scope(scope_name) + self._use_scope = True + return self + async def enable_test_server(self) -> AsyncTestEnvironment: if self._server_handler is None: raise AnalyticsTestEnvironmentError('No server handler provided, cannot enable test server.') - from tests.utils._async_client_adapter import _AsyncClientAdapter - from tests.utils._test_async_httpx import AsyncHTTPTransport + if self._async_cluster is None or not hasattr(self._async_cluster, '_impl'): + raise AnalyticsTestEnvironmentError('No cluster available, cannot enable test server.') + from tests.utils._async_client_adapter import _TestAsyncClientAdapter + from tests.utils._test_async_httpx import TestAsyncHTTPTransport # close the adapter here b/c we need to await await self._async_cluster._impl._client_adapter.close_client() - new_adapter = _AsyncClientAdapter(adapter=self._async_cluster._impl._client_adapter, - http_transport_cls=AsyncHTTPTransport) + new_adapter = _TestAsyncClientAdapter(adapter=self._async_cluster._impl._client_adapter, # type: ignore[call-arg] + http_transport_cls=TestAsyncHTTPTransport) await new_adapter.create_client() self._async_cluster._impl._client_adapter = new_adapter scheme, host, port = self._async_cluster._impl.client_adapter.connection_details.get_scheme_host_and_port() @@ -176,25 +364,77 @@ async def enable_test_server(self) -> AsyncTestEnvironment: self._server_handler.start_server() return self - def disable_test_server(self) -> AsyncTestEnvironment: - if self._server_handler is not None: - self._server_handler.stop_server() - self._server_handler = None - return self + async def setup(self) -> None: + if self.config.create_keyspace is False: + return + + setup_statements = [ + f'CREATE DATABASE `{self.config.database_name}` IF NOT EXISTS;', + f'CREATE SCOPE `{self.config.database_name}`.`{self.config.scope_name}` IF NOT EXISTS;', + ('CREATE COLLECTION ' + f'`{self.config.database_name}`.`{self.config.scope_name}`.`{self.config.collection_name}`' + ' IF NOT EXISTS PRIMARY KEY (pk: UUID) AUTOGENERATED;') + ] + + for statement in setup_statements: + try: + await self.cluster.execute_query(statement) + except Exception as ex: + raise AnalyticsTestEnvironmentError(f'Unable to execute statement={statement}. Error: {ex}') + + json_data = self.load_collection_data_from_file(TEST_AIRLINE_DATA_PATH) + docs = [] + for d in json_data: + if 'collection' in d: + d['collection'] = self.config.collection_name + if 'scope' in d: + d['scope'] = self.config.scope_name + docs.append(json.dumps(d)) + statement = (f'USE `{self.config.database_name}`.`{self.config.scope_name}`; ' + f'UPSERT INTO `{self.config.collection_name}` ({",".join(docs)})') + + try: + await self.cluster.execute_query(statement) + except Exception as ex: + raise AnalyticsTestEnvironmentError(f'Unable to load collection data. Error: {ex}') def set_url_path(self, url_path: str) -> None: if self._server_handler is None: raise AnalyticsTestEnvironmentError('No server handler provided, cannot set URL path.') + if self._async_cluster is None or not hasattr(self._async_cluster, '_impl'): + raise AnalyticsTestEnvironmentError('No cluster available, cannot enable test server.') self._async_cluster._impl._client_adapter.set_request_path(url_path) + async def teardown(self) -> None: + if self.config.create_keyspace is False: + return + + teardown_statements = [ + f'DROP DATABASE `{self.config.database_name}` IF EXISTS;', + f'DROP SCOPE `{self.config.database_name}`.`{self.config.scope_name}` IF EXISTS;', + ('DROP COLLECTION ' + f'`{self.config.database_name}`.`{self.config.scope_name}`.`{self.config.collection_name}`' + ' IF EXISTS;') + ] + + for statement in teardown_statements: + try: + await self.cluster.execute_query(statement) + except Exception as ex: + raise AnalyticsTestEnvironmentError(f'Unable to execute statement={statement}. Error: {ex}') + def update_request_extensions(self, extensions: Dict[str, object]) -> None: if self._server_handler is None: raise AnalyticsTestEnvironmentError('No server handler provided, cannot update request extensions.') + if self._async_cluster is None or not hasattr(self._async_cluster, '_impl'): + raise AnalyticsTestEnvironmentError('No cluster available, cannot enable test server.') self._async_cluster._impl._client_adapter.update_request_extensions(extensions) def update_request_json(self, json: Dict[str, object]) -> None: if self._server_handler is None: raise AnalyticsTestEnvironmentError('No server handler provided, cannot update request JSON.') + if self._async_cluster is None or not hasattr(self._async_cluster, '_impl'): + raise AnalyticsTestEnvironmentError('No cluster available, cannot enable test server.') self._async_cluster._impl._client_adapter.update_request_json(json) @classmethod @@ -233,6 +473,9 @@ def get_environment(cls, else: env_opts['async_cluster'] = AsyncCluster.create_instance(connstr, cred) + env_opts['database_name'] = config.database_name + env_opts['scope_name'] = config.scope_name + env_opts['collection_name'] = config.collection_name return cls(config, **env_opts) @pytest.fixture(scope='class', name='sync_test_env') diff --git a/tests/environments/simple_environment.py b/tests/environments/simple_environment.py index 0ba1814..3116f6f 100644 --- a/tests/environments/simple_environment.py +++ b/tests/environments/simple_environment.py @@ -8,6 +8,8 @@ class JsonDataType(Enum): SIMPLE_REQUEST = 'simple_request' MULTIPLE_RESULTS = 'multiple_results' FAILED_REQUEST = 'failed_request' + FAILED_REQUEST_MULTI_ERRORS = 'failed_request_multi_errors' + FAILED_REQUEST_MID_STREAM = 'failed_request_mid_stream' JSON_DATA = { 'simple_request':""" @@ -112,11 +114,65 @@ class JsonDataType(Enum): "bufferCachePageReadCount": 0, "errorCount": 1 } +}""".strip(), + 'failed_request_multi_errors':""" +{ + "requestID": "c5f50c58-c044-481f-a26a-357a29f7446e", + "errors": [ + { + "code": 24000, + "msg": "Syntax error: TokenMgrError: Lexical error at line 1, column 14. Encountered: after : \\"'m not N1QL;\\"" + }, + { + "code": 20001, + "msg": "Insufficient permissions or the requested object does not exist" + } + ], + "status": "fatal", + "metrics": { + "elapsedTime": "3.146092ms", + "executionTime": "1.907313ms", + "compileTime": "0ns", + "queueWaitTime": "0ns", + "resultCount": 0, + "resultSize": 0, + "processedObjects": 0, + "bufferCacheHitRatio": "0.00%", + "bufferCachePageReadCount": 0, + "errorCount": 2 + } +}""".strip(), + 'failed_request_mid_stream':""" +{ + "requestID": "c5f50c58-c044-481f-a26a-357a29f7446e", + "results": [ + {"id": 1, "name": "John Doe", "age": 30, "city": "New York"}, + {"id": 2, "name": "Jane Smith", "age": 25, "city": "Los Angeles"} + ], + "errors": [ + { + "code": 24000, + "msg": "Syntax error: TokenMgrError: Lexical error at line 1, column 14. Encountered: after : \\"'m not N1QL;\\"" + } + ], + "status": "fatal", + "metrics": { + "elapsedTime": "3.146092ms", + "executionTime": "1.907313ms", + "compileTime": "0ns", + "queueWaitTime": "0ns", + "resultCount": 2, + "resultSize": 2, + "processedObjects": 2, + "bufferCacheHitRatio": "0.00%", + "bufferCachePageReadCount": 0, + "errorCount": 2 + } }""".strip() } class BaseSimpleEnvironment: - def __init__(self, backend) -> None: + def __init__(self, backend: str) -> None: self._backend = backend def get_json_data(self, json_type: JsonDataType) -> Tuple[Any, bytes]: @@ -130,17 +186,17 @@ def get_json_data(self, json_type: JsonDataType) -> Tuple[Any, bytes]: return json.loads(data), bytes(data, 'utf-8') class AsyncSimpleEnvironment(BaseSimpleEnvironment): - def __init__(self, backend) -> None: + def __init__(self, backend: str) -> None: super().__init__(backend) class SimpleEnvironment(BaseSimpleEnvironment): - def __init__(self, backend) -> None: + def __init__(self, backend: str) -> None: super().__init__(backend) @pytest.fixture(scope='class', name='simple_async_test_env') -def simple_async_test_environment(anyio_backend) -> AsyncSimpleEnvironment: +def simple_async_test_environment(anyio_backend: str) -> AsyncSimpleEnvironment: return AsyncSimpleEnvironment(anyio_backend) @pytest.fixture(scope='class', name='simple_test_env') -def simple_test_environment(anyio_backend) -> SimpleEnvironment: +def simple_test_environment(anyio_backend: str) -> SimpleEnvironment: return SimpleEnvironment(anyio_backend) \ No newline at end of file diff --git a/tests/test_config.ini b/tests/test_config.ini index 2cbb886..b7ceda2 100644 --- a/tests/test_config.ini +++ b/tests/test_config.ini @@ -1,6 +1,7 @@ [analytics] -scheme = https -host = localhost +scheme = http +host = 1d870384-20250618.cb-sdk.bemdas.com +port = 8095 username = Administrator password = password nonprod = False diff --git a/tests/test_data/airline.json b/tests/test_data/airline.json new file mode 100644 index 0000000..0470e02 --- /dev/null +++ b/tests/test_data/airline.json @@ -0,0 +1,189 @@ +[ + {"callsign":"OA","collection":"airline","country":"United States","iata":null,"icao":"OAR","id":17629,"name":"Orbit Regional Airlines","scope":"inventory","type":"airline"}, + {"callsign":"COMAIR","collection":"airline","country":"United States","iata":"OH","icao":"COM","id":1828,"name":"Comair","scope":"inventory","type":"airline"}, + {"callsign":"GIANT","collection":"airline","country":"United States","iata":"5Y","icao":"GTI","id":928,"name":"Atlas Air","scope":"inventory","type":"airline"}, + {"callsign":"FRONTIER-AIR","collection":"airline","country":"United States","iata":"2F","icao":"FTA","id":2470,"name":"Frontier Flying Service","scope":"inventory","type":"airline"}, + {"callsign":"STARWAY","collection":"airline","country":"France","iata":"SE","icao":"SEU","id":5479,"name":"XL Airways France","scope":"inventory","type":"airline"}, + {"callsign":"AMTRAN","collection":"airline","country":"United States","iata":null,"icao":"AMT","id":315,"name":"ATA Airlines","scope":"inventory","type":"airline"}, + {"callsign":"RYAN AIR","collection":"airline","country":"United States","iata":null,"icao":"RYA","id":4294,"name":"Ryan Air Services","scope":"inventory","type":"airline"}, + {"callsign":"LOCAIR","collection":"airline","country":"United States","iata":"ZQ","icao":"LOC","id":10748,"name":"Locair","scope":"inventory","type":"airline"}, + {"callsign":"AIRCALIN","collection":"airline","country":"France","iata":"SB","icao":"ACI","id":139,"name":"Air Caledonie International","scope":"inventory","type":"airline"}, + {"callsign":null,"collection":"airline","country":"United States","iata":"WP","icao":"MKU","id":8809,"name":"Island Air (WP)","scope":"inventory","type":"airline"}, + {"callsign":"CHAUTAUQUA","collection":"airline","country":"United States","iata":"RP","icao":"CHQ","id":1739,"name":"Chautauqua Airlines","scope":"inventory","type":"airline"}, + {"callsign":"MIDWAY","collection":"airline","country":"United States","iata":"JI","icao":"MDW","id":3494,"name":"Midway Airlines","scope":"inventory","type":"airline"}, + {"callsign":null,"collection":"airline","country":"United States","iata":"T6","icao":"TP6","id":16264,"name":"Trans Pas Air","scope":"inventory","type":"airline"}, + {"callsign":"CORSICA","collection":"airline","country":"France","iata":"XK","icao":"CCM","id":1909,"name":"Corse-Mediterranee","scope":"inventory","type":"airline"}, + {"callsign":"Envoy","collection":"airline","country":"United States","iata":null,"icao":"ENY","id":19619,"name":"Envoy Air","scope":"inventory","type":"airline"}, + {"callsign":"COUNTY","collection":"airline","country":"United Kingdom","iata":null,"icao":"NCF","id":3684,"name":"Norfolk County Flight College","scope":"inventory","type":"airline"}, + {"callsign":"WESTERN","collection":"airline","country":"United States","iata":"WA","icao":"WAL","id":5424,"name":"Western Airlines","scope":"inventory","type":"airline"}, + {"callsign":"HAGELAND","collection":"airline","country":"United States","iata":"H6","icao":"HAG","id":2657,"name":"Hageland Aviation Services","scope":"inventory","type":"airline"}, + {"callsign":null,"collection":"airline","country":"United States","iata":null,"icao":"OAN","id":17630,"name":"Orbit Atlantic Airways","scope":"inventory","type":"airline"}, + {"callsign":"RAW","collection":"airline","country":"United States","iata":"KG","icao":"RAW","id":18241,"name":"Royal Airways","scope":"inventory","type":"airline"}, + {"callsign":"Rainbow Air","collection":"airline","country":"United States","iata":"RX","icao":"RPO","id":19676,"name":"Rainbow Air Polynesia","scope":"inventory","type":"airline"}, + {"callsign":"AIR SHUTTLE","collection":"airline","country":"United States","iata":"YV","icao":"ASH","id":3466,"name":"Mesa Airlines","scope":"inventory","type":"airline"}, + {"callsign":"GULF FLIGHT","collection":"airline","country":"United States","iata":null,"icao":"GFT","id":2645,"name":"Gulfstream International Airlines","scope":"inventory","type":"airline"}, + {"callsign":"CITRUS","collection":"airline","country":"United States","iata":"FL","icao":"TRS","id":1316,"name":"AirTran Airways","scope":"inventory","type":"airline"}, + {"callsign":"AIGLE AZUR","collection":"airline","country":"France","iata":"ZI","icao":"AAF","id":21,"name":"Aigle Azur","scope":"inventory","type":"airline"}, + {"callsign":null,"collection":"airline","country":"United Kingdom","iata":"1F","icao":"CIF","id":16261,"name":"CB Airways UK ( Interliging Flights )","scope":"inventory","type":"airline"}, + {"callsign":"FLIGHTLINE","collection":"airline","country":"United Kingdom","iata":"B5","icao":"FLT","id":2395,"name":"Flightline","scope":"inventory","type":"airline"}, + {"callsign":"GATEWAY","collection":"airline","country":"United States","iata":"G7","icao":"GJS","id":2577,"name":"GoJet Airlines","scope":"inventory","type":"airline"}, + {"callsign":"KINLOSS","collection":"airline","country":"United Kingdom","iata":null,"icao":"KIN","id":4113,"name":"Kinloss Flying Training Unit","scope":"inventory","type":"airline"}, + {"callsign":"FRENCH WEST","collection":"airline","country":"France","iata":"TX","icao":"FWI","id":567,"name":"Air Caraïbes","scope":"inventory","type":"airline"}, + {"callsign":"PIEDMONT","collection":"airline","country":"United States","iata":"PI","icao":"PDT","id":3969,"name":"Piedmont Airlines (1948-1989)","scope":"inventory","type":"airline"}, + {"callsign":"TSUNAMI","collection":"airline","country":"United States","iata":"LW","icao":"NMI","id":3865,"name":"Pacific Wings","scope":"inventory","type":"airline"}, + {"callsign":"VIRGIN","collection":"airline","country":"United Kingdom","iata":"VS","icao":"VIR","id":5347,"name":"Virgin Atlantic Airways","scope":"inventory","type":"airline"}, + {"callsign":"TWINJET","collection":"airline","country":"France","iata":"T7","icao":"TJT","id":4965,"name":"Twin Jet","scope":"inventory","type":"airline"}, + {"callsign":"CHANNEX","collection":"airline","country":"United Kingdom","iata":"LS","icao":"EXS","id":3026,"name":"Jet2.com","scope":"inventory","type":"airline"}, + {"callsign":null,"collection":"airline","country":"United States","iata":null,"icao":"CEO","id":19351,"name":"Comfort Express Virtual Charters","scope":"inventory","type":"airline"}, + {"callsign":null,"collection":"airline","country":"United States","iata":null,"icao":"XSR","id":18257,"name":"Executive AirShare","scope":"inventory","type":"airline"}, + {"callsign":"USKY","collection":"airline","country":"United States","iata":"E1","icao":"ES2","id":16702,"name":"Usa Sky Cargo","scope":"inventory","type":"airline"}, + {"callsign":"WORLD","collection":"airline","country":"United States","iata":"WO","icao":"WOA","id":5465,"name":"World Airways","scope":"inventory","type":"airline"}, + {"callsign":"SPEEDBIRD","collection":"airline","country":"United Kingdom","iata":"BA","icao":"BAW","id":1355,"name":"British Airways","scope":"inventory","type":"airline"}, + {"callsign":"AIRFRANS","collection":"airline","country":"France","iata":"AF","icao":"AFR","id":137,"name":"Air France","scope":"inventory","type":"airline"}, + {"callsign":"Comfort Express","collection":"airline","country":"United States","iata":null,"icao":"EVC","id":19350,"name":"Comfort Express Virtual Charters Albany","scope":"inventory","type":"airline"}, + {"callsign":"MIDLAND","collection":"airline","country":"United Kingdom","iata":"BD","icao":"BMA","id":1437,"name":"bmi","scope":"inventory","type":"airline"}, + {"callsign":"PACIFIC ISLE","collection":"airline","country":"United States","iata":null,"icao":"PSA","id":3860,"name":"Pacific Island Aviation","scope":"inventory","type":"airline"}, + {"callsign":"ALLEGIANT","collection":"airline","country":"United States","iata":"G4","icao":"AAY","id":35,"name":"Allegiant Air","scope":"inventory","type":"airline"}, + {"callsign":"AIR SUNSHINE","collection":"airline","country":"United States","iata":null,"icao":"RSI","id":295,"name":"Air Sunshine","scope":"inventory","type":"airline"}, + {"callsign":null,"collection":"airline","country":"France","iata":"PJ","icao":"SPM","id":477,"name":"Air Saint Pierre","scope":"inventory","type":"airline"}, + {"callsign":"Vickjet","collection":"airline","country":"France","iata":"KT","icao":"VKJ","id":16837,"name":"VickJet","scope":"inventory","type":"airline"}, + {"callsign":"AMERICAN","collection":"airline","country":"United States","iata":"AA","icao":"AAL","id":24,"name":"American Airlines","scope":"inventory","type":"airline"}, + {"callsign":"BERING AIR","collection":"airline","country":"United States","iata":"8E","icao":"BRG","id":1472,"name":"Bering Air","scope":"inventory","type":"airline"}, + {"callsign":null,"collection":"airline","country":"United States","iata":"-+","icao":"--+","id":13391,"name":"U.S. Air","scope":"inventory","type":"airline"}, + {"callsign":"AIR CHIEF","collection":"airline","country":"United States","iata":null,"icao":"AIO","id":5279,"name":"United States Air Force","scope":"inventory","type":"airline"}, + {"callsign":"CACTUS","collection":"airline","country":"United States","iata":"HP","icao":"AWE","id":281,"name":"America West Airlines","scope":"inventory","type":"airline"}, + {"callsign":"RUBY","collection":"airline","country":"United States","iata":"V2","icao":"RBY","id":18178,"name":"Vision Airlines (V2)","scope":"inventory","type":"airline"}, + {"callsign":"SUN COUNTRY","collection":"airline","country":"United States","iata":"SY","icao":"SCX","id":4356,"name":"Sun Country Airlines","scope":"inventory","type":"airline"}, + {"callsign":"KESTREL","collection":"airline","country":"United Kingdom","iata":"MT","icao":"TCX","id":4897,"name":"Thomas Cook Airlines","scope":"inventory","type":"airline"}, + {"callsign":"CYCLONE","collection":"airline","country":"United States","iata":"ZA","icao":"CYD","id":792,"name":"Access Air","scope":"inventory","type":"airline"}, + {"callsign":"HORIZON AIR","collection":"airline","country":"United States","iata":"QX","icao":"QXE","id":2778,"name":"Horizon Air","scope":"inventory","type":"airline"}, + {"callsign":"Epic","collection":"airline","country":"United States","iata":"FA","icao":"4AA","id":9833,"name":"Epic Holiday","scope":"inventory","type":"airline"}, + {"callsign":"NEW ENGLAND","collection":"airline","country":"United States","iata":"EJ","icao":"NEA","id":3644,"name":"New England Airlines","scope":"inventory","type":"airline"}, + {"callsign":"TOMSON","collection":"airline","country":"United Kingdom","iata":"BY","icao":"TOM","id":5013,"name":"Thomsonfly","scope":"inventory","type":"airline"}, + {"callsign":"SASQUATCH","collection":"airline","country":"United States","iata":"K5","icao":"SQH","id":10765,"name":"SeaPort Airlines","scope":"inventory","type":"airline"}, + {"callsign":"ERAH","collection":"airline","country":"United States","iata":"7H","icao":"ERR","id":16726,"name":"Era Alaska","scope":"inventory","type":"airline"}, + {"callsign":"INDIGO BLUE","collection":"airline","country":"United States","iata":"I9","icao":"IBU","id":2855,"name":"Indigo","scope":"inventory","type":"airline"}, + {"callsign":null,"collection":"airline","country":"United Kingdom","iata":"N9","icao":"N99","id":19808,"name":"All Europe","scope":"inventory","type":"airline"}, + {"callsign":"ACEY","collection":"airline","country":"United States","iata":"EV","icao":"ASQ","id":452,"name":"Atlantic Southeast Airlines","scope":"inventory","type":"airline"}, + {"callsign":"FREEDOM AIR","collection":"airline","country":"United States","iata":null,"icao":"FRL","id":2456,"name":"Freedom Airlines","scope":"inventory","type":"airline"}, + {"callsign":"COLGAN","collection":"airline","country":"United States","iata":"9L","icao":"CJC","id":1821,"name":"Colgan Air","scope":"inventory","type":"airline"}, + {"callsign":"NIGHT CARGO","collection":"airline","country":"United States","iata":"2Q","icao":"SNC","id":149,"name":"Air Cargo Carriers","scope":"inventory","type":"airline"}, + {"callsign":"AIR MIKE","collection":"airline","country":"United States","iata":"CS","icao":"CMI","id":1884,"name":"Continental Micronesia","scope":"inventory","type":"airline"}, + {"callsign":"BEE MED","collection":"airline","country":"United Kingdom","iata":"KJ","icao":"LAJ","id":1543,"name":"British Mediterranean Airways","scope":"inventory","type":"airline"}, + {"callsign":"Rainbow","collection":"airline","country":"United States","iata":"RN","icao":"RAB","id":19674,"name":"Rainbow Air (RAI)","scope":"inventory","type":"airline"}, + {"callsign":"FREEDOM","collection":"airline","country":"United States","iata":"FP","icao":"FRE","id":2454,"name":"Freedom Air","scope":"inventory","type":"airline"}, + {"callsign":"NORTHWEST","collection":"airline","country":"United States","iata":"NW","icao":"NWA","id":3731,"name":"Northwest Airlines","scope":"inventory","type":"airline"}, + {"callsign":"Orbit","collection":"airline","country":"United States","iata":null,"icao":"OBT","id":16932,"name":"Orbit Airlines","scope":"inventory","type":"airline"}, + {"callsign":"AIRLIFT","collection":"airline","country":"United States","iata":null,"icao":"AIR","id":210,"name":"Airlift International","scope":"inventory","type":"airline"}, + {"callsign":"GLOBESPAN","collection":"airline","country":"United Kingdom","iata":"B4","icao":"GSM","id":2425,"name":"Flyglobespan","scope":"inventory","type":"airline"}, + {"callsign":"JETSET","collection":"airline","country":"United Kingdom","iata":"DP","icao":"FCA","id":2357,"name":"First Choice Airways","scope":"inventory","type":"airline"}, + {"callsign":"SOUTH PACIFIC","collection":"airline","country":"United States","iata":null,"icao":"SPI","id":4816,"name":"South Pacific Island Airways","scope":"inventory","type":"airline"}, + {"callsign":"CROWN AIRWAYS","collection":"airline","country":"United States","iata":null,"icao":"CRO","id":1931,"name":"Crown Airways","scope":"inventory","type":"airline"}, + {"callsign":"T\u0026","collection":"airline","country":"France","iata":"\u0026T","icao":"T\u0026O","id":13947,"name":"Tom\\'s \u0026 co airliners","scope":"inventory","type":"airline"}, + {"callsign":"ACE AIR","collection":"airline","country":"United States","iata":"KO","icao":"AER","id":109,"name":"Alaska Central Express","scope":"inventory","type":"airline"}, + {"callsign":"SCENIC","collection":"airline","country":"United States","iata":null,"icao":"SCE","id":4342,"name":"Scenic Airlines","scope":"inventory","type":"airline"}, + {"callsign":null,"collection":"airline","country":"United States","iata":"76","icao":"SJS","id":17859,"name":"Southjet","scope":"inventory","type":"airline"}, + {"callsign":"SOUTHWEST","collection":"airline","country":"United States","iata":"WN","icao":"SWA","id":4547,"name":"Southwest Airlines","scope":"inventory","type":"airline"}, + {"callsign":"FLAGSHIP","collection":"airline","country":"United States","iata":"9E","icao":"FLG","id":3976,"name":"Pinnacle Airlines","scope":"inventory","type":"airline"}, + {"callsign":"AYLINE","collection":"airline","country":"United Kingdom","iata":"GR","icao":"AUR","id":508,"name":"Aurigny Air Services","scope":"inventory","type":"airline"}, + {"callsign":"OA","collection":"airline","country":"United States","iata":null,"icao":"OAI","id":17628,"name":"Orbit International Airlines","scope":"inventory","type":"airline"}, + {"callsign":"MERCURY","collection":"airline","country":"United States","iata":"S5","icao":"TCF","id":4822,"name":"Shuttle America","scope":"inventory","type":"airline"}, + {"callsign":"FLO WEST","collection":"airline","country":"United States","iata":"RF","icao":"FWL","id":2404,"name":"Florida West International Airways","scope":"inventory","type":"airline"}, + {"callsign":"REUNION","collection":"airline","country":"France","iata":"UU","icao":"REU","id":1191,"name":"Air Austral","scope":"inventory","type":"airline"}, + {"callsign":"CONTINENTAL","collection":"airline","country":"United States","iata":"CO","icao":"COA","id":1881,"name":"Continental Airlines","scope":"inventory","type":"airline"}, + {"callsign":"DELTA","collection":"airline","country":"United States","iata":"DL","icao":"DAL","id":2009,"name":"Delta Air Lines","scope":"inventory","type":"airline"}, + {"callsign":"Stallion","collection":"airline","country":"United States","iata":"BZ","icao":"BSA","id":15975,"name":"Black Stallion Airways","scope":"inventory","type":"airline"}, + {"callsign":null,"collection":"airline","country":"United States","iata":"78","icao":"XAN","id":17862,"name":"Southjet cargo","scope":"inventory","type":"airline"}, + {"callsign":"LONGHORN","collection":"airline","country":"United States","iata":"EO","icao":"LHN","id":2293,"name":"Express One International","scope":"inventory","type":"airline"}, + {"callsign":"BRITAIR","collection":"airline","country":"France","iata":"DB","icao":"BZH","id":1523,"name":"Brit Air","scope":"inventory","type":"airline"}, + {"callsign":"RYAN INTERNATIONAL","collection":"airline","country":"United States","iata":"RD","icao":"RYN","id":4295,"name":"Ryan International Airlines","scope":"inventory","type":"airline"}, + {"callsign":null,"collection":"airline","country":"United States","iata":"H1","icao":"HA1","id":16735,"name":"Hankook Air US","scope":"inventory","type":"airline"}, + {"callsign":"EXECJET","collection":"airline","country":"United States","iata":"1I","icao":"EJA","id":3641,"name":"NetJets","scope":"inventory","type":"airline"}, + {"callsign":"SPIRIT WINGS","collection":"airline","country":"United States","iata":"NK","icao":"NKS","id":4687,"name":"Spirit Airlines","scope":"inventory","type":"airline"}, + {"callsign":"BBN","collection":"airline","country":"United Kingdom","iata":null,"icao":"EGH","id":19525,"name":"BBN-Airways","scope":"inventory","type":"airline"}, + {"callsign":null,"collection":"airline","country":"United States","iata":"77","icao":"ZCS","id":17860,"name":"Southjet connect","scope":"inventory","type":"airline"}, + {"callsign":"MONARCH","collection":"airline","country":"United Kingdom","iata":"ZB","icao":"MON","id":3532,"name":"Monarch Airlines","scope":"inventory","type":"airline"}, + {"callsign":"JETBLUE","collection":"airline","country":"United States","iata":"B6","icao":"JBU","id":3029,"name":"JetBlue Airways","scope":"inventory","type":"airline"}, + {"callsign":"EAGLE FLIGHT","collection":"airline","country":"United States","iata":"MQ","icao":"EGF","id":659,"name":"American Eagle Airlines","scope":"inventory","type":"airline"}, + {"callsign":"FLYSTAR","collection":"airline","country":"United Kingdom","iata":"5W","icao":"AEU","id":112,"name":"Astraeus","scope":"inventory","type":"airline"}, + {"callsign":"PENINSULA","collection":"airline","country":"United States","iata":"KS","icao":"PEN","id":3935,"name":"Peninsula Airways","scope":"inventory","type":"airline"}, + {"callsign":"FLIGHTVUE","collection":"airline","country":"United Kingdom","iata":null,"icao":"VUE","id":665,"name":"AD Aviation","scope":"inventory","type":"airline"}, + {"callsign":"EASY","collection":"airline","country":"United Kingdom","iata":"U2","icao":"EZY","id":2297,"name":"easyJet","scope":"inventory","type":"airline"}, + {"callsign":"COMMUTAIR","collection":"airline","country":"United States","iata":"C5","icao":"UCA","id":1843,"name":"CommutAir","scope":"inventory","type":"airline"}, + {"callsign":"TXW","collection":"airline","country":"United States","iata":"TQ","icao":"TXW","id":10123,"name":"Texas Wings","scope":"inventory","type":"airline"}, + {"callsign":null,"collection":"airline","country":"United Kingdom","iata":null,"icao":"BMR","id":1445,"name":"British Midland Regional","scope":"inventory","type":"airline"}, + {"callsign":"BRINTEL","collection":"airline","country":"United Kingdom","iata":"BS","icao":"BIH","id":1411,"name":"British International Helicopters","scope":"inventory","type":"airline"}, + {"callsign":"HELI EXCEL","collection":"airline","country":"United Kingdom","iata":null,"icao":"XEL","id":2265,"name":"Excel Charter","scope":"inventory","type":"airline"}, + {"callsign":"CORSAIR","collection":"airline","country":"France","iata":"SS","icao":"CRL","id":1908,"name":"Corsairfly","scope":"inventory","type":"airline"}, + {"callsign":"EAVA","collection":"airline","country":"United States","iata":"13","icao":"EAV","id":19290,"name":"Eastern Atlantic Virtual Airlines","scope":"inventory","type":"airline"}, + {"callsign":null,"collection":"airline","country":"United Kingdom","iata":null,"icao":"JRB","id":10642,"name":"Jc royal.britannica","scope":"inventory","type":"airline"}, + {"callsign":"REDWOOD","collection":"airline","country":"United States","iata":"VX","icao":"VRD","id":5331,"name":"Virgin America","scope":"inventory","type":"airline"}, + {"callsign":"MILE-AIR","collection":"airline","country":"United States","iata":"Q5","icao":"MLA","id":10,"name":"40-Mile Air","scope":"inventory","type":"airline"}, + {"callsign":"AIRMAX","collection":"airline","country":"United States","iata":null,"icao":"XBM","id":15887,"name":"CBM America","scope":"inventory","type":"airline"}, + {"callsign":"GETAWAY","collection":"airline","country":"United States","iata":"U5","icao":"GWY","id":5207,"name":"USA3000 Airlines","scope":"inventory","type":"airline"}, + {"callsign":"APACHE","collection":"airline","country":"United States","iata":"ZM","icao":"IWA","id":19016,"name":"Apache Air","scope":"inventory","type":"airline"}, + {"callsign":"SKYWEST","collection":"airline","country":"United States","iata":"OO","icao":"SKW","id":4738,"name":"SkyWest","scope":"inventory","type":"airline"}, + {"callsign":"CAIR","collection":"airline","country":"United States","iata":"9K","icao":"KAP","id":1629,"name":"Cape Air","scope":"inventory","type":"airline"}, + {"callsign":"AIR MOOREA","collection":"airline","country":"France","iata":null,"icao":"TAH","id":551,"name":"Air Moorea","scope":"inventory","type":"airline"}, + {"callsign":"MEDITERRANEE","collection":"airline","country":"France","iata":"DR","icao":"BIE","id":547,"name":"Air Mediterranee","scope":"inventory","type":"airline"}, + {"callsign":"SKAGWAY AIR","collection":"airline","country":"United States","iata":"N5","icao":"SGY","id":4411,"name":"Skagway Air Service","scope":"inventory","type":"airline"}, + {"callsign":"SWALLOW","collection":"airline","country":"United Kingdom","iata":null,"icao":"WOW","id":492,"name":"Air Southwest","scope":"inventory","type":"airline"}, + {"callsign":"AIR FLORIDA","collection":"airline","country":"United States","iata":"QH","icao":"FLZ","id":882,"name":"Air Florida","scope":"inventory","type":"airline"}, + {"callsign":"SUCKLING","collection":"airline","country":"United Kingdom","iata":null,"icao":"SAY","id":4323,"name":"ScotAirways","scope":"inventory","type":"airline"}, + {"callsign":"HIWAY","collection":"airline","country":"United Kingdom","iata":null,"icao":"HWY","id":2761,"name":"Highland Airways","scope":"inventory","type":"airline"}, + {"callsign":"SOUTHEAST AIR","collection":"airline","country":"United States","iata":null,"icao":"SEA","id":4370,"name":"Southeast Air","scope":"inventory","type":"airline"}, + {"callsign":"UNITED","collection":"airline","country":"United States","iata":"UA","icao":"UAL","id":5209,"name":"United Airlines","scope":"inventory","type":"airline"}, + {"callsign":"Maryland Flight","collection":"airline","country":"United States","iata":"M1","icao":"M1F","id":18930,"name":"Maryland Air","scope":"inventory","type":"airline"}, + {"callsign":"RED DRAGON","collection":"airline","country":"United Kingdom","iata":"6G","icao":"AWW","id":565,"name":"Air Wales","scope":"inventory","type":"airline"}, + {"callsign":"SEABORNE","collection":"airline","country":"United States","iata":"BB","icao":"SBS","id":4335,"name":"Seaborne Airlines","scope":"inventory","type":"airline"}, + {"callsign":"AIRLINAIR","collection":"airline","country":"France","iata":"A5","icao":"RLA","id":1203,"name":"Airlinair","scope":"inventory","type":"airline"}, + {"callsign":"SOUTHERN EXPRESS","collection":"airline","country":"United States","iata":null,"icao":"SOU","id":4804,"name":"Southern Airways","scope":"inventory","type":"airline"}, + {"callsign":"U S AIR","collection":"airline","country":"United States","iata":"US","icao":"USA","id":5265,"name":"US Airways","scope":"inventory","type":"airline"}, + {"callsign":null,"collection":"airline","country":"United States","iata":"YX","icao":"MEP","id":3497,"name":"Midwest Airlines","scope":"inventory","type":"airline"}, + {"callsign":"MESABA","collection":"airline","country":"United States","iata":"XJ","icao":"MES","id":3467,"name":"Mesaba Airlines","scope":"inventory","type":"airline"}, + {"callsign":"FRONTIER FLIGHT","collection":"airline","country":"United States","iata":"F9","icao":"FFT","id":2468,"name":"Frontier Airlines","scope":"inventory","type":"airline"}, + {"callsign":"BEMIDJI","collection":"airline","country":"United States","iata":"CH","icao":"BMJ","id":1442,"name":"Bemidji Airlines","scope":"inventory","type":"airline"}, + {"callsign":"JET LINK","collection":"airline","country":"United States","iata":"XE","icao":"BTA","id":2295,"name":"ExpressJet","scope":"inventory","type":"airline"}, + {"callsign":"OMNI-EXPRESS","collection":"airline","country":"United States","iata":"OY","icao":"OAE","id":3781,"name":"Omni Air International","scope":"inventory","type":"airline"}, + {"callsign":"AIR WISCONSIN","collection":"airline","country":"United States","iata":"ZW","icao":"AWI","id":282,"name":"Air Wisconsin","scope":"inventory","type":"airline"}, + {"callsign":"WATERSKI","collection":"airline","country":"United States","iata":"AX","icao":"LOF","id":5160,"name":"Trans States Airlines","scope":"inventory","type":"airline"}, + {"callsign":"DISTRICT","collection":"airline","country":"United States","iata":"BK","icao":"PDC","id":4026,"name":"Potomac Air","scope":"inventory","type":"airline"}, + {"callsign":"HELIFRANCE","collection":"airline","country":"France","iata":"8H","icao":"HFR","id":2704,"name":"Heli France","scope":"inventory","type":"airline"}, + {"callsign":"KENMORE","collection":"airline","country":"United States","iata":"M5","icao":"KEN","id":3123,"name":"Kenmore Air","scope":"inventory","type":"airline"}, + {"callsign":"HEX AIRLINE","collection":"airline","country":"France","iata":"UD","icao":"HER","id":2757,"name":"Hex'Air","scope":"inventory","type":"airline"}, + {"callsign":"CREST","collection":"airline","country":"United Kingdom","iata":null,"icao":"CAN","id":1923,"name":"Crest Aviation","scope":"inventory","type":"airline"}, + {"callsign":"ALLEGHENY","collection":"airline","country":"United States","iata":null,"icao":"ALO","id":287,"name":"Allegheny Commuter Airlines","scope":"inventory","type":"airline"}, + {"callsign":"Spike Air","collection":"airline","country":"United States","iata":"S0","icao":"SAL","id":19774,"name":"Spike Airlines","scope":"inventory","type":"airline"}, + {"callsign":"JERSEY","collection":"airline","country":"United Kingdom","iata":"BE","icao":"BEE","id":2421,"name":"Flybe","scope":"inventory","type":"airline"}, + {"callsign":"FOYLE","collection":"airline","country":"United Kingdom","iata":"GS","icao":"UPA","id":690,"name":"Air Foyle","scope":"inventory","type":"airline"}, + {"callsign":"T.J. Air","collection":"airline","country":"United States","iata":"TJ","icao":"TJA","id":18529,"name":"T.J. Air","scope":"inventory","type":"airline"}, + {"callsign":"FLYER","collection":"airline","country":"United Kingdom","iata":"CJ","icao":"CFE","id":1795,"name":"BA CityFlyer","scope":"inventory","type":"airline"}, + {"callsign":"EASTFLIGHT","collection":"airline","country":"United Kingdom","iata":"T3","icao":"EZE","id":2117,"name":"Eastern Airways","scope":"inventory","type":"airline"}, + {"callsign":"TAHITI AIRLINES","collection":"airline","country":"France","iata":"TN","icao":"THT","id":225,"name":"Air Tahiti Nui","scope":"inventory","type":"airline"}, + {"callsign":"BRICKYARD","collection":"airline","country":"United States","iata":"RW","icao":"RPA","id":4187,"name":"Republic Airlines","scope":"inventory","type":"airline"}, + {"callsign":"ALOHA","collection":"airline","country":"United States","iata":"AQ","icao":"AAH","id":22,"name":"Aloha Airlines","scope":"inventory","type":"airline"}, + {"callsign":null,"collection":"airline","country":"United States","iata":"YE","icao":"YEL","id":18239,"name":"Yellowtail","scope":"inventory","type":"airline"}, + {"callsign":"HAWAIIAN","collection":"airline","country":"United States","iata":"HA","icao":"HAL","id":2688,"name":"Hawaiian Airlines","scope":"inventory","type":"airline"}, + {"callsign":"aws","collection":"airline","country":"United States","iata":"B0","icao":"666","id":17841,"name":"Aws express","scope":"inventory","type":"airline"}, + {"callsign":"LAKES AIR","collection":"airline","country":"United States","iata":"ZK","icao":"GLA","id":2607,"name":"Great Lakes Airlines","scope":"inventory","type":"airline"}, + {"callsign":"XAIR","collection":"airline","country":"United States","iata":"XA","icao":"XAU","id":19433,"name":"XAIR USA","scope":"inventory","type":"airline"}, + {"callsign":"EXPO","collection":"airline","country":"United Kingdom","iata":"JN","icao":"XLA","id":2264,"name":"Excel Airways","scope":"inventory","type":"airline"}, + {"callsign":"GEEBEE AIRWAYS","collection":"airline","country":"United Kingdom","iata":"GT","icao":"GBL","id":2486,"name":"GB Airways","scope":"inventory","type":"airline"}, + {"callsign":"Rainbow Air","collection":"airline","country":"United Kingdom","iata":"RU","icao":"RUE","id":19677,"name":"Rainbow Air Euro","scope":"inventory","type":"airline"}, + {"callsign":"SKYWAY-EX","collection":"airline","country":"United States","iata":"AL","icao":"SYX","id":4589,"name":"Skywalk Airlines","scope":"inventory","type":"airline"}, + {"callsign":null,"collection":"airline","country":"United States","iata":"A2","icao":"AL2","id":19804,"name":"All America","scope":"inventory","type":"airline"}, + {"callsign":"atifly","collection":"airline","country":"United States","iata":"A1","icao":"A1F","id":10226,"name":"Atifly","scope":"inventory","type":"airline"}, + {"callsign":"KESTREL","collection":"airline","country":"United Kingdom","iata":"VZ","icao":"MYT","id":3568,"name":"MyTravel Airways","scope":"inventory","type":"airline"}, + {"callsign":null,"collection":"airline","country":"United States","iata":"N8","icao":"NCR","id":19287,"name":"National Air Cargo","scope":"inventory","type":"airline"}, + {"callsign":"Cudlua","collection":"airline","country":"United Kingdom","iata":null,"icao":"CUD","id":16881,"name":"Air Cudlua","scope":"inventory","type":"airline"}, + {"callsign":null,"collection":"airline","country":"United States","iata":null,"icao":"XOJ","id":17563,"name":"XOJET","scope":"inventory","type":"airline"}, + {"callsign":"BIG A","collection":"airline","country":"United States","iata":"JW","icao":"APW","id":397,"name":"Arrow Air","scope":"inventory","type":"airline"}, + {"callsign":"Compass Rose","collection":"airline","country":"United States","iata":"CP","icao":"CPZ","id":1860,"name":"Compass Airlines","scope":"inventory","type":"airline"}, + {"callsign":null,"collection":"airline","country":"United States","iata":"WQ","icao":"PQW","id":13633,"name":"PanAm World Airways","scope":"inventory","type":"airline"}, + {"callsign":"Rainbow Air","collection":"airline","country":"United States","iata":"RM","icao":"RNY","id":19678,"name":"Rainbow Air US","scope":"inventory","type":"airline"}, + {"callsign":"EVERGREEN","collection":"airline","country":"United States","iata":"EZ","icao":"EIA","id":2261,"name":"Evergreen International Airlines","scope":"inventory","type":"airline"}, + {"callsign":"BABY","collection":"airline","country":"United Kingdom","iata":"WW","icao":"BMI","id":1441,"name":"bmibaby","scope":"inventory","type":"airline"}, + {"callsign":"FRENCH SUN","collection":"airline","country":"France","iata":"TO","icao":"TVF","id":8745,"name":"Transavia France","scope":"inventory","type":"airline"}, + {"callsign":"US-HELI","collection":"airline","country":"United States","iata":null,"icao":"USH","id":5268,"name":"US Helicopter","scope":"inventory","type":"airline"}, + {"callsign":"REGIONAL EUROPE","collection":"airline","country":"France","iata":"YS","icao":"RAE","id":4299,"name":"Régional","scope":"inventory","type":"airline"} + ] diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py index 5679f6b..6e10238 100644 --- a/tests/utils/__init__.py +++ b/tests/utils/__init__.py @@ -15,31 +15,42 @@ # See the License for the specific language governing permissions and # limitations under the License. -from __future__ import print_function +from __future__ import annotations +import os +import pathlib import random -from typing import Optional, Union + +from collections.abc import AsyncIterator as PyAsyncIterator +from collections.abc import Iterator +from typing import (Any, + Dict, + List, + Optional, + Tuple, + Union) +from urllib.parse import quote import anyio -class AsyncBytesIterator: +class AsyncBytesIterator(PyAsyncIterator[bytes]): def __init__(self, data: Union[bytes, str], chunk_size: Optional[int] = 100, simulate_delay: Optional[bool] = False, - simulate_delay_range: Optional[tuple] = (0.01, 0.1)): + simulate_delay_range: Optional[Tuple[float, float]] = (0.01, 0.1)) -> None: self._data = data if isinstance(data, bytes) else bytes(data, 'utf-8') - self._chunk_size = chunk_size - self._simulate_delay = simulate_delay - self._simulate_delay_range = simulate_delay_range + self._chunk_size = chunk_size or 100 + self._simulate_delay = simulate_delay or False + self._simulate_delay_range = simulate_delay_range or (0.01, 0.1) self._start = 0 self._stop = self._chunk_size - def __aiter__(self): + def __aiter__(self) -> AsyncBytesIterator: return self - async def __anext__(self): + async def __anext__(self) -> bytes: if self._simulate_delay: delay = random.uniform(*self._simulate_delay_range) await anyio.sleep(delay) @@ -60,18 +71,18 @@ async def __anext__(self): self._stop += self._chunk_size return chunk -class BytesIterator: +class BytesIterator(Iterator[bytes]): - def __init__(self, data: Union[bytes, str], chunk_size: Optional[int] = 100): + def __init__(self, data: Union[bytes, str], chunk_size: Optional[int] = 100) -> None: self._data = data if isinstance(data, bytes) else bytes(data, 'utf-8') - self._chunk_size = chunk_size + self._chunk_size = chunk_size or 100 self._start = 0 self._stop = self._chunk_size - def __iter__(self): + def __iter__(self) -> BytesIterator: return self - def __next__(self): + def __next__(self) -> bytes: if not self._data: raise StopIteration while True: @@ -87,4 +98,27 @@ def __next__(self): chunk = self._data[self._start:self._stop] self._start = self._stop self._stop += self._chunk_size - return chunk \ No newline at end of file + return chunk + + +def get_test_cert_path() -> str: + return os.path.join(pathlib.Path(__file__).parent, 'certs', 'dinocluster.pem') + +def get_test_cert_list() -> List[str]: + cert_file = pathlib.Path(get_test_cert_path()) + cert_file1 = pathlib.Path(os.path.join(pathlib.Path(__file__).parent, 'certs', 'dinoca.pem')) + return [cert_file.read_text(), cert_file1.read_text()] + +def get_test_cert_str() -> str: + cert_file = pathlib.Path(get_test_cert_path()) + return cert_file.read_text() + +def to_query_str(params: Dict[str, Any]) -> str: + encoded_params = [] + for k, v in params.items(): + if v in [True, False]: + encoded_params.append(f'{quote(k)}={quote(str(v).lower())}') + else: + encoded_params.append(f'{quote(k)}={quote(str(v))}') + + return '&'.join(encoded_params) \ No newline at end of file diff --git a/tests/utils/_async_client_adapter.py b/tests/utils/_async_client_adapter.py index c371e96..5d47fee 100644 --- a/tests/utils/_async_client_adapter.py +++ b/tests/utils/_async_client_adapter.py @@ -7,7 +7,7 @@ from couchbase_analytics.protocol.core.request import QueryRequest -def client_adapter_init_override(self, *args, **kwargs) -> None: +def client_adapter_init_override(self, *args, **kwargs) -> None: # type: ignore[no-untyped-def] if not hasattr(self, 'PYCBAC_TESTING'): raise RuntimeError('This is a testing only adapter') self._http_transport_cls = kwargs.pop('http_transport_cls', None) @@ -21,26 +21,29 @@ def client_adapter_init_override(self, *args, **kwargs) -> None: if self._http_transport_cls is None: self._http_transport_cls = adapter._http_transport_cls -async def create_client_override(self) -> None: - if not hasattr(self, '_client'): - auth = BasicAuth(*self._conn_details.credential) - if self._conn_details.is_secure(): - transport = None - if self._http_transport_cls is not None: - transport = self._http_transport_cls(verify=self._conn_details.ssl_context) - self._client = AsyncClient(verify=self._conn_details.ssl_context, - auth=auth, - transport=transport) - else: - transport = None - if self._http_transport_cls is not None: - transport = self._http_transport_cls() - self._client = AsyncClient(auth=auth, transport=transport) +# async def create_client_override(self: _AsyncClientAdapter) -> None: +# if not hasattr(self, '_client'): +# auth = BasicAuth(*self._conn_details.credential) +# if self._conn_details.is_secure(): +# transport = None +# if self._http_transport_cls is not None: +# transport = self._http_transport_cls(verify=self._conn_details.ssl_context) +# self._client = AsyncClient(verify=self._conn_details.ssl_context, +# auth=auth, +# transport=transport) +# else: +# transport = None +# if self._http_transport_cls is not None: +# transport = self._http_transport_cls() +# self._client = AsyncClient(auth=auth, transport=transport) -async def send_request_override(self, request: QueryRequest) -> Response: +async def send_request_override(self: _AsyncClientAdapter, request: QueryRequest) -> Response: if not hasattr(self, '_client'): raise RuntimeError('Client not created yet') + if request.url is None: + raise ValueError('Request URL cannot be None') + print(f'Sending request: {request.method} {request.url}') request_json = request.body if hasattr(self, '_request_json') and self._request_json is not None: @@ -66,19 +69,23 @@ async def send_request_override(self, request: QueryRequest) -> Response: raise RuntimeError(f'Unable to connect to {self._conn_details.get_scheme_host_and_port()}') from err -def set_request_path(self, path: str) -> None: +def set_request_path(self: _AsyncClientAdapter, path: str) -> None: self._ANALYTICS_PATH = path -def update_request_json(self, json: Dict[str, object]) -> None: - self._request_json = json +def update_request_json(self: _AsyncClientAdapter, json: Dict[str, object]) -> None: + self._request_json = json # type: ignore[attr-defined] -def update_request_extensions(self, extensions: Dict[str, str]) -> None: - self._request_extensions = extensions +def update_request_extensions(self: _AsyncClientAdapter, extensions: Dict[str, str]) -> None: + self._request_extensions = extensions # type: ignore[attr-defined] -_AsyncClientAdapter.__init__ = client_adapter_init_override -_AsyncClientAdapter.create_client = create_client_override -_AsyncClientAdapter.send_request = send_request_override +_AsyncClientAdapter.__init__ = client_adapter_init_override # type: ignore[method-assign] +# _AsyncClientAdapter.create_client = create_client_override # type: ignore[method-assign] +_AsyncClientAdapter.send_request = send_request_override # type: ignore[method-assign] setattr(_AsyncClientAdapter, 'set_request_path', set_request_path) setattr(_AsyncClientAdapter, 'update_request_json', update_request_json) setattr(_AsyncClientAdapter, 'update_request_extensions', update_request_extensions) -setattr(_AsyncClientAdapter, 'PYCBAC_TESTING', True) \ No newline at end of file +setattr(_AsyncClientAdapter, 'PYCBAC_TESTING', True) + +_TestAsyncClientAdapter = _AsyncClientAdapter + +__all__ = ["_TestAsyncClientAdapter"] \ No newline at end of file diff --git a/tests/utils/_async_utils.py b/tests/utils/_async_utils.py index a0b8e4e..80dbeae 100644 --- a/tests/utils/_async_utils.py +++ b/tests/utils/_async_utils.py @@ -1,33 +1,57 @@ - +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from types import TracebackType from typing import (Any, - List) + Callable, + List, + Optional, + Type) + import anyio class TaskGroupResultCollector: - def __init__(self): - self._results = [] + def __init__(self) -> None: + self._results: List[Any] = [] @property def results(self) -> List[Any]: return self._results - async def _execute(self, fn, *args): + async def _execute(self, fn: Callable[..., Any], *args: object) -> None: result = await fn(*args) self._results.append(result) - def start_soon(self, fn, *args): + def start_soon(self, fn: Callable[..., Any], *args: object) -> None: self._taskgroup.start_soon(self._execute, fn, *args) - async def __aenter__(self): + async def __aenter__(self) -> TaskGroupResultCollector: self._taskgroup = anyio.create_task_group() await self._taskgroup.__aenter__() return self - async def __aexit__(self, *tb): + async def __aexit__(self, + exc_type: Optional[Type[BaseException]], + exc_val: Optional[BaseException], + exc_tb: Optional[TracebackType]) -> Any: try: - res = await self._taskgroup.__aexit__(*tb) + res = await self._taskgroup.__aexit__(exc_type= exc_type, exc_val=exc_val, exc_tb=exc_tb) return res finally: del self._taskgroup \ No newline at end of file diff --git a/tests/utils/_async_web_server.py b/tests/utils/_async_web_server.py index f2d19d3..f6fdf96 100644 --- a/tests/utils/_async_web_server.py +++ b/tests/utils/_async_web_server.py @@ -37,7 +37,7 @@ def __init__(self, host: Optional[str]='0.0.0.0', port:Optional[int]=8080) -> No self._app.add_routes([web.get('/test_get', self.handle_get_request), web.post('/test_post', self.handle_post_request)]) - async def handle_get_request(self, request): + async def handle_get_request(self, request: web.Request) -> web.Response: path = request.match_info['path'] query_params = request.query_string response_data = { @@ -46,7 +46,7 @@ async def handle_get_request(self, request): } return web.json_response(response_data) - async def handle_post_request(self, request): + async def handle_post_request(self, request: web.Request) -> web.Response: try: received_json = await request.json() logger.info(f"Received JSON: {received_json}") @@ -59,18 +59,19 @@ async def handle_post_request(self, request): msg = "POST request received, but data is not valid JSON. Showing as plain text." logger.error(msg) logger.error(f'Received text: {received_text}') + return web.Response(status=400, text="Bad Request") except Exception as e: logger.error(f'An error occurred: {e}', exc_info=True) return web.Response(status=400, text="Bad Request") - async def start(self): + async def start(self) -> None: runner = web.AppRunner(self._app) await runner.setup() site = web.TCPSite(runner, self._host, self._port) await site.start() logger.info(f'Server running on http://{self._host}:{self._port}') - async def stop(self): + async def stop(self) -> None: await self._app.shutdown() await self._app.cleanup() diff --git a/tests/utils/_client_adapter.py b/tests/utils/_client_adapter.py index 948efe7..35c70d2 100644 --- a/tests/utils/_client_adapter.py +++ b/tests/utils/_client_adapter.py @@ -8,7 +8,7 @@ from couchbase_analytics.protocol.core.request import QueryRequest -def client_adapter_init_override(self, *args, **kwargs) -> None: +def client_adapter_init_override(self, *args, **kwargs) -> None: # type: ignore[no-untyped-def] if not hasattr(self, 'PYCBAC_TESTING'): raise RuntimeError('This is a testing only adapter') self._http_transport_cls = kwargs.pop('http_transport_cls', None) @@ -39,10 +39,13 @@ def client_adapter_init_override(self, *args, **kwargs) -> None: # self._client = Client(auth=BasicAuth(*self._conn_details.credential), # transport=transport) -def send_request_override(self, request: QueryRequest) -> Response: +def send_request_override(self: _ClientAdapter, request: QueryRequest) -> Response: if not hasattr(self, '_client'): raise RuntimeError('Client not created yet') + if request.url is None: + raise ValueError('Request URL cannot be None') + print(f'Sending request: {request.method} {request.url}') request_json = request.body if hasattr(self, '_request_json') and self._request_json is not None: @@ -67,19 +70,23 @@ def send_request_override(self, request: QueryRequest) -> Response: except socket.gaierror as err: raise RuntimeError(f'Unable to connect to {self._conn_details.get_scheme_host_and_port()}') from err -def set_request_path(self, path: str) -> None: +def set_request_path(self: _ClientAdapter, path: str) -> None: self._ANALYTICS_PATH = path -def update_request_json(self, json: Dict[str, object]) -> None: - self._request_json = json +def update_request_json(self: _ClientAdapter, json: Dict[str, object]) -> None: + self._request_json = json # type: ignore[attr-defined] -def update_request_extensions(self, extensions: Dict[str, str]) -> None: - self._request_extensions = extensions +def update_request_extensions(self: _ClientAdapter, extensions: Dict[str, str]) -> None: + self._request_extensions = extensions # type: ignore[attr-defined] -_ClientAdapter.__init__ = client_adapter_init_override +_ClientAdapter.__init__ = client_adapter_init_override # type: ignore[method-assign] # _ClientAdapter.create_client = create_client_override -_ClientAdapter.send_request = send_request_override +_ClientAdapter.send_request = send_request_override # type: ignore[method-assign] setattr(_ClientAdapter, 'set_request_path', set_request_path) setattr(_ClientAdapter, 'update_request_json', update_request_json) setattr(_ClientAdapter, 'update_request_extensions', update_request_extensions) -setattr(_ClientAdapter, 'PYCBAC_TESTING', True) \ No newline at end of file +setattr(_ClientAdapter, 'PYCBAC_TESTING', True) + +_TestClientAdapter = _ClientAdapter + +__all__ = ["_TestClientAdapter"] \ No newline at end of file diff --git a/tests/utils/_run_web_server.py b/tests/utils/_run_web_server.py index 92e717a..903e211 100644 --- a/tests/utils/_run_web_server.py +++ b/tests/utils/_run_web_server.py @@ -34,9 +34,9 @@ class WebServerHandler: def __init__(self, host: Optional[str]='0.0.0.0', port:Optional[int]=8080) -> None: - self._host = host - self._port = port - self._server_process = None + self._host = host or '0.0.0.0' + self._port = port or 8080 + self._server_process: Optional[subprocess.Popen[bytes]] atexit.register(self.stop_server) @property diff --git a/tests/utils/_test_async_httpx.py b/tests/utils/_test_async_httpx.py index 2de6c65..63818b9 100644 --- a/tests/utils/_test_async_httpx.py +++ b/tests/utils/_test_async_httpx.py @@ -21,7 +21,7 @@ from httpcore._trace import Trace class TestAsyncHTTPConnection(AsyncHTTPConnection): - def __init__(self, *args, **kwargs) -> None: + def __init__(self, *args, **kwargs) -> None: # type: ignore super().__init__(*args, **kwargs) async def _connect(self, request: Request) -> AsyncNetworkStream: @@ -90,7 +90,7 @@ async def _connect(self, request: Request) -> AsyncNetworkStream: await self._network_backend.sleep(delay) class TestAsyncConnectionPool(AsyncConnectionPool): - def __init__(self, *args, **kwargs) -> None: + def __init__(self, *args, **kwargs) -> None: # type: ignore super().__init__(*args, **kwargs) def create_connection(self, origin: Origin) -> AsyncConnectionInterface: @@ -221,11 +221,11 @@ async def handle_async_request(self, request: Request) -> Response: extensions=response.extensions, ) -def async_http_transport_init_override(self, *args, **kwargs) -> None: +def async_http_transport_init_override(self, *args, **kwargs) -> None: # type: ignore verify = kwargs.get('verify') cert = kwargs.get('cert') trust_env = kwargs.get('trust_env') - ssl_context = create_ssl_context(verify=verify, cert=cert, trust_env=trust_env) + ssl_context = create_ssl_context(verify=verify, cert=cert, trust_env=trust_env) # type: ignore # See https://github.com/encode/httpx/blob/master/httpx/_config.py for defaults # default keepalive_expiry is 5 seconds @@ -249,5 +249,9 @@ def async_http_transport_init_override(self, *args, **kwargs) -> None: socket_options=socket_options, ) -AsyncHTTPTransport.__init__ = async_http_transport_init_override -setattr(AsyncHTTPTransport, 'PYCBAC_TESTING', True) \ No newline at end of file +AsyncHTTPTransport.__init__ = async_http_transport_init_override # type: ignore +setattr(AsyncHTTPTransport, 'PYCBAC_TESTING', True) + +TestAsyncHTTPTransport = AsyncHTTPTransport + +__all__ = ["TestAsyncHTTPTransport"] \ No newline at end of file diff --git a/tests/utils/_test_httpx.py b/tests/utils/_test_httpx.py index e80629e..0227975 100644 --- a/tests/utils/_test_httpx.py +++ b/tests/utils/_test_httpx.py @@ -23,7 +23,7 @@ from httpcore._trace import Trace class TestHTTPConnection(HTTPConnection): - def __init__(self, *args, **kwargs) -> None: + def __init__(self, *args, **kwargs) -> None: # type: ignore super().__init__(*args, **kwargs) def _connect(self, request: Request) -> NetworkStream: @@ -103,7 +103,7 @@ def _connect(self, request: Request) -> NetworkStream: self._network_backend.sleep(delay) class TestConnectionPool(ConnectionPool): - def __init__(self, *args, **kwargs) -> None: + def __init__(self, *args, **kwargs) -> None: # type: ignore super().__init__(*args, **kwargs) def create_connection(self, origin: Origin) -> ConnectionInterface: @@ -249,11 +249,11 @@ def handle_request(self, request: Request) -> Response: extensions=response.extensions, ) -def http_transport_init_override(self, *args, **kwargs) -> None: +def http_transport_init_override(self, *args, **kwargs) -> None: # type: ignore verify = kwargs.get('verify') cert = kwargs.get('cert') trust_env = kwargs.get('trust_env') - ssl_context = create_ssl_context(verify=verify, cert=cert, trust_env=trust_env) + ssl_context = create_ssl_context(verify=verify, cert=cert, trust_env=trust_env) # type: ignore # See https://github.com/encode/httpx/blob/master/httpx/_config.py for defaults # default keepalive_expiry is 5 seconds @@ -277,5 +277,9 @@ def http_transport_init_override(self, *args, **kwargs) -> None: socket_options=socket_options, ) -HTTPTransport.__init__ = http_transport_init_override -setattr(HTTPTransport, 'PYCBAC_TESTING', True) \ No newline at end of file +HTTPTransport.__init__ = http_transport_init_override # type: ignore +setattr(HTTPTransport, 'PYCBAC_TESTING', True) + +TestHTTPTransport = HTTPTransport + +__all__ = ["TestHTTPTransport"] \ No newline at end of file diff --git a/tests/utils/certs/dinoca.pem b/tests/utils/certs/dinoca.pem new file mode 100644 index 0000000..c63a645 --- /dev/null +++ b/tests/utils/certs/dinoca.pem @@ -0,0 +1,31 @@ +-----BEGIN CERTIFICATE----- +MIIFTDCCAzSgAwIBAgIBATANBgkqhkiG9w0BAQsFADA4MTYwNAYDVQQDEy1kaW5v +Y2VydC0xQTlBODI1Ri0wNDU1LTU2ODgtOTE2My1GN0Y0NTkyREUwRkUwHhcNMjUw +MTAxMDAwMDAwWhcNMzUwMTAxMDAwMDAwWjA4MTYwNAYDVQQDEy1kaW5vY2VydC0x +QTlBODI1Ri0wNDU1LTU2ODgtOTE2My1GN0Y0NTkyREUwRkUwggIiMA0GCSqGSIb3 +DQEBAQUAA4ICDwAwggIKAoICAQDOeBwdVDdX4u255kqXv7bw6unoCNYVjiuv4eos +XSGvLnHTffMzb185BNEjfv6Otu4rm3eo6y37UeeT8WVOr/8YUekZE9MJBRcofgd2 +G3ACk/LUsugl9egmR7Ivj9WHG9ILNQPSXq9cubZXo52k+s53/dvP12vleHYmW274 +/as0FXn+mcXbbjF/Nru9HV1OokmLsAcxxJceQjb9wJntOr36ej+ROPaKDmaD11uv +gXRgXnA2ngPP82DsLImplD5OBEhOjtIaD+0G/TWXpHEV3ZKADYSMYRrex6JHrcBh +2Es6E4Xckv06VHSEfhVEJPS2in59fcHUpxuvaYVBHilWbLEsCkN5DzDxb3G/m9vX +UqZYgF+gQ6+30A4Zbbn9tQQIX30cz1ml0kOKirNEPM58/RxOFIDLTQ+5n1tSP/c1 +oPit5c/e+YzKVWeoJSdXC+zlYSrVDDJ1R3XTkSg9Ja5fsGn9OCBvWrrGII2H0RF2 +8lCU9YUTO0kTbVLNA03e4/RXUKnZAhUE2LEwk5A8FK61SPSiRq4mkilJ8yi8ZFZW +I+tCU4q6xjuwysu2D8DG4kY7M8S0zoafNYvT4cosdN2OGPqe8rKhgpfFRZBgiiks +BW59CBkdui9/q5oYPm6KGD1dX9PCzjRFyT/wFMHs2jCqcC7ejmUUufdN3aZgPyCg +C0VRvwIDAQABo2EwXzAOBgNVHQ8BAf8EBAMCAoQwHQYDVR0lBBYwFAYIKwYBBQUH +AwIGCCsGAQUFBwMBMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFMeOieNqIXlo +aZ6Q3wbunaMjI75DMA0GCSqGSIb3DQEBCwUAA4ICAQAJAiQck9JoXItAu+eWRKKf +6T/MaaF3ukGF+Yusqqj3fOm5VJ23gUQhpEtZ09ALVmOvGf9LUGJ7Yvotsl+GLCLL +XzRDVEAjU2i2j31INQgK26H/HsekYQ0GppMpDj44BMlWL1XfbcQgHC4WgUep3ju2 +Kt+LNUjc0CEc8nGqd3jeg801sgFNrstStRPFwIeFgcapcSPuggxbLVAlNYci6CpK +ufxUEFwOMrQGwrFblW104j1GWd/f8R4Mn6FH/Ru7ZCdZcp2hRjtjnnqi8zkKefGB +dQdOSr00FM01cqYfGL4J0JHn9Q50OfrhfVzHe7h33iulrqsIDvPK0nGr5LMIrlZL +mWK0KKOGbq6IWXx5pKa5/Lve/B6RO3wbJheGC2vNOF/JuMuhds2bGVfaEPCHBvM1 +3fPN90FzGTxKTsug/Tg/C7+zoTUqJ4I0zdqW0fzJ65Rpb/INU0WjrX5h5w+fSgY0 +wQdKIkezcQ7OoPmpIsuEQvnCoPdVFsNA4eHdRp5u877olE/iDmljsu3sa0Y6xxnv +wmK1E44EciNQ7aj5lzeLrSP0/uFZRTDP4h7B4jlkFWqgPpE6uSYTNVZgwVwQCcA7 +ZlQIVK3aOQvact80pqXn8Zu2MHlvVR6L3po+FIBsa8ha2rGTsOasVFkXQLnKBO7r +Og2yKTFaMcFFhK2+PVFxQQ== +-----END CERTIFICATE----- \ No newline at end of file diff --git a/tests/utils/certs/dinocluster.pem b/tests/utils/certs/dinocluster.pem new file mode 100644 index 0000000..dd46e08 --- /dev/null +++ b/tests/utils/certs/dinocluster.pem @@ -0,0 +1,31 @@ +-----BEGIN CERTIFICATE----- +MIIFWzCCA0OgAwIBAgIBATANBgkqhkiG9w0BAQsFADA4MTYwNAYDVQQDEy1kaW5v +Y2VydC0xQTlBODI1Ri0wNDU1LTU2ODgtOTE2My1GN0Y0NTkyREUwRkUwHhcNMjUw +MTAxMDAwMDAwWhcNMzUwMTAxMDAwMDAwWjAkMSIwIAYDVQQDExlkaW5vY2VydC1j +bHVzdGVyLTIwZDM1YTlhMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA +loAWLxlfNyGOu3PzG3Dm0qV5lRFjiMK5tWu4vTdzEcJhwWq/bA4MFKodTy1KIfn5 +RrTMCURsYxn7TJkoN5RO8o+6ZEUa5bPcTc+jmaTvLT5gPjKVDVmPdhJVq0ywcyqG +9y5tVXnd9smNMPqT4w+5V01CCkb3ThDduOPGPWp6kQSWcqZ+bZYR+V86cTv5WyTo +vMW0Yj5GHY3k0Ag79BSrtzhsVOowgVq5FP38KtXeo5f0WVmez5d+p08ZxHbKTZps +ZvCjYL9WPQ2c1M4nnAholFhWFDMMhvO5ACHdU+62jD8yGCgmyknZ8sQ9cFHMYEng +t6Tz/ZQHuvOBI8haCop60tVKp1CN2Sr7pNnhoYooVE7sLhL9i4JVSt1NLqg3tf5o +PXmg7xVi5HBnDriDVRCRvCuRPpz3HizYkuXzOOifnUlqkFiuV/iD6W4UlvA9Tz1Y +yAAEzwOWukNjJYtQFOtwy9QEfSp0hHqeRNjqQzCCqiDNgNEVQ8qShNlY9lgg06qb +LwT1+GLWIvg51+tJrmX0sG5o31brZeZmhEFC0jexJSjkfSdm7WdzvO9BGrlElVHa +2IIj3INBSz7IiYPpjmIFrAq2ffbQgI5TBaze4KIKEMsFH0uw7wKx9kwn8NhsKf6/ +d6mCjWFgtFfxg8st0tGoCathWR+lECBG6+K2GcE+WX0CAwEAAaOBgzCBgDAOBgNV +HQ8BAf8EBAMCAoQwHQYDVR0lBBYwFAYIKwYBBQUHAwIGCCsGAQUFBwMBMA8GA1Ud +EwEB/wQFMAMBAf8wHQYDVR0OBBYEFH1QPn4vjPED0IC+7kTDagKvnA00MB8GA1Ud +IwQYMBaAFMeOieNqIXloaZ6Q3wbunaMjI75DMA0GCSqGSIb3DQEBCwUAA4ICAQCR +vGQxPK3nt1WWOC0yoRL6WAMiwE1fzRKsnqbKkEH2BRLAPNxmBMl76YySn6xSIQZf +uVbYuo8wPHB+//3J/238bJ29WIHdls+6pB5ioTYUx3NzXZAk8Lz/Pex0XqEvOGHV +WoZMGc/dDb8H1Qp62lU6lzlki61nA+NUm8vQaJ2vb7XHnPWIhRXVfvUbcc08aClm +5PEQoXytXPc7S1yDeqOZ1fyKzz+mqTpCAYjT5m1uJ5FBqK1iChi5Fye4aF+wWSQ8 +RDRsB7MZsQueRCSxvesmmmtxU91MXYVftiZNwHKsXoqFOWEORboVsYk6I/CWXiaC +ijRVxCep2L3h8T4bOBsWAbW+UhvL0ZzTfm/YNOOWtKy9F0HYpLbhHP4hwX577KGQ +4pHUWRq3j0iSDkg6ORdLRBI51nDEYseHSTFYxUqD3FU12kYesWNcvRBqAh8nsiU/ +c9Yc230EF3ZXiar9voqBZmQ7P4S6Pkud4tllo4yotNS1TLn7UvzpudOeEwMroi+n +ATY0+MXcgOd21yKVgk833TuYzl3Alj9RK1jeJY+GVualZzTqyLeaEvbGN0fmZbj6 +OwNoRoZIknh6pLvsup/XZjluuLj7+8atZLfsa/Dd+pVTNjWkDdMljiHQQ6nQWo/P +KMfDlOlfgAsxPTY9XPrb6HWFfLEeHN7FTkLK2ZccvA== +-----END CERTIFICATE----- \ No newline at end of file From aa5610b161de4cbaf1042bfb0547fd7db7bcaaf5 Mon Sep 17 00:00:00 2001 From: Jared Casey Date: Fri, 20 Jun 2025 18:49:48 -0600 Subject: [PATCH 03/18] Update to sync query tests Changes ======= * Better handling of timeout errors * Update sync tests to handle various forms (normal, lazy, cancellable) --- couchbase_analytics/common/errors.py | 4 +- couchbase_analytics/protocol/streaming.py | 27 +- .../tests/query_integration_t.py | 271 +++++++++++++++--- 3 files changed, 254 insertions(+), 48 deletions(-) diff --git a/couchbase_analytics/common/errors.py b/couchbase_analytics/common/errors.py index 3d80950..f8a51e9 100644 --- a/couchbase_analytics/common/errors.py +++ b/couchbase_analytics/common/errors.py @@ -124,8 +124,8 @@ class TimeoutError(AnalyticsError): Indicates that a request was unable to complete prior to reaching the deadline specified for the reqest. """ - def __init__(self, base: Optional[Exception] = None, message: Optional[str] = None) -> None: - super().__init__(base, message) + def __init__(self, cause: Optional[Exception] = None, message: Optional[str] = None) -> None: + super().__init__(cause, message) def __repr__(self) -> str: return super().__repr__() diff --git a/couchbase_analytics/protocol/streaming.py b/couchbase_analytics/protocol/streaming.py index 6f68c88..cc8dcd4 100644 --- a/couchbase_analytics/protocol/streaming.py +++ b/couchbase_analytics/protocol/streaming.py @@ -32,7 +32,7 @@ from httpx import Response as HttpCoreResponse # TODO: errors? -from couchbase_analytics.common.errors import AnalyticsError, InternalSDKError +from couchbase_analytics.common.errors import AnalyticsError, InternalSDKError, TimeoutError from couchbase_analytics.common.core import (JsonStreamConfig, ParsedResult, ParsedResultType) @@ -69,7 +69,7 @@ def wrapped_fn(self: HttpStreamingResponse) -> None: if self._request_context.request_error is not None: raise self._request_context.request_error from None if self._request_context.timed_out: - raise TimeoutError() from None + raise TimeoutError(message='Request timeout.') from None if self._request_context.cancelled: raise CancelledError('Request was cancelled.') from None raise InternalSDKError(ex) from None @@ -112,6 +112,17 @@ def _finish_processing_stream(self) -> None: while not self._json_stream.token_stream_exhausted: self._json_stream.continue_parsing() + def _handle_iteration_abort(self) -> None: + self.close() + if self._request_context.cancelled: + print('Request was cancelled, closing stream.') + raise StopIteration + elif self._request_context.timed_out: + print('Request timed out, closing stream.') + raise TimeoutError(message='Request timeout.') + else: + raise StopIteration + def _maybe_continue_to_process_stream(self) -> None: if not self._request_context.has_stage_completed: return @@ -190,17 +201,17 @@ def get_next_row(self) -> Any: if not (hasattr(self, '_core_response') and self._core_response is not None and self._request_context.okay_to_iterate): - self.close() - raise StopIteration + self._handle_iteration_abort() self._maybe_continue_to_process_stream() + check_state = False while True: - if self._request_context.cancelled: - self.close() - raise StopIteration - # TODO: handle timeout + if check_state and not self._request_context.okay_to_iterate: + self._handle_iteration_abort() + raw_response = self._json_stream.get_result(self._stream_config.queue_timeout) if raw_response is None: + check_state = True continue if raw_response.result_type == ParsedResultType.ROW: if raw_response.value is None: diff --git a/couchbase_analytics/tests/query_integration_t.py b/couchbase_analytics/tests/query_integration_t.py index 63f49f1..1d24469 100644 --- a/couchbase_analytics/tests/query_integration_t.py +++ b/couchbase_analytics/tests/query_integration_t.py @@ -17,8 +17,11 @@ import json from concurrent.futures import Future +from enum import Enum from datetime import timedelta -from typing import TYPE_CHECKING +from typing import (TYPE_CHECKING, + Any, + Dict) import pytest @@ -34,6 +37,12 @@ from tests.environments.base_environment import BlockingTestEnvironment +class SyncQueryType(Enum): + NORMAL = 'normal' + LAZY = 'lazy' + CANCELLABLE = 'cancellable' + + class QueryTestSuite: TEST_MANIFEST = [ # 'test_cancel_positional_params_override', @@ -88,10 +97,21 @@ def query_statement_limit5(self, test_env: BlockingTestEnvironment) -> str: else: return f'SELECT * FROM {test_env.fqdn} LIMIT 5;' + @pytest.mark.parametrize('query_type', [SyncQueryType.NORMAL, SyncQueryType.LAZY, SyncQueryType.CANCELLABLE]) def test_query_metadata(self, test_env: BlockingTestEnvironment, - query_statement_limit5: str) -> None: - result = test_env.cluster_or_scope.execute_query(query_statement_limit5) + query_statement_limit5: str, + query_type: SyncQueryType) -> None: + if query_type == SyncQueryType.NORMAL: + result = test_env.cluster_or_scope.execute_query(query_statement_limit5) + elif query_type == SyncQueryType.LAZY: + result = test_env.cluster_or_scope.execute_query(query_statement_limit5, + QueryOptions(lazy_execute=True)) + else: + res = test_env.cluster_or_scope.execute_query(query_statement_limit5, enable_cancel=True) + assert isinstance(res, Future) + result = res.result() + expected_count = 5 test_env.assert_rows(result, expected_count) @@ -108,10 +128,21 @@ def test_query_metadata(self, assert metrics.elapsed_time() > timedelta(0) assert metrics.execution_time() > timedelta(0) + @pytest.mark.parametrize('query_type', [SyncQueryType.NORMAL, SyncQueryType.LAZY, SyncQueryType.CANCELLABLE]) def test_query_metadata_not_available(self, test_env: BlockingTestEnvironment, - query_statement_limit5: str) -> None: - result = test_env.cluster_or_scope.execute_query(query_statement_limit5) + query_statement_limit5: str, + query_type: SyncQueryType) -> None: + + if query_type == SyncQueryType.NORMAL: + result = test_env.cluster_or_scope.execute_query(query_statement_limit5) + elif query_type == SyncQueryType.LAZY: + result = test_env.cluster_or_scope.execute_query(query_statement_limit5, + QueryOptions(lazy_execute=True)) + else: + res = test_env.cluster_or_scope.execute_query(query_statement_limit5, enable_cancel=True) + assert isinstance(res, Future) + result = res.result() with pytest.raises(RuntimeError): result.metadata() @@ -122,6 +153,11 @@ def test_query_metadata_not_available(self, with pytest.raises(RuntimeError): result.metadata() + # This would attempt to send the request when using lazy execution + if query_type == SyncQueryType.LAZY: + with pytest.raises(RuntimeError): + list(result.rows()) + return # Iterate the rest of the rows rows = list(result.rows()) assert len(rows) == 4 @@ -130,56 +166,161 @@ def test_query_metadata_not_available(self, assert len(metadata.warnings()) == 0 assert len(metadata.request_id()) > 0 + @pytest.mark.parametrize('query_type', [SyncQueryType.NORMAL, SyncQueryType.LAZY, SyncQueryType.CANCELLABLE]) def test_query_named_parameters(self, test_env: BlockingTestEnvironment, - query_statement_named_params_limit2: str) -> None: - result = test_env.cluster_or_scope.execute_query(query_statement_named_params_limit2, - QueryOptions(named_parameters={'country': 'United States'})) + query_statement_named_params_limit2: str, + query_type: SyncQueryType) -> None: + + named_parameters: Dict[str, Any] = {'country': 'United States'} + if query_type == SyncQueryType.NORMAL: + result = test_env.cluster_or_scope.execute_query(query_statement_named_params_limit2, + QueryOptions(named_parameters=named_parameters)) + elif query_type == SyncQueryType.LAZY: + result = test_env.cluster_or_scope.execute_query(query_statement_named_params_limit2, + QueryOptions(named_parameters=named_parameters, + lazy_execute=True)) + else: + res = test_env.cluster_or_scope.execute_query(query_statement_named_params_limit2, + QueryOptions(named_parameters=named_parameters), + enable_cancel=True) + assert isinstance(res, Future) + result = res.result() test_env.assert_rows(result, 2) + @pytest.mark.parametrize('query_type', [SyncQueryType.NORMAL, SyncQueryType.LAZY, SyncQueryType.CANCELLABLE]) def test_query_named_parameters_no_options(self, test_env: BlockingTestEnvironment, - query_statement_named_params_limit2: str) -> None: - result = test_env.cluster_or_scope.execute_query(query_statement_named_params_limit2, country='United States') + query_statement_named_params_limit2: str, + query_type: SyncQueryType) -> None: + if query_type == SyncQueryType.NORMAL: + result = test_env.cluster_or_scope.execute_query(query_statement_named_params_limit2, + country='United States') + elif query_type == SyncQueryType.LAZY: + # this format does not really make sense, if users are using static type checking it will prevent them + # but, technically viable so we test it + result = test_env.cluster_or_scope.execute_query(query_statement_named_params_limit2, # type: ignore[call-overload] + lazy_execute=True, + country='United States') + else: + res = test_env.cluster_or_scope.execute_query(query_statement_named_params_limit2, + country='United States', + enable_cancel=True) + assert isinstance(res, Future) + result = res.result() test_env.assert_rows(result, 2) + @pytest.mark.parametrize('query_type', [SyncQueryType.NORMAL, SyncQueryType.LAZY, SyncQueryType.CANCELLABLE]) def test_query_named_parameters_override(self, test_env: BlockingTestEnvironment, - query_statement_named_params_limit2: str) -> None: - result = test_env.cluster_or_scope.execute_query(query_statement_named_params_limit2, - QueryOptions(named_parameters={'country': 'abcdefg'}), - country='United States') + query_statement_named_params_limit2: str, + query_type: SyncQueryType) -> None: + + if query_type == SyncQueryType.NORMAL: + result = test_env.cluster_or_scope.execute_query(query_statement_named_params_limit2, + QueryOptions(named_parameters={'country': 'abcdefg'}), + country='United States') + elif query_type == SyncQueryType.LAZY: + result = test_env.cluster_or_scope.execute_query(query_statement_named_params_limit2, + QueryOptions(named_parameters={'country': 'abcdefg'}, + lazy_execute=True), + country='United States') + else: + res = test_env.cluster_or_scope.execute_query(query_statement_named_params_limit2, + QueryOptions(named_parameters={'country': 'abcdefg'}), + country='United States', + enable_cancel=True) + assert isinstance(res, Future) + result = res.result() test_env.assert_rows(result, 2) + @pytest.mark.parametrize('query_type', [SyncQueryType.NORMAL, SyncQueryType.LAZY, SyncQueryType.CANCELLABLE]) def test_query_positional_params(self, test_env: BlockingTestEnvironment, - query_statement_pos_params_limit2: str) -> None: - result = test_env.cluster_or_scope.execute_query(query_statement_pos_params_limit2, - QueryOptions(positional_parameters=['United States'])) + query_statement_pos_params_limit2: str, + query_type: SyncQueryType) -> None: + if query_type == SyncQueryType.NORMAL: + result = test_env.cluster_or_scope.execute_query(query_statement_pos_params_limit2, + QueryOptions(positional_parameters=['United States'])) + elif query_type == SyncQueryType.LAZY: + result = test_env.cluster_or_scope.execute_query(query_statement_pos_params_limit2, + QueryOptions(positional_parameters=['United States'], + lazy_execute=True)) + else: + res = test_env.cluster_or_scope.execute_query(query_statement_pos_params_limit2, + QueryOptions(positional_parameters=['United States']), + enable_cancel=True) + assert isinstance(res, Future) + result = res.result() test_env.assert_rows(result, 2) + @pytest.mark.parametrize('query_type', [SyncQueryType.NORMAL, SyncQueryType.LAZY, SyncQueryType.CANCELLABLE]) def test_query_positional_params_no_option(self, test_env: BlockingTestEnvironment, - query_statement_pos_params_limit2: str) -> None: - result = test_env.cluster_or_scope.execute_query(query_statement_pos_params_limit2, 'United States') + query_statement_pos_params_limit2: str, + query_type: SyncQueryType) -> None: + + if query_type == SyncQueryType.NORMAL: + result = test_env.cluster_or_scope.execute_query(query_statement_pos_params_limit2, 'United States') + elif query_type == SyncQueryType.LAZY: + # this format does not really make sense, if users are using static type checking it will prevent them + # but, technically viable so we test it + result = test_env.cluster_or_scope.execute_query(query_statement_pos_params_limit2, # type: ignore[call-overload] + 'United States', + lazy_execute=True) + else: + res = test_env.cluster_or_scope.execute_query(query_statement_pos_params_limit2, + 'United States', + enable_cancel=True) + assert isinstance(res, Future) + result = res.result() test_env.assert_rows(result, 2) + @pytest.mark.parametrize('query_type', [SyncQueryType.NORMAL, SyncQueryType.LAZY, SyncQueryType.CANCELLABLE]) def test_query_positional_params_override(self, test_env: BlockingTestEnvironment, - query_statement_pos_params_limit2: str) -> None: - result = test_env.cluster_or_scope.execute_query(query_statement_pos_params_limit2, - QueryOptions(positional_parameters=['abcdefg']), - 'United States') + query_statement_pos_params_limit2: str, + query_type: SyncQueryType) -> None: + + if query_type == SyncQueryType.NORMAL: + result = test_env.cluster_or_scope.execute_query(query_statement_pos_params_limit2, + QueryOptions(positional_parameters=['abcdefg']), + 'United States') + elif query_type == SyncQueryType.LAZY: + result = test_env.cluster_or_scope.execute_query(query_statement_pos_params_limit2, + QueryOptions(positional_parameters=['abcdefg'], + lazy_execute=True), + 'United States') + else: + res = test_env.cluster_or_scope.execute_query(query_statement_pos_params_limit2, + QueryOptions(positional_parameters=['abcdefg']), + 'United States', + enable_cancel=True) + assert isinstance(res, Future) + result = res.result() test_env.assert_rows(result, 2) - def test_query_raises_exception_prior_to_iterating(self, test_env: BlockingTestEnvironment) -> None: + # We test lazy execution in a separate test + @pytest.mark.parametrize('query_type', [SyncQueryType.NORMAL, SyncQueryType.CANCELLABLE]) + def test_query_raises_exception_prior_to_iterating(self, + test_env: BlockingTestEnvironment, + query_type: SyncQueryType) -> None: statement = "I'm not N1QL!" - with pytest.raises(QueryError): - test_env.cluster_or_scope.execute_query(statement) + if query_type == SyncQueryType.NORMAL: + with pytest.raises(QueryError): + test_env.cluster_or_scope.execute_query(statement) + else: + res = test_env.cluster_or_scope.execute_query(statement, enable_cancel=True) + assert isinstance(res, Future) + with pytest.raises(QueryError): + res.result() + + @pytest.mark.parametrize('query_type', [SyncQueryType.NORMAL, SyncQueryType.LAZY, SyncQueryType.CANCELLABLE]) def test_query_raw_options(self, test_env: BlockingTestEnvironment, - query_statement_pos_params_limit2: str) -> None: + query_statement_pos_params_limit2: str, + query_type: SyncQueryType) -> None: # via raw, we should be able to pass any option # if using named params, need to match full name param in query # which is different for when we pass in name_parameters via their specific @@ -188,19 +329,56 @@ def test_query_raw_options(self, statement = f'SELECT * FROM {test_env.collection_name} WHERE country = $country LIMIT $1;' else: statement = f'SELECT * FROM {test_env.fqdn} WHERE country = $country LIMIT $1;' - result = test_env.cluster_or_scope.execute_query(statement, - QueryOptions(raw={'$country': 'United States', - 'args': [2]})) - test_env.assert_rows(result, 2) - result = test_env.cluster_or_scope.execute_query(query_statement_pos_params_limit2, - QueryOptions(raw={'args': ['United States']})) + + if query_type == SyncQueryType.NORMAL: + result = test_env.cluster_or_scope.execute_query(statement, + QueryOptions(raw={'$country': 'United States', + 'args': [2]})) + elif query_type == SyncQueryType.LAZY: + result = test_env.cluster_or_scope.execute_query(statement, + QueryOptions(raw={'$country': 'United States', + 'args': [2]}, + lazy_execute=True)) + else: + res = test_env.cluster_or_scope.execute_query(statement, + QueryOptions(raw={'$country': 'United States', + 'args': [2]}), + enable_cancel=True) + assert isinstance(res, Future) + result = res.result() + + test_env.assert_rows(result, 2) + + if query_type == SyncQueryType.NORMAL: + result = test_env.cluster_or_scope.execute_query(query_statement_pos_params_limit2, + QueryOptions(raw={'args': ['United States']})) + elif query_type == SyncQueryType.LAZY: + result = test_env.cluster_or_scope.execute_query(query_statement_pos_params_limit2, + QueryOptions(raw={'args': ['United States']}, + lazy_execute=True)) + else: + res = test_env.cluster_or_scope.execute_query(query_statement_pos_params_limit2, + QueryOptions(raw={'args': ['United States']}), + enable_cancel=True) + assert isinstance(res, Future) + result = res.result() test_env.assert_rows(result, 2) + @pytest.mark.parametrize('query_type', [SyncQueryType.NORMAL, SyncQueryType.LAZY, SyncQueryType.CANCELLABLE]) def test_simple_query(self, test_env: BlockingTestEnvironment, - query_statement_limit2: str) -> None: - result = test_env.cluster_or_scope.execute_query(query_statement_limit2) + query_statement_limit2: str, + query_type: SyncQueryType) -> None: + + if query_type == SyncQueryType.NORMAL: + result = test_env.cluster_or_scope.execute_query(query_statement_limit2) + elif query_type == SyncQueryType.LAZY: + result = test_env.cluster_or_scope.execute_query(query_statement_limit2, lazy_execute=True) + else: + res = test_env.cluster_or_scope.execute_query(query_statement_limit2, enable_cancel=True) + assert isinstance(res, Future) + result = res.result() test_env.assert_rows(result, 2) def test_query_with_lazy_execution(self, @@ -226,10 +404,27 @@ def test_query_with_lazy_execution_raises_exception(self, test_env: BlockingTest with pytest.raises(QueryError): [r for r in result.rows()] - def test_query_passthrough_deserializer(self, test_env: BlockingTestEnvironment) -> None: + @pytest.mark.parametrize('query_type', [SyncQueryType.NORMAL, SyncQueryType.LAZY, SyncQueryType.CANCELLABLE]) + def test_query_passthrough_deserializer(self, + test_env: BlockingTestEnvironment, + query_type: SyncQueryType) -> None: statement = 'FROM range(0, 10) AS num SELECT *' - result = test_env.cluster_or_scope.execute_query(statement, - QueryOptions(deserializer=PassthroughDeserializer())) + + + if query_type == SyncQueryType.NORMAL: + result = test_env.cluster_or_scope.execute_query(statement, + QueryOptions(deserializer=PassthroughDeserializer())) + elif query_type == SyncQueryType.LAZY: + result = test_env.cluster_or_scope.execute_query(statement, + QueryOptions(deserializer=PassthroughDeserializer(), + lazy_execute=True)) + else: + res = test_env.cluster_or_scope.execute_query(statement, + QueryOptions(deserializer=PassthroughDeserializer()), + enable_cancel=True) + assert isinstance(res, Future) + result = res.result() + for idx, row in enumerate(result.rows()): assert isinstance(row, bytes) assert json.loads(row) == {'num': idx} From f17377eb86c515fb6cf516d74f2b466f159a114d Mon Sep 17 00:00:00 2001 From: Jared Casey Date: Mon, 23 Jun 2025 12:11:56 -0500 Subject: [PATCH 04/18] Updates to include further testing Changes ======= * PYCO-58: Timeout tests * PYCO-57: Cancellation tests --- .../protocol/core/_request_context.py | 101 ++++-- acouchbase_analytics/protocol/streaming.py | 48 ++- acouchbase_analytics/scope.pyi | 17 +- .../tests/query_integration_t.py | 109 +++++- couchbase_analytics/common/errors.py | 9 +- couchbase_analytics/common/result.py | 15 +- couchbase_analytics/common/streaming.py | 13 +- .../protocol/core/_request_context.py | 5 + couchbase_analytics/protocol/core/request.py | 1 - couchbase_analytics/protocol/streaming.py | 5 + .../tests/query_integration_t.py | 325 ++++++++++++++++-- 11 files changed, 546 insertions(+), 102 deletions(-) diff --git a/acouchbase_analytics/protocol/core/_request_context.py b/acouchbase_analytics/protocol/core/_request_context.py index f2e5876..fbde265 100644 --- a/acouchbase_analytics/protocol/core/_request_context.py +++ b/acouchbase_analytics/protocol/core/_request_context.py @@ -10,11 +10,14 @@ List, Optional, Type, + Union, TYPE_CHECKING) from uuid import uuid4 import anyio -from httpx import Response as HttpCoreResponse +from httpx import (Response as HttpCoreResponse, + TimeoutException) + from acouchbase_analytics.protocol.core._anyio_utils import (AsyncBackend, current_async_library, @@ -45,8 +48,10 @@ def __init__(self, # self._response_task: Optional[Task] = None self._request_state = StreamingState.NotStarted self._stage_completed: Optional[anyio.Event] = None - self._request_error: Optional[Exception] = None - self._connect_timeout = self._client_adapter.connection_details.get_connect_timeout() + self._request_error: Optional[Union[BaseException, Exception]] = None + connect_timeout = self._client_adapter.connection_details.get_connect_timeout() + self._connect_deadline = get_time() + connect_timeout + self._cancel_scope_deadline_updated = False @property def deserializer(self) -> Deserializer: @@ -61,14 +66,16 @@ def has_stage_completed(self) -> bool: @property def okay_to_iterate(self) -> bool: + self._check_cancelled_or_timed_out() return StreamingState.okay_to_iterate(self._request_state) @property def okay_to_stream(self) -> bool: + self._check_cancelled_or_timed_out() return StreamingState.okay_to_stream(self._request_state) @property - def request_error(self) -> Optional[Exception]: + def request_error(self) -> Optional[Union[BaseException, Exception]]: return self._request_error @property @@ -81,37 +88,90 @@ def request_state(self, state: StreamingState) -> None: raise TypeError('request_state must be an instance of StreamingState') self._request_state = state - # @property - # def stage_completed(self) -> Optional[anyio.Event]: - # return self._stage_completed - @property def timed_out(self) -> bool: + self._check_cancelled_or_timed_out() return self._request_state == StreamingState.Timeout @property def cancelled(self) -> bool: - return self._request_state == StreamingState.Cancelled + self._check_cancelled_or_timed_out() + return self._request_state in [StreamingState.Cancelled, StreamingState.AsyncCancelledPriorToTimeout] + + def _check_cancelled_or_timed_out(self) -> None: + if self._request_state in [StreamingState.Timeout, StreamingState.Cancelled, StreamingState.Error]: + return + + if hasattr(self, '_request_deadline') is False: + return + + current_time = get_time() + if self._cancel_scope_deadline_updated is False: + timed_out = current_time >= self._connect_deadline + else: + timed_out = current_time >= self._request_deadline + + if timed_out: + if self._request_state == StreamingState.Cancelled: + self._request_state = StreamingState.AsyncCancelledPriorToTimeout + else: + self._request_state = StreamingState.Timeout async def _execute(self, fn: Callable[..., Awaitable[Any]], *args: object) -> None: await fn(*args) if self._stage_completed is not None: self._stage_completed.set() + def _maybe_set_request_error(self, + exc_type: Optional[Type[BaseException]]=None, + exc_val: Optional[BaseException]=None) -> None: + self._check_cancelled_or_timed_out() + # TODO: Do either of these conditions need to be checked? Does _check_cancelled_or_timed_out() already handle this + # if self._taskgroup.cancel_scope.cancelled_caught and get_time() >= self._taskgroup.cancel_scope.deadline: + # if isinstance(exc_val, CancelledError): + if exc_val is None: + return + if not StreamingState.is_timeout_or_cancelled(self._request_state): + # This handles httpx timeouts + if exc_type is not None and issubclass(exc_type, TimeoutException): + self._request_state = StreamingState.Timeout + elif issubclass(type(exc_val), TimeoutException): + self._request_state = StreamingState.Timeout + else: + self._request_state = StreamingState.Error + self._request_error = exc_val + + async def _trace_handler(self, event_name: str, _: str) -> None: if event_name == 'connection.connect_tcp.complete': # after connection is established, we need to update the cancel_scope deadline to match the query_timeout self._update_cancel_scope_deadline(self._request_deadline, is_absolute=True) + self._cancel_scope_deadline_updated = True + elif self._cancel_scope_deadline_updated is False and event_name.endswith('send_request_headers.started'): + # if the socket is reused, we won't get the connect_tcp.complete event, so the deadline at the next closest event + self._update_cancel_scope_deadline(self._request_deadline, is_absolute=True) + self._cancel_scope_deadline_updated = True def _update_cancel_scope_deadline(self, deadline: float, is_absolute: Optional[bool]=False) -> None: # TODO: confirm scenario of get_time() < self._taskgroup.cancel_scope.deadline is handled by anyio - new_deadline = deadline if is_absolute else get_time() + deadline + # TODO: Useful debug log message + # print(f'Updating cancel scope deadline: {self._taskgroup.cancel_scope.deadline} -> {new_deadline}') if get_time() >= new_deadline: self._taskgroup.cancel_scope.cancel() else: self._taskgroup.cancel_scope.deadline = new_deadline + def cancel_request(self, + fn: Optional[Callable[..., Awaitable[Any]]]=None, + *args: object) -> None: + if fn is not None: + self._taskgroup.start_soon(fn, *args) + if self._request_state == StreamingState.Timeout: + return + self._taskgroup.cancel_scope.cancel() + self._request_state = StreamingState.Cancelled + async def initialize(self) -> None: await self.__aenter__() self._request_state = StreamingState.Started @@ -119,7 +179,7 @@ async def initialize(self) -> None: # closer to when the upstream logic will begin to use the request context timeouts = self._request.get_request_timeouts() or {} self._request_deadline = get_time() + (timeouts.get('read', None) or DEFAULT_TIMEOUTS['query_timeout']) - self._update_cancel_scope_deadline(self._connect_timeout) + self._update_cancel_scope_deadline(self._connect_deadline, is_absolute=True) async def send_request(self, enable_trace_handling: Optional[bool]=False) -> HttpCoreResponse: ip = await get_request_ip_async(self._request.host, self._request.port, self._request.previous_ips) @@ -133,6 +193,7 @@ async def send_request(self, enable_trace_handling: Optional[bool]=False) -> Htt .update_previous_ips(ip)) else: self._request.update_url(ip, self._client_adapter.analytics_path).update_previous_ips(ip) + # TODO: add logging; provide request details (to/from, deadlines, etc.) response = await self._client_adapter.send_request(self._request) self._request.set_client_server_addrs(response) if response.status_code == 401: @@ -150,10 +211,8 @@ async def shutdown(self, exc_tb: Optional[TracebackType]=None) -> None: if hasattr(self, '_taskgroup'): await self.__aexit__(exc_type, exc_val, exc_tb) - elif isinstance(exc_val, CancelledError): - self._request_state = StreamingState.Cancelled - elif exc_val is not None: - self._request_state = StreamingState.Error + else: + self._maybe_set_request_error() if StreamingState.is_okay(self._request_state): self._request_state = StreamingState.Completed @@ -168,7 +227,7 @@ def create_response_task(self, fn: Callable[..., Coroutine[Any, Any, Any]], *arg task: Task[Any] = self._backend.loop.create_task(fn(*args), name=task_name) # TODO: I don't think this callback is necessary...need to add more tests to confirm def task_done(t: Task[Any]) -> None: - print(f'Task ({t.get_name()}) done: {t.done()}, cancelled: {t.cancelled()}') + print(f'Task done callback task=({t.get_name()}); done: {t.done()}, cancelled: {t.cancelled()}') task.add_done_callback(task_done) self._response_task = task @@ -181,9 +240,6 @@ def start_next_stage(self, fn: Callable[..., Awaitable[Any]], *args: object, reset_previous_stage: Optional[bool]=False) -> None: - # if reset_previous_stage is True: - # if self._stage_completed is not None: - # self._stage_completed = None if self._stage_completed is not None: if reset_previous_stage is True: self._stage_completed = None @@ -222,12 +278,7 @@ async def __aexit__(self, except BaseException as ex: pass # we handle the error when the context is shutdown (which is what calls __aexit__()) finally: - if self._taskgroup.cancel_scope.cancelled_caught and get_time() >= self._taskgroup.cancel_scope.deadline: - self._request_state = StreamingState.Timeout - elif isinstance(exc_val, CancelledError): - self._request_state = StreamingState.Cancelled - elif exc_val is not None: - self._request_state = StreamingState.Error + self._maybe_set_request_error() del self._taskgroup # TODO: should we suppress here (e.g., return True) return None \ No newline at end of file diff --git a/acouchbase_analytics/protocol/streaming.py b/acouchbase_analytics/protocol/streaming.py index 881143d..a853b2f 100644 --- a/acouchbase_analytics/protocol/streaming.py +++ b/acouchbase_analytics/protocol/streaming.py @@ -72,12 +72,13 @@ async def wrapped_fn(self: AsyncHttpStreamingResponse) -> None: raise ex except BaseException as ex: await self._request_context.shutdown(type(ex), ex, ex.__traceback__) - if self._request_context.request_error is not None: - raise self._request_context.request_error from None if self._request_context.timed_out: - raise TimeoutError(message='Request timed out.') from None + raise TimeoutError(cause=self._request_context.request_error, + message='Request timed out.') from None if self._request_context.cancelled: raise CancelledError('Request was cancelled.') from None + if self._request_context.request_error is not None: + raise self._request_context.request_error from None raise InternalSDKError(ex) from None finally: if not StreamingState.is_okay(self._request_context.request_state): @@ -106,6 +107,15 @@ async def _finish_processing_stream(self) -> None: self._request_context.start_next_stage(self._json_stream.continue_parsing, reset_previous_stage=True) await self._request_context.wait_for_stage_to_complete() + async def _handle_iteration_abort(self) -> None: + await self.close() + if self._request_context.cancelled: + raise StopAsyncIteration + elif self._request_context.timed_out: + raise TimeoutError(message='Request timeout.') + else: + raise StopAsyncIteration + def _maybe_continue_to_process_stream(self) -> None: if not self._request_context.has_stage_completed: return @@ -124,12 +134,13 @@ async def _process_response(self, raw_response: Optional[ParsedResult]=None) -> if raw_response.value is None: raise AnalyticsError(message='Received unexpected empty result from JsonStream.') + # we have all the data, close the core response/stream + await self.close() + json_response = json.loads(raw_response.value) if 'errors' in json_response: await self._request_context.process_error(json_response['errors']) self.set_metadata(json_data=json_response) - # we have all the data, close the core response/stream - await self.close() def _start(self) -> None: """ @@ -142,6 +153,9 @@ def _start(self) -> None: self._json_stream = AsyncJsonStream(self._core_response.aiter_bytes(), stream_config=self._stream_config) self._request_context.start_next_stage(self._json_stream.start_parsing) + async def _close_in_background(self) -> None: + await self.close() + async def close(self) -> None: """ **INTERNAL** @@ -150,14 +164,26 @@ async def close(self) -> None: await self._core_response.aclose() del self._core_response - async def cancel(self) -> None: + def cancel(self) -> None: + """ + **INTERNAL** + """ + self._request_context.cancel_request(self._close_in_background) + + async def cancel_async(self) -> None: """ **INTERNAL** """ await self.close() + self._request_context.cancel_request() + await self._request_context.shutdown() def get_metadata(self) -> QueryMetadata: if self._metadata is None: + if self._request_context.cancelled: + raise CancelledError('Request was cancelled.') + elif self._request_context.timed_out: + raise TimeoutError(message='Request timeout.') raise RuntimeError('Query metadata is only available after all rows have been iterated.') return self._metadata @@ -175,8 +201,10 @@ async def get_next_row(self) -> Any: """ **INTERNAL** """ - if self._core_response is None or not self._request_context.okay_to_iterate: - raise StopAsyncIteration + if not (hasattr(self, '_core_response') + and self._core_response is not None + and self._request_context.okay_to_iterate): + await self._handle_iteration_abort() self._maybe_continue_to_process_stream() raw_response = await self._json_stream.get_result() @@ -211,6 +239,10 @@ async def send_request(self) -> None: await self._finish_processing_stream() await self._process_response() + async def shutdown(self) -> None: + await self.close() + await self._request_context.shutdown() + SendRequestFunc: TypeAlias = Callable[[AsyncHttpStreamingResponse], Coroutine[Any, Any, None]] # Although, SendRequestFunc is the same type as WrappedSendRequestFunc, keep separate for clarity and indicate # WrappedSendRequestFunc is a decorator diff --git a/acouchbase_analytics/scope.pyi b/acouchbase_analytics/scope.pyi index b1358bb..fff196b 100644 --- a/acouchbase_analytics/scope.pyi +++ b/acouchbase_analytics/scope.pyi @@ -14,8 +14,7 @@ # limitations under the License. import sys -from asyncio import Future -from typing import overload +from typing import Awaitable, overload if sys.version_info < (3, 11): from typing_extensions import Unpack @@ -33,36 +32,36 @@ class AsyncScope: def name(self) -> str: ... @overload - def execute_query(self, statement: str) -> Future[AsyncQueryResult]: ... + def execute_query(self, statement: str) -> Awaitable[AsyncQueryResult]: ... @overload - def execute_query(self, statement: str, options: QueryOptions) -> Future[AsyncQueryResult]: ... + def execute_query(self, statement: str, options: QueryOptions) -> Awaitable[AsyncQueryResult]: ... @overload - def execute_query(self, statement: str, **kwargs: Unpack[QueryOptionsKwargs]) -> Future[AsyncQueryResult]: ... + def execute_query(self, statement: str, **kwargs: Unpack[QueryOptionsKwargs]) -> Awaitable[AsyncQueryResult]: ... @overload def execute_query(self, statement: str, options: QueryOptions, - **kwargs: Unpack[QueryOptionsKwargs]) -> Future[AsyncQueryResult]: ... + **kwargs: Unpack[QueryOptionsKwargs]) -> Awaitable[AsyncQueryResult]: ... @overload def execute_query(self, statement: str, options: QueryOptions, *args: str, - **kwargs: Unpack[QueryOptionsKwargs]) -> Future[AsyncQueryResult]: ... + **kwargs: Unpack[QueryOptionsKwargs]) -> Awaitable[AsyncQueryResult]: ... @overload def execute_query(self, statement: str, options: QueryOptions, *args: str, - **kwargs: str) -> Future[AsyncQueryResult]: ... + **kwargs: str) -> Awaitable[AsyncQueryResult]: ... @overload def execute_query(self, statement: str, *args: str, - **kwargs: str) -> Future[AsyncQueryResult]: ... + **kwargs: str) -> Awaitable[AsyncQueryResult]: ... diff --git a/acouchbase_analytics/tests/query_integration_t.py b/acouchbase_analytics/tests/query_integration_t.py index b6fafb7..65d2186 100644 --- a/acouchbase_analytics/tests/query_integration_t.py +++ b/acouchbase_analytics/tests/query_integration_t.py @@ -16,14 +16,14 @@ from __future__ import annotations import json -from asyncio import CancelledError, Future +from asyncio import CancelledError, Task from datetime import timedelta from typing import TYPE_CHECKING import pytest from acouchbase_analytics.deserializer import PassthroughDeserializer -from acouchbase_analytics.errors import QueryError +from acouchbase_analytics.errors import QueryError, TimeoutError from acouchbase_analytics.options import QueryOptions from acouchbase_analytics.result import AsyncQueryResult from couchbase_analytics.common.streaming import StreamingState @@ -36,20 +36,23 @@ class QueryTestSuite: TEST_MANIFEST = [ - # 'test_query_cancel_prior_iterating', - # 'test_query_cancel_while_iterating', + 'test_query_cancel_prior_iterating', + 'test_query_cancel_async_while_iterating', + 'test_query_cancel_while_iterating', 'test_query_metadata', 'test_query_metadata_not_available', 'test_query_named_parameters', 'test_query_named_parameters_no_options', 'test_query_named_parameters_override', + 'test_query_passthrough_deserializer', 'test_query_positional_params', 'test_query_positional_params_no_option', 'test_query_positional_params_override', 'test_query_raises_exception_prior_to_iterating', 'test_query_raw_options', + 'test_query_timeout', + 'test_query_timeout_while_streaming', 'test_simple_query', - 'test_query_passthrough_deserializer', ] @pytest.fixture(scope='class') @@ -80,6 +83,64 @@ def query_statement_limit5(self, test_env: AsyncTestEnvironment) -> str: else: return f'SELECT * FROM {test_env.fqdn} LIMIT 5;' + async def test_query_cancel_prior_iterating(self, test_env: AsyncTestEnvironment) -> None: + statement = 'FROM range(0, 100000) AS r SELECT *' + qtask = test_env.cluster_or_scope.execute_query(statement) + assert isinstance(qtask, Task) + qtask.cancel() + with pytest.raises(CancelledError): + await qtask + + async def test_query_cancel_async_while_iterating(self, + test_env: AsyncTestEnvironment, + query_statement_limit5: str) -> None: + qtask = test_env.cluster_or_scope.execute_query(query_statement_limit5) + assert isinstance(qtask, Task) + res = await qtask + assert isinstance(res, AsyncQueryResult) + expected_state = StreamingState.StreamingResults + assert res._http_response._request_context.request_state == expected_state + rows = [] + count = 0 + async for row in res.rows(): + if count == 2: + await res.cancel_async() + assert row is not None + rows.append(row) + count += 1 + + assert len(rows) == count + expected_state = StreamingState.Cancelled + assert res._http_response._request_context.request_state == expected_state + with pytest.raises(CancelledError): + res.metadata() + + async def test_query_cancel_while_iterating(self, + test_env: AsyncTestEnvironment, + query_statement_limit5: str) -> None: + qtask = test_env.cluster_or_scope.execute_query(query_statement_limit5) + assert isinstance(qtask, Task) + res = await qtask + assert isinstance(res, AsyncQueryResult) + expected_state = StreamingState.StreamingResults + assert res._http_response._request_context.request_state == expected_state + rows = [] + count = 0 + async for row in res.rows(): + if count == 2: + res.cancel() + assert row is not None + rows.append(row) + count += 1 + + assert len(rows) == count + expected_state = StreamingState.Cancelled + assert res._http_response._request_context.request_state == expected_state + with pytest.raises(CancelledError): + res.metadata() + # if we don't cancel via the async path, we want to ensure the stream/response is shutdown appropriately + await res.shutdown() + async def test_query_metadata(self, test_env: AsyncTestEnvironment, query_statement_limit5: str) -> None: @@ -148,6 +209,16 @@ async def test_query_named_parameters_override(self, country='United States') await test_env.assert_rows(result, 2) + async def test_query_passthrough_deserializer(self, test_env: AsyncTestEnvironment) -> None: + statement = 'FROM range(0, 10) AS num SELECT *' + result = await test_env.cluster_or_scope.execute_query(statement, + QueryOptions(deserializer=PassthroughDeserializer())) + idx = 0 + async for row in result.rows(): + assert isinstance(row, bytes) + assert json.loads(row) == {'num': idx} + idx += 1 + async def test_query_positional_params(self, test_env: AsyncTestEnvironment, query_statement_pos_params_limit2: str) -> None: @@ -195,22 +266,30 @@ async def test_query_raw_options(self, QueryOptions(raw={'args': ['United States']})) await test_env.assert_rows(result, 2) + async def test_query_timeout(self, test_env: AsyncTestEnvironment) -> None: + statement = 'SELECT sleep("some value", 10000) AS some_field;' + + with pytest.raises(TimeoutError): + await test_env.cluster_or_scope.execute_query(statement, + QueryOptions(timeout=timedelta(seconds=2))) + + async def test_query_timeout_while_streaming(self, test_env: AsyncTestEnvironment) -> None: + statement = 'SELECT {"x1": 1, "x2": 2, "x3": 3} FROM range(1, 100000) r;' + res = test_env.cluster_or_scope.execute_query(statement, + QueryOptions(timeout=timedelta(seconds=2))) + assert isinstance(res, Task) + result = await res + + with pytest.raises(TimeoutError): + async for _ in result.rows(): + pass + async def test_simple_query(self, test_env: AsyncTestEnvironment, query_statement_limit2: str) -> None: result = await test_env.cluster_or_scope.execute_query(query_statement_limit2) await test_env.assert_rows(result, 2) - async def test_query_passthrough_deserializer(self, test_env: AsyncTestEnvironment) -> None: - statement = 'FROM range(0, 10) AS num SELECT *' - result = await test_env.cluster_or_scope.execute_query(statement, - QueryOptions(deserializer=PassthroughDeserializer())) - idx = 0 - async for row in result.rows(): - assert isinstance(row, bytes) - assert json.loads(row) == {'num': idx} - idx += 1 - class ClusterQueryTests(QueryTestSuite): @pytest.fixture(scope='class', autouse=True) diff --git a/couchbase_analytics/common/errors.py b/couchbase_analytics/common/errors.py index f8a51e9..83e7080 100644 --- a/couchbase_analytics/common/errors.py +++ b/couchbase_analytics/common/errors.py @@ -16,7 +16,8 @@ from __future__ import annotations from typing import (Dict, - Optional) + Optional, + Union) """ @@ -30,7 +31,7 @@ class AnalyticsError(Exception): Generic base error. Analytics specific errors inherit from this base error. """ - def __init__(self, cause: Optional[Exception] = None, message: Optional[str] = None) -> None: + def __init__(self, cause: Optional[Union[BaseException, Exception]] = None, message: Optional[str] = None) -> None: self._cause = cause self._message = message super().__init__(message) @@ -56,7 +57,7 @@ class InvalidCredentialError(AnalyticsError): Indicates that an error occurred authenticating the user to the cluster. """ - def __init__(self, context: str, cause: Optional[Exception] = None, message: Optional[str] = None) -> None: + def __init__(self, context: str, cause: Optional[Union[BaseException, Exception]] = None, message: Optional[str] = None) -> None: super().__init__(cause=cause, message=message) self._context = context @@ -124,7 +125,7 @@ class TimeoutError(AnalyticsError): Indicates that a request was unable to complete prior to reaching the deadline specified for the reqest. """ - def __init__(self, cause: Optional[Exception] = None, message: Optional[str] = None) -> None: + def __init__(self, cause: Optional[Union[BaseException, Exception]] = None, message: Optional[str] = None) -> None: super().__init__(cause, message) def __repr__(self) -> str: diff --git a/couchbase_analytics/common/result.py b/couchbase_analytics/common/result.py index c9c4ebc..7490a06 100644 --- a/couchbase_analytics/common/result.py +++ b/couchbase_analytics/common/result.py @@ -87,12 +87,19 @@ class AsyncQueryResult(QueryResult): def __init__(self, http_response: AsyncHttpStreamingResponse) -> None: self._http_response = http_response - async def cancel(self) -> None: + def cancel(self) -> None: + """Cancel streaming the query results. + + **VOLATILE** This API is subject to change at any time. + """ + self._http_response.cancel() + + async def cancel_async(self) -> None: """Cancel streaming the query results. **VOLATILE** This API is subject to change at any time. """ - await self._http_response.cancel() + await self._http_response.cancel_async() async def get_all_rows(self) -> List[Any]: """Convenience method to load all query results into memory. @@ -131,6 +138,10 @@ def rows(self) -> AsyncIterator: An async iterator for iterating over query results. """ return AsyncIterator(self._http_response) + + async def shutdown(self) -> None: + """Shutdown the streaming connection.""" + await self._http_response.shutdown() def __aiter__(self) -> AsyncIterator: return AsyncIterator(self._http_response).__aiter__() diff --git a/couchbase_analytics/common/streaming.py b/couchbase_analytics/common/streaming.py index 1d2a190..4e4ea0d 100644 --- a/couchbase_analytics/common/streaming.py +++ b/couchbase_analytics/common/streaming.py @@ -42,7 +42,8 @@ class StreamingState(IntEnum): StreamingResults = 4 Error = 5 Timeout = 6 - SyncCancelledPriorToTimeout = 7 + AsyncCancelledPriorToTimeout = 7 + SyncCancelledPriorToTimeout = 8 @staticmethod def okay_to_stream(state: StreamingState) -> bool: @@ -66,6 +67,16 @@ def is_okay(state: StreamingState) -> bool: return state not in [StreamingState.Cancelled, StreamingState.Error, StreamingState.Timeout] + + @staticmethod + def is_timeout_or_cancelled(state: StreamingState) -> bool: + """ + **INTERNAL + """ + return state in [StreamingState.Cancelled, + StreamingState.Timeout, + StreamingState.AsyncCancelledPriorToTimeout, + StreamingState.SyncCancelledPriorToTimeout] class BlockingIterator(Iterator[Any]): diff --git a/couchbase_analytics/protocol/core/_request_context.py b/couchbase_analytics/protocol/core/_request_context.py index 180453b..f123492 100644 --- a/couchbase_analytics/protocol/core/_request_context.py +++ b/couchbase_analytics/protocol/core/_request_context.py @@ -208,6 +208,11 @@ def _trace_handler(self, event_name: str, _: str) -> None: if event_name == 'connection.connect_tcp.complete': print('Connection established, updating cancel scope deadline') + def cancel_request(self) -> None: + if self._request_state == StreamingState.Timeout: + return + self._request_state = StreamingState.Cancelled + def initialize(self) -> None: self._request_state = StreamingState.Started timeouts = self._request.get_request_timeouts() or {} diff --git a/couchbase_analytics/protocol/core/request.py b/couchbase_analytics/protocol/core/request.py index 2f83a1d..13a7cb9 100644 --- a/couchbase_analytics/protocol/core/request.py +++ b/couchbase_analytics/protocol/core/request.py @@ -82,7 +82,6 @@ def get_request_timeouts(self) -> Optional[RequestTimeoutExtensions]: return self.extensions['timeout'] def set_client_server_addrs(self, response: HttpCoreResponse) -> None: - # TODO: this logic comes from httpcore, typing won't be happy network_stream = response.extensions.get('network_stream', None) # TODO: what if network_stream is None? if network_stream is not None: diff --git a/couchbase_analytics/protocol/streaming.py b/couchbase_analytics/protocol/streaming.py index cc8dcd4..7beeacd 100644 --- a/couchbase_analytics/protocol/streaming.py +++ b/couchbase_analytics/protocol/streaming.py @@ -177,10 +177,15 @@ def cancel(self) -> None: """ **INTERNAL** """ + self._request_context.cancel_request() self.close() def get_metadata(self) -> QueryMetadata: if self._metadata is None: + if self._request_context.cancelled: + raise CancelledError('Request was cancelled.') + elif self._request_context.timed_out: + raise TimeoutError(message='Request timeout.') raise RuntimeError('Query metadata is only available after all rows have been iterated.') return self._metadata diff --git a/couchbase_analytics/tests/query_integration_t.py b/couchbase_analytics/tests/query_integration_t.py index 1d24469..2bb804b 100644 --- a/couchbase_analytics/tests/query_integration_t.py +++ b/couchbase_analytics/tests/query_integration_t.py @@ -16,18 +16,19 @@ from __future__ import annotations import json -from concurrent.futures import Future +from concurrent.futures import CancelledError, Future from enum import Enum from datetime import timedelta from typing import (TYPE_CHECKING, Any, - Dict) + Dict, + Optional) import pytest from couchbase_analytics.common.streaming import StreamingState from couchbase_analytics.deserializer import PassthroughDeserializer -from couchbase_analytics.errors import QueryError +from couchbase_analytics.errors import QueryError, TimeoutError from couchbase_analytics.options import QueryOptions from couchbase_analytics.query import QueryScanConsistency from couchbase_analytics.result import BlockingQueryResult @@ -44,29 +45,31 @@ class SyncQueryType(Enum): class QueryTestSuite: + TEST_MANIFEST = [ - # 'test_cancel_positional_params_override', - # 'test_cancel_positional_params_override_token_in_kwargs', - # 'test_cancel_prior_iterating', - # 'test_cancel_prior_iterating_positional_params', - # 'test_cancel_prior_iterating_with_kwargs', - # 'test_cancel_prior_iterating_with_options', - # 'test_cancel_prior_iterating_with_opts_and_kwargs', - # 'test_cancel_while_iterating', + 'test_cancel_prior_iterating', + 'test_cancel_prior_iterating_positional_params', + 'test_cancel_prior_iterating_with_kwargs', + 'test_cancel_prior_iterating_with_options', + 'test_cancel_prior_iterating_with_opts_and_kwargs', + 'test_cancel_while_iterating', + 'test_query_cannot_set_both_cancel_and_lazy_execution', 'test_query_metadata', 'test_query_metadata_not_available', 'test_query_named_parameters', 'test_query_named_parameters_no_options', 'test_query_named_parameters_override', + 'test_query_passthrough_deserializer', 'test_query_positional_params', 'test_query_positional_params_no_option', 'test_query_positional_params_override', 'test_query_raises_exception_prior_to_iterating', 'test_query_raw_options', + 'test_query_timeout', + 'test_query_timeout_while_streaming', 'test_simple_query', 'test_query_with_lazy_execution', - 'test_query_with_lazy_execution_raises_exception', - 'test_query_passthrough_deserializer', + 'test_query_with_lazy_execution_raises_exception', ] @pytest.fixture(scope='class') @@ -97,6 +100,208 @@ def query_statement_limit5(self, test_env: BlockingTestEnvironment) -> str: else: return f'SELECT * FROM {test_env.fqdn} LIMIT 5;' + @pytest.mark.parametrize('cancel_via_future', [False, True]) + def test_cancel_prior_iterating(self, test_env: BlockingTestEnvironment, cancel_via_future: bool) -> None: + statement = 'FROM range(0, 100000) AS r SELECT *' + ft = test_env.cluster_or_scope.execute_query(statement, enable_cancel=True) + assert isinstance(ft, Future) + res: Optional[BlockingQueryResult] = None + rows = [] + if cancel_via_future: + ft.cancel() + with pytest.raises(CancelledError): + res = ft.result() + for row in res.rows(): + rows.append(row) + + assert res is None + assert len(rows) == 0 + else: + res = ft.result() + res.cancel() + + assert isinstance(res, BlockingQueryResult) + assert res._http_response._request_context.request_state == StreamingState.Cancelled + + for row in res.rows(): + rows.append(row) + + with pytest.raises(CancelledError): + res.metadata() + + @pytest.mark.parametrize('cancel_via_future', [False, True]) + def test_cancel_prior_iterating_positional_params(self, + test_env: BlockingTestEnvironment, + query_statement_pos_params_limit2: str, + cancel_via_future: bool) -> None: + ft = test_env.cluster_or_scope.execute_query(query_statement_pos_params_limit2, + 'United States', + enable_cancel=True) + assert isinstance(ft, Future) + res: Optional[BlockingQueryResult] = None + rows = [] + if cancel_via_future: + ft.cancel() + with pytest.raises(CancelledError): + res = ft.result() + for row in res.rows(): + rows.append(row) + + assert res is None + assert len(rows) == 0 + else: + res = ft.result() + res.cancel() + + assert isinstance(res, BlockingQueryResult) + assert res._http_response._request_context.request_state == StreamingState.Cancelled + + for row in res.rows(): + rows.append(row) + + with pytest.raises(CancelledError): + res.metadata() + + @pytest.mark.parametrize('cancel_via_future', [False, True]) + def test_cancel_prior_iterating_with_kwargs(self, + test_env: BlockingTestEnvironment, + cancel_via_future: bool) -> None: + statement = 'FROM range(0, 100000) AS r SELECT *' + ft = test_env.cluster_or_scope.execute_query(statement, + timeout=timedelta(seconds=4), + enable_cancel=True) + assert isinstance(ft, Future) + res: Optional[BlockingQueryResult] = None + rows = [] + if cancel_via_future: + ft.cancel() + with pytest.raises(CancelledError): + res = ft.result() + for row in res.rows(): + rows.append(row) + + assert res is None + assert len(rows) == 0 + else: + res = ft.result() + res.cancel() + + assert isinstance(res, BlockingQueryResult) + assert res._http_response._request_context.request_state == StreamingState.Cancelled + + for row in res.rows(): + rows.append(row) + + with pytest.raises(CancelledError): + res.metadata() + + @pytest.mark.parametrize('cancel_via_future', [False, True]) + def test_cancel_prior_iterating_with_options(self, + test_env: BlockingTestEnvironment, + cancel_via_future: bool) -> None: + statement = 'FROM range(0, 100000) AS r SELECT *' + ft = test_env.cluster_or_scope.execute_query(statement, + QueryOptions(timeout=timedelta(seconds=4)), + enable_cancel=True) + assert isinstance(ft, Future) + res: Optional[BlockingQueryResult] = None + rows = [] + if cancel_via_future: + ft.cancel() + with pytest.raises(CancelledError): + res = ft.result() + for row in res.rows(): + rows.append(row) + + assert res is None + assert len(rows) == 0 + else: + res = ft.result() + res.cancel() + + assert isinstance(res, BlockingQueryResult) + assert res._http_response._request_context.request_state == StreamingState.Cancelled + + for row in res.rows(): + rows.append(row) + + with pytest.raises(CancelledError): + res.metadata() + + @pytest.mark.parametrize('cancel_via_future', [False, True]) + def test_cancel_prior_iterating_with_opts_and_kwargs(self, + test_env: BlockingTestEnvironment, + cancel_via_future: bool) -> None: + statement = 'FROM range(0, 100000) AS r SELECT *' + ft = test_env.cluster_or_scope.execute_query(statement, + QueryOptions(scan_consistency=QueryScanConsistency.NOT_BOUNDED), + timeout=timedelta(seconds=4), + enable_cancel=True) + assert isinstance(ft, Future) + res: Optional[BlockingQueryResult] = None + rows = [] + if cancel_via_future: + ft.cancel() + with pytest.raises(CancelledError): + res = ft.result() + for row in res.rows(): + rows.append(row) + + assert res is None + assert len(rows) == 0 + else: + res = ft.result() + res.cancel() + + assert isinstance(res, BlockingQueryResult) + assert res._http_response._request_context.request_state == StreamingState.Cancelled + + for row in res.rows(): + rows.append(row) + + with pytest.raises(CancelledError): + res.metadata() + + @pytest.mark.parametrize('query_type', [SyncQueryType.NORMAL, SyncQueryType.LAZY, SyncQueryType.CANCELLABLE]) + def test_cancel_while_iterating(self, + test_env: BlockingTestEnvironment, + query_statement_limit5: str, + query_type: SyncQueryType) -> None: + if query_type == SyncQueryType.NORMAL: + result = test_env.cluster_or_scope.execute_query(query_statement_limit5) + elif query_type == SyncQueryType.LAZY: + result = test_env.cluster_or_scope.execute_query(query_statement_limit5, + QueryOptions(lazy_execute=True)) + else: + res = test_env.cluster_or_scope.execute_query(query_statement_limit5, enable_cancel=True) + assert isinstance(res, Future) + result = res.result() + + assert isinstance(result, BlockingQueryResult) + expected_state = StreamingState.StreamingResults if query_type != SyncQueryType.LAZY else StreamingState.NotStarted + assert result._http_response._request_context.request_state == expected_state + rows = [] + count = 0 + for row in result.rows(): + if count == 2: + result.cancel() + assert row is not None + rows.append(row) + count += 1 + + assert len(rows) == count + expected_state = StreamingState.Cancelled + assert result._http_response._request_context.request_state == expected_state + with pytest.raises(CancelledError): + result.metadata() + + def test_query_cannot_set_both_cancel_and_lazy_execution(self, test_env: BlockingTestEnvironment) -> None: + statement = 'SELECT 1=1' + with pytest.raises(RuntimeError): + test_env.cluster_or_scope.execute_query(statement, + QueryOptions(lazy_execute=True), + enable_cancel=True) + @pytest.mark.parametrize('query_type', [SyncQueryType.NORMAL, SyncQueryType.LAZY, SyncQueryType.CANCELLABLE]) def test_query_metadata(self, test_env: BlockingTestEnvironment, @@ -234,6 +439,31 @@ def test_query_named_parameters_override(self, result = res.result() test_env.assert_rows(result, 2) + @pytest.mark.parametrize('query_type', [SyncQueryType.NORMAL, SyncQueryType.LAZY, SyncQueryType.CANCELLABLE]) + def test_query_passthrough_deserializer(self, + test_env: BlockingTestEnvironment, + query_type: SyncQueryType) -> None: + statement = 'FROM range(0, 10) AS num SELECT *' + + + if query_type == SyncQueryType.NORMAL: + result = test_env.cluster_or_scope.execute_query(statement, + QueryOptions(deserializer=PassthroughDeserializer())) + elif query_type == SyncQueryType.LAZY: + result = test_env.cluster_or_scope.execute_query(statement, + QueryOptions(deserializer=PassthroughDeserializer(), + lazy_execute=True)) + else: + res = test_env.cluster_or_scope.execute_query(statement, + QueryOptions(deserializer=PassthroughDeserializer()), + enable_cancel=True) + assert isinstance(res, Future) + result = res.result() + + for idx, row in enumerate(result.rows()): + assert isinstance(row, bytes) + assert json.loads(row) == {'num': idx} + @pytest.mark.parametrize('query_type', [SyncQueryType.NORMAL, SyncQueryType.LAZY, SyncQueryType.CANCELLABLE]) def test_query_positional_params(self, test_env: BlockingTestEnvironment, @@ -365,6 +595,51 @@ def test_query_raw_options(self, result = res.result() test_env.assert_rows(result, 2) + @pytest.mark.parametrize('query_type', [SyncQueryType.NORMAL, SyncQueryType.LAZY, SyncQueryType.CANCELLABLE]) + def test_query_timeout(self, test_env: BlockingTestEnvironment, query_type: SyncQueryType) -> None: + statement = 'SELECT sleep("some value", 10000) AS some_field;' + + if query_type == SyncQueryType.NORMAL: + with pytest.raises(TimeoutError): + result = test_env.cluster_or_scope.execute_query(statement, + QueryOptions(timeout=timedelta(seconds=2))) + elif query_type == SyncQueryType.LAZY: + result = test_env.cluster_or_scope.execute_query(statement, + QueryOptions(timeout=timedelta(seconds=2), + lazy_execute=True)) + with pytest.raises(TimeoutError): + for _ in result.rows(): + pass + else: + res = test_env.cluster_or_scope.execute_query(statement, + QueryOptions(timeout=timedelta(seconds=2)), + enable_cancel=True) + assert isinstance(res, Future) + with pytest.raises(TimeoutError): + result = res.result() + + @pytest.mark.parametrize('query_type', [SyncQueryType.NORMAL, SyncQueryType.LAZY, SyncQueryType.CANCELLABLE]) + def test_query_timeout_while_streaming(self, test_env: BlockingTestEnvironment, query_type: SyncQueryType) -> None: + statement = 'SELECT {"x1": 1, "x2": 2, "x3": 3} FROM range(1, 100000) r;' + if query_type == SyncQueryType.NORMAL: + result = test_env.cluster_or_scope.execute_query(statement, + QueryOptions(timeout=timedelta(seconds=2))) + elif query_type == SyncQueryType.LAZY: + result = test_env.cluster_or_scope.execute_query(statement, + QueryOptions(timeout=timedelta(seconds=2), + lazy_execute=True)) + else: + res = test_env.cluster_or_scope.execute_query(statement, + QueryOptions(timeout=timedelta(seconds=2)), + enable_cancel=True) + assert isinstance(res, Future) + result = res.result() + + with pytest.raises(TimeoutError): + for _ in result.rows(): + pass + + @pytest.mark.parametrize('query_type', [SyncQueryType.NORMAL, SyncQueryType.LAZY, SyncQueryType.CANCELLABLE]) def test_simple_query(self, test_env: BlockingTestEnvironment, @@ -404,31 +679,7 @@ def test_query_with_lazy_execution_raises_exception(self, test_env: BlockingTest with pytest.raises(QueryError): [r for r in result.rows()] - @pytest.mark.parametrize('query_type', [SyncQueryType.NORMAL, SyncQueryType.LAZY, SyncQueryType.CANCELLABLE]) - def test_query_passthrough_deserializer(self, - test_env: BlockingTestEnvironment, - query_type: SyncQueryType) -> None: - statement = 'FROM range(0, 10) AS num SELECT *' - - if query_type == SyncQueryType.NORMAL: - result = test_env.cluster_or_scope.execute_query(statement, - QueryOptions(deserializer=PassthroughDeserializer())) - elif query_type == SyncQueryType.LAZY: - result = test_env.cluster_or_scope.execute_query(statement, - QueryOptions(deserializer=PassthroughDeserializer(), - lazy_execute=True)) - else: - res = test_env.cluster_or_scope.execute_query(statement, - QueryOptions(deserializer=PassthroughDeserializer()), - enable_cancel=True) - assert isinstance(res, Future) - result = res.result() - - for idx, row in enumerate(result.rows()): - assert isinstance(row, bytes) - assert json.loads(row) == {'num': idx} - class ClusterQueryTests(QueryTestSuite): @pytest.fixture(scope='class', autouse=True) From 0770b9eaa40cade2352fd9426a14aa27b668bc3b Mon Sep 17 00:00:00 2001 From: Jared Casey Date: Wed, 2 Jul 2025 16:35:20 -0600 Subject: [PATCH 05/18] Updates to move baseline SDK to internal preview ready. Changes ======= * Introduce request_context to handle bulk of logic for a request (state, retries, etc.) * Increase test coverage * Apply formatting/lint via ruff * Apply static type checking via mypy * Cleanup and reorganize code structure --- MANIFEST.in | 2 + acouchbase_analytics/cluster.py | 2 +- acouchbase_analytics/cluster.pyi | 5 +- .../protocol/{core => _core}/__init__.py | 0 .../_anyio_utils.py => _core/anyio_utils.py} | 4 + .../protocol/_core}/async_json_stream.py | 37 +- .../_core}/async_json_token_parser.py | 38 +- .../{core => _core}/client_adapter.py | 20 +- .../protocol/_core/net_utils.py | 57 +++ .../request_context.py} | 298 +++++++++------ .../protocol/_core/retries.py | 99 +++++ acouchbase_analytics/protocol/cluster.py | 19 +- acouchbase_analytics/protocol/cluster.pyi | 7 +- acouchbase_analytics/protocol/database.py | 2 +- acouchbase_analytics/protocol/database.pyi | 2 +- acouchbase_analytics/protocol/errors.py | 42 +++ acouchbase_analytics/protocol/scope.py | 17 +- acouchbase_analytics/protocol/scope.pyi | 2 +- acouchbase_analytics/protocol/streaming.py | 215 ++++------- acouchbase_analytics/tests/connection_t.py | 11 +- acouchbase_analytics/tests/json_parsing_t.py | 49 ++- acouchbase_analytics/tests/options_t.py | 23 +- .../tests/query_integration_t.py | 22 +- acouchbase_analytics/tests/query_options_t.py | 10 +- acouchbase_analytics/tests/test_server_t.py | 154 +++++++- conftest.py | 4 + couchbase_analytics/_version.py | 4 +- couchbase_analytics/cluster.py | 4 +- couchbase_analytics/cluster.pyi | 5 +- couchbase_analytics/common/__init__.py | 5 +- .../common/{core => _core}/__init__.py | 0 .../_capella_certificates/_capella.pem | 0 .../common/{core => _core}/_certificates.py | 3 +- .../_nonprod_certificates/_nonprod.pem | 0 .../{core => _core}/duration_str_utils.py | 8 +- .../common/_core/error_context.py | 90 +++++ .../common/{core => _core}/json_parsing.py | 3 +- .../{core => _core}/json_token_parser_base.py | 43 ++- .../common/{core => _core}/query.py | 7 +- .../common/{core => _core}/result.py | 6 +- .../common/{core => _core}/utils.py | 18 +- couchbase_analytics/common/core/exception.py | 66 ---- couchbase_analytics/common/core/net_utils.py | 107 ------ couchbase_analytics/common/credential.py | 4 +- couchbase_analytics/common/errors.py | 87 +++-- couchbase_analytics/common/options.py | 10 +- couchbase_analytics/common/options_base.py | 11 +- couchbase_analytics/common/query.py | 5 +- couchbase_analytics/common/request.py | 45 +++ couchbase_analytics/common/result.py | 10 +- couchbase_analytics/common/streaming.py | 33 +- .../protocol/{core => _core}/__init__.py | 0 .../{core => _core}/client_adapter.py | 21 +- .../http_transport.py} | 43 +-- .../core => protocol/_core}/json_stream.py | 29 +- .../_core}/json_token_parser.py | 28 +- .../protocol/_core/net_utils.py | 54 +++ .../protocol/{core => _core}/request.py | 83 ++--- .../request_context.py} | 280 +++++++++------ couchbase_analytics/protocol/_core/retries.py | 99 +++++ .../protocol/{core => _core}/utils.py | 0 couchbase_analytics/protocol/cluster.py | 18 +- couchbase_analytics/protocol/cluster.pyi | 7 +- couchbase_analytics/protocol/connection.py | 72 ++-- couchbase_analytics/protocol/database.py | 2 +- couchbase_analytics/protocol/database.pyi | 2 +- couchbase_analytics/protocol/errors.py | 200 ++++++++--- couchbase_analytics/protocol/options.py | 60 ++-- couchbase_analytics/protocol/scope.py | 13 +- couchbase_analytics/protocol/scope.pyi | 2 +- couchbase_analytics/protocol/streaming.py | 184 +++------- couchbase_analytics/tests/connection_t.py | 12 +- .../tests/duration_parsing_t.py | 8 +- couchbase_analytics/tests/json_parsing_t.py | 43 ++- couchbase_analytics/tests/options_t.py | 22 +- .../tests/query_integration_t.py | 59 +-- couchbase_analytics/tests/query_options_t.py | 10 +- couchbase_analytics/tests/test_server_t.py | 177 ++++++++- couchbase_analytics_version.py | 4 +- pyproject.toml | 19 +- tests/__init__.py | 11 +- tests/environments/__init__.py | 15 + tests/environments/base_environment.py | 77 +++- tests/environments/simple_environment.py | 33 ++ tests/test_config.ini | 2 +- tests/test_server/__init__.py | 73 ++++ tests/test_server/request.py | 96 +++++ tests/test_server/response.py | 340 ++++++++++++++++++ tests/test_server/web_server.py | 257 +++++++++++++ tests/utils/__init__.py | 80 ++++- tests/utils/_async_client_adapter.py | 40 ++- tests/utils/_async_utils.py | 8 +- tests/utils/_async_web_server.py | 113 ------ tests/utils/_client_adapter.py | 39 +- tests/utils/_run_web_server.py | 5 +- tests/utils/_test_async_httpx.py | 21 +- tests/utils/_test_httpx.py | 22 +- 97 files changed, 3018 insertions(+), 1480 deletions(-) rename acouchbase_analytics/protocol/{core => _core}/__init__.py (100%) rename acouchbase_analytics/protocol/{core/_anyio_utils.py => _core/anyio_utils.py} (96%) rename {couchbase_analytics/common/core => acouchbase_analytics/protocol/_core}/async_json_stream.py (86%) rename {couchbase_analytics/common/core => acouchbase_analytics/protocol/_core}/async_json_token_parser.py (74%) rename acouchbase_analytics/protocol/{core => _core}/client_adapter.py (88%) create mode 100644 acouchbase_analytics/protocol/_core/net_utils.py rename acouchbase_analytics/protocol/{core/_request_context.py => _core/request_context.py} (54%) create mode 100644 acouchbase_analytics/protocol/_core/retries.py create mode 100644 acouchbase_analytics/protocol/errors.py rename couchbase_analytics/common/{core => _core}/__init__.py (100%) rename couchbase_analytics/common/{core => _core}/_capella_certificates/_capella.pem (100%) rename couchbase_analytics/common/{core => _core}/_certificates.py (98%) rename couchbase_analytics/common/{core => _core}/_nonprod_certificates/_nonprod.pem (100%) rename couchbase_analytics/common/{core => _core}/duration_str_utils.py (93%) create mode 100644 couchbase_analytics/common/_core/error_context.py rename couchbase_analytics/common/{core => _core}/json_parsing.py (97%) rename couchbase_analytics/common/{core => _core}/json_token_parser_base.py (83%) rename couchbase_analytics/common/{core => _core}/query.py (94%) rename couchbase_analytics/common/{core => _core}/result.py (93%) rename couchbase_analytics/common/{core => _core}/utils.py (91%) delete mode 100644 couchbase_analytics/common/core/exception.py delete mode 100644 couchbase_analytics/common/core/net_utils.py create mode 100644 couchbase_analytics/common/request.py rename couchbase_analytics/protocol/{core => _core}/__init__.py (100%) rename couchbase_analytics/protocol/{core => _core}/client_adapter.py (88%) rename couchbase_analytics/protocol/{core/_http_transport.py => _core/http_transport.py} (91%) rename couchbase_analytics/{common/core => protocol/_core}/json_stream.py (90%) rename couchbase_analytics/{common/core => protocol/_core}/json_token_parser.py (79%) create mode 100644 couchbase_analytics/protocol/_core/net_utils.py rename couchbase_analytics/protocol/{core => _core}/request.py (79%) rename couchbase_analytics/protocol/{core/_request_context.py => _core/request_context.py} (57%) create mode 100644 couchbase_analytics/protocol/_core/retries.py rename couchbase_analytics/protocol/{core => _core}/utils.py (100%) create mode 100644 tests/test_server/__init__.py create mode 100644 tests/test_server/request.py create mode 100644 tests/test_server/response.py create mode 100644 tests/test_server/web_server.py delete mode 100644 tests/utils/_async_web_server.py diff --git a/MANIFEST.in b/MANIFEST.in index 8f48798..401f9c7 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -3,6 +3,8 @@ include couchbase-sdk-analytics-python-black-duck-manifest.yaml include couchbase_analytics/common/core/_nonprod_certificates/*.pem include couchbase_analytics/common/core/_capella_certificates/*.pem recursive-include couchbase_analytics *.py +exclude couchbase_analytics/tests/*.py recursive-include acouchbase_analytics *.py +exclude acouchbase_analytics/tests/*.py global-exclude *.py[cod] *.DS_Store exclude .git .gitignore .gitmodules gocaves* *.jar .clang* .cmake* .pre* .flake* MANIFEST.in \ No newline at end of file diff --git a/acouchbase_analytics/cluster.py b/acouchbase_analytics/cluster.py index 22ba39b..ed1bc1f 100644 --- a/acouchbase_analytics/cluster.py +++ b/acouchbase_analytics/cluster.py @@ -16,7 +16,7 @@ from __future__ import annotations import sys -from typing import Awaitable, TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, Awaitable, Optional if sys.version_info < (3, 10): from typing_extensions import TypeAlias diff --git a/acouchbase_analytics/cluster.pyi b/acouchbase_analytics/cluster.pyi index f68c462..251f9f8 100644 --- a/acouchbase_analytics/cluster.pyi +++ b/acouchbase_analytics/cluster.pyi @@ -23,10 +23,7 @@ else: from acouchbase_analytics.database import AsyncDatabase from couchbase_analytics.credential import Credential -from couchbase_analytics.options import (ClusterOptions, - ClusterOptionsKwargs, - QueryOptions, - QueryOptionsKwargs) +from couchbase_analytics.options import ClusterOptions, ClusterOptionsKwargs, QueryOptions, QueryOptionsKwargs from couchbase_analytics.result import AsyncQueryResult class AsyncCluster: diff --git a/acouchbase_analytics/protocol/core/__init__.py b/acouchbase_analytics/protocol/_core/__init__.py similarity index 100% rename from acouchbase_analytics/protocol/core/__init__.py rename to acouchbase_analytics/protocol/_core/__init__.py diff --git a/acouchbase_analytics/protocol/core/_anyio_utils.py b/acouchbase_analytics/protocol/_core/anyio_utils.py similarity index 96% rename from acouchbase_analytics/protocol/core/_anyio_utils.py rename to acouchbase_analytics/protocol/_core/anyio_utils.py index 9c4e9f2..bcf48bc 100644 --- a/acouchbase_analytics/protocol/core/_anyio_utils.py +++ b/acouchbase_analytics/protocol/_core/anyio_utils.py @@ -5,12 +5,16 @@ import anyio + def get_time() -> float: """ Get the current time in seconds since the epoch. """ return anyio.current_time() +async def sleep(delay: float) -> None: + await anyio.sleep(delay) + class AsyncBackend: def __init__(self, backend_lib: str) -> None: """ diff --git a/couchbase_analytics/common/core/async_json_stream.py b/acouchbase_analytics/protocol/_core/async_json_stream.py similarity index 86% rename from couchbase_analytics/common/core/async_json_stream.py rename to acouchbase_analytics/protocol/_core/async_json_stream.py index 7c4b209..2fe94da 100644 --- a/couchbase_analytics/common/core/async_json_stream.py +++ b/acouchbase_analytics/protocol/_core/async_json_stream.py @@ -15,22 +15,21 @@ from __future__ import annotations -from typing import (AsyncIterator, - Optional) +from typing import AsyncIterator, Optional import ijson -from anyio import (create_memory_object_stream, - Event, - EndOfStream) - - -from couchbase_analytics.common.core.async_json_token_parser import AsyncJsonTokenParser -from couchbase_analytics.common.core.json_parsing import (JsonParsingError, - JsonStreamConfig, - ParsedResult, - ParsedResultType) +from anyio import EndOfStream, Event, create_memory_object_stream + +from acouchbase_analytics.protocol._core.async_json_token_parser import AsyncJsonTokenParser +from couchbase_analytics.common._core.json_parsing import ( + JsonParsingError, + JsonStreamConfig, + ParsedResult, + ParsedResultType, +) from couchbase_analytics.common.errors import AnalyticsError + class AsyncJsonStream: def __init__(self, http_stream_iter: AsyncIterator[bytes], @@ -46,14 +45,14 @@ def __init__(self, self._http_stream_exhausted = False # results handling - self._send_stream, self._receive_stream = create_memory_object_stream[ParsedResult](max_buffer_size=stream_config.buffered_row_max) + self._send_stream, self._receive_stream = create_memory_object_stream[ParsedResult](max_buffer_size=stream_config.buffered_row_max) # noqa: E501 self._json_stream_parser = None self._buffer_entire_result = stream_config.buffer_entire_result handler = None if self._buffer_entire_result is True else self._handle_json_result self._json_token_parser = AsyncJsonTokenParser(handler) self._token_stream_exhausted = False self._has_results_or_errors_evt = Event() - self._has_results_or_errors_type = ParsedResultType.UNKNOWN + self._results_or_errors_type = ParsedResultType.UNKNOWN @property def has_results_or_errors(self) -> Event: @@ -63,11 +62,11 @@ def has_results_or_errors(self) -> Event: return self._has_results_or_errors_evt @property - def has_results_or_errors_type(self) -> ParsedResultType: + def results_or_errors_type(self) -> ParsedResultType: """ **INTERNAL** """ - return self._has_results_or_errors_type + return self._results_or_errors_type @property def token_stream_exhausted(self) -> bool: @@ -111,11 +110,11 @@ def _handle_notification(self, result_type: Optional[ParsedResultType]=None) -> return if result_type is None: - self._has_results_or_errors_type = ParsedResultType.END + self._results_or_errors_type = ParsedResultType.END self._has_results_or_errors_evt.set() return - self._has_results_or_errors_type = result_type + self._results_or_errors_type = result_type self._has_results_or_errors_evt.set() async def _process_token_stream(self) -> None: @@ -131,7 +130,7 @@ async def _process_token_stream(self) -> None: # this is a hack b/c the ijson.parse_async iterator does not yield to the event loop # TODO: create PYCO to either build custom JSON parsing, or dig into ijson root cause await self._json_token_parser.parse_token(event, value) - except StopAsyncIteration as ex: + except StopAsyncIteration: self._token_stream_exhausted = True except ijson.common.IncompleteJSONError as ex: raise JsonParsingError(cause=ex) from None diff --git a/couchbase_analytics/common/core/async_json_token_parser.py b/acouchbase_analytics/protocol/_core/async_json_token_parser.py similarity index 74% rename from couchbase_analytics/common/core/async_json_token_parser.py rename to acouchbase_analytics/protocol/_core/async_json_token_parser.py index ec542ab..cd702f3 100644 --- a/couchbase_analytics/common/core/async_json_token_parser.py +++ b/acouchbase_analytics/protocol/_core/async_json_token_parser.py @@ -15,18 +15,17 @@ from __future__ import annotations -from typing import (Any, - Callable, - Coroutine, - List, - Optional) +from typing import Any, Callable, Coroutine, List, Optional + +from couchbase_analytics.common._core.json_token_parser_base import ( + POP_EVENTS, + START_EVENTS, + VALUE_TOKENS, + JsonTokenParserBase, + ParsingState, + TokenType, +) -from couchbase_analytics.common.core.json_token_parser_base import (JsonTokenParserBase, - ParsingState, - TokenType, - POP_EVENTS, - START_EVENTS, - VALUE_TOKENS,) class AsyncJsonTokenParser(JsonTokenParserBase): def __init__(self, @@ -37,7 +36,7 @@ def __init__(self, async def _handle_obj_emit(self, obj: str) -> bool: if (self._emit_results_enabled and self._results_handler is not None - and self._state == ParsingState.PROCESSING_RESULTS): + and ParsingState.okay_to_emit(self._state, self._previous_state)): await self._results_handler(bytes(obj, 'utf-8')) return True return False @@ -54,13 +53,16 @@ async def _handle_pop_event(self, token_type: TokenType) -> None: obj = f'[{",".join(reversed(obj_pairs))}]' else: obj = f'{{{",".join(reversed(obj_pairs))}}}' - object_emitted = await self._handle_obj_emit(obj) - if should_emit and object_emitted: - break # this means we emiited the result/error, so stop processing the stack + + if should_emit: + object_emitted = await self._handle_obj_emit(obj) + if object_emitted: + break # this means we emiited the result/error, so stop processing the stack if len(self._stack) > 0 and self._stack[-1].type == TokenType.MAP_KEY: map_key = self._pop() - # If we are emitting rows and/or errors, we don't keep them in the stack and therefore don't need to return the results + # If we are emitting rows and/or errors, + # we don't keep them in the stack and therefore don't need to return the results if self._should_push_pair(next_token): self._push(TokenType.PAIR, f'{map_key.value}:{obj}') else: @@ -75,7 +77,9 @@ def get_result(self) -> Optional[bytes]: async def parse_token(self, token: str, value: str) -> None: token_type = TokenType.from_str(token) if token_type in VALUE_TOKENS: - self._handle_value_token(token_type, value) + val = self._handle_value_token(token_type, value) + if val is not None: + await self._handle_obj_emit(val) elif token_type == TokenType.MAP_KEY: self._handle_map_key_token(value) elif token_type in START_EVENTS: diff --git a/acouchbase_analytics/protocol/core/client_adapter.py b/acouchbase_analytics/protocol/_core/client_adapter.py similarity index 88% rename from acouchbase_analytics/protocol/core/client_adapter.py rename to acouchbase_analytics/protocol/_core/client_adapter.py index f591490..096f784 100644 --- a/acouchbase_analytics/protocol/core/client_adapter.py +++ b/acouchbase_analytics/protocol/_core/client_adapter.py @@ -16,11 +16,10 @@ from __future__ import annotations import socket - -from typing import Optional, TYPE_CHECKING +from typing import TYPE_CHECKING, Optional from uuid import uuid4 -from httpx import BasicAuth, AsyncClient, Response +from httpx import URL, AsyncClient, BasicAuth, Response from couchbase_analytics.common.credential import Credential from couchbase_analytics.common.deserializer import Deserializer @@ -28,7 +27,7 @@ from couchbase_analytics.protocol.options import OptionsBuilder if TYPE_CHECKING: - from couchbase_analytics.protocol.core.request import QueryRequest + from couchbase_analytics.protocol._core.request import QueryRequest class _AsyncClientAdapter: @@ -139,17 +138,22 @@ async def send_request(self, request: QueryRequest) -> Response: if not hasattr(self, '_client'): raise RuntimeError('Client not created yet') - if request.url is None: - raise ValueError('Request URL cannot be None') + # if request.url is None: + # raise ValueError('Request URL cannot be None') + url = URL(scheme=request.url.scheme, + host=request.url.host, + port=request.url.port, + path=request.url.path,) req = self._client.build_request(request.method, - request.url, + url, json=request.body, extensions=request.extensions) try: return await self._client.send(req, stream=True) except socket.gaierror as err: - raise RuntimeError(f'Unable to connect to {self._conn_details.get_scheme_host_and_port()}') from err + req_url = self._conn_details.url.get_formatted_url() + raise RuntimeError(f'Unable to connect to {req_url}') from err def reset_client(self) -> None: """ diff --git a/acouchbase_analytics/protocol/_core/net_utils.py b/acouchbase_analytics/protocol/_core/net_utils.py new file mode 100644 index 0000000..54c2b4b --- /dev/null +++ b/acouchbase_analytics/protocol/_core/net_utils.py @@ -0,0 +1,57 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import socket +from ipaddress import IPv4Address, IPv6Address, ip_address +from random import choice +from typing import Optional, Set, Union + +import anyio + +from acouchbase_analytics.protocol.errors import ErrorMapper + + +@ErrorMapper.handle_socket_error_async +async def get_request_ip_async(host: str, + port: int, + previous_ips: Optional[Set[str]]=None) -> Optional[str]: + # Lets not call getaddrinfo, if the host is already an IP address + try: + ip: Optional[Union[IPv4Address, IPv6Address, str]] = ip_address(host) + except ValueError: + ip = None + + # if we have localhost, httpx does not seem to be able to resolve IPv6 localhost (::1) properly + # TODO: IPv6 support for localhost?? + if host == 'localhost': + ip = '127.0.0.1' + + if previous_ips is None: + previous_ips = set() + + if not ip: + result = await anyio.getaddrinfo(host, port, type=socket.SOCK_STREAM, family=socket.AF_UNSPEC) + try: + res_ip = choice([addr[4][0] for addr in result if addr[4][0] not in previous_ips]) + ip = str(res_ip) + except IndexError: + ip = None + else: + ip_str = str(ip) if not isinstance(ip, str) else ip + ip = None if ip_str in previous_ips else ip_str + + return ip \ No newline at end of file diff --git a/acouchbase_analytics/protocol/core/_request_context.py b/acouchbase_analytics/protocol/_core/request_context.py similarity index 54% rename from acouchbase_analytics/protocol/core/_request_context.py rename to acouchbase_analytics/protocol/_core/request_context.py index fbde265..674a9ca 100644 --- a/acouchbase_analytics/protocol/core/_request_context.py +++ b/acouchbase_analytics/protocol/_core/request_context.py @@ -1,37 +1,28 @@ from __future__ import annotations +import json from asyncio import CancelledError, Task from types import TracebackType -from typing import (Any, - Awaitable, - Callable, - Coroutine, - Dict, - List, - Optional, - Type, - Union, - TYPE_CHECKING) +from typing import TYPE_CHECKING, Any, Awaitable, Callable, Coroutine, Dict, List, Optional, Type, Union from uuid import uuid4 import anyio -from httpx import (Response as HttpCoreResponse, - TimeoutException) - - -from acouchbase_analytics.protocol.core._anyio_utils import (AsyncBackend, - current_async_library, - get_time) -from couchbase_analytics.common.core.net_utils import get_request_ip_async -from couchbase_analytics.common.deserializer import Deserializer +from httpx import Response as HttpCoreResponse +from httpx import TimeoutException + +from acouchbase_analytics.protocol._core.anyio_utils import AsyncBackend, current_async_library, get_time +from acouchbase_analytics.protocol._core.async_json_stream import AsyncJsonStream +from acouchbase_analytics.protocol._core.net_utils import get_request_ip_async +from couchbase_analytics.common._core import JsonStreamConfig, ParsedResult, ParsedResultType +from couchbase_analytics.common._core.error_context import ErrorContext from couchbase_analytics.common.errors import AnalyticsError, InvalidCredentialError from couchbase_analytics.common.streaming import StreamingState from couchbase_analytics.protocol.connection import DEFAULT_TIMEOUTS from couchbase_analytics.protocol.errors import ErrorMapper if TYPE_CHECKING: - from acouchbase_analytics.protocol.core.client_adapter import _AsyncClientAdapter - from couchbase_analytics.protocol.core.request import QueryRequest + from acouchbase_analytics.protocol._core.client_adapter import _AsyncClientAdapter + from couchbase_analytics.protocol._core.request import QueryRequest class AsyncRequestContext: # TODO: AsyncExitStack?? @@ -40,30 +31,42 @@ class AsyncRequestContext: def __init__(self, client_adapter: _AsyncClientAdapter, request: QueryRequest, + stream_config: Optional[JsonStreamConfig]=None, backend: Optional[AsyncBackend]=None) -> None: self._id = str(uuid4()) self._client_adapter = client_adapter self._request = request self._backend = backend or current_async_library() - # self._response_task: Optional[Task] = None + self._error_ctx = ErrorContext(num_attempts=0, + method=request.method, + statement=request.get_request_statement()) self._request_state = StreamingState.NotStarted + self._stream_config = stream_config or JsonStreamConfig() + self._json_stream: AsyncJsonStream self._stage_completed: Optional[anyio.Event] = None self._request_error: Optional[Union[BaseException, Exception]] = None connect_timeout = self._client_adapter.connection_details.get_connect_timeout() self._connect_deadline = get_time() + connect_timeout self._cancel_scope_deadline_updated = False + self._shutdown = False + + @property + def cancelled(self) -> bool: + self._check_cancelled_or_timed_out() + return self._request_state in [StreamingState.Cancelled, StreamingState.AsyncCancelledPriorToTimeout] @property - def deserializer(self) -> Deserializer: - """ - Returns the deserializer used by this request context. - """ - return self._request.deserializer + def error_context(self) -> ErrorContext: + return self._error_ctx @property def has_stage_completed(self) -> bool: return self._stage_completed is not None and self._stage_completed.is_set() + @property + def is_shutdown(self) -> bool: + return self._shutdown + @property def okay_to_iterate(self) -> bool: self._check_cancelled_or_timed_out() @@ -81,22 +84,15 @@ def request_error(self) -> Optional[Union[BaseException, Exception]]: @property def request_state(self) -> StreamingState: return self._request_state - - @request_state.setter - def request_state(self, state: StreamingState) -> None: - if not isinstance(state, StreamingState): - raise TypeError('request_state must be an instance of StreamingState') - self._request_state = state @property - def timed_out(self) -> bool: - self._check_cancelled_or_timed_out() - return self._request_state == StreamingState.Timeout + def results_or_errors_type(self) -> ParsedResultType: + return self._json_stream.results_or_errors_type @property - def cancelled(self) -> bool: + def timed_out(self) -> bool: self._check_cancelled_or_timed_out() - return self._request_state in [StreamingState.Cancelled, StreamingState.AsyncCancelledPriorToTimeout] + return self._request_state == StreamingState.Timeout def _check_cancelled_or_timed_out(self) -> None: if self._request_state in [StreamingState.Timeout, StreamingState.Cancelled, StreamingState.Error]: @@ -126,9 +122,6 @@ def _maybe_set_request_error(self, exc_type: Optional[Type[BaseException]]=None, exc_val: Optional[BaseException]=None) -> None: self._check_cancelled_or_timed_out() - # TODO: Do either of these conditions need to be checked? Does _check_cancelled_or_timed_out() already handle this - # if self._taskgroup.cancel_scope.cancelled_caught and get_time() >= self._taskgroup.cancel_scope.deadline: - # if isinstance(exc_val, CancelledError): if exc_val is None: return if not StreamingState.is_timeout_or_cancelled(self._request_state): @@ -137,10 +130,46 @@ def _maybe_set_request_error(self, self._request_state = StreamingState.Timeout elif issubclass(type(exc_val), TimeoutException): self._request_state = StreamingState.Timeout + elif isinstance(exc_val, CancelledError): + self._request_state = StreamingState.Cancelled else: self._request_state = StreamingState.Error self._request_error = exc_val - + + async def _process_error(self, + json_data: List[Dict[str, Any]], + handle_context_shutdown: Optional[bool]=False) -> None: + self._request_state = StreamingState.Error + if not isinstance(json_data, list): + self._request_error = AnalyticsError('Cannot parse error response; expected JSON array', + context=str(self._error_ctx)) + else: + self._request_error = ErrorMapper.build_error_from_json(json_data, self._error_ctx) + if handle_context_shutdown is True: + await self.reraise_after_shutdown(self._request_error) + + raise self._request_error + + def _reset_stream(self) -> None: + if hasattr(self, '_json_stream'): + del self._json_stream + self._request_state = StreamingState.ResetAndNotStarted + self._request.previous_ips = set() + self._stage_completed = None + self._cancel_scope_deadline_updated = False + + def _start_next_stage(self, + fn: Callable[..., Awaitable[Any]], + *args: object, + reset_previous_stage: Optional[bool]=False) -> None: + if self._stage_completed is not None: + if reset_previous_stage is True: + self._stage_completed = None + else: + raise RuntimeError('Task already running in this context.') + + self._stage_completed = anyio.Event() + self._taskgroup.start_soon(self._execute, fn, *args) async def _trace_handler(self, event_name: str, _: str) -> None: if event_name == 'connection.connect_tcp.complete': @@ -148,7 +177,8 @@ async def _trace_handler(self, event_name: str, _: str) -> None: self._update_cancel_scope_deadline(self._request_deadline, is_absolute=True) self._cancel_scope_deadline_updated = True elif self._cancel_scope_deadline_updated is False and event_name.endswith('send_request_headers.started'): - # if the socket is reused, we won't get the connect_tcp.complete event, so the deadline at the next closest event + # if the socket is reused, we won't get the connect_tcp.complete event, + # so the deadline at the next closest event self._update_cancel_scope_deadline(self._request_deadline, is_absolute=True) self._cancel_scope_deadline_updated = True @@ -162,6 +192,11 @@ def _update_cancel_scope_deadline(self, deadline: float, is_absolute: Optional[b else: self._taskgroup.cancel_scope.deadline = new_deadline + async def _wait_for_stage_to_complete(self) -> None: + if self._stage_completed is None: + return + await self._stage_completed.wait() + def cancel_request(self, fn: Optional[Callable[..., Awaitable[Any]]]=None, *args: object) -> None: @@ -172,20 +207,113 @@ def cancel_request(self, self._taskgroup.cancel_scope.cancel() self._request_state = StreamingState.Cancelled + def create_response_task(self, fn: Callable[..., Coroutine[Any, Any, Any]], *args: object) -> Task[Any]: + if self._backend is None or self._backend.backend_lib != 'asyncio': + raise RuntimeError('Must use the asyncio backend to create a response task.') + if self._backend.loop is None: + raise RuntimeError('Async backend loop is not initialized.') + task_name = f'{self._id}-response-task' + task: Task[Any] = self._backend.loop.create_task(fn(*args), name=task_name) + # TODO: Confirm if callback is useful/necessary; + # def task_done(t: Task[Any]) -> None: + # print(f'Task done callback task=({t.get_name()}); done: {t.done()}, cancelled: {t.cancelled()}') + # task.add_done_callback(task_done) + self._response_task = task + return task + + def deserialize_result(self, result: bytes) -> Any: + return self._request.deserializer.deserialize(result) + + async def finish_processing_stream(self) -> None: + if not self.has_stage_completed: + await self._wait_for_stage_to_complete() + + while not self._json_stream.token_stream_exhausted: + self._start_next_stage(self._json_stream.continue_parsing, reset_previous_stage=True) + await self._wait_for_stage_to_complete() + + async def get_result_from_stream(self) -> ParsedResult: + return await self._json_stream.get_result() + async def initialize(self) -> None: + # TODO: Add useful logging messages + if self._request_state == StreamingState.ResetAndNotStarted: + self._update_cancel_scope_deadline(self._connect_deadline, is_absolute=True) + # print('Skipping initialization as request is a retry') + return await self.__aenter__() self._request_state = StreamingState.Started # we set the request timeout once the context is initialized in order to create the deadline # closer to when the upstream logic will begin to use the request context timeouts = self._request.get_request_timeouts() or {} - self._request_deadline = get_time() + (timeouts.get('read', None) or DEFAULT_TIMEOUTS['query_timeout']) + current_time = get_time() + self._request_deadline = current_time + (timeouts.get('read', None) or DEFAULT_TIMEOUTS['query_timeout']) self._update_cancel_scope_deadline(self._connect_deadline, is_absolute=True) + # print(f'initialize request ctx: {current_time=}; req_deadline={self._request_deadline}') + + def maybe_continue_to_process_stream(self) -> None: + if not self.has_stage_completed: + return + + if self._json_stream.token_stream_exhausted: + return + + self._start_next_stage(self._json_stream.continue_parsing, reset_previous_stage=True) + + def okay_to_delay_and_retry(self, delay: float) -> bool: + # TODO: Add useful logging messages + self._check_cancelled_or_timed_out() + if self._request_state in [StreamingState.Timeout, StreamingState.Cancelled]: + return False + + current_time = get_time() + delay_time = current_time + delay + will_time_out = self._request_deadline < delay_time + # print(f'{current_time=}; {delay_time=}; req_deadline={self._request_deadline}; {will_time_out=}') + if will_time_out: + self._request_state = StreamingState.Timeout + return False + else: + self._reset_stream() + return True + + async def process_response(self, + close_handler: Callable[[], Coroutine[Any, Any, None]], + raw_response: Optional[ParsedResult]=None, + handle_context_shutdown: Optional[bool]=False) -> Any: + if raw_response is None: + raw_response = await self._json_stream.get_result() + if raw_response is None: + await close_handler() + raise AnalyticsError(message='Received unexpected empty result from JsonStream.', + context=str(self._error_ctx)) + + if raw_response.value is None: + await close_handler() + raise AnalyticsError(message='Received unexpected empty result from JsonStream.', + context=str(self._error_ctx)) + + # we have all the data, close the core response/stream + await close_handler() + + json_response = json.loads(raw_response.value) + if 'errors' in json_response: + await self._process_error(json_response['errors'], handle_context_shutdown=handle_context_shutdown) + return json_response + + async def reraise_after_shutdown(self, err: Exception) -> None: + try: + raise err + except Exception as ex: + await self.shutdown(type(ex), ex, ex.__traceback__) + raise ex from None async def send_request(self, enable_trace_handling: Optional[bool]=False) -> HttpCoreResponse: - ip = await get_request_ip_async(self._request.host, self._request.port, self._request.previous_ips) + ip = await get_request_ip_async(self._request.url.host, self._request.url.port, self._request.previous_ips) if ip is None: attempted_ips = ', '.join(self._request.previous_ips or []) - raise AnalyticsError(message=f'Connect failure. Attempted to connect to resolved IPs: {attempted_ips}.') + raise AnalyticsError(message=f'Connect failure. Unable to connect to any resolved IPs: {attempted_ips}.', + context=str(self._error_ctx)) if enable_trace_handling is True: (self._request.update_url(ip, self._client_adapter.analytics_path) @@ -194,74 +322,41 @@ async def send_request(self, enable_trace_handling: Optional[bool]=False) -> Htt else: self._request.update_url(ip, self._client_adapter.analytics_path).update_previous_ips(ip) # TODO: add logging; provide request details (to/from, deadlines, etc.) + self._error_ctx.update_request_context(self._request) response = await self._client_adapter.send_request(self._request) - self._request.set_client_server_addrs(response) + self._error_ctx.update_response_context(response) if response.status_code == 401: - context = { - 'client_addr': self._request.client_addr, - 'server_addr': self._request.server_addr, - 'http_status': response.status_code, - } - raise InvalidCredentialError(str(context)) + raise InvalidCredentialError(context=str(self._error_ctx)) return response async def shutdown(self, exc_type: Optional[Type[BaseException]]=None, exc_val: Optional[BaseException]=None, exc_tb: Optional[TracebackType]=None) -> None: + if self.is_shutdown: + return if hasattr(self, '_taskgroup'): await self.__aexit__(exc_type, exc_val, exc_tb) else: - self._maybe_set_request_error() + self._maybe_set_request_error(exc_type, exc_val) if StreamingState.is_okay(self._request_state): self._request_state = StreamingState.Completed + self._shutdown = True - def create_response_task(self, fn: Callable[..., Coroutine[Any, Any, Any]], *args: object) -> Task[Any]: - if self._backend is None or self._backend.backend_lib != 'asyncio': - raise RuntimeError('Must use the asyncio backend to create a response task.') - if self._backend.loop is None: - raise RuntimeError('Async backend loop is not initialized.') - task_name = f'{self._id}-response-task' - print(f'Creating response task: {task_name}') - task: Task[Any] = self._backend.loop.create_task(fn(*args), name=task_name) - # TODO: I don't think this callback is necessary...need to add more tests to confirm - def task_done(t: Task[Any]) -> None: - print(f'Task done callback task=({t.get_name()}); done: {t.done()}, cancelled: {t.cancelled()}') - - task.add_done_callback(task_done) - self._response_task = task - return task - - def set_state_to_streaming(self) -> None: - self._request_state = StreamingState.StreamingResults - - def start_next_stage(self, - fn: Callable[..., Awaitable[Any]], - *args: object, - reset_previous_stage: Optional[bool]=False) -> None: - if self._stage_completed is not None: - if reset_previous_stage is True: - self._stage_completed = None - else: - raise RuntimeError('Task already running in this context.') - - self._stage_completed = anyio.Event() - self._taskgroup.start_soon(self._execute, fn, *args) - - async def wait_for_stage_to_complete(self) -> None: - if self._stage_completed is None: + def start_stream(self, core_response: HttpCoreResponse) -> None: + if hasattr(self, '_json_stream'): + # TODO: logging; I don't think this is an error... return - await self._stage_completed.wait() - - async def process_error(self, json_data: List[Dict[str, Any]]) -> None: - self._request_state = StreamingState.Error - if not isinstance(json_data, list): - self._request_error = AnalyticsError('Cannot parse error response; expected JSON array') + + self._json_stream = AsyncJsonStream(core_response.aiter_bytes(), stream_config=self._stream_config) + self._start_next_stage(self._json_stream.start_parsing) - self._request_error = ErrorMapper.build_error_from_json(json_data, status_code=self._request.response_status_code) - await self.shutdown() - raise self._request_error + async def wait_for_results_or_errors(self) -> None: + await self._json_stream.has_results_or_errors.wait() + if self._json_stream.results_or_errors_type == ParsedResultType.ROW: + # we move to iterating rows + self._request_state = StreamingState.StreamingResults async def __aenter__(self) -> AsyncRequestContext: self._taskgroup = anyio.create_task_group() @@ -273,12 +368,11 @@ async def __aexit__(self, exc_val: Optional[BaseException], exc_tb: Optional[TracebackType]) -> Optional[bool]: try: - res = await self._taskgroup.__aexit__(exc_type, exc_val, exc_tb) - return res - except BaseException as ex: + await self._taskgroup.__aexit__(exc_type, exc_val, exc_tb) + except BaseException: pass # we handle the error when the context is shutdown (which is what calls __aexit__()) finally: - self._maybe_set_request_error() + self._maybe_set_request_error(exc_type, exc_val) del self._taskgroup # TODO: should we suppress here (e.g., return True) - return None \ No newline at end of file + return None # noqa: B012 diff --git a/acouchbase_analytics/protocol/_core/retries.py b/acouchbase_analytics/protocol/_core/retries.py new file mode 100644 index 0000000..0c253e8 --- /dev/null +++ b/acouchbase_analytics/protocol/_core/retries.py @@ -0,0 +1,99 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from asyncio import CancelledError +from functools import wraps +from random import uniform +from typing import TYPE_CHECKING, Any, Callable, Coroutine + +from httpx import ConnectError, ConnectTimeout + +from acouchbase_analytics.protocol._core.anyio_utils import sleep +from couchbase_analytics.common.errors import AnalyticsError, InternalSDKError, TimeoutError +from couchbase_analytics.common.streaming import StreamingState +from couchbase_analytics.protocol.errors import WrappedError + +if TYPE_CHECKING: + from acouchbase_analytics.protocol.streaming import AsyncHttpStreamingResponse + + +class AsyncRetryHandler: + """ + **INTERNAL** + """ + + @staticmethod + def with_retries(fn: Callable[[AsyncHttpStreamingResponse], Coroutine[Any, Any, None]] # noqa: C901 + ) -> Callable[[AsyncHttpStreamingResponse], Coroutine[Any, Any, None]]: + @wraps(fn) + async def wrapped_fn(self: AsyncHttpStreamingResponse) -> None: # noqa: C901 + while True: + try: + await fn(self) + break + except WrappedError as ex: + if ex.retriable is True: + delay = calc_backoff(self._request_context.error_context.num_attempts) + if not self._request_context.okay_to_delay_and_retry(delay): + await self._request_context.shutdown(type(ex), ex, ex.__traceback__) + raise TimeoutError(message='Request timed out during retry delay.', + context=str(self._request_context.error_context)) from None + await sleep(delay) + continue + await self._request_context.shutdown(type(ex), ex, ex.__traceback__) + ex.maybe_set_cause_context(self._request_context.error_context) + raise ex.unwrap() from None + except AnalyticsError: + # if an AnalyticsError is raised, we have already shut down the request context + raise + except RuntimeError as ex: + await self._request_context.shutdown(type(ex), ex, ex.__traceback__) + raise ex + except ConnectError as ex: + await self._request_context.shutdown(type(ex), ex, ex.__traceback__) + raise AnalyticsError(cause=ex, + message='Unable to establish connection for request.', + context=str(self._request_context.error_context)) from None + except ConnectTimeout as ex: + await self._request_context.shutdown(type(ex), ex, ex.__traceback__) + raise TimeoutError(cause=ex, + message='Request timed out trying to establish connection.', + context=str(self._request_context.error_context)) from None + except BaseException as ex: + await self._request_context.shutdown(type(ex), ex, ex.__traceback__) + if self._request_context.timed_out: + raise TimeoutError(message='Request timed out.', + context=str(self._request_context.error_context)) from None + if self._request_context.cancelled: + raise CancelledError('Request was cancelled.') from None + if self._request_context.request_error is not None: + raise self._request_context.request_error from None + raise InternalSDKError(cause=ex, + message=str(ex), + context=str(self._request_context.error_context)) from None + finally: + if not StreamingState.is_okay(self._request_context.request_state): + await self.close() + + return wrapped_fn + +def calc_backoff(retry_count: int) -> float: + min_ms = 100 + max_ms = 60000 + delay_ms = min_ms * pow(2, retry_count) + capped_ms = min(max_ms, delay_ms) + return uniform(0, capped_ms / 1000.0) \ No newline at end of file diff --git a/acouchbase_analytics/protocol/cluster.py b/acouchbase_analytics/protocol/cluster.py index 217f93b..be2c446 100644 --- a/acouchbase_analytics/protocol/cluster.py +++ b/acouchbase_analytics/protocol/cluster.py @@ -16,7 +16,7 @@ from __future__ import annotations import sys -from typing import Awaitable, TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, Awaitable, Optional from uuid import uuid4 if sys.version_info < (3, 10): @@ -24,13 +24,12 @@ else: from typing import TypeAlias -from acouchbase_analytics.protocol.core.client_adapter import _AsyncClientAdapter -from acouchbase_analytics.protocol.core._anyio_utils import current_async_library -from acouchbase_analytics.protocol.core._request_context import AsyncRequestContext +from acouchbase_analytics.protocol._core.anyio_utils import current_async_library +from acouchbase_analytics.protocol._core.client_adapter import _AsyncClientAdapter +from acouchbase_analytics.protocol._core.request_context import AsyncRequestContext from acouchbase_analytics.protocol.streaming import AsyncHttpStreamingResponse from couchbase_analytics.common.result import AsyncQueryResult -from couchbase_analytics.protocol.core.request import _RequestBuilder - +from couchbase_analytics.protocol._core.request import _RequestBuilder if TYPE_CHECKING: from couchbase_analytics.common.credential import Credential @@ -44,6 +43,7 @@ def __init__(self, credential: Credential, options: Optional[ClusterOptions] = None, **kwargs: object) -> None: + print(f'Adapter module: {_AsyncClientAdapter.__module__}') self._client_adapter = _AsyncClientAdapter(connstr, credential, options, **kwargs) self._cluster_id = str(uuid4()) self._request_builder = _RequestBuilder(self._client_adapter) @@ -108,8 +108,11 @@ async def _execute_query(self, http_resp: AsyncHttpStreamingResponse) -> AsyncQu def execute_query(self, statement: str, *args: object, **kwargs: object) -> Awaitable[AsyncQueryResult]: base_req = self._request_builder.build_base_query_request(statement, *args, is_async=True, **kwargs) stream_config = base_req.options.pop('stream_config', None) - request_context = AsyncRequestContext(client_adapter=self.client_adapter, request=base_req, backend=self._backend) - resp = AsyncHttpStreamingResponse(request_context, stream_config=stream_config) + request_context = AsyncRequestContext(client_adapter=self.client_adapter, + request=base_req, + stream_config=stream_config, + backend=self._backend) + resp = AsyncHttpStreamingResponse(request_context) if self._backend.backend_lib == 'asyncio': return request_context.create_response_task(self._execute_query, resp) return self._execute_query(resp) diff --git a/acouchbase_analytics/protocol/cluster.pyi b/acouchbase_analytics/protocol/cluster.pyi index 7fce7dd..f273183 100644 --- a/acouchbase_analytics/protocol/cluster.pyi +++ b/acouchbase_analytics/protocol/cluster.pyi @@ -21,14 +21,11 @@ if sys.version_info < (3, 11): else: from typing import Unpack -from acouchbase_analytics.protocol.core.client_adapter import _AsyncClientAdapter +from acouchbase_analytics.protocol._core.client_adapter import _AsyncClientAdapter from acouchbase_analytics.protocol.database import AsyncDatabase from couchbase_analytics.common.credential import Credential from couchbase_analytics.common.result import AsyncQueryResult -from couchbase_analytics.options import (ClusterOptions, - ClusterOptionsKwargs, - QueryOptions, - QueryOptionsKwargs) +from couchbase_analytics.options import ClusterOptions, ClusterOptionsKwargs, QueryOptions, QueryOptionsKwargs class AsyncCluster: @overload diff --git a/acouchbase_analytics/protocol/database.py b/acouchbase_analytics/protocol/database.py index 117ad8c..e2270a3 100644 --- a/acouchbase_analytics/protocol/database.py +++ b/acouchbase_analytics/protocol/database.py @@ -24,7 +24,7 @@ from typing import TypeAlias -from acouchbase_analytics.protocol.core.client_adapter import _AsyncClientAdapter +from acouchbase_analytics.protocol._core.client_adapter import _AsyncClientAdapter from acouchbase_analytics.protocol.scope import AsyncScope if TYPE_CHECKING: diff --git a/acouchbase_analytics/protocol/database.pyi b/acouchbase_analytics/protocol/database.pyi index 7d91d3f..3d8560c 100644 --- a/acouchbase_analytics/protocol/database.pyi +++ b/acouchbase_analytics/protocol/database.pyi @@ -13,8 +13,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +from acouchbase_analytics.protocol._core.client_adapter import _AsyncClientAdapter from acouchbase_analytics.protocol.cluster import AsyncCluster as AsyncCluster -from acouchbase_analytics.protocol.core.client_adapter import _AsyncClientAdapter from couchbase_analytics.protocol.scope import Scope class AsyncDatabase: diff --git a/acouchbase_analytics/protocol/errors.py b/acouchbase_analytics/protocol/errors.py new file mode 100644 index 0000000..d31aede --- /dev/null +++ b/acouchbase_analytics/protocol/errors.py @@ -0,0 +1,42 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import socket +from functools import wraps +from typing import Any, Callable, Coroutine, Optional, Set + +from couchbase_analytics.common.errors import AnalyticsError +from couchbase_analytics.protocol.errors import _NON_RETRYABLE_SOCKET_ERRORS, WrappedError + + +class ErrorMapper: + @staticmethod + def handle_socket_error_async(fn: Callable[[str, int, Optional[Set[str]]], Coroutine[Any, Any, Optional[str]]] + ) -> Callable[[str, int, Optional[Set[str]]], Coroutine[Any, Any, Optional[str]]]: + @wraps(fn) + async def wrapped_fn(host: str, + port: int, + previous_ips: Optional[Set[str]]=None) -> Optional[str]: + try: + return await fn(host, port, previous_ips) + except socket.gaierror as ex: + # print(f'getaddrinfo failed for {host}:{port} with error: {ex}') + msg='Connection error occurred while sending request.' + raise WrappedError(AnalyticsError(cause=ex, message=msg), + retriable=(ex.errno not in _NON_RETRYABLE_SOCKET_ERRORS)) from None + + return wrapped_fn \ No newline at end of file diff --git a/acouchbase_analytics/protocol/scope.py b/acouchbase_analytics/protocol/scope.py index 7a06752..b95e707 100644 --- a/acouchbase_analytics/protocol/scope.py +++ b/acouchbase_analytics/protocol/scope.py @@ -16,19 +16,19 @@ from __future__ import annotations import sys -from typing import Awaitable, TYPE_CHECKING +from typing import TYPE_CHECKING, Awaitable if sys.version_info < (3, 10): from typing_extensions import TypeAlias else: from typing import TypeAlias -from acouchbase_analytics.protocol.core._anyio_utils import current_async_library -from acouchbase_analytics.protocol.core._request_context import AsyncRequestContext -from acouchbase_analytics.protocol.core.client_adapter import _AsyncClientAdapter +from acouchbase_analytics.protocol._core.anyio_utils import current_async_library +from acouchbase_analytics.protocol._core.client_adapter import _AsyncClientAdapter +from acouchbase_analytics.protocol._core.request_context import AsyncRequestContext from acouchbase_analytics.protocol.streaming import AsyncHttpStreamingResponse from couchbase_analytics.common.result import AsyncQueryResult -from couchbase_analytics.protocol.core.request import _RequestBuilder +from couchbase_analytics.protocol._core.request import _RequestBuilder if TYPE_CHECKING: from acouchbase_analytics.protocol.database import AsyncDatabase @@ -72,8 +72,11 @@ async def _execute_query(self, http_resp: AsyncHttpStreamingResponse) -> AsyncQu def execute_query(self, statement: str, *args: object, **kwargs: object) -> Awaitable[AsyncQueryResult]: base_req = self._request_builder.build_base_query_request(statement, *args, is_async=True, **kwargs) stream_config = base_req.options.pop('stream_config', None) - request_context = AsyncRequestContext(client_adapter=self.client_adapter, request=base_req, backend=self._backend) - resp = AsyncHttpStreamingResponse(request_context, stream_config=stream_config) + request_context = AsyncRequestContext(client_adapter=self.client_adapter, + request=base_req, + stream_config=stream_config, + backend=self._backend) + resp = AsyncHttpStreamingResponse(request_context) if self._backend.backend_lib == 'asyncio': return request_context.create_response_task(self._execute_query, resp) return self._execute_query(resp) diff --git a/acouchbase_analytics/protocol/scope.pyi b/acouchbase_analytics/protocol/scope.pyi index 2817523..b08b0d7 100644 --- a/acouchbase_analytics/protocol/scope.pyi +++ b/acouchbase_analytics/protocol/scope.pyi @@ -21,7 +21,7 @@ if sys.version_info < (3, 11): else: from typing import Unpack -from acouchbase_analytics.protocol.core.client_adapter import _AsyncClientAdapter +from acouchbase_analytics.protocol._core.client_adapter import _AsyncClientAdapter from acouchbase_analytics.protocol.database import AsyncDatabase as AsyncDatabase from couchbase_analytics.options import QueryOptions, QueryOptionsKwargs from couchbase_analytics.result import AsyncQueryResult diff --git a/acouchbase_analytics/protocol/streaming.py b/acouchbase_analytics/protocol/streaming.py index a853b2f..dc2b536 100644 --- a/acouchbase_analytics/protocol/streaming.py +++ b/acouchbase_analytics/protocol/streaming.py @@ -15,146 +15,58 @@ from __future__ import annotations -import json -import sys - -from asyncio import CancelledError -from functools import wraps -from typing import (Any, - Callable, - Coroutine, - Optional) - -if sys.version_info < (3, 10): - from typing_extensions import TypeAlias -else: - from typing import TypeAlias +from typing import Any, Optional from httpx import Response as HttpCoreResponse -# TODO: errors? -from couchbase_analytics.common.errors import (AnalyticsError, - InternalSDKError, - TimeoutError) -from acouchbase_analytics.protocol.core._request_context import AsyncRequestContext -from couchbase_analytics.common.core import (JsonStreamConfig, - ParsedResult, - ParsedResultType) -from couchbase_analytics.common.core.async_json_stream import AsyncJsonStream -from couchbase_analytics.common.core.query import build_query_metadata +from acouchbase_analytics.protocol._core.request_context import AsyncRequestContext +from acouchbase_analytics.protocol._core.retries import AsyncRetryHandler +from couchbase_analytics.common._core import ParsedResult, ParsedResultType +from couchbase_analytics.common._core.query import build_query_metadata +from couchbase_analytics.common.errors import AnalyticsError, InternalSDKError, TimeoutError from couchbase_analytics.common.query import QueryMetadata -from couchbase_analytics.common.streaming import StreamingState - - -class RequestWrapper: - """ - **INTERNAL** - """ - - @classmethod - def handle_retries(cls) -> Callable[[SendRequestFunc], WrappedSendRequestFunc]: # noqa: C901 - """ - **INTERNAL** - """ - - def decorator(fn: SendRequestFunc) -> WrappedSendRequestFunc: # noqa: C901 - @wraps(fn) - async def wrapped_fn(self: AsyncHttpStreamingResponse) -> None: - try: - await fn(self) - except AnalyticsError: - # if an AnalyticsError is raised, we have already shut down the request context - raise - except RuntimeError as ex: - await self._request_context.shutdown(type(ex), ex, ex.__traceback__) - raise ex - except BaseException as ex: - await self._request_context.shutdown(type(ex), ex, ex.__traceback__) - if self._request_context.timed_out: - raise TimeoutError(cause=self._request_context.request_error, - message='Request timed out.') from None - if self._request_context.cancelled: - raise CancelledError('Request was cancelled.') from None - if self._request_context.request_error is not None: - raise self._request_context.request_error from None - raise InternalSDKError(ex) from None - finally: - if not StreamingState.is_okay(self._request_context.request_state): - await self.close() - - - return wrapped_fn - return decorator - class AsyncHttpStreamingResponse: - def __init__(self, - request_context: AsyncRequestContext, - stream_config: Optional[JsonStreamConfig]=None) -> None: + def __init__(self, request_context: AsyncRequestContext) -> None: self._metadata: Optional[QueryMetadata] = None self._core_response: HttpCoreResponse - self._stream_config = stream_config or JsonStreamConfig() - self._json_stream: AsyncJsonStream # Goal is to treat the AsyncHttpStreamingResponse as a "task group" self._request_context = request_context - async def _finish_processing_stream(self) -> None: - if not self._request_context.has_stage_completed: - await self._request_context.wait_for_stage_to_complete() - - while not self._json_stream.token_stream_exhausted: - self._request_context.start_next_stage(self._json_stream.continue_parsing, reset_previous_stage=True) - await self._request_context.wait_for_stage_to_complete() + async def _close_in_background(self) -> None: + """ + **INTERNAL** + """ + await self.close() async def _handle_iteration_abort(self) -> None: + """ + **INTERNAL** + """ await self.close() if self._request_context.cancelled: + await self._request_context.shutdown() raise StopAsyncIteration elif self._request_context.timed_out: - raise TimeoutError(message='Request timeout.') + err = TimeoutError(message='Unable to complete iteration. Request timed out.', + context=str(self._request_context.error_context)) + await self._request_context.reraise_after_shutdown(err) else: + await self._request_context.shutdown() raise StopAsyncIteration - - def _maybe_continue_to_process_stream(self) -> None: - if not self._request_context.has_stage_completed: - return - - if self._json_stream.token_stream_exhausted: - return - self._request_context.start_next_stage(self._json_stream.continue_parsing, reset_previous_stage=True) - - async def _process_response(self, raw_response: Optional[ParsedResult]=None) -> None: - if raw_response is None: - raw_response = await self._json_stream.get_result() - if raw_response is None: - raise AnalyticsError(message='Received unexpected empty result from JsonStream.') - - if raw_response.value is None: - raise AnalyticsError(message='Received unexpected empty result from JsonStream.') - - # we have all the data, close the core response/stream - await self.close() - - json_response = json.loads(raw_response.value) - if 'errors' in json_response: - await self._request_context.process_error(json_response['errors']) - self.set_metadata(json_data=json_response) - def _start(self) -> None: + async def _process_response(self, + raw_response: Optional[ParsedResult]=None, + handle_context_shutdown: Optional[bool]=False) -> None: """ **INTERNAL** """ - if hasattr(self, '_json_stream'): - # TODO: logging; I don't think this is an error... - return - - self._json_stream = AsyncJsonStream(self._core_response.aiter_bytes(), stream_config=self._stream_config) - self._request_context.start_next_stage(self._json_stream.start_parsing) - - async def _close_in_background(self) -> None: - await self.close() + json_response = await self._request_context.process_response(self.close, + raw_response=raw_response, + handle_context_shutdown=handle_context_shutdown) + await self.set_metadata(json_data=json_response) async def close(self) -> None: """ @@ -179,71 +91,78 @@ async def cancel_async(self) -> None: await self._request_context.shutdown() def get_metadata(self) -> QueryMetadata: + """ + **INTERNAL** + """ if self._metadata is None: - if self._request_context.cancelled: - raise CancelledError('Request was cancelled.') - elif self._request_context.timed_out: - raise TimeoutError(message='Request timeout.') raise RuntimeError('Query metadata is only available after all rows have been iterated.') return self._metadata - def set_metadata(self, - json_data: Optional[Any]=None, - raw_metadata: Optional[bytes]=None) -> None: + async def set_metadata(self, + json_data: Optional[Any]=None, + raw_metadata: Optional[bytes]=None) -> None: + """ + **INTERNAL** + """ try: self._metadata = QueryMetadata(build_query_metadata(json_data=json_data, raw_metadata=raw_metadata)) - except AnalyticsError as err: - raise err + await self._request_context.shutdown() + except (AnalyticsError, ValueError) as err: + await self._request_context.reraise_after_shutdown(err) except Exception as ex: - raise InternalSDKError(str(ex)) + internal_err = InternalSDKError(cause=ex, + message=str(ex), + context=str(self._request_context.error_context)) + await self._request_context.reraise_after_shutdown(internal_err) + finally: + await self.close() async def get_next_row(self) -> Any: """ - **INTERNAL** + **INTERNAL** """ if not (hasattr(self, '_core_response') and self._core_response is not None and self._request_context.okay_to_iterate): await self._handle_iteration_abort() - self._maybe_continue_to_process_stream() - raw_response = await self._json_stream.get_result() + self._request_context.maybe_continue_to_process_stream() + raw_response = await self._request_context.get_result_from_stream() if raw_response.result_type == ParsedResultType.ROW: if raw_response.value is None: - raise AnalyticsError(message='Unexpected empty row response while streaming.') - return self._request_context.deserializer.deserialize(raw_response.value) + await self.close() + raise AnalyticsError(message='Unexpected empty row response while streaming.', + context=str(self._request_context.error_context)) + return self._request_context.deserialize_result(raw_response.value) elif raw_response.result_type in [ParsedResultType.ERROR, ParsedResultType.UNKNOWN]: - await self._process_response(raw_response=raw_response) + await self._process_response(raw_response=raw_response, handle_context_shutdown=True) elif raw_response.result_type == ParsedResultType.END: - self.set_metadata(raw_metadata=raw_response.value) - await self.close() + await self.set_metadata(raw_metadata=raw_response.value) raise StopAsyncIteration else: - await self._process_response(raw_response=raw_response) + await self._process_response(raw_response=raw_response, handle_context_shutdown=True) - @RequestWrapper.handle_retries() + @AsyncRetryHandler.with_retries async def send_request(self) -> None: + """ + **INTERNAL** + """ if not self._request_context.okay_to_stream: raise RuntimeError('Query has been canceled or previously executed.') # start cancel scope await self._request_context.initialize() self._core_response = await self._request_context.send_request(enable_trace_handling=True) - self._start() + self._request_context.start_stream(self._core_response) # block until we either know we have rows or we have an error - await self._json_stream.has_results_or_errors.wait() - if self._json_stream.has_results_or_errors_type == ParsedResultType.ROW: - # we move to iterating rows - self._request_context.set_state_to_streaming() - else: - await self._finish_processing_stream() + await self._request_context.wait_for_results_or_errors() + if not self._request_context.okay_to_iterate: + await self._request_context.finish_processing_stream() await self._process_response() async def shutdown(self) -> None: + """ + **INTERNAL** + """ await self.close() - await self._request_context.shutdown() - -SendRequestFunc: TypeAlias = Callable[[AsyncHttpStreamingResponse], Coroutine[Any, Any, None]] -# Although, SendRequestFunc is the same type as WrappedSendRequestFunc, keep separate for clarity and indicate -# WrappedSendRequestFunc is a decorator -WrappedSendRequestFunc: TypeAlias = Callable[[AsyncHttpStreamingResponse], Coroutine[Any, Any, None]] \ No newline at end of file + await self._request_context.shutdown() \ No newline at end of file diff --git a/acouchbase_analytics/tests/connection_t.py b/acouchbase_analytics/tests/connection_t.py index e482f97..ccb13a4 100644 --- a/acouchbase_analytics/tests/connection_t.py +++ b/acouchbase_analytics/tests/connection_t.py @@ -21,9 +21,9 @@ import pytest from acouchbase_analytics.cluster import AsyncCluster -from acouchbase_analytics.protocol.core.client_adapter import _AsyncClientAdapter +from acouchbase_analytics.protocol._core.client_adapter import _AsyncClientAdapter from couchbase_analytics.credential import Credential -from couchbase_analytics.protocol.core.request import _RequestBuilder +from couchbase_analytics.protocol._core.request import _RequestBuilder from tests.utils import get_test_cert_path, to_query_str TEST_CERT_PATH = get_test_cert_path() @@ -81,7 +81,8 @@ def test_connstr_options_timeout(self, expected_seconds: str) -> None: opt_keys = ['timeout.connect_timeout', 'timeout.query_timeout'] - opts = {k: duration for k in opt_keys} + # opts = {k: duration for k in opt_keys} + opts = dict.fromkeys(opt_keys, duration) cred = Credential.from_username_and_password('Administrator', 'password') connstr = f'https://localhost?{to_query_str(opts)}' client = _AsyncClientAdapter(connstr, cred) @@ -212,8 +213,8 @@ def test_valid_connection_strings(self, connstr: str) -> None: assert {} == client.connection_details.cluster_options parsed_connstr = urlparse(connstr) parsed_port = parsed_connstr.port or (80 if parsed_connstr.scheme == 'http' else 443) - scheme, host, port = client.connection_details.get_scheme_host_and_port() - assert f'{parsed_connstr.scheme}://{parsed_connstr.hostname}:{parsed_port}' == f'{scheme}://{host}:{port}' + url = client.connection_details.url.get_formatted_url() + assert f'{parsed_connstr.scheme}://{parsed_connstr.hostname}:{parsed_port}' == url class ConnectionTests(ConnectionTestSuite): diff --git a/acouchbase_analytics/tests/json_parsing_t.py b/acouchbase_analytics/tests/json_parsing_t.py index 70e622e..3d4599b 100644 --- a/acouchbase_analytics/tests/json_parsing_t.py +++ b/acouchbase_analytics/tests/json_parsing_t.py @@ -17,16 +17,12 @@ import json from time import time -from typing import Dict, TYPE_CHECKING +from typing import TYPE_CHECKING, Dict import pytest -from couchbase_analytics.common.core import (JsonParsingError, - JsonStreamConfig, - ParsedResult, - ParsedResultType) - -from couchbase_analytics.common.core.async_json_stream import AsyncJsonStream +from acouchbase_analytics.protocol._core.async_json_stream import AsyncJsonStream +from couchbase_analytics.common._core import JsonParsingError, JsonStreamConfig, ParsedResult, ParsedResultType from couchbase_analytics.common.errors import AnalyticsError from tests.environments.simple_environment import JsonDataType from tests.utils import AsyncBytesIterator @@ -41,6 +37,7 @@ class JsonParsingTestSuite: 'test_analytics_error', 'test_analytics_error_mid_stream', 'test_analytics_many_rows', + 'test_analytics_many_rows_raw', 'test_analytics_multiple_errors', 'test_analytics_parses_async', 'test_analytics_simple_result', @@ -138,6 +135,42 @@ async def test_analytics_many_rows(self, async_test_env: AsyncSimpleEnvironment) with pytest.raises(AnalyticsError): await parser.get_result() + @pytest.mark.parametrize('buffered_result', [True, False]) + async def test_analytics_many_rows_raw(self, + async_test_env: AsyncSimpleEnvironment, + buffered_result: bool) -> None: + json_object, bytes_data = async_test_env.get_json_data(JsonDataType.MULTIPLE_RESULTS_RAW) + if buffered_result: + parser = AsyncJsonStream(AsyncBytesIterator(bytes_data), + stream_config=JsonStreamConfig(buffer_entire_result=True)) + else: + parser = AsyncJsonStream(AsyncBytesIterator(bytes_data)) + + await parser.start_parsing() + if not buffered_result: + row_idx = 0 + while row_idx < 10: + result = await parser.get_result() + if result is None and not parser.token_stream_exhausted: + await parser.continue_parsing() + continue + assert isinstance(result, ParsedResult) + assert result.result_type == ParsedResultType.ROW + assert isinstance(result.value, bytes) + assert json.loads(result.value.decode('utf-8')) == json_object['results'][row_idx] + row_idx += 1 + + final_result = await parser.get_result() + assert isinstance(final_result, ParsedResult) + assert final_result.result_type == ParsedResultType.END + assert isinstance(final_result.value, bytes) + if not buffered_result: + # if we are not buffering the entire result, the final result will exclude the results key + json_object.pop('results') + assert json.loads(final_result.value.decode('utf-8')) == json_object + with pytest.raises(AnalyticsError): + await parser.get_result() + @pytest.mark.parametrize('buffered_result', [True, False]) async def test_analytics_multiple_errors(self, async_test_env: AsyncSimpleEnvironment, @@ -182,7 +215,7 @@ async def _run_async(idx: int) -> Dict[float, int]: for idx in range(10): tg.start_soon(_run_async, idx) ordered_results = dict(sorted({k: v for r in tg.results for k, v in r.items()}.items())) - assert list(ordered_results.values()) != list(i for i in range(10)) + assert list(ordered_results.values()) != list(range(10)) @pytest.mark.parametrize('buffered_result', [True, False]) async def test_analytics_simple_result(self, diff --git a/acouchbase_analytics/tests/options_t.py b/acouchbase_analytics/tests/options_t.py index ba18d6d..12f9840 100644 --- a/acouchbase_analytics/tests/options_t.py +++ b/acouchbase_analytics/tests/options_t.py @@ -20,20 +20,17 @@ import pytest -from acouchbase_analytics.protocol.core.client_adapter import _AsyncClientAdapter +from acouchbase_analytics.protocol._core.client_adapter import _AsyncClientAdapter from couchbase_analytics.credential import Credential -from couchbase_analytics.deserializer import (Deserializer, - DefaultJsonDeserializer, - PassthroughDeserializer) -from couchbase_analytics.options import (ClusterOptions, - SecurityOptions, - SecurityOptionsKwargs, - TimeoutOptions, - TimeoutOptionsKwargs) - -from tests.utils import (get_test_cert_path, - get_test_cert_list, - get_test_cert_str) +from couchbase_analytics.deserializer import DefaultJsonDeserializer, Deserializer, PassthroughDeserializer +from couchbase_analytics.options import ( + ClusterOptions, + SecurityOptions, + SecurityOptionsKwargs, + TimeoutOptions, + TimeoutOptionsKwargs, +) +from tests.utils import get_test_cert_list, get_test_cert_path, get_test_cert_str TEST_CERT_PATH = get_test_cert_path() TEST_CERT_LIST = get_test_cert_list() diff --git a/acouchbase_analytics/tests/query_integration_t.py b/acouchbase_analytics/tests/query_integration_t.py index 65d2186..24e06fc 100644 --- a/acouchbase_analytics/tests/query_integration_t.py +++ b/acouchbase_analytics/tests/query_integration_t.py @@ -84,9 +84,11 @@ def query_statement_limit5(self, test_env: AsyncTestEnvironment) -> str: return f'SELECT * FROM {test_env.fqdn} LIMIT 5;' async def test_query_cancel_prior_iterating(self, test_env: AsyncTestEnvironment) -> None: - statement = 'FROM range(0, 100000) AS r SELECT *' + # simulate query that takes time to return + statement = 'SELECT sleep("some value", 10000) AS some_field;' qtask = test_env.cluster_or_scope.execute_query(statement) assert isinstance(qtask, Task) + await test_env.sleep(1) qtask.cancel() with pytest.raises(CancelledError): await qtask @@ -112,8 +114,9 @@ async def test_query_cancel_async_while_iterating(self, assert len(rows) == count expected_state = StreamingState.Cancelled assert res._http_response._request_context.request_state == expected_state - with pytest.raises(CancelledError): + with pytest.raises(RuntimeError): res.metadata() + test_env.assert_streaming_response_state(res) async def test_query_cancel_while_iterating(self, test_env: AsyncTestEnvironment, @@ -136,10 +139,11 @@ async def test_query_cancel_while_iterating(self, assert len(rows) == count expected_state = StreamingState.Cancelled assert res._http_response._request_context.request_state == expected_state - with pytest.raises(CancelledError): + with pytest.raises(RuntimeError): res.metadata() # if we don't cancel via the async path, we want to ensure the stream/response is shutdown appropriately await res.shutdown() + test_env.assert_streaming_response_state(res) async def test_query_metadata(self, test_env: AsyncTestEnvironment, @@ -160,6 +164,7 @@ async def test_query_metadata(self, assert metrics.processed_objects() > 0 assert metrics.elapsed_time() > timedelta(0) assert metrics.execution_time() > timedelta(0) + test_env.assert_streaming_response_state(result) async def test_query_metadata_not_available(self, test_env: AsyncTestEnvironment, @@ -185,6 +190,7 @@ async def test_query_metadata_not_available(self, metadata = result.metadata() assert len(metadata.warnings()) == 0 assert len(metadata.request_id()) > 0 + test_env.assert_streaming_response_state(result) async def test_query_named_parameters(self, test_env: AsyncTestEnvironment, @@ -192,6 +198,7 @@ async def test_query_named_parameters(self, q_opts = QueryOptions(named_parameters={'country': 'United States'}) result = await test_env.cluster_or_scope.execute_query(query_statement_named_params_limit2, q_opts) await test_env.assert_rows(result, 2) + test_env.assert_streaming_response_state(result) async def test_query_named_parameters_no_options(self, test_env: AsyncTestEnvironment, @@ -199,6 +206,7 @@ async def test_query_named_parameters_no_options(self, result = await test_env.cluster_or_scope.execute_query(query_statement_named_params_limit2, country='United States') await test_env.assert_rows(result, 2) + test_env.assert_streaming_response_state(result) async def test_query_named_parameters_override(self, test_env: AsyncTestEnvironment, @@ -208,6 +216,7 @@ async def test_query_named_parameters_override(self, q_opts, country='United States') await test_env.assert_rows(result, 2) + test_env.assert_streaming_response_state(result) async def test_query_passthrough_deserializer(self, test_env: AsyncTestEnvironment) -> None: statement = 'FROM range(0, 10) AS num SELECT *' @@ -218,6 +227,7 @@ async def test_query_passthrough_deserializer(self, test_env: AsyncTestEnvironme assert isinstance(row, bytes) assert json.loads(row) == {'num': idx} idx += 1 + test_env.assert_streaming_response_state(result) async def test_query_positional_params(self, test_env: AsyncTestEnvironment, @@ -225,12 +235,14 @@ async def test_query_positional_params(self, q_opts = QueryOptions(positional_parameters=['United States']) result = await test_env.cluster_or_scope.execute_query(query_statement_pos_params_limit2, q_opts) await test_env.assert_rows(result, 2) + test_env.assert_streaming_response_state(result) async def test_query_positional_params_no_option(self, test_env: AsyncTestEnvironment, query_statement_pos_params_limit2: str) -> None: result = await test_env.cluster_or_scope.execute_query(query_statement_pos_params_limit2, 'United States') await test_env.assert_rows(result, 2) + test_env.assert_streaming_response_state(result) async def test_query_positional_params_override(self, test_env: AsyncTestEnvironment, @@ -240,6 +252,7 @@ async def test_query_positional_params_override(self, q_opts, 'United States') await test_env.assert_rows(result, 2) + test_env.assert_streaming_response_state(result) async def test_query_raises_exception_prior_to_iterating(self, test_env: AsyncTestEnvironment) -> None: statement = "I'm not N1QL!" @@ -265,6 +278,7 @@ async def test_query_raw_options(self, result = await test_env.cluster_or_scope.execute_query(query_statement_pos_params_limit2, QueryOptions(raw={'args': ['United States']})) await test_env.assert_rows(result, 2) + test_env.assert_streaming_response_state(result) async def test_query_timeout(self, test_env: AsyncTestEnvironment) -> None: statement = 'SELECT sleep("some value", 10000) AS some_field;' @@ -283,12 +297,14 @@ async def test_query_timeout_while_streaming(self, test_env: AsyncTestEnvironmen with pytest.raises(TimeoutError): async for _ in result.rows(): pass + test_env.assert_streaming_response_state(result) async def test_simple_query(self, test_env: AsyncTestEnvironment, query_statement_limit2: str) -> None: result = await test_env.cluster_or_scope.execute_query(query_statement_limit2) await test_env.assert_rows(result, 2) + test_env.assert_streaming_response_state(result) class ClusterQueryTests(QueryTestSuite): diff --git a/acouchbase_analytics/tests/query_options_t.py b/acouchbase_analytics/tests/query_options_t.py index 0ec4d5d..4ab489e 100644 --- a/acouchbase_analytics/tests/query_options_t.py +++ b/acouchbase_analytics/tests/query_options_t.py @@ -17,20 +17,16 @@ from dataclasses import dataclass from datetime import timedelta -from typing import (Any, - Dict, - List, - Optional, - Union) +from typing import Any, Dict, List, Optional, Union import pytest -from acouchbase_analytics.protocol.core.client_adapter import _AsyncClientAdapter +from acouchbase_analytics.protocol._core.client_adapter import _AsyncClientAdapter from couchbase_analytics import JSONType from couchbase_analytics.credential import Credential from couchbase_analytics.options import QueryOptions, QueryOptionsKwargs +from couchbase_analytics.protocol._core.request import _RequestBuilder from couchbase_analytics.protocol.options import QueryOptionsTransformedKwargs -from couchbase_analytics.protocol.core.request import _RequestBuilder @dataclass diff --git a/acouchbase_analytics/tests/test_server_t.py b/acouchbase_analytics/tests/test_server_t.py index 884663f..0780c48 100644 --- a/acouchbase_analytics/tests/test_server_t.py +++ b/acouchbase_analytics/tests/test_server_t.py @@ -16,11 +16,16 @@ from __future__ import annotations from asyncio import Task +from datetime import timedelta from typing import TYPE_CHECKING import pytest +from acouchbase_analytics.errors import InvalidCredentialError, QueryError, TimeoutError +from acouchbase_analytics.options import QueryOptions +from acouchbase_analytics.result import AsyncQueryResult from tests import AsyncYieldFixture +from tests.test_server import ErrorType, NonRetriableSpecificationType, ResultType, RetriableGroupType if TYPE_CHECKING: from tests.environments.base_environment import AsyncTestEnvironment @@ -29,30 +34,143 @@ class TestServerTestSuite: TEST_MANIFEST = [ - 'test_simple', + 'test_auth_error_unauthorized', + 'test_auth_error_insufficient_permissions', + 'test_error_non_retriable_response', + 'test_error_retriable_response', + 'test_error_timeout', + 'test_results_object_values', + 'test_results_raw_values' ] - async def test_simple(self, test_env: AsyncTestEnvironment) -> None: - test_env.set_url_path('/test_post') - # test_env.update_request_json({'test_timeout': 10}) - # test_env.update_request_extensions({'timeout': {'pool': 5, - # 'test_pool_timeout': 5, - # 'test_connect_timeout': 5}}) + async def test_auth_error_unauthorized(self, test_env: AsyncTestEnvironment) -> None: + test_env.set_url_path('/test_error') + test_env.update_request_json({'error_type': ErrorType.Unauthorized.value}) statement = 'SELECT "Hello, data!" AS greeting' - rtask = test_env.cluster.execute_query(statement) - print(f'Have result: {rtask=}') - if isinstance(rtask, Task): - print('Result is a Task') - res = await rtask + with pytest.raises(InvalidCredentialError) as ex: + await test_env.cluster_or_scope.execute_query(statement) + test_env.assert_error_context_num_attempts(1, ex.value._context) + test_env.assert_error_context_contains_last_dispatch(ex.value._context) -class TestServerTests(TestServerTestSuite): + async def test_auth_error_insufficient_permissions(self, test_env: AsyncTestEnvironment) -> None: + test_env.set_url_path('/test_error') + test_env.update_request_json({'error_type': ErrorType.InsufficientPermissions.value}) + statement = 'SELECT "Hello, data!" AS greeting' + with pytest.raises(QueryError) as ex: + await test_env.cluster_or_scope.execute_query(statement) + assert ex.value.code == 20001 + assert 'Insufficient permissions' in ex.value.server_message + test_env.assert_error_context_num_attempts(1, ex.value._context) + test_env.assert_error_context_contains_last_dispatch(ex.value._context) + + @pytest.mark.parametrize('retry_group_type', + [RetriableGroupType.Zero, + RetriableGroupType.First, + RetriableGroupType.Middle, + RetriableGroupType.Last]) + @pytest.mark.parametrize('non_retriable_spec', + [NonRetriableSpecificationType.AllEmpty, + NonRetriableSpecificationType.AllFalse, + NonRetriableSpecificationType.Random]) + async def test_error_non_retriable_response(self, + test_env: AsyncTestEnvironment, + retry_group_type: RetriableGroupType, + non_retriable_spec: NonRetriableSpecificationType) -> None: + test_env.set_url_path('/test_error') + test_env.update_request_json({'error_type': ErrorType.Retriable.value, + 'retry_group_type': retry_group_type.value, + 'non_retriable_spec': non_retriable_spec.value}) + statement = 'SELECT "Hello, data!" AS greeting' + with pytest.raises(QueryError) as ex: + await test_env.cluster_or_scope.execute_query(statement) + test_env.assert_error_context_num_attempts(1, ex.value._context) + test_env.assert_error_context_contains_last_dispatch(ex.value._context) + + async def test_error_retriable_response(self, test_env: AsyncTestEnvironment) -> None: + test_env.set_url_path('/test_error') + test_env.update_request_json({'error_type': ErrorType.Retriable.value, + 'retry_group_type': RetriableGroupType.All.value}) + statement = 'SELECT "Hello, data!" AS greeting' + with pytest.raises(TimeoutError) as ex: + await test_env.cluster_or_scope.execute_query(statement, QueryOptions(timeout=timedelta(seconds=2))) + + test_env.assert_error_context_num_attempts(4 , ex.value._context, exact=False) + test_env.assert_error_context_contains_last_dispatch(ex.value._context) + + @pytest.mark.parametrize('server_side', [False, True]) + async def test_error_timeout(self, test_env: AsyncTestEnvironment, server_side: bool) -> None: + test_env.set_url_path('/test_error') + if server_side: + req_json = {'error_type': ErrorType.Timeout.value, 'timeout': 1, 'server_side': True} + else: + req_json = {'error_type': ErrorType.Timeout.value, 'timeout': 2} + + test_env.update_request_json(req_json) + statement = 'SELECT "Hello, data!" AS greeting' + with pytest.raises(TimeoutError) as ex: + await test_env.cluster_or_scope.execute_query(statement, QueryOptions(timeout=timedelta(seconds=2))) + test_env.assert_error_context_num_attempts(1, ex.value._context) + if server_side: + test_env.assert_error_context_contains_last_dispatch(ex.value._context) + else: + test_env.assert_error_context_missing_last_dispatch(ex.value._context) + + @pytest.mark.parametrize('stream', [False, True]) + async def test_results_object_values(self, + test_env: AsyncTestEnvironment, + stream: bool) -> None: + expected_rows = 50 + test_env.set_url_path('/test_results') + test_env.update_request_json({'result_type': ResultType.Object.value, + 'row_count': expected_rows, + 'stream': stream}) + statement = 'SELECT "Hello, data!" AS greeting' + result = await test_env.cluster_or_scope.execute_query(statement) + assert isinstance(result, AsyncQueryResult) + await test_env.assert_rows(result, expected_rows) + + @pytest.mark.parametrize('stream', [False, True]) + async def test_results_raw_values(self, + test_env: AsyncTestEnvironment, + stream: bool) -> None: + expected_rows = 50 + test_env.set_url_path('/test_results') + test_env.update_request_json({'result_type': ResultType.Raw.value, + 'row_count': expected_rows, + 'stream': stream}) + statement = 'SELECT "Hello, data!" AS greeting' + result = await test_env.cluster_or_scope.execute_query(statement) + assert isinstance(result, AsyncQueryResult) + await test_env.assert_rows(result, expected_rows) + +class ClusterTestServerTests(TestServerTestSuite): + + @pytest.fixture(scope='class', autouse=True) + def validate_test_manifest(self) -> None: + def valid_test_method(meth: str) -> bool: + attr = getattr(ClusterTestServerTests, meth) + return callable(attr) and not meth.startswith('__') and meth.startswith('test') + method_list = [meth for meth in dir(ClusterTestServerTests) if valid_test_method(meth)] + test_list = set(TestServerTestSuite.TEST_MANIFEST).symmetric_difference(method_list) + if test_list: + pytest.fail(f'Test manifest invalid. Missing/extra tests: {test_list}.') + + @pytest.fixture(scope='class', name='test_env') + async def couchbase_test_environment(self, + async_test_env_with_server: AsyncTestEnvironment + ) -> AsyncYieldFixture[AsyncTestEnvironment]: + test_env = await async_test_env_with_server.enable_test_server() + yield test_env + test_env.disable_test_server() + +class ScopeTestServerTests(TestServerTestSuite): @pytest.fixture(scope='class', autouse=True) def validate_test_manifest(self) -> None: def valid_test_method(meth: str) -> bool: - attr = getattr(TestServerTests, meth) + attr = getattr(ScopeTestServerTests, meth) return callable(attr) and not meth.startswith('__') and meth.startswith('test') - method_list = [meth for meth in dir(TestServerTests) if valid_test_method(meth)] + method_list = [meth for meth in dir(ScopeTestServerTests) if valid_test_method(meth)] test_list = set(TestServerTestSuite.TEST_MANIFEST).symmetric_difference(method_list) if test_list: pytest.fail(f'Test manifest invalid. Missing/extra tests: {test_list}.') @@ -61,9 +179,7 @@ def valid_test_method(meth: str) -> bool: async def couchbase_test_environment(self, async_test_env_with_server: AsyncTestEnvironment ) -> AsyncYieldFixture[AsyncTestEnvironment]: - import asyncio - loop = asyncio.get_running_loop() - print(f'Running loop: {loop=}') test_env = await async_test_env_with_server.enable_test_server() + test_env.enable_scope() yield test_env - test_env.disable_test_server() \ No newline at end of file + test_env.disable_scope().disable_test_server() \ No newline at end of file diff --git a/conftest.py b/conftest.py index e5120c3..81a06db 100644 --- a/conftest.py +++ b/conftest.py @@ -29,12 +29,16 @@ 'acouchbase_analytics/tests/options_t.py::ClusterOptionsTests', 'acouchbase_analytics/tests/query_options_t.py::ClusterQueryOptionsTests', 'acouchbase_analytics/tests/query_options_t.py::ScopeQueryOptionsTests', + 'acouchbase_analytics/tests/test_server_t.py::ClusterTestServerTests', + 'acouchbase_analytics/tests/test_server_t.py::ScopeTestServerTests', 'couchbase_analytics/tests/connection_t.py::ConnectionTests', 'couchbase_analytics/tests/duration_parsing_t.py::DurationParsingTests', 'couchbase_analytics/tests/json_parsing_t.py::JsonParsingTests', 'couchbase_analytics/tests/options_t.py::ClusterOptionsTests', 'couchbase_analytics/tests/query_options_t.py::ClusterQueryOptionsTests', 'couchbase_analytics/tests/query_options_t.py::ScopeQueryOptionsTests', + 'couchbase_analytics/tests/test_server_t.py::ClusterTestServerTests', + 'couchbase_analytics/tests/test_server_t.py::ScopeTestServerTests', ] _INTEGRATRION_TESTS = [ diff --git a/couchbase_analytics/_version.py b/couchbase_analytics/_version.py index f2665a0..3f9c4a7 100644 --- a/couchbase_analytics/_version.py +++ b/couchbase_analytics/_version.py @@ -1,5 +1,5 @@ # This file automatically generated by -# /Users/jaredcasey/GIT/couchbase/clients/python/analytics-python-client/couchbase_analytics_version.py +# /Users/jaredcasey/GIT/couchbase/clients/python/analytics-python-client/./couchbase_analytics_version.py # at -# 2025-06-13 16:27:15.151489 +# 2025-07-02 15:23:28.150595 __version__ = '0.0.1' diff --git a/couchbase_analytics/cluster.py b/couchbase_analytics/cluster.py index 100f08e..056f917 100644 --- a/couchbase_analytics/cluster.py +++ b/couchbase_analytics/cluster.py @@ -16,9 +16,7 @@ from __future__ import annotations from concurrent.futures import Future -from typing import (TYPE_CHECKING, - Optional, - Union) +from typing import TYPE_CHECKING, Optional, Union from couchbase_analytics.database import Database from couchbase_analytics.result import BlockingQueryResult diff --git a/couchbase_analytics/cluster.pyi b/couchbase_analytics/cluster.pyi index 6d61e43..379aa0e 100644 --- a/couchbase_analytics/cluster.pyi +++ b/couchbase_analytics/cluster.pyi @@ -25,10 +25,7 @@ else: from couchbase_analytics import JSONType from couchbase_analytics.credential import Credential from couchbase_analytics.database import Database -from couchbase_analytics.options import (ClusterOptions, - ClusterOptionsKwargs, - QueryOptions, - QueryOptionsKwargs) +from couchbase_analytics.options import ClusterOptions, ClusterOptionsKwargs, QueryOptions, QueryOptionsKwargs from couchbase_analytics.result import BlockingQueryResult class Cluster: diff --git a/couchbase_analytics/common/__init__.py b/couchbase_analytics/common/__init__.py index 04ab2ad..962f59f 100644 --- a/couchbase_analytics/common/__init__.py +++ b/couchbase_analytics/common/__init__.py @@ -13,9 +13,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import (Any, - Dict, - List, - Union) +from typing import Any, Dict, List, Union JSONType = Union[str, int, float, bool, None, Dict[str, Any], List[Any]] diff --git a/couchbase_analytics/common/core/__init__.py b/couchbase_analytics/common/_core/__init__.py similarity index 100% rename from couchbase_analytics/common/core/__init__.py rename to couchbase_analytics/common/_core/__init__.py diff --git a/couchbase_analytics/common/core/_capella_certificates/_capella.pem b/couchbase_analytics/common/_core/_capella_certificates/_capella.pem similarity index 100% rename from couchbase_analytics/common/core/_capella_certificates/_capella.pem rename to couchbase_analytics/common/_core/_capella_certificates/_capella.pem diff --git a/couchbase_analytics/common/core/_certificates.py b/couchbase_analytics/common/_core/_certificates.py similarity index 98% rename from couchbase_analytics/common/core/_certificates.py rename to couchbase_analytics/common/_core/_certificates.py index 069148c..c4dd02c 100644 --- a/couchbase_analytics/common/core/_certificates.py +++ b/couchbase_analytics/common/_core/_certificates.py @@ -17,7 +17,6 @@ from __future__ import annotations import os - from pathlib import Path from typing import List @@ -63,7 +62,7 @@ def get_nonprod_certificates() -> List[str]: List[str]: List of nonprod Capella certificates. """ import warnings - warnings.warn('Only use non-prod certificate in DEVELOPMENT environments.', ResourceWarning) + warnings.warn('Only use non-prod certificate in DEVELOPMENT environments.', ResourceWarning, stacklevel=2) nonprod_cert_dir = Path(Path(__file__).resolve().parent, '_nonprod_certificates') nonprod_certs: List[str] = [] for cert in nonprod_cert_dir.iterdir(): diff --git a/couchbase_analytics/common/core/_nonprod_certificates/_nonprod.pem b/couchbase_analytics/common/_core/_nonprod_certificates/_nonprod.pem similarity index 100% rename from couchbase_analytics/common/core/_nonprod_certificates/_nonprod.pem rename to couchbase_analytics/common/_core/_nonprod_certificates/_nonprod.pem diff --git a/couchbase_analytics/common/core/duration_str_utils.py b/couchbase_analytics/common/_core/duration_str_utils.py similarity index 93% rename from couchbase_analytics/common/core/duration_str_utils.py rename to couchbase_analytics/common/_core/duration_str_utils.py index 43ec3ef..f87159d 100644 --- a/couchbase_analytics/common/core/duration_str_utils.py +++ b/couchbase_analytics/common/_core/duration_str_utils.py @@ -16,7 +16,7 @@ import re from typing import Optional -from couchbase_analytics.common.core.utils import is_null_or_empty +from couchbase_analytics.common._core.utils import is_null_or_empty # TODO: Apparently Go does not allow a leading decimal point without a leading zero, e.g., ".5s" is invalid. # We allowed this in the Columnar SDK due to how the C++ client parsed durations @@ -76,12 +76,12 @@ def parse_duration_str(duration_str: str, in_millis: Optional[bool]=False) -> fl total_seconds += value * unit_multipliers[unit_str] except OverflowError as e: raise ValueError((f'Invalid duration. Overflow error while parsing number "{num_str}{unit_str}". ' - f'Error details: {e}')) + f'Error details: {e}')) from None except ValueError as e: raise ValueError((f'Invalid duration. Parsing error while parsing number "{num_str}{unit_str}". ' - f'Error details: {e}')) + f'Error details: {e}')) from None except KeyError: - raise ValueError(f'Invalid duration. Unknown unit "{unit_str}"') + raise ValueError(f'Invalid duration. Unknown unit "{unit_str}"') from None if in_millis: total_seconds *= 1e3 diff --git a/couchbase_analytics/common/_core/error_context.py b/couchbase_analytics/common/_core/error_context.py new file mode 100644 index 0000000..ad8a62c --- /dev/null +++ b/couchbase_analytics/common/_core/error_context.py @@ -0,0 +1,90 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Dict, List, Optional + +from httpx import Response as HttpCoreResponse + +from couchbase_analytics.protocol._core.request import QueryRequest + + +@dataclass +class ErrorContext: + num_attempts: int = 0 + path: Optional[str] = None + method: Optional[str] = None + status_code: Optional[int] = None + statement: Optional[str] = None + last_dispatched_to: Optional[str] = None + last_dispatched_from: Optional[str] = None + errors: Optional[List[Dict[str, Any]]] = None + first_error: Optional[Dict[str, Any]] = None + + def set_errors(self, errors: List[Dict[str, Any]]) -> None: + self.errors: List[Dict[str, Any]] = errors + + def set_first_error(self, error: Dict[str, Any]) -> None: + self.first_error = error + + def maybe_update_errors(self) -> None: + if self.errors is not None and len(self.errors) > 0: + return + if self.first_error is not None: + self.errors = [self.first_error] + + def update_request_context(self, request: QueryRequest) -> None: + self.num_attempts += 1 + self.path = request.url.path + + def update_response_context(self, response: HttpCoreResponse) -> None: + network_stream = response.extensions.get('network_stream', None) + # TODO: what if network_stream is None? + if network_stream is not None: + addr, port = network_stream.get_extra_info('client_addr') + self.last_dispatched_from = f'{addr}:{port}' + addr, port = network_stream.get_extra_info('server_addr') + self.last_dispatched_to = f'{addr}:{port}' + print(f'Client address: {self.last_dispatched_from}, Server address: {self.last_dispatched_to}') + self.status_code = response.status_code + + def _ctx_details(self) -> Dict[str, str]: + details: Dict[str, str] = { + 'num_attempts': str(self.num_attempts), + } + if self.path is not None: + details['path'] = self.path + if self.method is not None: + details['method'] = self.method + if self.status_code is not None: + details['status_code'] = str(self.status_code) + if self.statement is not None: + details['statement'] = self.statement + if self.last_dispatched_to is not None: + details['last_dispatched_to'] = self.last_dispatched_to + if self.last_dispatched_from is not None: + details['last_dispatched_from'] = self.last_dispatched_from + if self.errors is not None: + errors = ', '.join(str(e) for e in self.errors) + details['errors'] = f'[{errors}]' + return details + + def __repr__(self) -> str: + return f'{type(self).__name__}({self._ctx_details()})' + + def __str__(self) -> str: + return str(self._ctx_details()) \ No newline at end of file diff --git a/couchbase_analytics/common/core/json_parsing.py b/couchbase_analytics/common/_core/json_parsing.py similarity index 97% rename from couchbase_analytics/common/core/json_parsing.py rename to couchbase_analytics/common/_core/json_parsing.py index 840a717..4d49834 100644 --- a/couchbase_analytics/common/core/json_parsing.py +++ b/couchbase_analytics/common/_core/json_parsing.py @@ -17,7 +17,8 @@ from dataclasses import dataclass from enum import IntEnum -from typing import Optional, NamedTuple +from typing import NamedTuple, Optional + class JsonParsingError(Exception): def __init__(self, cause: Optional[Exception]=None) -> None: diff --git a/couchbase_analytics/common/core/json_token_parser_base.py b/couchbase_analytics/common/_core/json_token_parser_base.py similarity index 83% rename from couchbase_analytics/common/core/json_token_parser_base.py rename to couchbase_analytics/common/_core/json_token_parser_base.py index 671afd2..a1be359 100644 --- a/couchbase_analytics/common/core/json_token_parser_base.py +++ b/couchbase_analytics/common/_core/json_token_parser_base.py @@ -17,9 +17,8 @@ from collections import deque from enum import Enum -from typing import (Deque, - Optional, - NamedTuple) +from typing import Deque, NamedTuple, Optional + class ParsingState(Enum): PROCESSING = 'processing' @@ -31,6 +30,16 @@ class ParsingState(Enum): PROCESSING_ERROR = 'processing_error' UNDEFINED = 'undefined' + @staticmethod + def okay_to_emit(state: ParsingState, previous_state: ParsingState) -> bool: + if state == ParsingState.PROCESSING_RESULTS: + return True + return previous_state == ParsingState.PROCESSING_RESULTS and state == ParsingState.PROCESSING + + @staticmethod + def should_pop_results_key(state: ParsingState, previous_state: ParsingState) -> bool: + return previous_state == ParsingState.PROCESSING_RESULTS and state == ParsingState.PROCESSING + def __str__(self) -> str: return self.value @@ -60,13 +69,14 @@ class TokenType(Enum): PAIR = 'pair' VALUE = 'value' OBJECT = 'object' + UNKNOWN = 'unknown' @classmethod def from_str(cls, value: str) -> TokenType: try: return cls[value.upper()] except KeyError: - raise ValueError(f'Invalid token type: {value}') + raise ValueError(f'Invalid token type: {value}') from None def __str__(self) -> str: return self.value @@ -104,11 +114,26 @@ def __init__(self, emit_results_enabled: bool) -> None: self._state = ParsingState.PROCESSING self._previous_state = ParsingState.UNDEFINED self._emit_results_enabled = emit_results_enabled + self._results_type = TokenType.UNKNOWN self._has_errors = False @property def has_errors(self) -> bool: return self._has_errors + + @property + def results_type(self) -> TokenType: + return self._results_type + + def _check_results_in_raw_array(self) -> None: + if self._results_type != TokenType.UNKNOWN: + return + if self._state == ParsingState.PROCESSING: + return + if self._state == ParsingState.PROCESSING_RESULTS: + self._results_type = TokenType.VALUE + else: + self._results_type = TokenType.OBJECT def _get_matching_token(self, token_type: TokenType) -> Token: if token_type == TokenType.END_ARRAY: @@ -169,7 +194,8 @@ def _handle_start_event(self, token_type: TokenType) -> None: self._push(token_type, EVENT_TOKENS[token_type].value, transition) - def _handle_value_token(self, token_type: TokenType, value: str) -> None: + def _handle_value_token(self, token_type: TokenType, value: str) -> Optional[str]: + self._check_results_in_raw_array() pair_key = val = None if len(self._stack) > 0 and self._stack[-1].type == TokenType.MAP_KEY: # no state transitions for a map_key token @@ -181,15 +207,20 @@ def _handle_value_token(self, token_type: TokenType, value: str) -> None: value = value.replace("\\'", "\\\\'") val = f'"{value}"' elif token_type == TokenType.NULL: - val = f'null' + val = 'null' elif token_type == TokenType.BOOLEAN: val = f'{value}'.lower() else: val = f'{value}' if pair_key is not None: + if self.results_type == TokenType.VALUE and self._state != ParsingState.PROCESSING: + raise RuntimeError('JsonTokenParser: Cannot return value when pair key is present.') self._push(TokenType.PAIR, f'{pair_key}:{val}') else: + if self._emit_results_enabled is True and self.results_type == TokenType.VALUE: + return val self._push(TokenType.VALUE, val) + return None def _push(self, token_type: TokenType, value: str, transition: Optional[bool]=False) -> None: token_state = None diff --git a/couchbase_analytics/common/core/query.py b/couchbase_analytics/common/_core/query.py similarity index 94% rename from couchbase_analytics/common/core/query.py rename to couchbase_analytics/common/_core/query.py index d233a1f..eae4a8a 100644 --- a/couchbase_analytics/common/core/query.py +++ b/couchbase_analytics/common/_core/query.py @@ -16,12 +16,9 @@ from __future__ import annotations import json -from typing import (Any, - List, - TypedDict, - Optional) +from typing import Any, List, Optional, TypedDict -from couchbase_analytics.common.core.duration_str_utils import parse_duration_str +from couchbase_analytics.common._core.duration_str_utils import parse_duration_str class QueryMetricsCore(TypedDict, total=False): diff --git a/couchbase_analytics/common/core/result.py b/couchbase_analytics/common/_core/result.py similarity index 93% rename from couchbase_analytics/common/core/result.py rename to couchbase_analytics/common/_core/result.py index 8cbd521..018b8c5 100644 --- a/couchbase_analytics/common/core/result.py +++ b/couchbase_analytics/common/_core/result.py @@ -17,11 +17,7 @@ import sys from abc import ABC, abstractmethod -from typing import (Any, - Coroutine, - List, - Optional, - Union) +from typing import Any, Coroutine, List, Optional, Union if sys.version_info < (3, 9): from typing import AsyncIterator as PyAsyncIterator diff --git a/couchbase_analytics/common/core/utils.py b/couchbase_analytics/common/_core/utils.py similarity index 91% rename from couchbase_analytics/common/core/utils.py rename to couchbase_analytics/common/_core/utils.py index 6117c4f..0e47cac 100644 --- a/couchbase_analytics/common/core/utils.py +++ b/couchbase_analytics/common/_core/utils.py @@ -18,13 +18,7 @@ from datetime import timedelta from enum import Enum from os import path -from typing import (Any, - Dict, - Generic, - List, - Optional, - TypeVar, - Union) +from typing import Any, Dict, Generic, List, Optional, TypeVar, Union from couchbase_analytics.common.deserializer import Deserializer @@ -33,7 +27,7 @@ def is_null_or_empty(value: Optional[str]) -> bool: - return value is None or value.isspace() + return not value or value.isspace() def timedelta_as_seconds(duration: timedelta) -> int: @@ -80,7 +74,7 @@ def to_seconds(value: Union[timedelta, float, int]) -> float: def validate_raw_dict(value: Dict[str, Any]) -> Dict[str, Any]: if not isinstance(value, dict): raise ValueError("Raw option must be of type Dict[str, Any].") - if not all(map(lambda k: isinstance(k, str), value.keys())): + if not all((isinstance(k, str) for k in value.keys())): raise ValueError("All keys in raw dict must be a str.") return value @@ -112,7 +106,7 @@ def __call__(self, value: Any) -> str: expected_type = self.__orig_class__.__args__[0] # type: ignore[attr-defined] if isinstance(value, str): - if value in map(lambda x: x.value, expected_type): + if value in (x.value for x in expected_type): # TODO: use warning -- maybe don't want to allow str representation? return value raise ValueError(f"Invalid str representation of {expected_type}. Received '{value}'.") @@ -136,8 +130,8 @@ def __call__(self, value: Any) -> List[T]: expected_type = self.__orig_class__.__args__[0] # type: ignore[attr-defined] if not isinstance(value, list): raise ValueError("Expected value to be a list.") - if not all(map(lambda x: isinstance(x, expected_type), value)): - item_types = list(map(lambda x: type(x), value)) + if not all((isinstance(v, expected_type) for v in value)): + item_types = [type(x) for x in value] raise ValueError(("Expected all items in list to be of type " f"{expected_type}. Provided item types {item_types}.")) # we are returning List[T] diff --git a/couchbase_analytics/common/core/exception.py b/couchbase_analytics/common/core/exception.py deleted file mode 100644 index 355064a..0000000 --- a/couchbase_analytics/common/core/exception.py +++ /dev/null @@ -1,66 +0,0 @@ -# Copyright 2016-2024. Couchbase, Inc. -# All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from __future__ import annotations - -from typing import (Optional, - Set, - Tuple, - TypedDict) - - -class GenericErrorContextCore(TypedDict, total=False): - # base - cinfo: Optional[Tuple[str, int]] - context_type: Optional[str] - error_message: Optional[str] - last_dispatched_from: Optional[str] - last_dispatched_to: Optional[str] - retry_attempts: Optional[int] - retry_reasons: Optional[Set[str]] - # http - client_context_id: Optional[str] - context_detail_type: Optional[str] - http_body: Optional[str] - http_status: Optional[int] - method: Optional[str] - path: Optional[str] - # mgmt - content: Optional[str] - # query/analytics/search - parameters: Optional[str] - # query/analytics - first_error_code: Optional[int] - first_error_message: Optional[str] - statement: Optional[str] - - -class ErrorContextCore(TypedDict, total=False): - cinfo: Optional[Tuple[str, int]] - context_type: Optional[str] - error_message: Optional[str] - last_dispatched_from: Optional[str] - last_dispatched_to: Optional[str] - retry_attempts: Optional[int] - retry_reasons: Optional[Set[str]] - - -class HTTPErrorContextCore(ErrorContextCore, total=False): - client_context_id: Optional[str] - context_detail_type: Optional[str] - http_body: Optional[str] - http_status: Optional[int] - method: Optional[str] - path: Optional[str] diff --git a/couchbase_analytics/common/core/net_utils.py b/couchbase_analytics/common/core/net_utils.py deleted file mode 100644 index 85b8f82..0000000 --- a/couchbase_analytics/common/core/net_utils.py +++ /dev/null @@ -1,107 +0,0 @@ -# Copyright 2016-2024. Couchbase, Inc. -# All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from __future__ import annotations - -import socket - -from ipaddress import (IPv4Address, IPv6Address, ip_address) -from random import choice -from typing import (Any, - Dict, - Optional, - Set, - Union) -from urllib.parse import quote - -import anyio - -def get_request_ip(host: str, - port: int, - previous_ips: Optional[Set[str]]=None) -> Optional[str]: - # Lets not call getaddrinfo, if the host is already an IP address - try: - ip: Optional[Union[IPv4Address, IPv6Address, str]] = ip_address(host) - except ValueError: - ip = None - - # if we have localhost, httpx does not seem to be able to resolve IPv6 localhost (::1) properly - # TODO: IPv6 support for localhost?? - if host == 'localhost': - ip = '127.0.0.1' - - if previous_ips is None: - previous_ips = set() - if not ip: - # TODO: getaddrinfo() will raise an exception if name resolution fails - result = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM, family=socket.AF_UNSPEC) - # TODO: Handle IPv4 vs IPv6; with or without port? - # ips = [f'{addr[4][0]}:{addr[4][1]}' for addr in result] - try: - res_ip = choice([addr[4][0] for addr in result if addr[4][0] not in previous_ips]) - ip = str(res_ip) - except IndexError: - ip = None - else: - ip_str = str(ip) if not isinstance(ip, str) else ip - ip = None if ip_str in previous_ips else ip_str - - return ip - - -async def get_request_ip_async(host: str, - port: int, - previous_ips: Optional[Set[str]]=None) -> Optional[str]: - # Lets not call getaddrinfo, if the host is already an IP address - try: - ip: Optional[Union[IPv4Address, IPv6Address, str]] = ip_address(host) - except ValueError: - ip = None - - # if we have localhost, httpx does not seem to be able to resolve IPv6 localhost (::1) properly - # TODO: IPv6 support for localhost?? - if host == 'localhost': - ip = '127.0.0.1' - - if previous_ips is None: - previous_ips = set() - - if not ip: - # TODO: getaddrinfo() will raise an exception if name resolution fails - result = await anyio.getaddrinfo(host, port, type=socket.SOCK_STREAM, family=socket.AF_UNSPEC) - # TODO: Handle IPv4 vs IPv6; with or without port? - # ips = [f'{addr[4][0]}:{addr[4][1]}' for addr in result] - try: - res_ip = choice([addr[4][0] for addr in result if addr[4][0] not in previous_ips]) - ip = str(res_ip) - except IndexError: - ip = None - else: - ip_str = str(ip) if not isinstance(ip, str) else ip - ip = None if ip_str in previous_ips else ip_str - - return ip - - -# TODO: unused?? -def to_query_str(params: Dict[str, Any]) -> str: - encoded_params = [] - for k, v in params.items(): - if v in [True, False]: - encoded_params.append(f'{quote(k)}={quote(str(v).lower())}') - else: - encoded_params.append(f'{quote(k)}={quote(str(v))}') - - return '&'.join(encoded_params) \ No newline at end of file diff --git a/couchbase_analytics/common/credential.py b/couchbase_analytics/common/credential.py index 4d8719b..a8c53a6 100644 --- a/couchbase_analytics/common/credential.py +++ b/couchbase_analytics/common/credential.py @@ -15,9 +15,7 @@ from __future__ import annotations -from typing import (Callable, - Dict, - Tuple) +from typing import Callable, Dict, Tuple class Credential: diff --git a/couchbase_analytics/common/errors.py b/couchbase_analytics/common/errors.py index 83e7080..4a0402a 100644 --- a/couchbase_analytics/common/errors.py +++ b/couchbase_analytics/common/errors.py @@ -15,9 +15,7 @@ from __future__ import annotations -from typing import (Dict, - Optional, - Union) +from typing import Dict, Optional, Union """ @@ -31,19 +29,27 @@ class AnalyticsError(Exception): Generic base error. Analytics specific errors inherit from this base error. """ - def __init__(self, cause: Optional[Union[BaseException, Exception]] = None, message: Optional[str] = None) -> None: + def __init__(self, + cause: Optional[Union[BaseException, Exception]] = None, + message: Optional[str] = None, + context: Optional[str] = None) -> None: self._cause = cause self._message = message + self._context = context super().__init__(message) - def __repr__(self) -> str: + def _err_details(self) -> Dict[str, str]: details: Dict[str, str] = {} + if self._context is not None: + details['context'] = self._context if self._cause is not None: details['cause'] = self._cause.__repr__() - if self._message is not None and not self._message.isspace(): details['message'] = self._message + return details + def __repr__(self) -> str: + details = self._err_details() if details: return f'{type(self).__name__}({details})' return f'{type(self).__name__}()' @@ -57,21 +63,17 @@ class InvalidCredentialError(AnalyticsError): Indicates that an error occurred authenticating the user to the cluster. """ - def __init__(self, context: str, cause: Optional[Union[BaseException, Exception]] = None, message: Optional[str] = None) -> None: - super().__init__(cause=cause, message=message) - self._context = context + def __init__(self, + cause: Optional[Union[BaseException, Exception]] = None, + context: Optional[str] = None, + message: Optional[str] = None) -> None: + super().__init__(cause=cause, context=context, message=message) def __repr__(self) -> str: - details: Dict[str, str] = { - 'context': self._context - } - if self._cause is not None: - details['cause'] = self._cause.__repr__() - - if self._message is not None and not self._message.isspace(): - details['message'] = self._message - - return f'{type(self).__name__}({details})' + details = self._err_details() + if details: + return f'{type(self).__name__}({details})' + return f'{type(self).__name__}()' def __str__(self) -> str: return self.__repr__() @@ -87,10 +89,9 @@ def __init__(self, server_message: str, context: str, message: Optional[str] = None) -> None: - super().__init__(message=message) + super().__init__(message=message, context=context) self._code = code self._server_message = server_message - self._context = context @property def code(self) -> int: @@ -112,7 +113,7 @@ def __repr__(self) -> str: details: Dict[str, str] = { 'code': str(self._code), 'server_message': self._server_message, - 'context': self._context + 'context': self._context or '' } return f"{type(self).__name__}({details})" @@ -125,11 +126,17 @@ class TimeoutError(AnalyticsError): Indicates that a request was unable to complete prior to reaching the deadline specified for the reqest. """ - def __init__(self, cause: Optional[Union[BaseException, Exception]] = None, message: Optional[str] = None) -> None: - super().__init__(cause, message) + def __init__(self, + cause: Optional[Union[BaseException, Exception]] = None, + context: Optional[str] = None, + message: Optional[str] = None) -> None: + super().__init__(cause=cause, context=context, message=message) def __repr__(self) -> str: - return super().__repr__() + details = self._err_details() + if details: + return f'{type(self).__name__}({details})' + return f'{type(self).__name__}()' def __str__(self) -> str: return self.__repr__() @@ -153,20 +160,26 @@ class InternalSDKError(Exception): (this doesn't mean *you* didn't do anything wrong, it does mean you should not be seeing this message) """ - def __repr__(self) -> str: - return f"{type(self).__name__}({super().__repr__()})" - - def __str__(self) -> str: - return self.__repr__() - - -class QueryOperationCanceledError(Exception): - """ - **INTERNAL** - """ + def __init__(self, + cause: Optional[Union[BaseException, Exception]] = None, + context: Optional[str] = None, + message: Optional[str] = None) -> None: + self._cause = cause + self._message = message + self._context = context + super().__init__(message) def __repr__(self) -> str: - return f"{type(self).__name__}({super().__repr__()})" + details: Dict[str, str] = {} + if self._context is not None: + details['context'] = self._context + if self._cause is not None: + details['cause'] = self._cause.__repr__() + if self._message is not None and not self._message.isspace(): + details['message'] = self._message + if details: + return f'{type(self).__name__}({details})' + return f'{type(self).__name__}()' def __str__(self) -> str: return self.__repr__() diff --git a/couchbase_analytics/common/options.py b/couchbase_analytics/common/options.py index c689891..ba35bdd 100644 --- a/couchbase_analytics/common/options.py +++ b/couchbase_analytics/common/options.py @@ -24,13 +24,15 @@ else: from typing import TypeAlias -from couchbase_analytics.common.options_base import ClusterOptionsBase +from couchbase_analytics.common.options_base import ( + ClusterOptionsBase, + QueryOptionsBase, + SecurityOptionsBase, + TimeoutOptionsBase, +) from couchbase_analytics.common.options_base import ClusterOptionsKwargs as ClusterOptionsKwargs # noqa: F401 -from couchbase_analytics.common.options_base import QueryOptionsBase from couchbase_analytics.common.options_base import QueryOptionsKwargs as QueryOptionsKwargs # noqa: F401 -from couchbase_analytics.common.options_base import SecurityOptionsBase from couchbase_analytics.common.options_base import SecurityOptionsKwargs as SecurityOptionsKwargs # noqa: F401 -from couchbase_analytics.common.options_base import TimeoutOptionsBase from couchbase_analytics.common.options_base import TimeoutOptionsKwargs as TimeoutOptionsKwargs # noqa: F401 """ diff --git a/couchbase_analytics/common/options_base.py b/couchbase_analytics/common/options_base.py index c0799ed..ae5912b 100644 --- a/couchbase_analytics/common/options_base.py +++ b/couchbase_analytics/common/options_base.py @@ -18,14 +18,7 @@ import sys from datetime import timedelta -from typing import (Any, - Dict, - Iterable, - List, - Literal, - Optional, - TypedDict, - Union) +from typing import Any, Dict, Iterable, List, Literal, Optional, TypedDict, Union if sys.version_info < (3, 10): from typing_extensions import TypeAlias, Unpack @@ -38,7 +31,7 @@ from typing import TypeAlias, Unpack from couchbase_analytics.common import JSONType -from couchbase_analytics.common.core import JsonStreamConfig +from couchbase_analytics.common._core import JsonStreamConfig from couchbase_analytics.common.deserializer import Deserializer from couchbase_analytics.common.enums import QueryScanConsistency diff --git a/couchbase_analytics/common/query.py b/couchbase_analytics/common/query.py index 0ec8b30..e580c4b 100644 --- a/couchbase_analytics/common/query.py +++ b/couchbase_analytics/common/query.py @@ -18,9 +18,8 @@ from datetime import timedelta from typing import List, Optional -from couchbase_analytics.common.core.query import (QueryMetadataCore, - QueryMetricsCore, - QueryWarningCore) +from couchbase_analytics.common._core.query import QueryMetadataCore, QueryMetricsCore, QueryWarningCore + class QueryWarning: def __init__(self, raw: QueryWarningCore) -> None: diff --git a/couchbase_analytics/common/request.py b/couchbase_analytics/common/request.py new file mode 100644 index 0000000..489505c --- /dev/null +++ b/couchbase_analytics/common/request.py @@ -0,0 +1,45 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Dict, Optional + + +@dataclass +class RequestURL: + scheme: str + host: str + port: int + path: Optional[str] = None + + def get_formatted_url(self) -> str: + """Get the formatted URL for this request.""" + if self.path is None: + return f'{self.scheme}://{self.host}:{self.port}' + return f'{self.scheme}://{self.host}:{self.port}{self.path}' + + def __repr__(self) -> str: + details: Dict[str, str] = { + 'scheme': self.scheme, + 'host': self.host, + 'port': str(self.port), + 'path': self.path if self.path else '' + } + return f'{type(self).__name__}({details})' + + def __str__(self) -> str: + return self.__repr__() \ No newline at end of file diff --git a/couchbase_analytics/common/result.py b/couchbase_analytics/common/result.py index 7490a06..d9010f2 100644 --- a/couchbase_analytics/common/result.py +++ b/couchbase_analytics/common/result.py @@ -15,15 +15,11 @@ from __future__ import annotations -from typing import (Any, - List, - Optional, - TYPE_CHECKING) +from typing import TYPE_CHECKING, Any, List, Optional -from couchbase_analytics.common.core.result import QueryResult as QueryResult +from couchbase_analytics.common._core.result import QueryResult as QueryResult from couchbase_analytics.common.query import QueryMetadata -from couchbase_analytics.common.streaming import (AsyncIterator, - BlockingIterator) +from couchbase_analytics.common.streaming import AsyncIterator, BlockingIterator if TYPE_CHECKING: from acouchbase_analytics.protocol.streaming import AsyncHttpStreamingResponse diff --git a/couchbase_analytics/common/streaming.py b/couchbase_analytics/common/streaming.py index 4e4ea0d..3a85e22 100644 --- a/couchbase_analytics/common/streaming.py +++ b/couchbase_analytics/common/streaming.py @@ -18,11 +18,7 @@ from collections.abc import AsyncIterator as PyAsyncIterator from collections.abc import Iterator from enum import IntEnum - -from typing import (Any, - List, - NamedTuple, - TYPE_CHECKING) +from typing import TYPE_CHECKING, Any, List, NamedTuple from couchbase_analytics.common.errors import AnalyticsError, InternalSDKError @@ -36,21 +32,22 @@ class StreamingState(IntEnum): **INTERNAL """ NotStarted = 0 - Started = 1 - Cancelled = 2 - Completed = 3 - StreamingResults = 4 - Error = 5 - Timeout = 6 - AsyncCancelledPriorToTimeout = 7 - SyncCancelledPriorToTimeout = 8 + ResetAndNotStarted = 1 + Started = 2 + Cancelled = 3 + Completed = 4 + StreamingResults = 5 + Error = 6 + Timeout = 7 + AsyncCancelledPriorToTimeout = 8 + SyncCancelledPriorToTimeout = 9 @staticmethod def okay_to_stream(state: StreamingState) -> bool: """ **INTERNAL """ - return state == StreamingState.NotStarted + return state in [StreamingState.NotStarted, StreamingState.ResetAndNotStarted] @staticmethod def okay_to_iterate(state: StreamingState) -> bool: @@ -91,7 +88,7 @@ def get_all_rows(self) -> List[Any]: """ **INTERNAL """ - return [r for r in list(self)] + return list(self) def __iter__(self) -> BlockingIterator: """ @@ -109,13 +106,11 @@ def __next__(self) -> Any: try: return self._http_response.get_next_row() except StopIteration: - # TODO: get metadata automatically? - # self._executor.set_metadata() raise except AnalyticsError as err: raise err except Exception as ex: - raise InternalSDKError(str(ex)) + raise InternalSDKError(cause=ex, message='Error attempting to obtain next row.') from None class AsyncIterator(PyAsyncIterator[Any]): """ @@ -148,7 +143,7 @@ async def __anext__(self) -> Any: except AnalyticsError as err: raise err except Exception as ex: - raise InternalSDKError(str(ex)) + raise InternalSDKError(cause=ex, message='Error attempting to obtain next row.') from None class HttpResponseType(IntEnum): """ diff --git a/couchbase_analytics/protocol/core/__init__.py b/couchbase_analytics/protocol/_core/__init__.py similarity index 100% rename from couchbase_analytics/protocol/core/__init__.py rename to couchbase_analytics/protocol/_core/__init__.py diff --git a/couchbase_analytics/protocol/core/client_adapter.py b/couchbase_analytics/protocol/_core/client_adapter.py similarity index 88% rename from couchbase_analytics/protocol/core/client_adapter.py rename to couchbase_analytics/protocol/_core/client_adapter.py index 34e75f9..dbd8552 100644 --- a/couchbase_analytics/protocol/core/client_adapter.py +++ b/couchbase_analytics/protocol/_core/client_adapter.py @@ -16,20 +16,20 @@ from __future__ import annotations import socket - -from typing import Optional, TYPE_CHECKING +from typing import TYPE_CHECKING, Optional from uuid import uuid4 -from httpx import BasicAuth, Client, Response +from httpx import URL, BasicAuth, Client, Response from couchbase_analytics.common.credential import Credential from couchbase_analytics.common.deserializer import Deserializer from couchbase_analytics.protocol.connection import _ConnectionDetails from couchbase_analytics.protocol.options import OptionsBuilder + # from couchbase_analytics.protocol.core._http_transport import AnalyticsHTTPTransport if TYPE_CHECKING: - from couchbase_analytics.protocol.core.request import QueryRequest + from couchbase_analytics.protocol._core.request import QueryRequest class _ClientAdapter: @@ -142,17 +142,22 @@ def send_request(self, request: QueryRequest) -> Response: if not hasattr(self, '_client'): raise RuntimeError('Client not created yet') - if request.url is None: - raise ValueError('Request URL cannot be None') + # if request.url is None: + # raise ValueError('Request URL cannot be None') + url = URL(scheme=request.url.scheme, + host=request.url.host, + port=request.url.port, + path=request.url.path) req = self._client.build_request(request.method, - request.url, + url, json=request.body, extensions=request.extensions) try: return self._client.send(req, stream=True) except socket.gaierror as err: - raise RuntimeError(f'Unable to connect to {self._conn_details.get_scheme_host_and_port()}') from err + req_url = self._conn_details.url.get_formatted_url() + raise RuntimeError(f'Unable to connect to {req_url}') from err def reset_client(self) -> None: """ diff --git a/couchbase_analytics/protocol/core/_http_transport.py b/couchbase_analytics/protocol/_core/http_transport.py similarity index 91% rename from couchbase_analytics/protocol/core/_http_transport.py rename to couchbase_analytics/protocol/_core/http_transport.py index f26b684..0469f19 100644 --- a/couchbase_analytics/protocol/core/_http_transport.py +++ b/couchbase_analytics/protocol/_core/http_transport.py @@ -1,35 +1,24 @@ import ssl import time - -from typing import (Iterable, - Optional, - TypeVar, - Union) from types import TracebackType - -from httpx import (BaseTransport, - HTTPTransport, - Limits, - Proxy, - Response, - SyncByteStream, - URL, - create_ssl_context) -from httpx._transports.default import (map_httpcore_exceptions, - ResponseStream, - SOCKET_OPTION) -from httpx._types import CertTypes, ProxyTypes -from httpcore import (ConnectionPool, - ConnectionInterface, - HTTP11Connection, - HTTP2Connection, - HTTPConnection, - Origin, - Request, - Response as CoreResponse) -from httpcore._sync.connection_pool import PoolRequest, PoolByteStream +from typing import Iterable, Optional, TypeVar, Union + +from httpcore import ( + ConnectionInterface, + ConnectionPool, + HTTP2Connection, + HTTP11Connection, + HTTPConnection, + Origin, + Request, +) +from httpcore import Response as CoreResponse from httpcore._exceptions import ConnectionNotAvailable, UnsupportedProtocol +from httpcore._sync.connection_pool import PoolByteStream, PoolRequest +from httpx import URL, BaseTransport, HTTPTransport, Limits, Proxy, Response, SyncByteStream, create_ssl_context +from httpx._transports.default import SOCKET_OPTION, ResponseStream, map_httpcore_exceptions +from httpx._types import CertTypes, ProxyTypes # httpx._transports.default.py T = TypeVar("T", bound="HTTPTransport") diff --git a/couchbase_analytics/common/core/json_stream.py b/couchbase_analytics/protocol/_core/json_stream.py similarity index 90% rename from couchbase_analytics/common/core/json_stream.py rename to couchbase_analytics/protocol/_core/json_stream.py index 44af4ee..de1d9f7 100644 --- a/couchbase_analytics/common/core/json_stream.py +++ b/couchbase_analytics/protocol/_core/json_stream.py @@ -16,23 +16,23 @@ from __future__ import annotations from concurrent.futures import Future -from queue import (Queue, - Full as QueueFull, - Empty as QueueEmpty) -from threading import get_ident -from typing import (Iterator, - Optional) +from queue import Empty as QueueEmpty +from queue import Full as QueueFull +from queue import Queue +from typing import TYPE_CHECKING, Iterator, Optional import ijson +from couchbase_analytics.common._core.json_parsing import ( + JsonParsingError, + JsonStreamConfig, + ParsedResult, + ParsedResultType, +) +from couchbase_analytics.protocol._core.json_token_parser import JsonTokenParser -from couchbase_analytics.common.core.json_token_parser import JsonTokenParser - -from couchbase_analytics.common.core.json_parsing import (JsonParsingError, - JsonStreamConfig, - ParsedResult, - ParsedResultType) -from couchbase_analytics.protocol.core._request_context import RequestContext, ThreadSafeBytesIterator +if TYPE_CHECKING: + from couchbase_analytics.protocol._core.request_context import RequestContext class JsonStream: DEFAULT_HTTP_STREAM_BUFFER_SIZE = 2**16 @@ -109,6 +109,7 @@ def _handle_json_result(self, row: bytes) -> None: """ if self._notify_on_results_or_error is not None and not self._notify_on_results_or_error.done(): self._handle_notification(ParsedResultType.ROW) + self._put(ParsedResult(row, ParsedResultType.ROW)) def _handle_notification(self, result_type: ParsedResultType) -> None: @@ -128,7 +129,7 @@ def _process_token_stream(self, request_context: Optional[RequestContext]=None) try: _, event, value = next(self._json_stream_parser) # type: ignore[call-overload] self._json_token_parser.parse_token(event, value) - except StopIteration as ex: + except StopIteration: self._token_stream_exhausted = True except ijson.common.IncompleteJSONError as ex: raise JsonParsingError(cause=ex) from None diff --git a/couchbase_analytics/common/core/json_token_parser.py b/couchbase_analytics/protocol/_core/json_token_parser.py similarity index 79% rename from couchbase_analytics/common/core/json_token_parser.py rename to couchbase_analytics/protocol/_core/json_token_parser.py index 245484f..0177108 100644 --- a/couchbase_analytics/common/core/json_token_parser.py +++ b/couchbase_analytics/protocol/_core/json_token_parser.py @@ -15,16 +15,17 @@ from __future__ import annotations -from typing import (Callable, - List, - Optional) +from typing import Callable, List, Optional + +from couchbase_analytics.common._core.json_token_parser_base import ( + POP_EVENTS, + START_EVENTS, + VALUE_TOKENS, + JsonTokenParserBase, + ParsingState, + TokenType, +) -from couchbase_analytics.common.core.json_token_parser_base import (JsonTokenParserBase, - ParsingState, - TokenType, - POP_EVENTS, - START_EVENTS, - VALUE_TOKENS,) class JsonTokenParser(JsonTokenParserBase): def __init__(self, @@ -36,7 +37,7 @@ def __init__(self, def _handle_obj_emit(self, obj: str) -> bool: if (self._emit_results_enabled and self._result_handler is not None - and self._state == ParsingState.PROCESSING_RESULTS): + and ParsingState.okay_to_emit(self._state, self._previous_state)): self._result_handler(bytes(obj, 'utf-8')) return True return False @@ -58,7 +59,8 @@ def _handle_pop_event(self, token_type: TokenType) -> None: if len(self._stack) > 0 and self._stack[-1].type == TokenType.MAP_KEY: map_key = self._pop() - # If we are emitting rows and/or errors, we don't keep them in the stack and therefore don't need to return the results + # If we are emitting rows and/or errors, + # we don't keep them in the stack and therefore don't need to return the results if self._should_push_pair(next_token): self._push(TokenType.PAIR, f'{map_key.value}:{obj}') else: @@ -73,7 +75,9 @@ def get_result(self) -> Optional[bytes]: def parse_token(self, token: str, value: str) -> None: token_type = TokenType.from_str(token) if token_type in VALUE_TOKENS: - self._handle_value_token(token_type, value) + val = self._handle_value_token(token_type, value) + if val is not None: + self._handle_obj_emit(val) elif token_type == TokenType.MAP_KEY: self._handle_map_key_token(value) elif token_type in START_EVENTS: diff --git a/couchbase_analytics/protocol/_core/net_utils.py b/couchbase_analytics/protocol/_core/net_utils.py new file mode 100644 index 0000000..f4e18e9 --- /dev/null +++ b/couchbase_analytics/protocol/_core/net_utils.py @@ -0,0 +1,54 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import socket +from ipaddress import IPv4Address, IPv6Address, ip_address +from random import choice +from typing import Optional, Set, Union + +from couchbase_analytics.protocol.errors import ErrorMapper + + +@ErrorMapper.handle_socket_error +def get_request_ip(host: str, + port: int, + previous_ips: Optional[Set[str]]=None) -> Optional[str]: + # Lets not call getaddrinfo, if the host is already an IP address + try: + ip: Optional[Union[IPv4Address, IPv6Address, str]] = ip_address(host) + except ValueError: + ip = None + + # if we have localhost, httpx does not seem to be able to resolve IPv6 localhost (::1) properly + # TODO: IPv6 support for localhost?? + if host == 'localhost': + ip = '127.0.0.1' + + if previous_ips is None: + previous_ips = set() + if not ip: + result = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM, family=socket.AF_UNSPEC) + try: + res_ip = choice([addr[4][0] for addr in result if addr[4][0] not in previous_ips]) + ip = str(res_ip) + except IndexError: + ip = None + else: + ip_str = str(ip) if not isinstance(ip, str) else ip + ip = None if ip_str in previous_ips else ip_str + + return ip \ No newline at end of file diff --git a/couchbase_analytics/protocol/core/request.py b/couchbase_analytics/protocol/_core/request.py similarity index 79% rename from couchbase_analytics/protocol/core/request.py rename to couchbase_analytics/protocol/_core/request.py index 13a7cb9..e419d7d 100644 --- a/couchbase_analytics/protocol/core/request.py +++ b/couchbase_analytics/protocol/_core/request.py @@ -17,29 +17,18 @@ from copy import deepcopy from dataclasses import dataclass - -from typing import (TYPE_CHECKING, - Any, - Callable, - Coroutine, - Dict, - Optional, - Set, - Tuple, - TypedDict, - Union) +from typing import TYPE_CHECKING, Any, Callable, Coroutine, Dict, Optional, Set, TypedDict, Union, cast from uuid import uuid4 from couchbase_analytics.common.deserializer import Deserializer from couchbase_analytics.common.options import QueryOptions +from couchbase_analytics.common.request import RequestURL from couchbase_analytics.protocol.options import QueryOptionsTransformedKwargs from couchbase_analytics.query import QueryScanConsistency if TYPE_CHECKING: - from httpx import Response as HttpCoreResponse - - from acouchbase_analytics.protocol.core.client_adapter import _AsyncClientAdapter as AsyncClientAdapter - from couchbase_analytics.protocol.core.client_adapter import _ClientAdapter as BlockingClientAdapter + from acouchbase_analytics.protocol._core.client_adapter import _AsyncClientAdapter as AsyncClientAdapter + from couchbase_analytics.protocol._core.client_adapter import _ClientAdapter as BlockingClientAdapter class RequestTimeoutExtensions(TypedDict, total=False): pool: Optional[float] # Timeout for acquiring a connection from the pool @@ -54,60 +43,45 @@ class RequestExtensions(TypedDict, total=False): @dataclass class QueryRequest: - scheme: str - host: str - port: int + url: RequestURL deserializer: Deserializer body: Dict[str, Union[str, object]] extensions: RequestExtensions method: str = 'POST' - url: Optional[str] = None options: Optional[QueryOptionsTransformedKwargs] = None - client_addr: Optional[Tuple[str, int]] = None - server_addr: Optional[Tuple[str, int]] = None previous_ips: Optional[Set[str]] = None - response_status_code: Optional[int] = None enable_cancel: Optional[bool] = None - def get_request_timeouts(self) -> Optional[RequestTimeoutExtensions]: + def add_trace_to_extensions(self, handler: Callable[[str, str], + Union[None, Coroutine[Any, Any, None]]]) -> QueryRequest: """ **INTERNAL** - Get the request timeouts from the extensions. - Returns: - Dict[str, int]: The request timeouts. """ - if self.extensions is None or 'timeout' not in self.extensions: - return {} - return self.extensions['timeout'] + if self.extensions is None: + self.extensions = {} + self.extensions['trace'] = handler + return self - def set_client_server_addrs(self, response: HttpCoreResponse) -> None: - network_stream = response.extensions.get('network_stream', None) - # TODO: what if network_stream is None? - if network_stream is not None: - self.client_addr = network_stream.get_extra_info('client_addr') - self.server_addr = network_stream.get_extra_info('server_addr') - - self.response_status_code = response.status_code + def get_request_statement(self) -> Optional[str]: + """ + **INTERNAL** + """ + if 'statement' in self.body: + return cast(str, self.body['statement']) + return None - def add_trace_to_extensions(self, handler: Callable[[str, str], Union[None, Coroutine[Any, Any, None]]]) -> QueryRequest: + def get_request_timeouts(self) -> Optional[RequestTimeoutExtensions]: """ **INTERNAL** - Update the extensions of the request. - Args: - new_extensions (Dict[str, str]): The new extension(s) to add. """ - if self.extensions is None: - self.extensions = {} - self.extensions['trace'] = handler - return self + if self.extensions is None or 'timeout' not in self.extensions: + return {} + return self.extensions['timeout'] def update_previous_ips(self, ip: str) -> QueryRequest: """ **INTERNAL** - Update the previous IPs of the request. - Args: - ip (str): The new IP to add to the previous IPs. """ if self.previous_ips is None: self.previous_ips = set() @@ -117,11 +91,9 @@ def update_previous_ips(self, ip: str) -> QueryRequest: def update_url(self, ip: str, path: str) -> QueryRequest: """ **INTERNAL** - Update the URL of the request. - Args: - new_url (str): The new URL to set. """ - self.url = f'{self.scheme}://{ip}:{self.port}{path}' + self.url.host = ip + self.url.path = path return self @@ -221,7 +193,7 @@ def build_base_query_request(self, # noqa: C901 for k, v in opt_val.items(): # type: ignore[attr-defined] body[k] = v elif opt_key == 'positional_parameters': - body['args'] = [arg for arg in opt_val] # type: ignore[attr-defined] + body['args'] = list(opt_val) # type: ignore[call-overload] elif opt_key == 'named_parameters': for k, v in opt_val.items(): # type: ignore[attr-defined] key = f'${k}' if not k.startswith('$') else k @@ -234,10 +206,7 @@ def build_base_query_request(self, # noqa: C901 else: body['scan_consistency'] = opt_val - scheme, host, port = self._conn_details.get_scheme_host_and_port() - return QueryRequest(scheme, - host, - port, + return QueryRequest(self._conn_details.url, deserializer, body, extensions=extensions, diff --git a/couchbase_analytics/protocol/core/_request_context.py b/couchbase_analytics/protocol/_core/request_context.py similarity index 57% rename from couchbase_analytics/protocol/core/_request_context.py rename to couchbase_analytics/protocol/_core/request_context.py index f123492..59888b3 100644 --- a/couchbase_analytics/protocol/core/_request_context.py +++ b/couchbase_analytics/protocol/_core/request_context.py @@ -1,38 +1,28 @@ from __future__ import annotations +import json import math import time - -from concurrent.futures import (CancelledError, - Future, - ThreadPoolExecutor) -from threading import (Event, - Lock, - get_ident) -from typing import (Any, - Callable, - Dict, - Iterator, - List, - Optional, - Union, - TYPE_CHECKING) +from concurrent.futures import CancelledError, Future, ThreadPoolExecutor +from threading import Event, Lock +from typing import TYPE_CHECKING, Any, Callable, Dict, Iterator, List, Optional, Union from uuid import uuid4 from httpx import Response as HttpCoreResponse -from couchbase_analytics.common.core import ParsedResult, ParsedResultType -from couchbase_analytics.common.core.net_utils import get_request_ip -from couchbase_analytics.common.deserializer import Deserializer -from couchbase_analytics.common.errors import AnalyticsError, InvalidCredentialError +from couchbase_analytics.common._core import JsonStreamConfig, ParsedResult, ParsedResultType +from couchbase_analytics.common._core.error_context import ErrorContext +from couchbase_analytics.common.errors import AnalyticsError, InvalidCredentialError, TimeoutError from couchbase_analytics.common.result import BlockingQueryResult from couchbase_analytics.common.streaming import StreamingState +from couchbase_analytics.protocol._core.json_stream import JsonStream +from couchbase_analytics.protocol._core.net_utils import get_request_ip from couchbase_analytics.protocol.connection import DEFAULT_TIMEOUTS from couchbase_analytics.protocol.errors import ErrorMapper if TYPE_CHECKING: - from couchbase_analytics.protocol.core.client_adapter import _ClientAdapter - from couchbase_analytics.protocol.core.request import QueryRequest + from couchbase_analytics.protocol._core.client_adapter import _ClientAdapter + from couchbase_analytics.protocol._core.request import QueryRequest # TODO: might not be needed; need to validate httpx iterator behavior class ThreadSafeBytesIterator: @@ -102,11 +92,17 @@ class RequestContext: def __init__(self, client_adapter: _ClientAdapter, request: QueryRequest, - tp_executor: ThreadPoolExecutor) -> None: + tp_executor: ThreadPoolExecutor, + stream_config: Optional[JsonStreamConfig]=None) -> None: self._id = str(uuid4()) self._client_adapter = client_adapter self._request = request + self._error_ctx = ErrorContext(num_attempts=0, + method=request.method, + statement=request.get_request_statement()) self._request_state = StreamingState.NotStarted + self._stream_config = stream_config or JsonStreamConfig() + self._json_stream: JsonStream self._cancel_event = Event() self._request_error: Optional[Exception] = None self._tp_executor = tp_executor @@ -114,74 +110,54 @@ def __init__(self, self._stage_notification_ft: Optional[Future[ParsedResultType]] = None self._request_deadline = math.inf self._background_request: Optional[BackgroundRequest] = None - - # @property - # def stage_notification(self) -> Future[ParsedResult]: - # if self._stage_notification_ft is None: - # raise RuntimeError('Background future not created for this context.') - # return self._stage_notification_ft + self._shutdown = False @property def cancel_enabled(self) -> Optional[bool]: return self._request.enable_cancel - - # @property - # def cancel_event(self) -> Event: - # return self._request._cancel_event + + @property + def cancelled(self) -> bool: + self._check_cancelled_or_timed_out() + return self._request_state in [StreamingState.Cancelled, StreamingState.SyncCancelledPriorToTimeout] @property - def deserializer(self) -> Deserializer: - """ - Returns the deserializer used by this request context. - """ - return self._request.deserializer + def error_context(self) -> ErrorContext: + return self._error_ctx @property def has_stage_completed(self) -> bool: return self._stage_completed_ft is not None and self._stage_completed_ft.done() + + @property + def is_shutdown(self) -> bool: + return self._shutdown @property def okay_to_iterate(self) -> bool: - # Called prior to upstream logic attempting to iterate over results from HTTP client + # NOTE: Called prior to upstream logic attempting to iterate over results from HTTP client self._check_cancelled_or_timed_out() return StreamingState.okay_to_iterate(self._request_state) @property def okay_to_stream(self) -> bool: - # Called prior to upstream logic attempting to send request to HTTP client + # NOTE: Called prior to upstream logic attempting to send request to HTTP client self._check_cancelled_or_timed_out() return StreamingState.okay_to_stream(self._request_state) @property def request_error(self) -> Optional[Exception]: return self._request_error - - # @property - # def request_future(self) -> Future[BlockingQueryResult]: - # if self._request_future is None: - # raise RuntimeError('Request future not created for this context.') - # return self._request_future @property def request_state(self) -> StreamingState: return self._request_state - @request_state.setter - def request_state(self, state: StreamingState) -> None: - if not isinstance(state, StreamingState): - raise TypeError('request_state must be an instance of StreamingState') - self._request_state = state - @property def timed_out(self) -> bool: self._check_cancelled_or_timed_out() return self._request_state == StreamingState.Timeout - @property - def cancelled(self) -> bool: - self._check_cancelled_or_timed_out() - return self._request_state in [StreamingState.Cancelled, StreamingState.SyncCancelledPriorToTimeout] - def _check_cancelled_or_timed_out(self) -> None: if self._request_state in [StreamingState.Timeout, StreamingState.Cancelled, StreamingState.Error]: return @@ -191,7 +167,9 @@ def _check_cancelled_or_timed_out(self) -> None: and self._background_request.user_cancelled)): self._request_state = StreamingState.Cancelled - timed_out = self._request_deadline < time.monotonic() + current_time = time.monotonic() + timed_out = current_time >= self._request_deadline + # print(f'{current_time=}; req_deadline={self._request_deadline}; {timed_out=}') if timed_out: if self._request_state == StreamingState.Cancelled: self._request_state = StreamingState.SyncCancelledPriorToTimeout @@ -204,34 +182,145 @@ def _create_stage_notification_future(self) -> None: raise RuntimeError('Stage notification future already created for this context.') self._stage_notification_ft = Future[ParsedResultType]() + def _process_error(self, + json_data: List[Dict[str, Any]], + handle_context_shutdown: Optional[bool]=False) -> None: + self._request_state = StreamingState.Error + if not isinstance(json_data, list): + self._request_error = AnalyticsError(message='Cannot parse error response; expected JSON array', + context=str(self._request_context.error_context)) + else: + self._request_error = ErrorMapper.build_error_from_json(json_data, self._error_ctx) + if handle_context_shutdown is True: + self.shutdown() + raise self._request_error + + def _reset_stream(self) -> None: + if hasattr(self, '_json_stream'): + del self._json_stream + self._request_state = StreamingState.ResetAndNotStarted + self._request.previous_ips = set() + self._stage_notification_ft = None + + def _start_next_stage(self, + fn: Callable[..., Any], + *args: object, + create_notification: Optional[bool]=False, + reset_previous_stage: Optional[bool]=False) -> None: + if reset_previous_stage is True: + if self._stage_completed_ft is not None: + self._stage_completed_ft = None + elif self._stage_completed_ft is not None and not self._stage_completed_ft.done(): + raise RuntimeError('Future already running in this context.') + + kwargs: Dict[str, Union[RequestContext, Future[ParsedResultType]]] = {'request_context': self} + if create_notification is True: + self._create_stage_notification_future() + if self._stage_notification_ft is None: + raise RuntimeError('Unable to create stage notification future.') + kwargs['notify_on_results_or_error'] = self._stage_notification_ft + + self._stage_completed_ft = self._tp_executor.submit(fn, *args, **kwargs) + def _trace_handler(self, event_name: str, _: str) -> None: if event_name == 'connection.connect_tcp.complete': - print('Connection established, updating cancel scope deadline') + pass + + def _wait_for_stage_completed(self) -> None: + if self._stage_completed_ft is None: + raise RuntimeError('Stage completed future not created for this context.') + self._stage_completed_ft.result() def cancel_request(self) -> None: if self._request_state == StreamingState.Timeout: return self._request_state = StreamingState.Cancelled + def deserialize_result(self, result: bytes) -> Any: + return self._request.deserializer.deserialize(result) + + def finish_processing_stream(self) -> None: + if not self.has_stage_completed: + self._wait_for_stage_completed() + + if self.cancelled: + return + + while not self._json_stream.token_stream_exhausted: + self._json_stream.continue_parsing() + + def get_result_from_stream(self) -> Optional[ParsedResult]: + return self._json_stream.get_result(self._stream_config.queue_timeout) + def initialize(self) -> None: + # TODO: Add useful logging messages + if self._request_state == StreamingState.ResetAndNotStarted: + # print('Skipping initialization as request is a retry') + return self._request_state = StreamingState.Started timeouts = self._request.get_request_timeouts() or {} - self._request_deadline = time.monotonic() + (timeouts.get('read', None) or DEFAULT_TIMEOUTS['query_timeout']) + current_time = time.monotonic() + self._request_deadline = current_time + (timeouts.get('read', None) or DEFAULT_TIMEOUTS['query_timeout']) + # print(f'initialize request ctx: {current_time=}; req_deadline={self._request_deadline}') - def process_error(self, json_data: List[Dict[str, Any]]) -> None: - self._request_state = StreamingState.Error - if not isinstance(json_data, list): - self._request_error = AnalyticsError(message='Cannot parse error response; expected JSON array') + def maybe_continue_to_process_stream(self) -> None: + if not self.has_stage_completed: + return + + if self._json_stream.token_stream_exhausted: + return + + if self.cancelled: + return - self._request_error = ErrorMapper.build_error_from_json(json_data, status_code=self._request.response_status_code) - raise self._request_error + # NOTE: _start_next_stage injects the request context into args + self._start_next_stage(self._json_stream.continue_parsing, reset_previous_stage=True) + + def okay_to_delay_and_retry(self, delay: float) -> bool: + self._check_cancelled_or_timed_out() + if self._request_state in [StreamingState.Timeout, StreamingState.Cancelled]: + return False + + current_time = time.monotonic() + delay_time = current_time + delay + will_time_out = self._request_deadline < delay_time + # print(f'{current_time=}; {delay_time=}; req_deadline={self._request_deadline}; {will_time_out=}') + if will_time_out: + self._request_state = StreamingState.Timeout + return False + else: + self._reset_stream() + return True + + def process_response(self, + close_handler: Callable[[], None], + raw_response: Optional[ParsedResult]=None, + handle_context_shutdown: Optional[bool]=False) -> Any: + if raw_response is None: + raw_response = self._json_stream.get_result(self._stream_config.queue_timeout) + if raw_response is None: + close_handler() + raise AnalyticsError(message='Received unexpected empty result from JsonStream.', + context=str(self._error_ctx)) + + if raw_response.value is None: + close_handler() + raise AnalyticsError(message='Received unexpected empty response value from JsonStream.', + context=str(self._error_ctx)) + + # we have all the data, close the core response/stream + close_handler() + json_response = json.loads(raw_response.value) + if 'errors' in json_response: + self._process_error(json_response['errors'], handle_context_shutdown=handle_context_shutdown) + return json_response def send_request(self, enable_trace_handling: Optional[bool]=False) -> HttpCoreResponse: - # TODO: handle if lookup fails - ip = get_request_ip(self._request.host, self._request.port, self._request.previous_ips) + ip = get_request_ip(self._request.url.host, self._request.url.port, self._request.previous_ips) if ip is None: attempted_ips = ', '.join(self._request.previous_ips or []) - raise AnalyticsError(message=f'Connect failure. Attempted to connect to resolved IPs: {attempted_ips}.') + raise AnalyticsError(message=f'Connect failure. Unable to connect to any resolved IPs: {attempted_ips}.', + context=str(self._error_ctx)) if enable_trace_handling is True: (self._request.update_url(ip, self._client_adapter.analytics_path) @@ -239,15 +328,12 @@ def send_request(self, enable_trace_handling: Optional[bool]=False) -> HttpCoreR .update_previous_ips(ip)) else: self._request.update_url(ip, self._client_adapter.analytics_path).update_previous_ips(ip) + self._error_ctx.update_request_context(self._request) response = self._client_adapter.send_request(self._request) - self._request.set_client_server_addrs(response) + self._error_ctx.update_response_context(response) + # print(f'Response received: {response.status_code} for request {self._id}, body={self._request.body}.') if response.status_code == 401: - context = { - 'client_addr': self._request.client_addr, - 'server_addr': self._request.server_addr, - 'http_status': response.status_code, - } - raise InvalidCredentialError(str(context)) + raise InvalidCredentialError(context=str(self._error_ctx)) return response @@ -267,6 +353,8 @@ def set_state_to_streaming(self) -> None: self._request_state = StreamingState.StreamingResults def shutdown(self, exc_val: Optional[BaseException]=None) -> None: + if self.is_shutdown: + return if isinstance(exc_val, CancelledError): self._request_state = StreamingState.Cancelled elif exc_val is not None: @@ -278,36 +366,24 @@ def shutdown(self, exc_val: Optional[BaseException]=None) -> None: if StreamingState.is_okay(self._request_state): self._request_state = StreamingState.Completed + self._shutdown = True - def start_next_stage(self, - fn: Callable[..., Any], - *args: object, - create_notification: Optional[bool]=False, - reset_previous_stage: Optional[bool]=False) -> None: - if reset_previous_stage is True: - if self._stage_completed_ft is not None: - self._stage_completed_ft = None - elif self._stage_completed_ft is not None and not self._stage_completed_ft.done(): - raise RuntimeError('Future already running in this context.') + def start_stream(self, core_response: HttpCoreResponse) -> None: + if hasattr(self, '_json_stream'): + # TODO: logging; I don't think this is an error... + return - kwargs: Dict[str, Union[RequestContext, Future[ParsedResultType]]] = {'request_context': self} - if create_notification is True: - self._create_stage_notification_future() - if self._stage_notification_ft is None: - raise RuntimeError('Unable to create stage notification future.') - kwargs['notify_on_results_or_error'] = self._stage_notification_ft - - self._stage_completed_ft = self._tp_executor.submit(fn, *args, **kwargs) - - def wait_for_stage_completed(self) -> None: - if self._stage_completed_ft is None: - raise RuntimeError('Stage completed future not created for this context.') - self._stage_completed_ft.result() + # TODO: need to confirm if the httpx Response iterator is thread-safe + self._json_stream = JsonStream(core_response.iter_bytes(), stream_config=self._stream_config) + self._start_next_stage(self._json_stream.start_parsing, create_notification=True) - def wait_for_stage_notification(self) -> ParsedResultType: + def wait_for_stage_notification(self) -> None: if self._stage_notification_ft is None: raise RuntimeError('Stage notification future not created for this context.') - # TODO: what if the deadline is already passed? deadline = round(self._request_deadline - time.monotonic(), 6) # round to microseconds - res = self._stage_notification_ft.result(timeout=deadline) - return res \ No newline at end of file + if deadline <= 0: + raise TimeoutError(message='Request timed out waiting for stage notification', context=str(self._error_ctx)) + result_type = self._stage_notification_ft.result(timeout=deadline) + if result_type == ParsedResultType.ROW: + # we move to iterating rows + self._request_state = StreamingState.StreamingResults \ No newline at end of file diff --git a/couchbase_analytics/protocol/_core/retries.py b/couchbase_analytics/protocol/_core/retries.py new file mode 100644 index 0000000..607b34f --- /dev/null +++ b/couchbase_analytics/protocol/_core/retries.py @@ -0,0 +1,99 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from concurrent.futures import CancelledError +from functools import wraps +from random import uniform +from time import sleep +from typing import TYPE_CHECKING, Callable + +from httpx import ConnectError, ConnectTimeout + +from couchbase_analytics.common.errors import AnalyticsError, InternalSDKError, TimeoutError +from couchbase_analytics.common.streaming import StreamingState +from couchbase_analytics.protocol.errors import WrappedError + +if TYPE_CHECKING: + from couchbase_analytics.protocol.streaming import HttpStreamingResponse + +class RetryHandler: + """ + **INTERNAL** + """ + + @staticmethod + def with_retries(fn: Callable[[HttpStreamingResponse], None]) -> Callable[[HttpStreamingResponse], None]: # noqa: C901 + @wraps(fn) + def wrapped_fn(self: HttpStreamingResponse) -> None: # noqa: C901 + while True: + try: + fn(self) + break + except WrappedError as ex: + if ex.retriable is True: + delay = calc_backoff(self._request_context.error_context.num_attempts) + if not self._request_context.okay_to_delay_and_retry(delay): + self._request_context.shutdown(ex) + raise TimeoutError(message='Request timed out during retry delay.', + context=str(self._request_context.error_context)) from None + sleep(delay) + continue + self._request_context.shutdown(ex) + ex.maybe_set_cause_context(self._request_context.error_context) + raise ex.unwrap() from None + except AnalyticsError: + # if an AnalyticsError is raised, we have already shut down the request context + raise + except RuntimeError as ex: + self._request_context.shutdown(ex) + raise ex + except ConnectError as ex: + self._request_context.shutdown(ex) + raise AnalyticsError(cause=ex, + message='Unable to establish connection for request.', + context=str(self._request_context.error_context)) from None + except ConnectTimeout as ex: + self._request_context.shutdown(ex) + raise TimeoutError(cause=ex, + message='Request timed out trying to establish connection.', + context=str(self._request_context.error_context)) from None + except BaseException as ex: + self._request_context.shutdown(ex) + if self._request_context.request_error is not None: + raise self._request_context.request_error from None + if self._request_context.timed_out: + raise TimeoutError(message='Request timeout.', + context=str(self._request_context.error_context)) from None + if self._request_context.cancelled: + raise CancelledError('Request was cancelled.') from None + raise InternalSDKError(cause=ex, + message=str(ex), + context=str(self._request_context.error_context)) from None + finally: + if not StreamingState.is_okay(self._request_context.request_state): + self.close() + + return wrapped_fn + +def calc_backoff(retry_count: int) -> float: + min_ms = 100 + max_ms = 60000 + delay_ms = min_ms * pow(2, retry_count) + capped_ms = min(max_ms, delay_ms) + return uniform(0, capped_ms / 1000.0) + + diff --git a/couchbase_analytics/protocol/core/utils.py b/couchbase_analytics/protocol/_core/utils.py similarity index 100% rename from couchbase_analytics/protocol/core/utils.py rename to couchbase_analytics/protocol/_core/utils.py diff --git a/couchbase_analytics/protocol/cluster.py b/couchbase_analytics/protocol/cluster.py index 031646e..c8dc73f 100644 --- a/couchbase_analytics/protocol/cluster.py +++ b/couchbase_analytics/protocol/cluster.py @@ -17,18 +17,15 @@ import atexit from concurrent.futures import Future, ThreadPoolExecutor -from typing import (TYPE_CHECKING, - Optional, - Union) +from typing import TYPE_CHECKING, Optional, Union from uuid import uuid4 from couchbase_analytics.common.result import BlockingQueryResult -from couchbase_analytics.protocol.core._request_context import RequestContext -from couchbase_analytics.protocol.core.client_adapter import _ClientAdapter -from couchbase_analytics.protocol.core.request import _RequestBuilder +from couchbase_analytics.protocol._core.client_adapter import _ClientAdapter +from couchbase_analytics.protocol._core.request import _RequestBuilder +from couchbase_analytics.protocol._core.request_context import RequestContext from couchbase_analytics.protocol.streaming import HttpStreamingResponse - if TYPE_CHECKING: from couchbase_analytics.common.credential import Credential from couchbase_analytics.options import ClusterOptions @@ -122,16 +119,15 @@ def execute_query(self, statement: str, *args: object, **kwargs: object) -> Union[BlockingQueryResult, Future[BlockingQueryResult]]: - from threading import get_ident base_req = self._request_builder.build_base_query_request(statement, *args, **kwargs) lazy_execute = base_req.options.pop('lazy_execute', None) stream_config = base_req.options.pop('stream_config', None) request_context = RequestContext(self.client_adapter, base_req, - self.threadpool_executor) + self.threadpool_executor, + stream_config=stream_config) resp = HttpStreamingResponse(request_context, - lazy_execute=lazy_execute, - stream_config=stream_config) + lazy_execute=lazy_execute) def _execute_query(http_response: HttpStreamingResponse) -> BlockingQueryResult: http_response.send_request() diff --git a/couchbase_analytics/protocol/cluster.pyi b/couchbase_analytics/protocol/cluster.pyi index 4e3b4a5..d6ac5de 100644 --- a/couchbase_analytics/protocol/cluster.pyi +++ b/couchbase_analytics/protocol/cluster.pyi @@ -25,11 +25,8 @@ else: from couchbase_analytics import JSONType from couchbase_analytics.common.credential import Credential from couchbase_analytics.common.result import BlockingQueryResult -from couchbase_analytics.options import (ClusterOptions, - ClusterOptionsKwargs, - QueryOptions, - QueryOptionsKwargs) -from couchbase_analytics.protocol.core.client_adapter import _ClientAdapter +from couchbase_analytics.options import ClusterOptions, ClusterOptionsKwargs, QueryOptions, QueryOptionsKwargs +from couchbase_analytics.protocol._core.client_adapter import _ClientAdapter class Cluster: @overload diff --git a/couchbase_analytics/protocol/connection.py b/couchbase_analytics/protocol/connection.py index 0d87393..9a60dbe 100644 --- a/couchbase_analytics/protocol/connection.py +++ b/couchbase_analytics/protocol/connection.py @@ -16,32 +16,23 @@ from __future__ import annotations import ssl - from dataclasses import dataclass -from typing import (TYPE_CHECKING, - Dict, - List, - Optional, - Tuple, - TypedDict, - cast) +from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, TypedDict, cast from urllib.parse import parse_qs, urlparse -from couchbase_analytics.common.core._certificates import _Certificates -from couchbase_analytics.common.core.duration_str_utils import parse_duration_str +from couchbase_analytics.common._core._certificates import _Certificates +from couchbase_analytics.common._core.duration_str_utils import parse_duration_str +from couchbase_analytics.common._core.utils import is_null_or_empty from couchbase_analytics.common.credential import Credential from couchbase_analytics.common.deserializer import DefaultJsonDeserializer, Deserializer -from couchbase_analytics.common.options import (ClusterOptions, - SecurityOptions, - TimeoutOptions) - -from couchbase_analytics.protocol import PYCBAC_VERSION -from couchbase_analytics.protocol.options import (ClusterOptionsTransformedKwargs, - QueryStrVal, - SecurityOptionsTransformedKwargs, - TimeoutOptionsTransformedKwargs) - -from httpcore import URL +from couchbase_analytics.common.options import ClusterOptions, SecurityOptions, TimeoutOptions +from couchbase_analytics.common.request import RequestURL +from couchbase_analytics.protocol.options import ( + ClusterOptionsTransformedKwargs, + QueryStrVal, + SecurityOptionsTransformedKwargs, + TimeoutOptionsTransformedKwargs, +) if TYPE_CHECKING: from couchbase_analytics.protocol.options import OptionsBuilder @@ -61,8 +52,7 @@ class DefaultTimeouts(TypedDict): 'query_timeout': 60 * 10, } - -def parse_http_endpoint(http_endpoint: str) -> Tuple[URL, Dict[str, List[str]]]: +def parse_http_endpoint(http_endpoint: str) -> Tuple[RequestURL, Dict[str, List[str]]]: """ **INTERNAL** Parse the provided HTTP endpoint @@ -91,12 +81,16 @@ def parse_http_endpoint(http_endpoint: str) -> Tuple[URL, Dict[str, List[str]]]: port = parsed_endpoint.port if parsed_endpoint.port is None: port = 80 if parsed_endpoint.scheme == 'http' else 443 - - url = URL(scheme=parsed_endpoint.scheme, - host=host, - port=port, - target=parsed_endpoint.path or '/') + if port is None: + raise ValueError('The URL must have a port specified.') + + if not is_null_or_empty(parsed_endpoint.path): + raise ValueError('The SDK does not currently support HTTP endpoint paths.') + + url = RequestURL(scheme=parsed_endpoint.scheme, + host=host, + port=port) return url, parse_qs(parsed_endpoint.query) @@ -156,7 +150,7 @@ class _ConnectionDetails: """ **INTERNAL** """ - url: URL + url: RequestURL cluster_options: ClusterOptionsTransformedKwargs credential: Tuple[bytes, bytes] default_deserializer: Deserializer @@ -179,13 +173,8 @@ def get_query_timeout(self) -> float: return query_timeout return DEFAULT_TIMEOUTS['query_timeout'] - def get_scheme_host_and_port(self) -> Tuple[str, str, int]: - if self.url.port is None: - raise ValueError('The URL must have a port specified.') - return self.url.scheme.decode(), self.url.host.decode(), self.url.port - def is_secure(self) -> bool: - return self.url.scheme.decode() == 'https' + return self.url.scheme == 'https' def validate_security_options(self) -> None: security_opts: Optional[SecurityOptionsTransformedKwargs] = self.cluster_options.get('security_options') @@ -194,7 +183,7 @@ def validate_security_options(self) -> None: # separate between value options and boolean option (trust_only_capella) solo_security_opts = ['trust_only_pem_file', 'trust_only_pem_str', 'trust_only_certificates'] trust_capella = security_opts.get('trust_only_capella', None) - security_opt_count = sum(map(lambda k: 1 if security_opts.get(k, None) is not None else 0, solo_security_opts)) + security_opt_count = sum((1 if security_opts.get(opt, None) is not None else 0 for opt in solo_security_opts)) # noqa: E501 if security_opt_count > 1 or (security_opt_count == 1 and trust_capella is True): raise ValueError(('Can only set one of the following options: ' f'[{", ".join(["trust_only_capella"] + solo_security_opts)}]')) @@ -203,7 +192,7 @@ def validate_security_options(self) -> None: return self.ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) - self.sni_hostname = self.url.host.decode() + self.sni_hostname = self.url.host if security_opts is None: self.ssl_context.set_default_verify_paths() @@ -221,6 +210,15 @@ def validate_security_options(self) -> None: elif (certificates := security_opts.get('trust_only_certificates', None)) is not None: self.ssl_context.load_verify_locations(cadata='\n'.join(certificates)) security_opts['trust_only_capella'] = False + + if security_opts is not None and security_opts.get('disable_server_certificate_verification', False): + # TODO: log warning + print('Warning: Server certificate verification is disabled. This is not recommended for production use.') + self.ssl_context.check_hostname = False + self.ssl_context.verify_mode = ssl.CERT_NONE + else: + self.ssl_context.check_hostname = True + self.ssl_context.verify_mode = ssl.CERT_REQUIRED @classmethod diff --git a/couchbase_analytics/protocol/database.py b/couchbase_analytics/protocol/database.py index fea661e..cf0ac91 100644 --- a/couchbase_analytics/protocol/database.py +++ b/couchbase_analytics/protocol/database.py @@ -18,7 +18,7 @@ from concurrent.futures import ThreadPoolExecutor from typing import TYPE_CHECKING -from couchbase_analytics.protocol.core.client_adapter import _ClientAdapter +from couchbase_analytics.protocol._core.client_adapter import _ClientAdapter from couchbase_analytics.protocol.scope import Scope if TYPE_CHECKING: diff --git a/couchbase_analytics/protocol/database.pyi b/couchbase_analytics/protocol/database.pyi index 7bbccc8..6e8052a 100644 --- a/couchbase_analytics/protocol/database.pyi +++ b/couchbase_analytics/protocol/database.pyi @@ -15,8 +15,8 @@ from concurrent.futures import ThreadPoolExecutor +from couchbase_analytics.protocol._core.client_adapter import _ClientAdapter from couchbase_analytics.protocol.cluster import Cluster as Cluster -from couchbase_analytics.protocol.core.client_adapter import _ClientAdapter from couchbase_analytics.protocol.scope import Scope class Database: diff --git a/couchbase_analytics/protocol/errors.py b/couchbase_analytics/protocol/errors.py index ba16229..7694cf6 100644 --- a/couchbase_analytics/protocol/errors.py +++ b/couchbase_analytics/protocol/errors.py @@ -15,22 +15,24 @@ from __future__ import annotations +import socket import sys -from typing import (Any, - Dict, - List, - Optional, - Union) +from functools import wraps +from typing import Any, Callable, Dict, List, NamedTuple, Optional, Set, Union if sys.version_info < (3, 10): from typing_extensions import TypeAlias else: from typing import TypeAlias -from couchbase_analytics.common.errors import (AnalyticsError, - InternalSDKError, - InvalidCredentialError, - QueryError) +from couchbase_analytics.common._core.error_context import ErrorContext +from couchbase_analytics.common.errors import ( + AnalyticsError, + InternalSDKError, + InvalidCredentialError, + QueryError, + TimeoutError, +) AnalyticsClientError: TypeAlias = Union[AnalyticsError, InternalSDKError, @@ -38,49 +40,151 @@ RuntimeError, ValueError] -# class CoreErrorMap(Enum): -# AnalyticsError = 1 -# InvalidCredentialError = 2 -# TimeoutError = 3 -# QueryError = 4 - - -# class ClientErrorMap(Enum): -# ValueError = 1 -# RuntimeError = 2 -# QueryOperationCanceledError = 3 -# InternalSDKError = 4 - - -# PYCBAC_CORE_ERROR_MAP: Dict[int, type[AnalyticsError]] = { -# e.value: getattr(sys.modules['couchbase_analytics.common.errors'], e.name) for e in CoreErrorMap -# } - -# PYCBAC_CLIENT_ERROR_MAP: Dict[int, type[AnalyticsClientError]] = { -# 1: ValueError, -# 2: RuntimeError, -# 3: QueryOperationCanceledError, -# 4: InternalSDKError -# } +class ServerQueryError(NamedTuple): + """ + **INTERNAL** + """ + code: int + message: str + retriable: bool = False + + def to_dict(self) -> Dict[str, Any]: + output: Dict[str, Any] = { + 'code': self.code, + 'msg': self.message, + } + if self.retriable is not None: + output['retriable'] = self.retriable + return output + + def __eq__(self, other: object) -> bool: + if not isinstance(other, ServerQueryError): + return False + return self.code == other.code and self.message == other.message + + def __repr__(self) -> str: + return f'ServerQueryError(code={self.code}, message={self.message}, retriable={self.retriable})' + + @classmethod + def from_json(cls, json_data: Dict[str, Any]) -> ServerQueryError: + """ + **INTERNAL** + """ + code = json_data.get('code', 0) + message = json_data.get('msg', 'Unknown error') + retriable = bool(json_data.get('retriable', False)) + return cls(code=code, message=message, retriable=retriable) + +class WrappedError(Exception): + def __init__(self, + cause: Union[BaseException, Exception], + retriable: bool = False) -> None: + super().__init__() + self._cause = cause + self._retriable = retriable + + @property + def retriable(self) -> bool: + return self._retriable + + @retriable.setter + def retriable(self, value: bool) -> None: + self._retriable = value + + def maybe_set_cause_context(self, context: ErrorContext) -> None: + if not isinstance(self._cause, (AnalyticsError, InvalidCredentialError, QueryError, TimeoutError)): + return + + if hasattr(self._cause, '_context') and self._cause._context is None: + self._cause._context = str(context) + + def unwrap(self) -> Union[BaseException, Exception]: + """ + Unwraps the cause of the error, returning the original exception. + """ + return self._cause + + def __repr__(self) -> str: + return f'{type(self).__name__}(cause={self._cause!r}, retriable={self._retriable})' + + def __str__(self) -> str: + return self.__repr__() + +# https://github.com/python/cpython/blob/0f866cbfefd797b4dae25962457c5579bb90dde5/Modules/addrinfo.h#L58-L71 +_NON_RETRYABLE_SOCKET_ERRORS: List[int] = [ + socket.EAI_ADDRFAMILY, + socket.EAI_BADFLAGS, + socket.EAI_FAIL, + socket.EAI_FAMILY, + socket.EAI_MEMORY, + socket.EAI_NODATA, + socket.EAI_NONAME, + socket.EAI_SERVICE, + socket.EAI_SOCKTYPE, + socket.EAI_SYSTEM, + socket.EAI_BADHINTS, + socket.EAI_PROTOCOL, + socket.EAI_MAX +] class ErrorMapper: @staticmethod # noqa: C901 - def build_error_from_json(json_data: List[Dict[str, Any]], - status_code: Optional[int]=None) -> AnalyticsClientError: - context = {'errors': json_data, - 'http_status': status_code} - # TODO: error handling needs to be more robust - if status_code is None: - status_code = json_data[0].get('status', 500) - return AnalyticsError(message='Unknown error occurred.') - elif status_code == 401: - return InvalidCredentialError(str(context), message='Invalid credentials provided.') - else: - first_error = json_data[0] - code = first_error.get('code', 0) - server_message = first_error.get('msg', 'Unknown error occurred.') - return QueryError(code, server_message, str(context)) + def build_error_from_json(json_data: List[Dict[str, Any]], context: ErrorContext) -> WrappedError: + if context.status_code is None: + return WrappedError(AnalyticsError(context=str(context), message='Unknown error occurred.')) + if context.status_code == 401: + return WrappedError(InvalidCredentialError(context=str(context), message='Invalid credentials provided.')) + + first_non_retriable_error: Optional[ServerQueryError] = None + first_retriable_error: Optional[ServerQueryError] = None + errs: List[ServerQueryError] = [] + for err_data in json_data: + err = ServerQueryError.from_json(err_data) + errs.append(err) + retriable = bool(err_data.get('retriable', False)) or False + if not retriable and first_non_retriable_error is None: + first_non_retriable_error = err + + if retriable and first_retriable_error is None: + first_retriable_error = err + + first_err = first_non_retriable_error or first_retriable_error + context.set_errors([e.to_dict() for e in errs]) + if first_err is None: + err_msg = 'Could not parse errors from server response (expected JSON array).' + return WrappedError(AnalyticsError(context=str(context), message=err_msg)) + + if first_err.code == 20000: + return WrappedError(InvalidCredentialError(context=str(context))) + if first_err.code == 21002: + return WrappedError(TimeoutError(context=str(context), message='Received timeout error from server.')) + + retriable = first_non_retriable_error is None and first_retriable_error is not None + return WrappedError(QueryError(code=first_err.code, + server_message=first_err.message, + context=str(context)), + retriable=retriable) + + @staticmethod + def handle_socket_error(fn: Callable[[str, int, Optional[Set[str]]], Optional[str]] + ) -> Callable[[str, int, Optional[Set[str]]], Optional[str]]: + @wraps(fn) + def wrapped_fn(host: str, + port: int, + previous_ips: Optional[Set[str]]=None) -> Optional[str]: + try: + return fn(host, port, previous_ips) + except socket.gaierror as ex: + # print(f'getaddrinfo failed for {host}:{port} with error: {ex}') + msg='Connection error occurred while sending request.' + raise WrappedError(AnalyticsError(cause=ex, message=msg), + retriable=(ex.errno not in _NON_RETRYABLE_SOCKET_ERRORS)) from None + + return wrapped_fn + + + diff --git a/couchbase_analytics/protocol/options.py b/couchbase_analytics/protocol/options.py index 98986ce..ecc83df 100644 --- a/couchbase_analytics/protocol/options.py +++ b/couchbase_analytics/protocol/options.py @@ -16,38 +16,34 @@ from __future__ import annotations from copy import copy -from typing import (Any, - Callable, - Dict, - List, - Literal, - Optional, - Tuple, - TypedDict, - TypeVar, - Union) - -from couchbase_analytics.common.core.utils import (VALIDATE_BOOL, - VALIDATE_DESERIALIZER, - VALIDATE_STR, - VALIDATE_STR_LIST, - EnumToStr, - to_seconds, - to_microseconds, - validate_path, - validate_raw_dict) -from couchbase_analytics.common.core import JsonStreamConfig +from typing import Any, Callable, Dict, List, Literal, Optional, Tuple, TypedDict, TypeVar, Union + +from couchbase_analytics.common._core import JsonStreamConfig +from couchbase_analytics.common._core.utils import ( + VALIDATE_BOOL, + VALIDATE_DESERIALIZER, + VALIDATE_STR, + VALIDATE_STR_LIST, + EnumToStr, + to_seconds, + validate_path, + validate_raw_dict, +) from couchbase_analytics.common.deserializer import Deserializer from couchbase_analytics.common.enums import QueryScanConsistency -from couchbase_analytics.common.options import (ClusterOptions, - OptionsClass, - QueryOptions, - SecurityOptions, - TimeoutOptions) -from couchbase_analytics.common.options_base import (ClusterOptionsValidKeys, - QueryOptionsValidKeys, - SecurityOptionsValidKeys, - TimeoutOptionsValidKeys) +from couchbase_analytics.common.options import ( + ClusterOptions, + OptionsClass, + QueryOptions, + SecurityOptions, + TimeoutOptions, +) +from couchbase_analytics.common.options_base import ( + ClusterOptionsValidKeys, + QueryOptionsValidKeys, + SecurityOptionsValidKeys, + TimeoutOptionsValidKeys, +) QUERY_CONSISTENCY_TO_STR = EnumToStr[QueryScanConsistency]() @@ -186,14 +182,14 @@ def _get_options_copy(self, options_class: type[OptionsClass], orig_kwargs: Dict[str, object], options: Optional[object] = None) -> Dict[str, object]: - orig_kwargs = copy(orig_kwargs) if orig_kwargs else dict() + orig_kwargs = copy(orig_kwargs) if orig_kwargs else {} # set our options base dict() temp_options: Dict[str, object] = {} if options and isinstance(options, (options_class, dict)): # mypy cannot recognize that all our options classes are dicts temp_options = options_class(**options) # type: ignore[arg-type] else: - temp_options = dict() + temp_options = {} temp_options.update(orig_kwargs) return temp_options diff --git a/couchbase_analytics/protocol/scope.py b/couchbase_analytics/protocol/scope.py index ca5c9d8..5aa06a1 100644 --- a/couchbase_analytics/protocol/scope.py +++ b/couchbase_analytics/protocol/scope.py @@ -19,12 +19,11 @@ from typing import TYPE_CHECKING, Union from couchbase_analytics.common.result import BlockingQueryResult -from couchbase_analytics.protocol.core._request_context import RequestContext -from couchbase_analytics.protocol.core.client_adapter import _ClientAdapter -from couchbase_analytics.protocol.core.request import _RequestBuilder +from couchbase_analytics.protocol._core.client_adapter import _ClientAdapter +from couchbase_analytics.protocol._core.request import _RequestBuilder +from couchbase_analytics.protocol._core.request_context import RequestContext from couchbase_analytics.protocol.streaming import HttpStreamingResponse - if TYPE_CHECKING: from couchbase_analytics.protocol.database import Database @@ -66,10 +65,10 @@ def execute_query(self, stream_config = base_req.options.pop('stream_config', None) request_context = RequestContext(self.client_adapter, base_req, - self.threadpool_executor) + self.threadpool_executor, + stream_config=stream_config) resp = HttpStreamingResponse(request_context, - lazy_execute=lazy_execute, - stream_config=stream_config) + lazy_execute=lazy_execute) def _execute_query(http_response: HttpStreamingResponse) -> BlockingQueryResult: http_response.send_request() diff --git a/couchbase_analytics/protocol/scope.pyi b/couchbase_analytics/protocol/scope.pyi index 7f10e93..aec42e9 100644 --- a/couchbase_analytics/protocol/scope.pyi +++ b/couchbase_analytics/protocol/scope.pyi @@ -25,7 +25,7 @@ else: from couchbase_analytics import JSONType from couchbase_analytics.common.result import BlockingQueryResult from couchbase_analytics.options import QueryOptions, QueryOptionsKwargs -from couchbase_analytics.protocol.core.client_adapter import _ClientAdapter +from couchbase_analytics.protocol._core.client_adapter import _ClientAdapter from couchbase_analytics.protocol.database import Database as Database class Scope: diff --git a/couchbase_analytics/protocol/streaming.py b/couchbase_analytics/protocol/streaming.py index 7beeacd..aa22402 100644 --- a/couchbase_analytics/protocol/streaming.py +++ b/couchbase_analytics/protocol/streaming.py @@ -15,76 +15,23 @@ from __future__ import annotations -import json -import sys - from concurrent.futures import CancelledError -from functools import wraps -from typing import (Any, - Callable, - Optional) - -if sys.version_info < (3, 10): - from typing_extensions import TypeAlias -else: - from typing import TypeAlias +from typing import Any, Optional from httpx import Response as HttpCoreResponse -# TODO: errors? +from couchbase_analytics.common._core import ParsedResult, ParsedResultType +from couchbase_analytics.common._core.query import build_query_metadata from couchbase_analytics.common.errors import AnalyticsError, InternalSDKError, TimeoutError -from couchbase_analytics.common.core import (JsonStreamConfig, - ParsedResult, - ParsedResultType) -from couchbase_analytics.common.core.json_stream import JsonStream -from couchbase_analytics.common.core.query import build_query_metadata from couchbase_analytics.common.query import QueryMetadata -from couchbase_analytics.common.streaming import StreamingState -from couchbase_analytics.protocol.core._request_context import RequestContext, ThreadSafeBytesIterator - -class RequestWrapper: - """ - **INTERNAL** - """ - - @classmethod - def handle_retries(cls) -> Callable[[SendRequestFunc], WrappedSendRequestFunc]: - """ - **INTERNAL** - """ +from couchbase_analytics.protocol._core.request_context import RequestContext +from couchbase_analytics.protocol._core.retries import RetryHandler - def decorator(fn: SendRequestFunc) -> WrappedSendRequestFunc: - @wraps(fn) - def wrapped_fn(self: HttpStreamingResponse) -> None: - try: - fn(self) - except AnalyticsError: - # if an AnalyticsError is raised, we have already shut down the request context - raise - except RuntimeError as ex: - self._request_context.shutdown(ex) - raise ex - except BaseException as ex: - self._request_context.shutdown(ex) - if self._request_context.request_error is not None: - raise self._request_context.request_error from None - if self._request_context.timed_out: - raise TimeoutError(message='Request timeout.') from None - if self._request_context.cancelled: - raise CancelledError('Request was cancelled.') from None - raise InternalSDKError(ex) from None - finally: - if not StreamingState.is_okay(self._request_context.request_state): - self.close() - - return wrapped_fn - return decorator class HttpStreamingResponse: def __init__(self, request_context: RequestContext, - lazy_execute: Optional[bool] = None, - stream_config: Optional[JsonStreamConfig]=None) -> None: + lazy_execute: Optional[bool] = None) -> None: self._request_context = request_context if lazy_execute is not None: self._lazy_execute = lazy_execute @@ -92,8 +39,6 @@ def __init__(self, self._lazy_execute = False self._metadata: Optional[QueryMetadata] = None self._core_response: HttpCoreResponse - self._stream_config = stream_config or JsonStreamConfig() - self._json_stream: JsonStream @property def lazy_execute(self) -> bool: @@ -102,68 +47,27 @@ def lazy_execute(self) -> bool: """ return self._lazy_execute - def _finish_processing_stream(self) -> None: - if not self._request_context.has_stage_completed: - self._request_context.wait_for_stage_completed() - - if self._request_context.cancelled: - return - - while not self._json_stream.token_stream_exhausted: - self._json_stream.continue_parsing() - def _handle_iteration_abort(self) -> None: self.close() if self._request_context.cancelled: - print('Request was cancelled, closing stream.') + self._request_context.shutdown() raise StopIteration elif self._request_context.timed_out: - print('Request timed out, closing stream.') - raise TimeoutError(message='Request timeout.') + err = TimeoutError(message='Unable to complete iteration. Request timed out.', + context=str(self._request_context.error_context)) + self._request_context.shutdown(err) + raise err else: + self._request_context.shutdown() raise StopIteration - def _maybe_continue_to_process_stream(self) -> None: - if not self._request_context.has_stage_completed: - return - - if self._json_stream.token_stream_exhausted: - return - - if self._request_context.cancelled: - return - - # NOTE: start_next_stage injects the request context into args - self._request_context.start_next_stage(self._json_stream.continue_parsing, reset_previous_stage=True) - - def _process_response(self, raw_response: Optional[ParsedResult]=None) -> None: - if raw_response is None: - raw_response = self._json_stream.get_result(self._stream_config.queue_timeout) - if raw_response is None: - raise AnalyticsError(message='Received unexpected empty result from JsonStream.') - - if raw_response.value is None: - raise AnalyticsError(message='Received unexpected empty result from JsonStream.') - - json_response = json.loads(raw_response.value) - if 'errors' in json_response: - self._request_context.process_error(json_response['errors']) + def _process_response(self, + raw_response: Optional[ParsedResult]=None, + handle_context_shutdown: Optional[bool]=False) -> None: + json_response = self._request_context.process_response(self.close, + raw_response=raw_response, + handle_context_shutdown=handle_context_shutdown) self.set_metadata(json_data=json_response) - # we have all the data, close the core response/stream - self.close() - - def _start(self) -> None: - """ - **INTERNAL** - """ - if hasattr(self, '_json_stream'): - # TODO: logging; I don't think this is an error... - return - - # TODO: need to confirm if the httpx Response iterator is thread-safe - self._json_stream = JsonStream(self._core_response.iter_bytes(), stream_config=self._stream_config) - # NOTE: start_next_stage injects the request context into args - self._request_context.start_next_stage(self._json_stream.start_parsing, create_notification=True) def close(self) -> None: """ @@ -177,15 +81,13 @@ def cancel(self) -> None: """ **INTERNAL** """ - self._request_context.cancel_request() self.close() + self._request_context.cancel_request() + self._request_context.shutdown() + def get_metadata(self) -> QueryMetadata: if self._metadata is None: - if self._request_context.cancelled: - raise CancelledError('Request was cancelled.') - elif self._request_context.timed_out: - raise TimeoutError(message='Request timeout.') raise RuntimeError('Query metadata is only available after all rows have been iterated.') return self._metadata @@ -194,10 +96,17 @@ def set_metadata(self, raw_metadata: Optional[bytes]=None) -> None: try: self._metadata = QueryMetadata(build_query_metadata(json_data=json_data, raw_metadata=raw_metadata)) - except AnalyticsError as err: + self._request_context.shutdown() + except (AnalyticsError, ValueError) as err: + self._request_context.shutdown(err) raise err except Exception as ex: - raise InternalSDKError(str(ex)) + internal_err = InternalSDKError(cause=ex, + message=str(ex), + context=str(self._request_context.error_context)) + self._request_context.shutdown(internal_err) + finally: + self.close() def get_next_row(self) -> Any: """ @@ -208,48 +117,43 @@ def get_next_row(self) -> Any: and self._request_context.okay_to_iterate): self._handle_iteration_abort() - self._maybe_continue_to_process_stream() + self._request_context.maybe_continue_to_process_stream() check_state = False while True: if check_state and not self._request_context.okay_to_iterate: self._handle_iteration_abort() - raw_response = self._json_stream.get_result(self._stream_config.queue_timeout) + raw_response = self._request_context.get_result_from_stream() if raw_response is None: check_state = True continue if raw_response.result_type == ParsedResultType.ROW: if raw_response.value is None: - raise AnalyticsError(message='Unexpected empty row response while streaming.') - return self._request_context.deserializer.deserialize(raw_response.value) + err = AnalyticsError(message='Unexpected empty row response while streaming.', + context=str(self._request_context.error_context)) + self._request_context.shutdown(err) + self.close() + raise err + return self._request_context.deserialize_result(raw_response.value) elif raw_response.result_type in [ParsedResultType.ERROR, ParsedResultType.UNKNOWN]: - self._process_response(raw_response=raw_response) + self._process_response(raw_response=raw_response, handle_context_shutdown=True) elif raw_response.result_type == ParsedResultType.END: self.set_metadata(raw_metadata=raw_response.value) - self.close() raise StopIteration - @RequestWrapper.handle_retries() + @RetryHandler.with_retries def send_request(self) -> None: if not self._request_context.okay_to_stream: raise RuntimeError('Query has been canceled or previously executed.') self._request_context.initialize() - # TODO: do we need to use the tracing? self._core_response = self._request_context.send_request() if self._request_context.cancelled: raise CancelledError('Request was cancelled.') - self._start() + self._request_context.start_stream(self._core_response) # block until we either know we have rows or errors - result_type = self._request_context.wait_for_stage_notification() - if result_type == ParsedResultType.ROW: - # we move to iterating rows - self._request_context.set_state_to_streaming() - else: - self._finish_processing_stream() + self._request_context.wait_for_stage_notification() + if not self._request_context.okay_to_iterate: + self._request_context.finish_processing_stream() self._process_response() -SendRequestFunc: TypeAlias = Callable[[HttpStreamingResponse], None] -# Although, SendRequestFunc is the same type as WrappedSendRequestFunc, keep separate for clarity and indicate -# WrappedSendRequestFunc is a decorator -WrappedSendRequestFunc: TypeAlias = Callable[[HttpStreamingResponse], None] \ No newline at end of file diff --git a/couchbase_analytics/tests/connection_t.py b/couchbase_analytics/tests/connection_t.py index 60bc122..e598f0a 100644 --- a/couchbase_analytics/tests/connection_t.py +++ b/couchbase_analytics/tests/connection_t.py @@ -15,15 +15,15 @@ from __future__ import annotations -from typing import Dict, Optional +from typing import Dict from urllib.parse import urlparse import pytest from couchbase_analytics.cluster import Cluster from couchbase_analytics.credential import Credential -from couchbase_analytics.protocol.core.client_adapter import _ClientAdapter -from couchbase_analytics.protocol.core.request import _RequestBuilder +from couchbase_analytics.protocol._core.client_adapter import _ClientAdapter +from couchbase_analytics.protocol._core.request import _RequestBuilder from tests.utils import get_test_cert_path, to_query_str TEST_CERT_PATH = get_test_cert_path() @@ -81,7 +81,7 @@ def test_connstr_options_timeout(self, expected_seconds: str) -> None: opt_keys = ['timeout.connect_timeout', 'timeout.query_timeout'] - opts = {k: duration for k in opt_keys} + opts = dict.fromkeys(opt_keys, duration) cred = Credential.from_username_and_password('Administrator', 'password') connstr = f'https://localhost?{to_query_str(opts)}' client = _ClientAdapter(connstr, cred) @@ -212,8 +212,8 @@ def test_valid_connection_strings(self, connstr: str) -> None: assert {} == client.connection_details.cluster_options parsed_connstr = urlparse(connstr) parsed_port = parsed_connstr.port or (80 if parsed_connstr.scheme == 'http' else 443) - scheme, host, port = client.connection_details.get_scheme_host_and_port() - assert f'{parsed_connstr.scheme}://{parsed_connstr.hostname}:{parsed_port}' == f'{scheme}://{host}:{port}' + url = client.connection_details.url.get_formatted_url() + assert f'{parsed_connstr.scheme}://{parsed_connstr.hostname}:{parsed_port}' == url class ConnectionTests(ConnectionTestSuite): diff --git a/couchbase_analytics/tests/duration_parsing_t.py b/couchbase_analytics/tests/duration_parsing_t.py index 2b7badf..f0c7f3e 100644 --- a/couchbase_analytics/tests/duration_parsing_t.py +++ b/couchbase_analytics/tests/duration_parsing_t.py @@ -15,12 +15,9 @@ from __future__ import annotations -import json -from typing import TYPE_CHECKING - import pytest -from couchbase_analytics.common.core.duration_str_utils import parse_duration_str +from couchbase_analytics.common._core.duration_str_utils import parse_duration_str class DurationParsingTestSuite: @@ -59,7 +56,8 @@ def test_invalid_durations(self, duration: str) -> None: ('1.234h', 1.234 * 3.6e6), ('1h30m0s', 5.4e6), ('0.1h10m', 9.6e5), - ('.1h10m', 9.6e5), # TODO: apparently this is invalid in Go, but was okay w/ C++ implementation + # TODO: apparently this is invalid in Go, but was okay w/ C++ implementation + ('.1h10m', 9.6e5), ('0001h00010m', 4.2e6), ('100ns', 1e-4), ('100us', 0.1), diff --git a/couchbase_analytics/tests/json_parsing_t.py b/couchbase_analytics/tests/json_parsing_t.py index 11278c2..a1d4ae4 100644 --- a/couchbase_analytics/tests/json_parsing_t.py +++ b/couchbase_analytics/tests/json_parsing_t.py @@ -20,11 +20,8 @@ import pytest -from couchbase_analytics.common.core import (JsonParsingError, - JsonStreamConfig, - ParsedResult, - ParsedResultType) -from couchbase_analytics.common.core.json_stream import JsonStream +from couchbase_analytics.common._core import JsonParsingError, JsonStreamConfig, ParsedResult, ParsedResultType +from couchbase_analytics.protocol._core.json_stream import JsonStream from tests.environments.simple_environment import JsonDataType from tests.utils import BytesIterator @@ -38,6 +35,7 @@ class JsonParsingTestSuite: 'test_analytics_error', 'test_analytics_error_mid_stream', 'test_analytics_many_rows', + 'test_analytics_many_rows_raw', 'test_analytics_multiple_errors', 'test_analytics_simple_result', @@ -131,6 +129,41 @@ def test_analytics_many_rows(self, test_env: SimpleEnvironment) -> None: assert json.loads(final_result.value.decode('utf-8')) == json_object assert parser.get_result(0.01) is None + @pytest.mark.parametrize('buffered_result', [True, False]) + def test_analytics_many_rows_raw(self, + test_env: SimpleEnvironment, + buffered_result: bool) -> None: + json_object, bytes_data = test_env.get_json_data(JsonDataType.MULTIPLE_RESULTS_RAW) + if buffered_result: + parser = JsonStream(BytesIterator(bytes_data), + stream_config=JsonStreamConfig(buffer_entire_result=True)) + else: + parser = JsonStream(BytesIterator(bytes_data)) + + parser.start_parsing() + if not buffered_result: + row_idx = 0 + while row_idx < 10: + result = parser.get_result(0.01) + if result is None and not parser.token_stream_exhausted: + parser.continue_parsing() + continue + assert isinstance(result, ParsedResult) + assert result.result_type == ParsedResultType.ROW + assert isinstance(result.value, bytes) + assert json.loads(result.value.decode('utf-8')) == json_object['results'][row_idx] + row_idx += 1 + + final_result = parser.get_result(0.01) + assert isinstance(final_result, ParsedResult) + assert final_result.result_type == ParsedResultType.END + assert isinstance(final_result.value, bytes) + if not buffered_result: + # if we are not buffering the entire result, the final result will exclude the results key + json_object.pop('results') + assert json.loads(final_result.value.decode('utf-8')) == json_object + assert parser.get_result(0.01) is None + @pytest.mark.parametrize('buffered_result', [True, False]) def test_analytics_multiple_errors(self, test_env: SimpleEnvironment, diff --git a/couchbase_analytics/tests/options_t.py b/couchbase_analytics/tests/options_t.py index 14797ee..967bb9c 100644 --- a/couchbase_analytics/tests/options_t.py +++ b/couchbase_analytics/tests/options_t.py @@ -21,18 +21,16 @@ import pytest from couchbase_analytics.credential import Credential -from couchbase_analytics.deserializer import (Deserializer, - DefaultJsonDeserializer, - PassthroughDeserializer) -from couchbase_analytics.options import (ClusterOptions, - SecurityOptions, - SecurityOptionsKwargs, - TimeoutOptions, - TimeoutOptionsKwargs) -from couchbase_analytics.protocol.core.client_adapter import _ClientAdapter -from tests.utils import (get_test_cert_path, - get_test_cert_list, - get_test_cert_str) +from couchbase_analytics.deserializer import DefaultJsonDeserializer, Deserializer, PassthroughDeserializer +from couchbase_analytics.options import ( + ClusterOptions, + SecurityOptions, + SecurityOptionsKwargs, + TimeoutOptions, + TimeoutOptionsKwargs, +) +from couchbase_analytics.protocol._core.client_adapter import _ClientAdapter +from tests.utils import get_test_cert_list, get_test_cert_path, get_test_cert_str TEST_CERT_PATH = get_test_cert_path() TEST_CERT_LIST = get_test_cert_list() diff --git a/couchbase_analytics/tests/query_integration_t.py b/couchbase_analytics/tests/query_integration_t.py index 2bb804b..5a9fe6d 100644 --- a/couchbase_analytics/tests/query_integration_t.py +++ b/couchbase_analytics/tests/query_integration_t.py @@ -17,12 +17,8 @@ import json from concurrent.futures import CancelledError, Future -from enum import Enum from datetime import timedelta -from typing import (TYPE_CHECKING, - Any, - Dict, - Optional) +from typing import TYPE_CHECKING, Any, Dict, Optional import pytest @@ -30,20 +26,14 @@ from couchbase_analytics.deserializer import PassthroughDeserializer from couchbase_analytics.errors import QueryError, TimeoutError from couchbase_analytics.options import QueryOptions -from couchbase_analytics.query import QueryScanConsistency +from couchbase_analytics.query import QueryScanConsistency from couchbase_analytics.result import BlockingQueryResult -from tests import YieldFixture +from tests import SyncQueryType, YieldFixture if TYPE_CHECKING: from tests.environments.base_environment import BlockingTestEnvironment -class SyncQueryType(Enum): - NORMAL = 'normal' - LAZY = 'lazy' - CANCELLABLE = 'cancellable' - - class QueryTestSuite: TEST_MANIFEST = [ @@ -126,9 +116,11 @@ def test_cancel_prior_iterating(self, test_env: BlockingTestEnvironment, cancel_ for row in res.rows(): rows.append(row) - with pytest.raises(CancelledError): + with pytest.raises(RuntimeError): res.metadata() + test_env.assert_streaming_response_state(res) + @pytest.mark.parametrize('cancel_via_future', [False, True]) def test_cancel_prior_iterating_positional_params(self, test_env: BlockingTestEnvironment, @@ -159,9 +151,11 @@ def test_cancel_prior_iterating_positional_params(self, for row in res.rows(): rows.append(row) - with pytest.raises(CancelledError): + with pytest.raises(RuntimeError): res.metadata() + test_env.assert_streaming_response_state(res) + @pytest.mark.parametrize('cancel_via_future', [False, True]) def test_cancel_prior_iterating_with_kwargs(self, test_env: BlockingTestEnvironment, @@ -192,8 +186,10 @@ def test_cancel_prior_iterating_with_kwargs(self, for row in res.rows(): rows.append(row) - with pytest.raises(CancelledError): + with pytest.raises(RuntimeError): res.metadata() + + test_env.assert_streaming_response_state(res) @pytest.mark.parametrize('cancel_via_future', [False, True]) def test_cancel_prior_iterating_with_options(self, @@ -225,8 +221,9 @@ def test_cancel_prior_iterating_with_options(self, for row in res.rows(): rows.append(row) - with pytest.raises(CancelledError): + with pytest.raises(RuntimeError): res.metadata() + test_env.assert_streaming_response_state(res) @pytest.mark.parametrize('cancel_via_future', [False, True]) def test_cancel_prior_iterating_with_opts_and_kwargs(self, @@ -259,8 +256,9 @@ def test_cancel_prior_iterating_with_opts_and_kwargs(self, for row in res.rows(): rows.append(row) - with pytest.raises(CancelledError): + with pytest.raises(RuntimeError): res.metadata() + test_env.assert_streaming_response_state(res) @pytest.mark.parametrize('query_type', [SyncQueryType.NORMAL, SyncQueryType.LAZY, SyncQueryType.CANCELLABLE]) def test_cancel_while_iterating(self, @@ -278,7 +276,10 @@ def test_cancel_while_iterating(self, result = res.result() assert isinstance(result, BlockingQueryResult) - expected_state = StreamingState.StreamingResults if query_type != SyncQueryType.LAZY else StreamingState.NotStarted + if query_type != SyncQueryType.LAZY: + expected_state = StreamingState.StreamingResults + else: + expected_state = StreamingState.NotStarted assert result._http_response._request_context.request_state == expected_state rows = [] count = 0 @@ -292,8 +293,9 @@ def test_cancel_while_iterating(self, assert len(rows) == count expected_state = StreamingState.Cancelled assert result._http_response._request_context.request_state == expected_state - with pytest.raises(CancelledError): + with pytest.raises(RuntimeError): result.metadata() + test_env.assert_streaming_response_state(result) def test_query_cannot_set_both_cancel_and_lazy_execution(self, test_env: BlockingTestEnvironment) -> None: statement = 'SELECT 1=1' @@ -332,6 +334,7 @@ def test_query_metadata(self, assert metrics.processed_objects() > 0 assert metrics.elapsed_time() > timedelta(0) assert metrics.execution_time() > timedelta(0) + test_env.assert_streaming_response_state(result) @pytest.mark.parametrize('query_type', [SyncQueryType.NORMAL, SyncQueryType.LAZY, SyncQueryType.CANCELLABLE]) def test_query_metadata_not_available(self, @@ -370,6 +373,7 @@ def test_query_metadata_not_available(self, metadata = result.metadata() assert len(metadata.warnings()) == 0 assert len(metadata.request_id()) > 0 + test_env.assert_streaming_response_state(result) @pytest.mark.parametrize('query_type', [SyncQueryType.NORMAL, SyncQueryType.LAZY, SyncQueryType.CANCELLABLE]) def test_query_named_parameters(self, @@ -392,6 +396,7 @@ def test_query_named_parameters(self, assert isinstance(res, Future) result = res.result() test_env.assert_rows(result, 2) + test_env.assert_streaming_response_state(result) @pytest.mark.parametrize('query_type', [SyncQueryType.NORMAL, SyncQueryType.LAZY, SyncQueryType.CANCELLABLE]) def test_query_named_parameters_no_options(self, @@ -414,6 +419,7 @@ def test_query_named_parameters_no_options(self, assert isinstance(res, Future) result = res.result() test_env.assert_rows(result, 2) + test_env.assert_streaming_response_state(result) @pytest.mark.parametrize('query_type', [SyncQueryType.NORMAL, SyncQueryType.LAZY, SyncQueryType.CANCELLABLE]) def test_query_named_parameters_override(self, @@ -438,6 +444,7 @@ def test_query_named_parameters_override(self, assert isinstance(res, Future) result = res.result() test_env.assert_rows(result, 2) + test_env.assert_streaming_response_state(result) @pytest.mark.parametrize('query_type', [SyncQueryType.NORMAL, SyncQueryType.LAZY, SyncQueryType.CANCELLABLE]) def test_query_passthrough_deserializer(self, @@ -463,6 +470,7 @@ def test_query_passthrough_deserializer(self, for idx, row in enumerate(result.rows()): assert isinstance(row, bytes) assert json.loads(row) == {'num': idx} + test_env.assert_streaming_response_state(result) @pytest.mark.parametrize('query_type', [SyncQueryType.NORMAL, SyncQueryType.LAZY, SyncQueryType.CANCELLABLE]) def test_query_positional_params(self, @@ -483,6 +491,7 @@ def test_query_positional_params(self, assert isinstance(res, Future) result = res.result() test_env.assert_rows(result, 2) + test_env.assert_streaming_response_state(result) @pytest.mark.parametrize('query_type', [SyncQueryType.NORMAL, SyncQueryType.LAZY, SyncQueryType.CANCELLABLE]) def test_query_positional_params_no_option(self, @@ -505,6 +514,7 @@ def test_query_positional_params_no_option(self, assert isinstance(res, Future) result = res.result() test_env.assert_rows(result, 2) + test_env.assert_streaming_response_state(result) @pytest.mark.parametrize('query_type', [SyncQueryType.NORMAL, SyncQueryType.LAZY, SyncQueryType.CANCELLABLE]) def test_query_positional_params_override(self, @@ -529,6 +539,7 @@ def test_query_positional_params_override(self, assert isinstance(res, Future) result = res.result() test_env.assert_rows(result, 2) + test_env.assert_streaming_response_state(result) # We test lazy execution in a separate test @pytest.mark.parametrize('query_type', [SyncQueryType.NORMAL, SyncQueryType.CANCELLABLE]) @@ -536,7 +547,6 @@ def test_query_raises_exception_prior_to_iterating(self, test_env: BlockingTestEnvironment, query_type: SyncQueryType) -> None: statement = "I'm not N1QL!" - if query_type == SyncQueryType.NORMAL: with pytest.raises(QueryError): test_env.cluster_or_scope.execute_query(statement) @@ -594,6 +604,7 @@ def test_query_raw_options(self, assert isinstance(res, Future) result = res.result() test_env.assert_rows(result, 2) + test_env.assert_streaming_response_state(result) @pytest.mark.parametrize('query_type', [SyncQueryType.NORMAL, SyncQueryType.LAZY, SyncQueryType.CANCELLABLE]) def test_query_timeout(self, test_env: BlockingTestEnvironment, query_type: SyncQueryType) -> None: @@ -638,6 +649,7 @@ def test_query_timeout_while_streaming(self, test_env: BlockingTestEnvironment, with pytest.raises(TimeoutError): for _ in result.rows(): pass + test_env.assert_streaming_response_state(result) @pytest.mark.parametrize('query_type', [SyncQueryType.NORMAL, SyncQueryType.LAZY, SyncQueryType.CANCELLABLE]) @@ -655,6 +667,7 @@ def test_simple_query(self, assert isinstance(res, Future) result = res.result() test_env.assert_rows(result, 2) + test_env.assert_streaming_response_state(result) def test_query_with_lazy_execution(self, test_env: BlockingTestEnvironment, @@ -670,6 +683,7 @@ def test_query_with_lazy_execution(self, assert row is not None count += 1 assert count == 2 + test_env.assert_streaming_response_state(result) def test_query_with_lazy_execution_raises_exception(self, test_env: BlockingTestEnvironment) -> None: statement = "I'm not N1QL!" @@ -677,7 +691,8 @@ def test_query_with_lazy_execution_raises_exception(self, test_env: BlockingTest expected_state = StreamingState.NotStarted assert result._http_response._request_context.request_state == expected_state with pytest.raises(QueryError): - [r for r in result.rows()] + list(result.rows()) + test_env.assert_streaming_response_state(result) class ClusterQueryTests(QueryTestSuite): diff --git a/couchbase_analytics/tests/query_options_t.py b/couchbase_analytics/tests/query_options_t.py index cf436e2..be272c3 100644 --- a/couchbase_analytics/tests/query_options_t.py +++ b/couchbase_analytics/tests/query_options_t.py @@ -17,20 +17,16 @@ from dataclasses import dataclass from datetime import timedelta -from typing import (Any, - Dict, - List, - Optional, - Union) +from typing import Any, Dict, List, Optional, Union import pytest from couchbase_analytics import JSONType from couchbase_analytics.credential import Credential from couchbase_analytics.options import QueryOptions, QueryOptionsKwargs -from couchbase_analytics.protocol.core.client_adapter import _ClientAdapter +from couchbase_analytics.protocol._core.client_adapter import _ClientAdapter +from couchbase_analytics.protocol._core.request import _RequestBuilder from couchbase_analytics.protocol.options import QueryOptionsTransformedKwargs -from couchbase_analytics.protocol.core.request import _RequestBuilder @dataclass diff --git a/couchbase_analytics/tests/test_server_t.py b/couchbase_analytics/tests/test_server_t.py index 9649e06..ca6c9c1 100644 --- a/couchbase_analytics/tests/test_server_t.py +++ b/couchbase_analytics/tests/test_server_t.py @@ -15,13 +15,17 @@ from __future__ import annotations -import json from concurrent.futures import Future +from datetime import timedelta from typing import TYPE_CHECKING import pytest -from tests import YieldFixture +from couchbase_analytics.errors import InvalidCredentialError, QueryError, TimeoutError +from couchbase_analytics.options import QueryOptions +from couchbase_analytics.result import BlockingQueryResult +from tests import SyncQueryType, YieldFixture +from tests.test_server import ErrorType, NonRetriableSpecificationType, ResultType, RetriableGroupType if TYPE_CHECKING: from tests.environments.base_environment import BlockingTestEnvironment @@ -30,30 +34,146 @@ class TestServerTestSuite: TEST_MANIFEST = [ - 'test_simple', + 'test_auth_error_unauthorized', + 'test_auth_error_insufficient_permissions', + 'test_error_non_retriable_response', + 'test_error_retriable_response', + 'test_error_timeout', + 'test_results_object_values', + 'test_results_raw_values' ] - def test_simple(self, test_env: BlockingTestEnvironment) -> None: - test_env.set_url_path('/test_post') - test_env.update_request_json({'test_timeout': 10}) - test_env.update_request_extensions({'timeout': {'pool': 5, - 'test_pool_timeout': 5, - 'test_connect_timeout': 5}}) + def test_auth_error_unauthorized(self, test_env: BlockingTestEnvironment) -> None: + test_env.set_url_path('/test_error') + test_env.update_request_json({'error_type': ErrorType.Unauthorized.value}) statement = 'SELECT "Hello, data!" AS greeting' - res = test_env.cluster.execute_query(statement) - print(f'Have result: {res=}') - if isinstance(res, Future): - print('Result is a Future') - res = res.result() + with pytest.raises(InvalidCredentialError) as ex: + test_env.cluster_or_scope.execute_query(statement) + test_env.assert_error_context_num_attempts(1, ex.value._context) + test_env.assert_error_context_contains_last_dispatch(ex.value._context) + + def test_auth_error_insufficient_permissions(self, test_env: BlockingTestEnvironment) -> None: + test_env.set_url_path('/test_error') + test_env.update_request_json({'error_type': ErrorType.InsufficientPermissions.value}) + statement = 'SELECT "Hello, data!" AS greeting' + with pytest.raises(QueryError) as ex: + test_env.cluster_or_scope.execute_query(statement) + assert ex.value.code == 20001 + assert 'Insufficient permissions' in ex.value.server_message + test_env.assert_error_context_num_attempts(1, ex.value._context) + test_env.assert_error_context_contains_last_dispatch(ex.value._context) + + @pytest.mark.parametrize('retry_group_type', + [RetriableGroupType.Zero, + RetriableGroupType.First, + RetriableGroupType.Middle, + RetriableGroupType.Last]) + @pytest.mark.parametrize('non_retriable_spec', + [NonRetriableSpecificationType.AllEmpty, + NonRetriableSpecificationType.AllFalse, + NonRetriableSpecificationType.Random]) + def test_error_non_retriable_response(self, + test_env: BlockingTestEnvironment, + retry_group_type: RetriableGroupType, + non_retriable_spec: NonRetriableSpecificationType) -> None: + test_env.set_url_path('/test_error') + test_env.update_request_json({'error_type': ErrorType.Retriable.value, + 'retry_group_type': retry_group_type.value, + 'non_retriable_spec': non_retriable_spec.value}) + statement = 'SELECT "Hello, data!" AS greeting' + with pytest.raises(QueryError) as ex: + test_env.cluster_or_scope.execute_query(statement) + test_env.assert_error_context_num_attempts(1, ex.value._context) + test_env.assert_error_context_contains_last_dispatch(ex.value._context) + + def test_error_retriable_response(self, test_env: BlockingTestEnvironment) -> None: + test_env.set_url_path('/test_error') + test_env.update_request_json({'error_type': ErrorType.Retriable.value, + 'retry_group_type': RetriableGroupType.All.value}) + statement = 'SELECT "Hello, data!" AS greeting' + with pytest.raises(TimeoutError) as ex: + test_env.cluster_or_scope.execute_query(statement, QueryOptions(timeout=timedelta(seconds=2))) + + test_env.assert_error_context_num_attempts(4 , ex.value._context, exact=False) + test_env.assert_error_context_contains_last_dispatch(ex.value._context) + + @pytest.mark.parametrize('server_side', [False, True]) + def test_error_timeout(self, test_env: BlockingTestEnvironment, server_side: bool) -> None: + test_env.set_url_path('/test_error') + if server_side: + req_json = {'error_type': ErrorType.Timeout.value, 'timeout': 1, 'server_side': True} + else: + req_json = {'error_type': ErrorType.Timeout.value, 'timeout': 2} + + test_env.update_request_json(req_json) + statement = 'SELECT "Hello, data!" AS greeting' + with pytest.raises(TimeoutError) as ex: + test_env.cluster_or_scope.execute_query(statement, QueryOptions(timeout=timedelta(seconds=2))) + test_env.assert_error_context_num_attempts(1, ex.value._context) + if server_side: + test_env.assert_error_context_contains_last_dispatch(ex.value._context) + else: + test_env.assert_error_context_missing_last_dispatch(ex.value._context) + + @pytest.mark.parametrize('stream', [False, True]) + @pytest.mark.parametrize('query_type', [SyncQueryType.NORMAL, SyncQueryType.LAZY, SyncQueryType.CANCELLABLE]) + def test_results_object_values(self, + test_env: BlockingTestEnvironment, + query_type: SyncQueryType, + stream: bool) -> None: + expected_rows = 50 + test_env.set_url_path('/test_results') + test_env.update_request_json({'result_type': ResultType.Object.value, + 'row_count': expected_rows, + 'stream': stream}) + statement = 'SELECT "Hello, data!" AS greeting' + if query_type == SyncQueryType.NORMAL: + result = test_env.cluster_or_scope.execute_query(statement) + elif query_type == SyncQueryType.LAZY: + result = test_env.cluster_or_scope.execute_query(statement, + QueryOptions(lazy_execute=True)) + else: + res = test_env.cluster_or_scope.execute_query(statement, enable_cancel=True) + assert isinstance(res, Future) + result = res.result() + + assert isinstance(result, BlockingQueryResult) + test_env.assert_rows(result, expected_rows) + + @pytest.mark.parametrize('stream', [False, True]) + @pytest.mark.parametrize('query_type', [SyncQueryType.NORMAL, SyncQueryType.LAZY, SyncQueryType.CANCELLABLE]) + def test_results_raw_values(self, + test_env: BlockingTestEnvironment, + query_type: SyncQueryType, + stream: bool) -> None: + expected_rows = 50 + test_env.set_url_path('/test_results') + test_env.update_request_json({'result_type': ResultType.Raw.value, + 'row_count': expected_rows, + 'stream': stream}) + statement = 'SELECT "Hello, data!" AS greeting' + if query_type == SyncQueryType.NORMAL: + result = test_env.cluster_or_scope.execute_query(statement) + elif query_type == SyncQueryType.LAZY: + result = test_env.cluster_or_scope.execute_query(statement, + QueryOptions(lazy_execute=True)) + else: + res = test_env.cluster_or_scope.execute_query(statement, enable_cancel=True) + assert isinstance(res, Future) + result = res.result() + + assert isinstance(result, BlockingQueryResult) + test_env.assert_rows(result, expected_rows) -class TestServerTests(TestServerTestSuite): + +class ClusterTestServerTests(TestServerTestSuite): @pytest.fixture(scope='class', autouse=True) def validate_test_manifest(self) -> None: def valid_test_method(meth: str) -> bool: - attr = getattr(TestServerTests, meth) + attr = getattr(ClusterTestServerTests, meth) return callable(attr) and not meth.startswith('__') and meth.startswith('test') - method_list = [meth for meth in dir(TestServerTests) if valid_test_method(meth)] + method_list = [meth for meth in dir(ClusterTestServerTests) if valid_test_method(meth)] test_list = set(TestServerTestSuite.TEST_MANIFEST).symmetric_difference(method_list) if test_list: pytest.fail(f'Test manifest invalid. Missing/extra tests: {test_list}.') @@ -62,4 +182,25 @@ def valid_test_method(meth: str) -> bool: def couchbase_test_environment(self, sync_test_env_with_server: BlockingTestEnvironment) -> YieldFixture[BlockingTestEnvironment]: test_env = sync_test_env_with_server.enable_test_server() yield test_env - test_env.disable_test_server() \ No newline at end of file + test_env.disable_test_server() + +class ScopeTestServerTests(TestServerTestSuite): + + @pytest.fixture(scope='class', autouse=True) + def validate_test_manifest(self) -> None: + def valid_test_method(meth: str) -> bool: + attr = getattr(ScopeTestServerTests, meth) + return callable(attr) and not meth.startswith('__') and meth.startswith('test') + method_list = [meth for meth in dir(ScopeTestServerTests) if valid_test_method(meth)] + test_list = set(TestServerTestSuite.TEST_MANIFEST).symmetric_difference(method_list) + if test_list: + pytest.fail(f'Test manifest invalid. Missing/extra tests: {test_list}.') + + @pytest.fixture(scope='class', name='test_env') + def couchbase_test_environment(self, + sync_test_env_with_server: BlockingTestEnvironment + ) -> YieldFixture[BlockingTestEnvironment]: + test_env = sync_test_env_with_server.enable_test_server() + test_env.enable_scope() + yield test_env + test_env.disable_scope().disable_test_server() \ No newline at end of file diff --git a/couchbase_analytics_version.py b/couchbase_analytics_version.py index 55e9245..ef0ff57 100644 --- a/couchbase_analytics_version.py +++ b/couchbase_analytics_version.py @@ -136,7 +136,7 @@ def get_git_describe() -> str: ("git", "describe", "--tags", "--long", "--always"), stdout=subprocess.PIPE, stderr=subprocess.PIPE) except OSError as e: - raise CantInvokeGit(e) + raise CantInvokeGit(e) from None stdout, stderr = po.communicate() if po.returncode != 0: @@ -166,7 +166,7 @@ def gen_version(do_write: Optional[bool] = True, txt: Optional[str] = None) -> N info = VersionInfo(txt) vstr = info.package_version except MalformedGitTag: - warnings.warn("Malformed input '{0}'".format(txt)) + warnings.warn("Malformed input '{0}'".format(txt), stacklevel=2) vstr = '0.0.0' + txt if not do_write: diff --git a/pyproject.toml b/pyproject.toml index 8f8867b..80c74b6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,6 +7,7 @@ build-backend = "setuptools.build_meta" [project] name = "couchbase-analytics" +version = "0.0.1" dependencies = [ "anyio~=4.9.0", "httpx~=0.28.1", @@ -53,7 +54,9 @@ Repository = "https://github.com/couchbase/analytics-python-client" [dependency-groups] dev = [ "aiohttp~=3.11.10", - "pytest~=8.3.5" + "mypy>=1.16.1", + "pytest~=8.3.5", + "ruff>=0.12.0", ] sphinx = [ "Sphinx~=7.4.7", @@ -66,7 +69,6 @@ sphinx = [ [tool.pytest.ini_options] minversion = "8.0" log_cli = true -#addopts = "-ra -q" testpaths = [ "tests", "acouchbase_analytics/tests", @@ -83,4 +85,15 @@ markers = [ "pycbac_acouchbase: marks a test for the acouchbase API (deselect with '-m \"not pycbac_acouchbase\"')", "pycbac_unit: marks a test as a unit test", "pycbac_integration: marks a test as an integration test", -] \ No newline at end of file +] + +[tool.ruff] +line-length = 120 +extend-exclude = ["tests/test_config.ini", "test*.py", "*_tests.py"] + +[tool.ruff.lint] +select = ["E", "F", "B", "C", "I"] + +[tool.ruff.format] +quote-style = "single" +docstring-code-format = false diff --git a/tests/__init__.py b/tests/__init__.py index 14e7d51..d3f974a 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -13,16 +13,19 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import (AsyncGenerator, - Generator, - Optional, - TypeVar) +from enum import Enum +from typing import AsyncGenerator, Generator, Optional, TypeVar T = TypeVar('T') AsyncYieldFixture = AsyncGenerator[T, None] YieldFixture = Generator[T, None, None] +class SyncQueryType(Enum): + NORMAL = 'normal' + LAZY = 'lazy' + CANCELLABLE = 'cancellable' + class AnalyticsTestEnvironmentError(Exception): """Raised when something with the test environment is incorrect.""" diff --git a/tests/environments/__init__.py b/tests/environments/__init__.py index e69de29..b38b903 100644 --- a/tests/environments/__init__.py +++ b/tests/environments/__init__.py @@ -0,0 +1,15 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + diff --git a/tests/environments/base_environment.py b/tests/environments/base_environment.py index 1058166..0907464 100644 --- a/tests/environments/base_environment.py +++ b/tests/environments/base_environment.py @@ -18,21 +18,15 @@ import json import pathlib import sys - from os import path -from typing import (TYPE_CHECKING, - Any, - Dict, - List, - Optional, - TypedDict, - Union) +from typing import TYPE_CHECKING, Any, Dict, List, Optional, TypedDict, Union if sys.version_info < (3, 11): from typing_extensions import Unpack else: - from typing import Unpack + from typing import Unpack +import anyio import pytest from acouchbase_analytics.cluster import AsyncCluster @@ -43,11 +37,9 @@ from couchbase_analytics.options import ClusterOptions, SecurityOptions from couchbase_analytics.result import BlockingQueryResult from couchbase_analytics.scope import Scope - from tests import AnalyticsTestEnvironmentError from tests.utils._run_web_server import WebServerHandler - if TYPE_CHECKING: from tests.analytics_config import AnalyticsConfig @@ -95,6 +87,42 @@ def collection_name(self) -> Optional[str]: def use_scope(self) -> bool: return self._use_scope + def assert_error_context_num_attempts(self, + expected_attempts: int, + context: Optional[str], + exact: Optional[bool]=True) -> None: + assert isinstance(context, str) + ctx_keys = context.replace('{', '').replace('}', '').split(',') + assert len(ctx_keys) > 1 + match = next((k for k in ctx_keys if 'num_attempts' in k), None) + assert match is not None + match_keys = match.split() + assert len(match_keys) == 2 + if exact is True: + assert int(match_keys[1].replace("'", "").replace('"', '')) == expected_attempts + else: + assert int(match_keys[1].replace("'", "").replace('"', '')) >= expected_attempts + + def assert_error_context_contains_last_dispatch(self, context: Optional[str]) -> None: + assert isinstance(context, str) + ctx_keys = context.replace('{', '').replace('}', '').split(',') + assert len(ctx_keys) > 1 + match = next((k for k in ctx_keys if 'last_dispatched_to' in k), None) + assert match is not None + match = next((k for k in ctx_keys if 'last_dispatched_from' in k), None) + assert match is not None + + def assert_error_context_missing_last_dispatch(self, context: Optional[str]=None) -> None: + if context is None: + return + assert isinstance(context, str) + ctx_keys = context.replace('{', '').replace('}', '').split(',') + assert len(ctx_keys) > 1 + match = next((k for k in ctx_keys if 'last_dispatched_to' in k), None) + assert match is None + match = next((k for k in ctx_keys if 'last_dispatched_from' in k), None) + assert match is None + def load_collection_data_from_file(self, file_path: str, limit: Optional[int] = 100) -> List[Dict[str, Any]]: with open(file_path, mode='+r') as json_file: json_data: List[Dict[str, Any]] = json.load(json_file) @@ -133,6 +161,10 @@ def assert_rows(self, result: BlockingQueryResult, expected_count: int) -> None: count += 1 assert count >= expected_count + def assert_streaming_response_state(self, result: BlockingQueryResult) -> None: + assert hasattr(result._http_response, '_core_response') is False + assert result._http_response._request_context.is_shutdown is True + def disable_scope(self) -> BlockingTestEnvironment: self._scope = None self._use_scope = False @@ -141,7 +173,7 @@ def disable_scope(self) -> BlockingTestEnvironment: def disable_test_server(self) -> BlockingTestEnvironment: if self._server_handler is not None: self._server_handler.stop_server() - self._server_handler = None + # self._server_handler = None return self def enable_scope(self, @@ -167,12 +199,13 @@ def enable_test_server(self) -> BlockingTestEnvironment: raise AnalyticsTestEnvironmentError('No cluster available, cannot enable test server.') from tests.utils._client_adapter import _TestClientAdapter from tests.utils._test_httpx import TestHTTPTransport + print(f'{self._cluster=}') new_adapter = _TestClientAdapter(adapter=self._cluster._impl._client_adapter, # type: ignore[call-arg] http_transport_cls=TestHTTPTransport) new_adapter.create_client() self._cluster._impl._client_adapter = new_adapter - scheme, host, port = self._cluster._impl.client_adapter.connection_details.get_scheme_host_and_port() - print(f"Connecting to test server at {scheme}://{host}:{port}") + url = self._cluster._impl.client_adapter.connection_details.url.get_formatted_url() + print(f'Connecting to test server at {url}') self._server_handler.start_server() return self @@ -266,7 +299,7 @@ def get_environment(cls, cred = Credential.from_username_and_password(username, pw) sec_opts: Optional[SecurityOptions] = None if config.nonprod is True: - from couchbase_analytics.common.core._certificates import _Certificates + from couchbase_analytics.common._core._certificates import _Certificates sec_opts = SecurityOptions.trust_only_certificates(_Certificates.get_nonprod_certificates()) if config.disable_server_certificate_verification is True: @@ -319,6 +352,10 @@ async def assert_rows(self, result: AsyncQueryResult, expected_count: int) -> No count += 1 assert count >= expected_count + def assert_streaming_response_state(self, result: AsyncQueryResult) -> None: + assert hasattr(result._http_response, '_core_response') is False + assert result._http_response._request_context.is_shutdown is True + def disable_scope(self) -> AsyncTestEnvironment: self._async_scope = None self._use_scope = False @@ -327,7 +364,6 @@ def disable_scope(self) -> AsyncTestEnvironment: def disable_test_server(self) -> AsyncTestEnvironment: if self._server_handler is not None: self._server_handler.stop_server() - self._server_handler = None return self def enable_scope(self, @@ -359,8 +395,8 @@ async def enable_test_server(self) -> AsyncTestEnvironment: http_transport_cls=TestAsyncHTTPTransport) await new_adapter.create_client() self._async_cluster._impl._client_adapter = new_adapter - scheme, host, port = self._async_cluster._impl.client_adapter.connection_details.get_scheme_host_and_port() - print(f"Connecting to test server at {scheme}://{host}:{port}") + url = self._async_cluster._impl.client_adapter.connection_details.url.get_formatted_url() + print(f'Connecting to test server at {url}') self._server_handler.start_server() return self @@ -405,6 +441,9 @@ def set_url_path(self, url_path: str) -> None: raise AnalyticsTestEnvironmentError('No cluster available, cannot enable test server.') self._async_cluster._impl._client_adapter.set_request_path(url_path) + async def sleep(self, delay: float) -> None: + await anyio.sleep(delay) + async def teardown(self) -> None: if self.config.create_keyspace is False: return @@ -457,7 +496,7 @@ def get_environment(cls, cred = Credential.from_username_and_password(username, pw) sec_opts: Optional[SecurityOptions] = None if config.nonprod is True: - from couchbase_analytics.common.core._certificates import _Certificates + from couchbase_analytics.common._core._certificates import _Certificates sec_opts = SecurityOptions.trust_only_certificates(_Certificates.get_nonprod_certificates()) if config.disable_server_certificate_verification is True: diff --git a/tests/environments/simple_environment.py b/tests/environments/simple_environment.py index 3116f6f..d4449dd 100644 --- a/tests/environments/simple_environment.py +++ b/tests/environments/simple_environment.py @@ -4,9 +4,11 @@ import pytest + class JsonDataType(Enum): SIMPLE_REQUEST = 'simple_request' MULTIPLE_RESULTS = 'multiple_results' + MULTIPLE_RESULTS_RAW = 'multiple_results_raw' FAILED_REQUEST = 'failed_request' FAILED_REQUEST_MULTI_ERRORS = 'failed_request_multi_errors' FAILED_REQUEST_MID_STREAM = 'failed_request_mid_stream' @@ -91,6 +93,37 @@ class JsonDataType(Enum): "processedObjects": 2, "bufferCacheHitRatio": "100.00%" } +}""".strip(), +'multiple_results_raw':""" +{ + "requestID": "94c7f89f-92b6-4aba-a90d-be715ca47309", + "signature": { + "*": "*" + }, + "results": [ + "airline_19433", + "airline_137", + "airline_18239", + "airline_10123", + "airline_19290", + "airline_19774", + "airline_4738", + "airline_4816", + "airline_18178", + "airline_10226" + ], + "plans": {}, + "status": "success", + "metrics": { + "elapsedTime": "14.927542ms", + "executionTime": "12.875792ms", + "compileTime": "4.178042ms", + "queueWaitTime": "0ns", + "resultCount": 2, + "resultSize": 300, + "processedObjects": 2, + "bufferCacheHitRatio": "100.00%" + } }""".strip(), 'failed_request':""" { diff --git a/tests/test_config.ini b/tests/test_config.ini index b7ceda2..d65e657 100644 --- a/tests/test_config.ini +++ b/tests/test_config.ini @@ -1,6 +1,6 @@ [analytics] scheme = http -host = 1d870384-20250618.cb-sdk.bemdas.com +host = e19f1e4e-20250626.cb-sdk.bemdas.com port = 8095 username = Administrator password = password diff --git a/tests/test_server/__init__.py b/tests/test_server/__init__.py new file mode 100644 index 0000000..0071373 --- /dev/null +++ b/tests/test_server/__init__.py @@ -0,0 +1,73 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from enum import Enum + + +class ErrorType(Enum): + Timeout = 'timeout' + Unauthorized = 'unauthorized' + InsufficientPermissions = 'insufficient_permissions' + Retriable = 'retriable' + + @staticmethod + def from_str(error_type: str) -> ErrorType: + match = next((t for t in ErrorType if t.value == error_type), None) + if not match: + raise ValueError(f'Invalid error type: {error_type}. ' + f'Valid options are: {[e.value for e in ErrorType]}') + return match + +class ResultType(Enum): + Object = 'object' + Raw = 'raw' + + @staticmethod + def from_str(result_type: str) -> ResultType: + match = next((t for t in ResultType if t.value == result_type), None) + if not match: + raise ValueError(f'Invalid result type: {result_type}. ' + f'Valid options are: {[e.value for e in ResultType]}') + return match + +class RetriableGroupType(Enum): + All = 'all' + Zero = 'zero' + First = 'first' + Middle = 'middle' + Last = 'last' + + @staticmethod + def from_str(rgt: str) -> RetriableGroupType: + match = next((t for t in RetriableGroupType if t.value == rgt), None) + if not match: + raise ValueError(f'Invalid retriable group type: {rgt}. ' + f'Valid options are: {[e.value for e in RetriableGroupType]}') + return match + +class NonRetriableSpecificationType(Enum): + AllEmpty = 'all_empty' + AllFalse = 'all_false' + Random = 'random' + + @staticmethod + def from_str(nrst: str) -> NonRetriableSpecificationType: + match = next((t for t in NonRetriableSpecificationType if t.value == nrst), None) + if not match: + raise ValueError(f'Invalid non-retriable specification type: {nrst}. ' + f'Valid options are: {[e.value for e in NonRetriableSpecificationType]}') + return match \ No newline at end of file diff --git a/tests/test_server/request.py b/tests/test_server/request.py new file mode 100644 index 0000000..d2fcb62 --- /dev/null +++ b/tests/test_server/request.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import (Any, + Dict, + Optional) + +from tests.test_server import ErrorType, NonRetriableSpecificationType, ResultType, RetriableGroupType + + +@dataclass +class ServerErrorRequest: + error_type: ErrorType + retry_group_type: Optional[RetriableGroupType] = None + non_retriable_spec: Optional[NonRetriableSpecificationType] = None + error_count: Optional[int] = None + + @classmethod + def from_json(cls, json_data: Dict[str, Any]) -> ServerErrorRequest: + error_type = json_data.get('error_type', None) + if error_type is None: + raise ValueError('Missing "error_type" in JSON data.') + err_type = ErrorType.from_str(error_type) + retry_grp = json_data.get('retry_group_type', None) + rgt = None + if retry_grp is not None: + rgt = RetriableGroupType.from_str(retry_grp) + non_retry_spec = json_data.get('non_retriable_spec', None) + nrst = None + if non_retry_spec is not None: + nrst = NonRetriableSpecificationType.from_str(non_retry_spec) + return cls(error_type=err_type, + retry_group_type=rgt, + non_retriable_spec=nrst, + error_count=json_data.get('error_count', None)) + +@dataclass +class ServerResultsRequest: + result_type: ResultType + row_count: Optional[int] = None + chunk_size: Optional[int] = None + stream: Optional[bool] = False + until: Optional[float] = None + + @classmethod + def from_json(cls, json_data: Dict[str, Any]) -> ServerResultsRequest: + + until_raw = json_data.get('until', None) + if until_raw is not None and not isinstance(until_raw, (float, int)): + raise ValueError(f'Invalid "until" value: {until_raw}. Must be a number.') + until = float(until_raw) if until_raw is not None else None + + row_count = json_data.get('row_count', None) + if row_count is None and until is None: + raise ValueError('Missing "row_count" in JSON data.') + if until is None and not isinstance(row_count, int): + raise ValueError(f'Invalid "row_count" value: {row_count}. Must be an integer.') + + rtype = json_data.get('result_type', None) + result_type = ResultType.from_str(rtype) if rtype is not None else ResultType.Object + + chunk_raw = json_data.get('chunk_size', None) + if chunk_raw is not None and not isinstance(chunk_raw, int): + raise ValueError(f'Invalid "chunk_size" value: {chunk_raw}. Must be an integer.') + chunk_size = int(chunk_raw) if chunk_raw is not None else None + + return cls(result_type=result_type, + row_count=row_count, + chunk_size=chunk_size, + stream=json_data.get('stream', False), + until=until) + +@dataclass +class ServerSlowResultsRequest: + row_count: int + result_type: Optional[ResultType] = ResultType.Object + chunk_size: Optional[int] = None + stream: Optional[bool] = False + until: Optional[float] = None + +@dataclass +class ServerTimeoutRequest: + error_type: ErrorType + timeout: float + server_side: Optional[bool] = False + + @classmethod + def from_json(cls, json_data: Dict[str, Any]) -> ServerTimeoutRequest: + timeout = json_data.get('timeout', None) + if timeout is None: + raise ValueError('Missing "timeout" in JSON data.') + if not isinstance(timeout, (int, float)): + raise ValueError(f'Invalid "timeout" value: {timeout}. Must be a number.') + return cls(error_type=ErrorType.Timeout, + timeout=float(timeout), + server_side=json_data.get('server_side', False)) \ No newline at end of file diff --git a/tests/test_server/response.py b/tests/test_server/response.py new file mode 100644 index 0000000..37b946c --- /dev/null +++ b/tests/test_server/response.py @@ -0,0 +1,340 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from random import choice +from typing import Any, Callable, Dict, Generator, List, Optional, Union +from uuid import uuid4 + +from tests.test_server import ErrorType, NonRetriableSpecificationType, ResultType, RetriableGroupType + +US_CITIES = [ + "New York City", + "Los Angeles", + "Chicago", + "Houston", + "Phoenix", + "Philadelphia", + "San Antonio", + "San Diego", + "Dallas", + "San Jose", + "Austin", + "Jacksonville", + "Fort Worth", + "Columbus", + "Charlotte", + "Indianapolis", + "San Francisco", + "Seattle", + "Denver", + "Washington, D.C.", + "Boston", + "El Paso", + "Nashville", + "Detroit", + "Oklahoma City", + "Portland", + "Las Vegas", + "Memphis", + "Louisville", + "Baltimore", + "Milwaukee", + "Albuquerque", + "Tucson", + "Fresno", + "Sacramento", + "Mesa", + "Atlanta", + "Kansas City", + "Colorado Springs", + "Raleigh", + "Omaha", + "Miami", + "Long Beach", + "Virginia Beach", + "Oakland", + "Minneapolis", + "Tampa", + "New Orleans", + "Cleveland", + "Orlando" +] + +NAMES = [ + "Alice Smith", + "Bob Johnson", + "Catherine Davis", + "David Miller", + "Emily Wilson", + "Frank Moore", + "Grace Taylor", + "Henry Anderson", + "Ivy Thomas", + "Jack Jackson", + "Karen White", + "Leo Harris", + "Mia Martin", + "Noah Garcia", + "Olivia Rodriguez", + "Peter Martinez", + "Quinn Clark", + "Rachel Lewis", + "Sam Lee", + "Tina Hall", + "Uma Young", + "Victor King", + "Wendy Wright", + "Xavier Lopez", + "Yara Hill", + "Zack Scott", + "Amelia Green", + "Ben Baker", + "Chloe Adams", + "Daniel Nelson", + "Ella Carter", + "Finn Mitchell", + "Georgia Perez", + "Harry Roberts", + "Isla Turner", + "James Phillips", + "Kim Campbell", + "Liam Parker", + "Maya Evans", + "Nathan Edwards", + "Owen Collins", + "Penelope Stewart", + "Ryan Sanchez", + "Sophia Morris", + "Thomas Rogers", + "Victoria Reed", + "William Cook", + "Zara Bell", + "Ethan Murphy", + "Lily Russell" +] + +@dataclass +class ServerResponseMetrics: + elapsed_time: float + execution_time: float + compile_time: float + queue_wait_time: float + result_count: int + result_size: int + processed_objects: int + buffer_cache_hit_ratio: float + buffer_cache_page_read_count: int + error_count: int + + def to_json_repr(self) -> Dict[str, Union[str, int]]: + return { + 'elapsedTime': f'{self.elapsed_time:.2f}s', + 'executionTime': f'{self.execution_time:.2f}s', + 'compileTime': f'{self.compile_time}ns', + 'queueWaitTime': f'{self.queue_wait_time}ns', + 'resultCount': self.result_count, + 'resultSize': self.result_size, + 'processedObjects': self.processed_objects, + 'bufferCacheHitRatio': f'{self.buffer_cache_hit_ratio}%', + 'bufferCachePageReadCount': self.buffer_cache_page_read_count, + 'errorCount': self.error_count + } + + @staticmethod + def create() -> ServerResponseMetrics: + return ServerResponseMetrics(0.0, 0.0, 0.0, 0.0, 0, 0, 0, 0.0, 0, 0) + +@dataclass +class ServerResponseError: + code: int + msg: str + retriable: Optional[bool] = None + + def to_json_repr(self) -> Dict[str, Union[str, int, bool]]: + output: Dict[str, Union[str, int, bool]] = { + 'code': self.code, + 'msg': self.msg + } + if self.retriable is not None: + output['retriable'] = self.retriable + return output + + @staticmethod + def _build_retry_group(retry_specification: NonRetriableSpecificationType, + err_count: int, + retriable_idx: Optional[int] = -1) -> List[ServerResponseError]: + errors: List[ServerResponseError] = [] + for err_idx in range(err_count): + if err_idx == retriable_idx: + errors.append(ServerResponseError(24045, 'Some unknown error occurred', True)) + elif retry_specification == NonRetriableSpecificationType.AllEmpty: + errors.append(ServerResponseError(24040, 'Some unknown error occurred')) + elif retry_specification == NonRetriableSpecificationType.AllFalse: + errors.append(ServerResponseError(24040, 'Some unknown error occurred', False)) + elif retry_specification == NonRetriableSpecificationType.Random: + if choice([0,1]): + errors.append(ServerResponseError(24040, 'Some unknown error occurred', False)) + else: + errors.append(ServerResponseError(24040, 'Some unknown error occurred')) + else: + raise RuntimeError('Unrecognized retry specification type.') + + return errors + + @staticmethod + def build_retry_group(group_type: RetriableGroupType, + retry_specification: Optional[NonRetriableSpecificationType] = None, + err_count: Optional[int] = None) -> List[ServerResponseError]: + if err_count is None: + err_count = choice([2, 3, 4, 5]) + if group_type == RetriableGroupType.All: + return [ServerResponseError(24045, + 'Some unknown retriable error occurred', + True) for _ in range(err_count)] + + if retry_specification is None: + raise RuntimeError('No non-retriable specification type provided.') + if group_type == RetriableGroupType.Zero: + return ServerResponseError._build_retry_group(retry_specification, err_count) + elif group_type == RetriableGroupType.First: + return ServerResponseError._build_retry_group(retry_specification, err_count, retriable_idx=0) + elif group_type == RetriableGroupType.Middle: + return ServerResponseError._build_retry_group(retry_specification, err_count, retriable_idx=(err_count // 2)) + elif group_type == RetriableGroupType.Last: + return ServerResponseError._build_retry_group(retry_specification, err_count, retriable_idx=err_count-1) + else: + raise RuntimeError('Unrecognized retriable group type.') + + + @staticmethod + def build_errors(resp: ServerResponse, + error_type: ErrorType, + group_type: Optional[RetriableGroupType]=None, + retry_specification: Optional[NonRetriableSpecificationType]=None, + err_count: Optional[int] = None) -> ServerResponse: + if error_type == ErrorType.Timeout: + resp.http_status = 200 + resp.status = 'timeout' + resp.metrics.error_count = 1 + resp.errors = [ServerResponseError(21002, 'Request timed out and will be cancelled.', True)] + elif error_type == ErrorType.InsufficientPermissions: + resp.http_status = 403 + resp.status = 'fatal' + resp.metrics.error_count = 1 + resp.errors = [ServerResponseError(20001, 'Insufficient permissions or the requested object does not exist')] + elif error_type == ErrorType.Unauthorized: + resp.http_status = 401 + resp.status = 'fatal' + resp.metrics.error_count = 1 + resp.errors = [ServerResponseError(20000, 'Unauthorized user.')] + elif error_type == ErrorType.Retriable: + resp.http_status = 200 + resp.status = 'fatal' + if group_type is None: + raise RuntimeError('No retry group type provided.') + if group_type != RetriableGroupType.All and retry_specification is None: + raise RuntimeError('No non-retriable specification type provided.') + resp.errors = ServerResponseError.build_retry_group(group_type, retry_specification, err_count=err_count) + resp.metrics.error_count = len(resp.errors) + else: + raise RuntimeError('Unrecognized error type.') + + return resp + + +@dataclass +class ServerResponseResults: + results: Union[List[str], List[Dict[str, Any]]] + + def to_json_repr(self) -> Union[List[str], List[Dict[str, Any]]]: + return self.results + + @staticmethod + def build_results(resp: ServerResponse, + row_count: int, + result_type: ResultType) -> None: + if result_type == ResultType.Object: + obj_results: List[Dict[str, Any]] = [] + for idx in range(row_count): + name = choice(NAMES) + city = choice(US_CITIES) + obj_results.append({'id': idx+1, 'name': name, 'city': city}) + resp.results = ServerResponseResults(obj_results) + resp.metrics.result_count = row_count + resp.metrics.result_size = row_count * 10 + elif result_type == ResultType.Raw: + resp.results = ServerResponseResults([choice(US_CITIES) for _ in range(row_count)]) + resp.metrics.result_count = row_count + resp.metrics.result_size = row_count * 10 + else: + raise RuntimeError(f'Unrecognized result type. Got type: {result_type}') + + @staticmethod + def get_result_genetaotr(result_type: ResultType) -> Callable[[], Union[Generator[bytes, None, None]]]: + if result_type == ResultType.Object: + def obj_generator() -> Generator[bytes, None, None]: + idx = 0 + while True: + name = choice(NAMES) + city = choice(US_CITIES) + yield bytes(json.dumps({'id': idx+1, 'name': name, 'city': city}), 'utf-8') + idx += 1 + return obj_generator + elif result_type == ResultType.Raw: + def raw_generator() -> Generator[bytes, None, None]: + while True: + yield bytes(choice(NAMES), 'utf-8') + return raw_generator + else: + raise RuntimeError(f'Unrecognized result type. Got type: {result_type}') + +@dataclass +class ServerResponse: + http_status: int + status: str + metrics: ServerResponseMetrics + request_id: str = field(default_factory=lambda: str(uuid4())) + signature: Optional[Dict[str, str]] = None + plans: Optional[Dict[str, Any]] = None + results: Optional[ServerResponseResults] = None + errors: Optional[List[ServerResponseError]] = None + + def to_json_repr(self) -> Dict[str, Any]: + output: Dict[str, Any] = { + 'requestID': self.request_id, + 'status': self.status, + 'metrics': self.metrics.to_json_repr(), + } + if self.signature is not None: + output['signature'] = self.signature + if self.plans is not None: + output['plans'] = self.plans + if self.results is not None: + output['results'] = self.results.to_json_repr() + if self.errors is not None: + output['errors'] = [e.to_json_repr() for e in self.errors] + return output + + def update_elapsed_time(self, t: float) -> None: + self.metrics.elapsed_time = t + self.metrics.execution_time = t + + @staticmethod + def create() -> ServerResponse: + return ServerResponse(200, 'success', ServerResponseMetrics.create()) \ No newline at end of file diff --git a/tests/test_server/web_server.py b/tests/test_server/web_server.py new file mode 100644 index 0000000..3bf724c --- /dev/null +++ b/tests/test_server/web_server.py @@ -0,0 +1,257 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import asyncio +import json +import logging +import pathlib +import sys +from time import perf_counter +from typing import Optional, Union +from uuid import uuid4 + +from aiohttp import web + +CLIENT_ROOT = pathlib.Path(__file__).parent.parent.parent +sys.path.append(str(CLIENT_ROOT)) + +from tests.test_server import ErrorType +from tests.test_server.request import (ServerErrorRequest, + ServerResultsRequest, + ServerTimeoutRequest) +from tests.test_server.response import (ServerResponse, + ServerResponseError, + ServerResponseResults) +from tests.utils import AsyncBytesIterator, AsyncInfiniteBytesIterator + +logging.basicConfig(level=logging.INFO, + stream=sys.stderr, + format='%(asctime)s - %(levelname)s - (PID:%(process)d) - %(message)s') +logger = logging.getLogger(__name__) + +class AsyncWebServer: + def __init__(self, host: Optional[str]='0.0.0.0', port:Optional[int]=8080) -> None: + self._app = web.Application() + self._host = host + self._port = port + self._app.add_routes([web.post('/test_error', self.handle_error_request), + web.post('/test_results', self.handle_results_request), + web.post('/test_slow_results', self.handle_slow_results_request)]) + + async def _handle_timeout_error_request(self, request: ServerTimeoutRequest) -> web.Response: + timeout = request.timeout + start = perf_counter() + await asyncio.sleep(timeout) + end = perf_counter() + elapsed = end - start + request_id = str(uuid4()) + resp = ServerResponse.create() + if request.server_side: + ServerResponseError.build_errors(resp, ErrorType.Timeout) + resp.update_elapsed_time(elapsed) + return web.json_response(resp.to_json_repr()) + + return web.json_response({ + 'requestID': request_id, + 'status': 'timeout', + 'elapsedTime': f'{elapsed}s', + 'message': f'Request timed out after {timeout} seconds.' + }) + + def _handle_auth_error_request(self, error_type: ErrorType) -> web.Response: + start = perf_counter() + resp = ServerResponse.create() + ServerResponseError.build_errors(resp, error_type) + end = perf_counter() + elapsed = end - start + resp.update_elapsed_time(elapsed) + return web.json_response(resp.to_json_repr()) + + async def _handle_retry_error_request(self, request: ServerErrorRequest) -> web.Response: + start = perf_counter() + resp = ServerResponse.create() + ServerResponseError.build_errors(resp, + request.error_type, + group_type=request.retry_group_type, + retry_specification=request.non_retriable_spec, + err_count=request.error_count) + end = perf_counter() + elapsed = end - start + resp.update_elapsed_time(elapsed) + res = resp.to_json_repr() + return web.json_response(resp.to_json_repr()) + + async def _handle_results_request(self, request: ServerResultsRequest, web_request: web.Request) -> Union[web.Response, web.StreamResponse]: + resp = ServerResponse.create() + start = perf_counter() + if request.until is not None: + response = web.StreamResponse() + await response.prepare(web_request) + now = asyncio.get_running_loop().time() + deadline = now + request.until + chunk_size = request.chunk_size or 100 + bytes_generator = ServerResponseResults.get_result_genetaotr(request.result_type) + initial_data = bytes(json.dumps({'requestID': resp.request_id, 'status': resp.status}), 'utf-8') + async_inf_iterator = AsyncInfiniteBytesIterator(bytes_generator(), initial_data=initial_data, chunk_size=chunk_size) + while deadline > now: + chunk = await async_inf_iterator.__anext__() + await response.write(chunk) + now = asyncio.get_running_loop().time() + end = perf_counter() + elapsed = end - start + resp.update_elapsed_time(elapsed) + metrics = resp.metrics + metrics.result_count = async_inf_iterator.get_data_count() + meta = bytes(json.dumps({'metrics': metrics.to_json_repr()}), 'utf-8') + async_inf_iterator.stop_iterating(end_data=meta) + async for chunk in async_inf_iterator: + await response.write(chunk) + await response.write_eof() + return response + + if request.row_count is None: + raise ValueError('Missing "row_count" in JSON data.') + + ServerResponseResults.build_results(resp, request.row_count, request.result_type) + end = perf_counter() + elapsed = end - start + resp.update_elapsed_time(elapsed) + if request.stream: + response = web.StreamResponse() + await response.prepare(web_request) + chunk_size = request.chunk_size or 100 + async_iterator = AsyncBytesIterator(bytes(json.dumps(resp.to_json_repr()), 'utf-8'), chunk_size=chunk_size) + async for chunk in async_iterator: + await response.write(chunk) + await response.write_eof() + return response + + res = resp.to_json_repr() + return web.json_response(res) + + async def handle_error_request(self, request: web.Request) -> web.Response: + try: + received_json = await request.json() + if 'error_type' not in received_json: + raise ValueError('Missing "error_type" in JSON data.') + + error_req = ServerErrorRequest.from_json(received_json) + if error_req.error_type == ErrorType.Timeout: + timeout_req = ServerTimeoutRequest.from_json(received_json) + return await self._handle_timeout_error_request(timeout_req) + elif error_req.error_type in [ErrorType.InsufficientPermissions, ErrorType.Unauthorized]: + return self._handle_auth_error_request(error_req.error_type) + elif error_req.error_type == ErrorType.Retriable: + return await self._handle_retry_error_request(error_req) + logger.info(f"Received JSON: {received_json}") + return web.json_response({ + 'status': 'success', + 'data': received_json + }) + except json.JSONDecodeError: + received_text = await request.text() + msg = "POST request received, but data is not valid JSON. Showing as plain text." + logger.error(msg) + logger.error(f'Received text: {received_text}') + return web.Response(status=400, text="Bad Request") + except Exception as e: + logger.error(f'An error occurred: {e}', exc_info=True) + return web.Response(status=400, text="Bad Request") + + async def handle_results_request(self, request: web.Request) -> Union[web.Response, web.StreamResponse]: + try: + received_json = await request.json() + result_req = ServerResultsRequest.from_json(received_json) + return await self._handle_results_request(result_req, request) + except json.JSONDecodeError: + received_text = await request.text() + msg = "POST request received, but data is not valid JSON. Showing as plain text." + logger.error(msg) + logger.error(f'Received text: {received_text}') + return web.Response(status=400, text="Bad Request") + except Exception as e: + logger.error(f'An error occurred: {e}', exc_info=True) + return web.Response(status=400, text="Bad Request") + + async def handle_slow_results_request(self, request: web.Request) -> web.StreamResponse: + try: + received_json = await request.json() + if 'request_type' not in received_json: + raise ValueError('Missing "request_type" in JSON data.') + + logger.info(f"Received JSON: {received_json}") + return web.json_response({ + 'status': 'success', + 'data': received_json + }) + except json.JSONDecodeError: + received_text = await request.text() + msg = "POST request received, but data is not valid JSON. Showing as plain text." + logger.error(msg) + logger.error(f'Received text: {received_text}') + return web.Response(status=400, text="Bad Request") + except Exception as e: + logger.error(f'An error occurred: {e}', exc_info=True) + return web.Response(status=400, text="Bad Request") + + async def start(self) -> None: + runner = web.AppRunner(self._app) + await runner.setup() + site = web.TCPSite(runner, self._host, self._port) + await site.start() + logger.info(f'Server running on http://{self._host}:{self._port}') + + async def stop(self) -> None: + await self._app.shutdown() + await self._app.cleanup() + +async def run_server(host: str, port: int) -> None: + server = AsyncWebServer(host=host, port=port) + logger.info(f'Attempting to start server on {host}:{port}...') + await server.start() + logger.info('Server started. Listening for requests...') + try: + while True: + await asyncio.sleep(300) + except asyncio.CancelledError: + logger.info('asyncio task cancelled (e.g., from SIGTERM). Shutting down.') + except Exception as e: + logger.error(f'Unexpected error: {e}', exc_info=True) + finally: + logger.info('Stopping server...') + await server.stop() + logger.info('Server stopped.') + + +if __name__ == '__main__': + from argparse import ArgumentParser + ap = ArgumentParser(description='Run Async Web Server') + ap.add_argument('--host', + type=str, + default='127.0.0.1', + help='Host address to bind to (e.g., 127.0.0.1 for localhost only)') + ap.add_argument('--port', + type=int, + default=8000, + help='Port number to listen on') + options = ap.parse_args() + try: + asyncio.run(run_server(host=options.host, port=options.port)) + except KeyboardInterrupt: + pass + except Exception as e: + logger.critical(f'Critical error: {e}', exc_info=True) \ No newline at end of file diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py index 6e10238..39555ed 100644 --- a/tests/utils/__init__.py +++ b/tests/utils/__init__.py @@ -20,19 +20,85 @@ import os import pathlib import random - from collections.abc import AsyncIterator as PyAsyncIterator from collections.abc import Iterator -from typing import (Any, - Dict, - List, - Optional, - Tuple, - Union) +from typing import Any, Dict, Generator, List, Optional, Tuple, Union from urllib.parse import quote import anyio + +class AsyncInfiniteBytesIterator(PyAsyncIterator[bytes]): + + def __init__(self, + data_generator: Generator[bytes, None, None], + initial_data: Optional[Union[bytes, str]] = None, + chunk_size: Optional[int] = 100, + simulate_delay: Optional[bool] = False, + simulate_delay_range: Optional[Tuple[float, float]] = (0.01, 0.1)) -> None: + self._data_generator = data_generator + self._initial_data = bytearray() + if initial_data is not None: + self._initial_data = bytearray(initial_data)[:-1] if isinstance(initial_data, bytes) else bytearray(initial_data, 'utf-8')[:-1] + self._initial_data += b',"results":[' + self._end_data = bytearray() + + self._data = bytearray() if self._initial_data is None else bytearray(self._initial_data) + self._chunk_size = chunk_size or 100 + self._simulate_delay = simulate_delay or False + self._simulate_delay_range = simulate_delay_range or (0.01, 0.1) + self._start = 0 + self._stop = self._chunk_size + self._stop_iterating = False + self._data_count = 0 + + def get_data_count(self) -> int: + return self._data_count + + def stop_iterating(self, end_data: Optional[Union[bytes, str]] = None) -> None: + self._stop_iterating = True + if end_data is not None: + self._end_data = bytearray(end_data)[1:-1] if isinstance(end_data, bytes) else bytearray(end_data, 'utf-8')[1:-1] + + def __aiter__(self) -> AsyncInfiniteBytesIterator: + return self + + async def __anext__(self) -> bytes: + if self._simulate_delay: + delay = random.uniform(*self._simulate_delay_range) + await anyio.sleep(delay) + + while True: + await anyio.sleep(0.5) + if len(self._data) == 0: + if self._stop_iterating: + if len(self._end_data) == 0: + raise StopAsyncIteration + self._data += b'],' + self._data += bytearray(self._end_data) + self._data += b'}' + self._end_data = bytearray() + else: + self._stop = self._chunk_size + while len(self._data) < (2 * self._chunk_size): + self._data += b',' + self._data += next(self._data_generator) + self._data_count += 1 + + # if self._start >= len(self._data): + # self._start = 0 + # self._stop = self._chunk_size + # self._data += next(self._data_generator) + + if self._stop >= len(self._data): + self._stop = len(self._data) + + chunk = bytes(self._data[:self._stop]) + del self._data[:self._stop] + self._stop += self._chunk_size + + return chunk + class AsyncBytesIterator(PyAsyncIterator[bytes]): def __init__(self, diff --git a/tests/utils/_async_client_adapter.py b/tests/utils/_async_client_adapter.py index 5d47fee..5757788 100644 --- a/tests/utils/_async_client_adapter.py +++ b/tests/utils/_async_client_adapter.py @@ -1,11 +1,11 @@ import socket from typing import Dict -from httpx import BasicAuth, AsyncClient, Response +from httpx import URL, Response + +from acouchbase_analytics.protocol._core.client_adapter import _AsyncClientAdapter +from couchbase_analytics.protocol._core.request import QueryRequest -from acouchbase_analytics.protocol.core.client_adapter import _AsyncClientAdapter -from couchbase_analytics.protocol.core.request import QueryRequest - def client_adapter_init_override(self, *args, **kwargs) -> None: # type: ignore[no-untyped-def] if not hasattr(self, 'PYCBAC_TESTING'): @@ -15,7 +15,9 @@ def client_adapter_init_override(self, *args, **kwargs) -> None: # type: ignore raise RuntimeError('http_transport_cls must be a test transport') adapter: _AsyncClientAdapter = kwargs.pop('adapter', None) # adapter.close_client() + print(f'current client_id={adapter._client_id}') self._client_id = adapter._client_id + print(f'client_id={self._client_id}') self._opts_builder = adapter._opts_builder self._conn_details = adapter._conn_details if self._http_transport_cls is None: @@ -41,8 +43,8 @@ async def send_request_override(self: _AsyncClientAdapter, request: QueryRequest if not hasattr(self, '_client'): raise RuntimeError('Client not created yet') - if request.url is None: - raise ValueError('Request URL cannot be None') + # if request.url is None: + # raise ValueError('Request URL cannot be None') print(f'Sending request: {request.method} {request.url}') request_json = request.body @@ -59,14 +61,19 @@ async def send_request_override(self: _AsyncClientAdapter, request: QueryRequest print(f'{request_extensions=}') + url = URL(scheme=request.url.scheme, + host=request.url.host, + port=request.url.port, + path=request.url.path) req = self._client.build_request(request.method, - request.url, + url, json=request_json, extensions=request_extensions) try: return await self._client.send(req, stream=True) except socket.gaierror as err: - raise RuntimeError(f'Unable to connect to {self._conn_details.get_scheme_host_and_port()}') from err + req_url = self._conn_details.url.get_formatted_url() + raise RuntimeError(f'Unable to connect to {req_url}') from err def set_request_path(self: _AsyncClientAdapter, path: str) -> None: @@ -78,14 +85,15 @@ def update_request_json(self: _AsyncClientAdapter, json: Dict[str, object]) -> N def update_request_extensions(self: _AsyncClientAdapter, extensions: Dict[str, str]) -> None: self._request_extensions = extensions # type: ignore[attr-defined] -_AsyncClientAdapter.__init__ = client_adapter_init_override # type: ignore[method-assign] -# _AsyncClientAdapter.create_client = create_client_override # type: ignore[method-assign] -_AsyncClientAdapter.send_request = send_request_override # type: ignore[method-assign] -setattr(_AsyncClientAdapter, 'set_request_path', set_request_path) -setattr(_AsyncClientAdapter, 'update_request_json', update_request_json) -setattr(_AsyncClientAdapter, 'update_request_extensions', update_request_extensions) -setattr(_AsyncClientAdapter, 'PYCBAC_TESTING', True) +class _TestAsyncClientAdapter(_AsyncClientAdapter): + pass -_TestAsyncClientAdapter = _AsyncClientAdapter +_TestAsyncClientAdapter.__init__ = client_adapter_init_override # type: ignore[method-assign] +# _TestAsyncClientAdapter.create_client = create_client_override # type: ignore[method-assign] +_TestAsyncClientAdapter.send_request = send_request_override # type: ignore[method-assign] +setattr(_TestAsyncClientAdapter, 'set_request_path', set_request_path) +setattr(_TestAsyncClientAdapter, 'update_request_json', update_request_json) +setattr(_TestAsyncClientAdapter, 'update_request_extensions', update_request_extensions) +setattr(_TestAsyncClientAdapter, 'PYCBAC_TESTING', True) __all__ = ["_TestAsyncClientAdapter"] \ No newline at end of file diff --git a/tests/utils/_async_utils.py b/tests/utils/_async_utils.py index 80dbeae..b000294 100644 --- a/tests/utils/_async_utils.py +++ b/tests/utils/_async_utils.py @@ -16,15 +16,11 @@ from __future__ import annotations from types import TracebackType -from typing import (Any, - Callable, - List, - Optional, - Type) - +from typing import Any, Callable, List, Optional, Type import anyio + class TaskGroupResultCollector: def __init__(self) -> None: diff --git a/tests/utils/_async_web_server.py b/tests/utils/_async_web_server.py deleted file mode 100644 index f6fdf96..0000000 --- a/tests/utils/_async_web_server.py +++ /dev/null @@ -1,113 +0,0 @@ -# Copyright 2016-2024. Couchbase, Inc. -# All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import asyncio -import logging -import json -import sys - -from typing import Optional -import urllib.parse - -from aiohttp import web - - -logging.basicConfig(level=logging.INFO, - stream=sys.stderr, - format='%(asctime)s - %(levelname)s - (PID:%(process)d) - %(message)s') -logger = logging.getLogger(__name__) - -class AsyncWebServer: - def __init__(self, host: Optional[str]='0.0.0.0', port:Optional[int]=8080) -> None: - self._app = web.Application() - self._host = host - self._port = port - self._app.add_routes([web.get('/test_get', self.handle_get_request), - web.post('/test_post', self.handle_post_request)]) - - async def handle_get_request(self, request: web.Request) -> web.Response: - path = request.match_info['path'] - query_params = request.query_string - response_data = { - 'path': path, - 'query': urllib.parse.parse_qs(query_params) - } - return web.json_response(response_data) - - async def handle_post_request(self, request: web.Request) -> web.Response: - try: - received_json = await request.json() - logger.info(f"Received JSON: {received_json}") - return web.json_response({ - 'status': 'success', - 'data': received_json - }) - except json.JSONDecodeError: - received_text = await request.text() - msg = "POST request received, but data is not valid JSON. Showing as plain text." - logger.error(msg) - logger.error(f'Received text: {received_text}') - return web.Response(status=400, text="Bad Request") - except Exception as e: - logger.error(f'An error occurred: {e}', exc_info=True) - return web.Response(status=400, text="Bad Request") - - async def start(self) -> None: - runner = web.AppRunner(self._app) - await runner.setup() - site = web.TCPSite(runner, self._host, self._port) - await site.start() - logger.info(f'Server running on http://{self._host}:{self._port}') - - async def stop(self) -> None: - await self._app.shutdown() - await self._app.cleanup() - -async def run_server(host: str, port: int) -> None: - server = AsyncWebServer(host=host, port=port) - logger.info(f'Attempting to start server on {host}:{port}...') - await server.start() - logger.info('Server started. Listening for requests...') - try: - while True: - await asyncio.sleep(300) - except asyncio.CancelledError: - logger.info('asyncio task cancelled (e.g., from SIGTERM). Shutting down.') - except Exception as e: - logger.error(f'Unexpected error: {e}', exc_info=True) - finally: - logger.info('Stopping server...') - await server.stop() - logger.info('Server stopped.') - - -if __name__ == '__main__': - from argparse import ArgumentParser - ap = ArgumentParser(description='Run Async Web Server') - ap.add_argument('--host', - type=str, - default='127.0.0.1', - help='Host address to bind to (e.g., 127.0.0.1 for localhost only)') - ap.add_argument('--port', - type=int, - default=8000, - help='Port number to listen on') - options = ap.parse_args() - try: - asyncio.run(run_server(host=options.host, port=options.port)) - except KeyboardInterrupt: - pass - except Exception as e: - logger.critical(f'Critical error: {e}', exc_info=True) \ No newline at end of file diff --git a/tests/utils/_client_adapter.py b/tests/utils/_client_adapter.py index 35c70d2..e2818c6 100644 --- a/tests/utils/_client_adapter.py +++ b/tests/utils/_client_adapter.py @@ -1,12 +1,11 @@ import socket - from typing import Dict -from httpx import Response +from httpx import URL, Response + +from couchbase_analytics.protocol._core.client_adapter import _ClientAdapter +from couchbase_analytics.protocol._core.request import QueryRequest -from couchbase_analytics.protocol.core.client_adapter import _ClientAdapter -from couchbase_analytics.protocol.core.request import QueryRequest - def client_adapter_init_override(self, *args, **kwargs) -> None: # type: ignore[no-untyped-def] if not hasattr(self, 'PYCBAC_TESTING'): @@ -43,8 +42,8 @@ def send_request_override(self: _ClientAdapter, request: QueryRequest) -> Respon if not hasattr(self, '_client'): raise RuntimeError('Client not created yet') - if request.url is None: - raise ValueError('Request URL cannot be None') + # if request.url is None: + # raise ValueError('Request URL cannot be None') print(f'Sending request: {request.method} {request.url}') request_json = request.body @@ -61,14 +60,19 @@ def send_request_override(self: _ClientAdapter, request: QueryRequest) -> Respon print(f'{request_extensions=}') + url = URL(scheme=request.url.scheme, + host=request.url.host, + port=request.url.port, + path=request.url.path) req = self._client.build_request(request.method, - request.url, + url, json=request_json, extensions=request_extensions) try: return self._client.send(req, stream=True) except socket.gaierror as err: - raise RuntimeError(f'Unable to connect to {self._conn_details.get_scheme_host_and_port()}') from err + req_url = self._conn_details.url.get_formatted_url() + raise RuntimeError(f'Unable to connect to {req_url}') from err def set_request_path(self: _ClientAdapter, path: str) -> None: self._ANALYTICS_PATH = path @@ -79,14 +83,15 @@ def update_request_json(self: _ClientAdapter, json: Dict[str, object]) -> None: def update_request_extensions(self: _ClientAdapter, extensions: Dict[str, str]) -> None: self._request_extensions = extensions # type: ignore[attr-defined] -_ClientAdapter.__init__ = client_adapter_init_override # type: ignore[method-assign] -# _ClientAdapter.create_client = create_client_override -_ClientAdapter.send_request = send_request_override # type: ignore[method-assign] -setattr(_ClientAdapter, 'set_request_path', set_request_path) -setattr(_ClientAdapter, 'update_request_json', update_request_json) -setattr(_ClientAdapter, 'update_request_extensions', update_request_extensions) -setattr(_ClientAdapter, 'PYCBAC_TESTING', True) +class _TestClientAdapter(_ClientAdapter): + pass -_TestClientAdapter = _ClientAdapter +_TestClientAdapter.__init__ = client_adapter_init_override # type: ignore[method-assign] +# _TestClientAdapter.create_client = create_client_override +_TestClientAdapter.send_request = send_request_override # type: ignore[method-assign] +setattr(_TestClientAdapter, 'set_request_path', set_request_path) +setattr(_TestClientAdapter, 'update_request_json', update_request_json) +setattr(_TestClientAdapter, 'update_request_extensions', update_request_extensions) +setattr(_TestClientAdapter, 'PYCBAC_TESTING', True) __all__ = ["_TestClientAdapter"] \ No newline at end of file diff --git a/tests/utils/_run_web_server.py b/tests/utils/_run_web_server.py index 903e211..0aa9935 100644 --- a/tests/utils/_run_web_server.py +++ b/tests/utils/_run_web_server.py @@ -19,11 +19,10 @@ import subprocess import sys import time - from os import path from typing import Optional -WEB_SERVER_PATH = path.join(pathlib.Path(__file__).parent, '_async_web_server.py') +WEB_SERVER_PATH = path.join(pathlib.Path(__file__).parent.parent, 'test_server', 'web_server.py') print(f'Web server script path: {WEB_SERVER_PATH}') @@ -36,7 +35,7 @@ class WebServerHandler: def __init__(self, host: Optional[str]='0.0.0.0', port:Optional[int]=8080) -> None: self._host = host or '0.0.0.0' self._port = port or 8080 - self._server_process: Optional[subprocess.Popen[bytes]] + self._server_process: Optional[subprocess.Popen[bytes]] = None atexit.register(self.stop_server) @property diff --git a/tests/utils/_test_async_httpx.py b/tests/utils/_test_async_httpx.py index 63818b9..a5e1441 100644 --- a/tests/utils/_test_async_httpx.py +++ b/tests/utils/_test_async_httpx.py @@ -1,24 +1,15 @@ import typing -from httpx import AsyncHTTPTransport, create_ssl_context, Limits -from httpcore import (AsyncConnectionPool, - Origin, - Request, - Response) -from httpcore._async.connection import (AsyncHTTPConnection, - exponential_backoff, - RETRIES_BACKOFF_FACTOR, - logger) +from httpcore import AsyncConnectionPool, Origin, Request, Response +from httpcore._async.connection import RETRIES_BACKOFF_FACTOR, AsyncHTTPConnection, exponential_backoff, logger from httpcore._async.connection_pool import AsyncPoolRequest, PoolByteStream from httpcore._async.interfaces import AsyncConnectionInterface from httpcore._backends.base import AsyncNetworkStream -from httpcore._exceptions import (ConnectError, - ConnectionNotAvailable, - ConnectTimeout, - UnsupportedProtocol) - +from httpcore._exceptions import ConnectError, ConnectionNotAvailable, ConnectTimeout, UnsupportedProtocol from httpcore._ssl import default_ssl_context from httpcore._trace import Trace +from httpx import AsyncHTTPTransport, Limits, create_ssl_context + class TestAsyncHTTPConnection(AsyncHTTPConnection): def __init__(self, *args, **kwargs) -> None: # type: ignore @@ -234,7 +225,7 @@ def async_http_transport_init_override(self, *args, **kwargs) -> None: # type: http2 = kwargs.get('http2') uds = kwargs.get('uds') local_address = kwargs.get('local_address') - retries = kwargs.get('retries') + retries = kwargs.get('retries', 0) socket_options = kwargs.get('socket_options') self._pool = TestAsyncConnectionPool( ssl_context=ssl_context, diff --git a/tests/utils/_test_httpx.py b/tests/utils/_test_httpx.py index 0227975..0421590 100644 --- a/tests/utils/_test_httpx.py +++ b/tests/utils/_test_httpx.py @@ -1,26 +1,16 @@ import time import typing -from httpx import HTTPTransport, create_ssl_context, Limits -from httpcore import (ConnectionPool, - Origin, - Request, - Response) +from httpcore import ConnectionPool, Origin, Request, Response from httpcore._backends.base import NetworkStream -from httpcore._exceptions import (ConnectError, - ConnectionNotAvailable, - ConnectTimeout, - PoolTimeout, - UnsupportedProtocol) +from httpcore._exceptions import ConnectError, ConnectionNotAvailable, ConnectTimeout, PoolTimeout, UnsupportedProtocol from httpcore._ssl import default_ssl_context - -from httpcore._sync.connection import (HTTPConnection, - exponential_backoff, - RETRIES_BACKOFF_FACTOR, - logger) -from httpcore._sync.connection_pool import PoolRequest, PoolByteStream +from httpcore._sync.connection import RETRIES_BACKOFF_FACTOR, HTTPConnection, exponential_backoff, logger +from httpcore._sync.connection_pool import PoolByteStream, PoolRequest from httpcore._sync.interfaces import ConnectionInterface from httpcore._trace import Trace +from httpx import HTTPTransport, Limits, create_ssl_context + class TestHTTPConnection(HTTPConnection): def __init__(self, *args, **kwargs) -> None: # type: ignore From de773d1a9a267cc0e331747c38f335b0fbcadb99 Mon Sep 17 00:00:00 2001 From: Jared Casey Date: Thu, 3 Jul 2025 10:47:10 -0600 Subject: [PATCH 06/18] pre-commit; backup calculator --- .gitignore | 3 + .pre-commit-config.yaml | 38 +++++ MANIFEST.in | 2 +- README.md | 2 +- acouchbase_analytics/cluster.pyi | 1 - .../protocol/_core/anyio_utils.py | 4 +- .../protocol/_core/async_json_stream.py | 12 +- .../protocol/_core/async_json_token_parser.py | 10 +- .../protocol/_core/client_adapter.py | 11 +- .../protocol/_core/net_utils.py | 2 +- .../protocol/_core/request_context.py | 26 +-- .../protocol/_core/retries.py | 4 +- acouchbase_analytics/protocol/cluster.py | 2 +- acouchbase_analytics/protocol/errors.py | 4 +- acouchbase_analytics/protocol/scope.py | 4 +- acouchbase_analytics/protocol/streaming.py | 10 +- acouchbase_analytics/tests/json_parsing_t.py | 4 +- acouchbase_analytics/tests/options_t.py | 2 +- .../tests/query_integration_t.py | 4 +- acouchbase_analytics/tests/query_options_t.py | 6 +- acouchbase_analytics/tests/test_server_t.py | 4 +- conftest.py | 10 +- couchbase_analytics/common/_core/__init__.py | 2 +- .../_core/_capella_certificates/_capella.pem | 2 +- .../_core/_nonprod_certificates/_nonprod.pem | 2 +- .../common/_core/duration_str_utils.py | 14 +- .../common/_core/error_context.py | 6 +- .../common/_core/json_parsing.py | 6 +- .../common/_core/json_token_parser_base.py | 12 +- couchbase_analytics/common/_core/query.py | 2 +- couchbase_analytics/common/_core/utils.py | 2 +- .../common/backoff_calculator.py | 42 +++++ couchbase_analytics/common/credential.py | 4 +- couchbase_analytics/common/request.py | 4 +- couchbase_analytics/common/result.py | 2 +- couchbase_analytics/common/streaming.py | 6 +- .../protocol/_core/client_adapter.py | 13 +- .../protocol/_core/http_transport.py | 2 +- .../protocol/_core/json_stream.py | 10 +- .../protocol/_core/json_token_parser.py | 4 +- .../protocol/_core/net_utils.py | 2 +- couchbase_analytics/protocol/_core/request.py | 10 +- .../protocol/_core/request_context.py | 42 ++--- couchbase_analytics/protocol/_core/retries.py | 4 +- couchbase_analytics/protocol/cluster.py | 4 +- couchbase_analytics/protocol/connection.py | 14 +- couchbase_analytics/protocol/errors.py | 27 ++- couchbase_analytics/protocol/scope.py | 2 +- couchbase_analytics/protocol/streaming.py | 11 +- couchbase_analytics/tests/backoff_calc_t.py | 61 +++++++ .../tests/duration_parsing_t.py | 4 +- couchbase_analytics/tests/json_parsing_t.py | 2 +- couchbase_analytics/tests/options_t.py | 2 +- .../tests/query_integration_t.py | 28 +-- couchbase_analytics/tests/query_options_t.py | 6 +- couchbase_analytics/tests/test_server_t.py | 4 +- couchbase_analytics_version.py | 30 ++-- dev_requirements.txt | 2 - pyproject.toml | 7 +- requirements-dev.in | 5 + requirements-dev.txt | 75 ++++++++ ...requirements.txt => requirements-sphinx.in | 2 +- requirements-sphinx.txt | 160 ++++++++++++++++++ requirements.in | 6 + requirements.txt | 39 ++++- setup.py | 34 ++-- tests/environments/__init__.py | 1 - tests/environments/base_environment.py | 26 +-- tests/environments/simple_environment.py | 2 +- tests/test_server/__init__.py | 2 +- tests/test_server/request.py | 14 +- tests/test_server/response.py | 16 +- tests/test_server/web_server.py | 22 +-- tests/utils/__init__.py | 12 +- tests/utils/_async_client_adapter.py | 4 +- tests/utils/_async_utils.py | 4 +- tests/utils/_client_adapter.py | 10 +- tests/utils/_run_web_server.py | 8 +- tests/utils/_test_async_httpx.py | 2 +- tests/utils/_test_httpx.py | 6 +- tests/utils/certs/dinoca.pem | 2 +- tests/utils/certs/dinocluster.pem | 2 +- 82 files changed, 708 insertions(+), 308 deletions(-) create mode 100644 .pre-commit-config.yaml create mode 100644 couchbase_analytics/common/backoff_calculator.py create mode 100644 couchbase_analytics/tests/backoff_calc_t.py delete mode 100644 dev_requirements.txt create mode 100644 requirements-dev.in create mode 100644 requirements-dev.txt rename sphinx_requirements.txt => requirements-sphinx.in (79%) create mode 100644 requirements-sphinx.txt create mode 100644 requirements.in diff --git a/.gitignore b/.gitignore index c864a1a..18378ca 100644 --- a/.gitignore +++ b/.gitignore @@ -180,3 +180,6 @@ CouchbaseMock*.jar gocaves* .pytest_cache/ test_scripts/ + +# rff +.ruff_cache/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..72ffd1d --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,38 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + args: [--allow-multiple-documents] + - id: check-added-large-files + - id: check-toml + - id: check-merge-conflict + - repo: https://github.com/astral-sh/ruff-pre-commit + # Ruff version. + rev: v0.12.1 + hooks: + # Run the linter. + - id: ruff-check + types_or: [ python, pyi ] + # Run the formatter. + # - id: ruff-format + # types_or: [ python, pyi ] + # Compile requirements + - repo: https://github.com/astral-sh/uv-pre-commit + # uv version. + rev: 0.7.19 + hooks: + # Compile requirements + - id: pip-compile + name: pip-compile requirements.in + args: [requirements.in, --python-version, '3.9', --universal, -o, requirements.txt] + - id: pip-compile + name: pip-compile requirements-dev.in + args: [requirements-dev.in, --python-version, '3.9', --universal, -o, requirements-dev.txt] + files: ^requirements-dev\.(in|txt)$ + - id: pip-compile + name: pip-compile requirements-sphinx.in + args: [requirements-sphinx.in, --python-version, '3.9', --universal, -o, requirements-sphinx.txt] + files: ^requirements-sphinx\.(in|txt)$ diff --git a/MANIFEST.in b/MANIFEST.in index 401f9c7..3e9e4fb 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -7,4 +7,4 @@ exclude couchbase_analytics/tests/*.py recursive-include acouchbase_analytics *.py exclude acouchbase_analytics/tests/*.py global-exclude *.py[cod] *.DS_Store -exclude .git .gitignore .gitmodules gocaves* *.jar .clang* .cmake* .pre* .flake* MANIFEST.in \ No newline at end of file +exclude .git .gitignore .gitmodules gocaves* *.jar .clang* .cmake* .pre* .flake* MANIFEST.in diff --git a/README.md b/README.md index bea1cba..7a07614 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,2 @@ # Couchbase Python Analytics Client -Python client for [Couchbase](https://couchbase.com) Analytics. \ No newline at end of file +Python client for [Couchbase](https://couchbase.com) Analytics. diff --git a/acouchbase_analytics/cluster.pyi b/acouchbase_analytics/cluster.pyi index 251f9f8..eb4b88c 100644 --- a/acouchbase_analytics/cluster.pyi +++ b/acouchbase_analytics/cluster.pyi @@ -113,4 +113,3 @@ class AsyncCluster: credential: Credential, options: ClusterOptions, **kwargs: Unpack[ClusterOptionsKwargs]) -> AsyncCluster: ... - diff --git a/acouchbase_analytics/protocol/_core/anyio_utils.py b/acouchbase_analytics/protocol/_core/anyio_utils.py index bcf48bc..e21e8f5 100644 --- a/acouchbase_analytics/protocol/_core/anyio_utils.py +++ b/acouchbase_analytics/protocol/_core/anyio_utils.py @@ -47,7 +47,7 @@ def current_async_library() -> Optional[AsyncBackend]: import sniffio except ImportError: async_lib = 'asyncio' - + # TODO: This helps make tests work. # Should we work through the scenario when sniffio cannot find the async library? try: @@ -62,4 +62,4 @@ def current_async_library() -> Optional[AsyncBackend]: if async_lib == 'trio': raise RuntimeError('trio currently not supported') - return AsyncBackend(async_lib) \ No newline at end of file + return AsyncBackend(async_lib) diff --git a/acouchbase_analytics/protocol/_core/async_json_stream.py b/acouchbase_analytics/protocol/_core/async_json_stream.py index 2fe94da..3318541 100644 --- a/acouchbase_analytics/protocol/_core/async_json_stream.py +++ b/acouchbase_analytics/protocol/_core/async_json_stream.py @@ -60,7 +60,7 @@ def has_results_or_errors(self) -> Event: **INTERNAL** """ return self._has_results_or_errors_evt - + @property def results_or_errors_type(self) -> ParsedResultType: """ @@ -74,7 +74,7 @@ def token_stream_exhausted(self) -> bool: **INTERNAL** """ return self._token_stream_exhausted - + def _continue_processing(self) -> bool: """ **INTERNAL** @@ -108,7 +108,7 @@ async def _handle_json_result(self, row: bytes) -> None: def _handle_notification(self, result_type: Optional[ParsedResultType]=None) -> None: if self._has_results_or_errors_evt.is_set(): return - + if result_type is None: self._results_or_errors_type = ParsedResultType.END self._has_results_or_errors_evt.set() @@ -140,14 +140,14 @@ async def _process_token_stream(self) -> None: result_type = ParsedResultType.ERROR if self._json_token_parser.has_errors else ParsedResultType.END await self._send_to_stream(ParsedResult(self._json_token_parser.get_result(), result_type), close=True) self._handle_notification(result_type) - + async def read(self, size: Optional[int]=-1) -> bytes: """ **INTERNAL** """ if size is None or size == 0 or self._http_stream_exhausted: return b'' - + while not self._http_stream_exhausted: if size >= 0 and len(self._http_response_buffer) > size: break @@ -181,4 +181,4 @@ async def start_parsing(self) -> None: async def continue_parsing(self) -> None: # TODO: error is _json_stream_parser is None? - await self._process_token_stream() \ No newline at end of file + await self._process_token_stream() diff --git a/acouchbase_analytics/protocol/_core/async_json_token_parser.py b/acouchbase_analytics/protocol/_core/async_json_token_parser.py index cd702f3..e131a4c 100644 --- a/acouchbase_analytics/protocol/_core/async_json_token_parser.py +++ b/acouchbase_analytics/protocol/_core/async_json_token_parser.py @@ -40,7 +40,7 @@ async def _handle_obj_emit(self, obj: str) -> bool: await self._results_handler(bytes(obj, 'utf-8')) return True return False - + async def _handle_pop_event(self, token_type: TokenType) -> None: matching_token = self._get_matching_token(token_type) obj_pairs: List[str] = [] @@ -53,7 +53,7 @@ async def _handle_pop_event(self, token_type: TokenType) -> None: obj = f'[{",".join(reversed(obj_pairs))}]' else: obj = f'{{{",".join(reversed(obj_pairs))}}}' - + if should_emit: object_emitted = await self._handle_obj_emit(obj) if object_emitted: @@ -61,8 +61,8 @@ async def _handle_pop_event(self, token_type: TokenType) -> None: if len(self._stack) > 0 and self._stack[-1].type == TokenType.MAP_KEY: map_key = self._pop() - # If we are emitting rows and/or errors, - # we don't keep them in the stack and therefore don't need to return the results + # If we are emitting rows and/or errors, + # we don't keep them in the stack and therefore don't need to return the results if self._should_push_pair(next_token): self._push(TokenType.PAIR, f'{map_key.value}:{obj}') else: @@ -88,4 +88,4 @@ async def parse_token(self, token: str, value: str) -> None: await self._handle_pop_event(token_type) else: # TODO: custom exception - raise ValueError(f'Invalid token type: {token_type}; {value=}') \ No newline at end of file + raise ValueError(f'Invalid token type: {token_type}; {value=}') diff --git a/acouchbase_analytics/protocol/_core/client_adapter.py b/acouchbase_analytics/protocol/_core/client_adapter.py index 096f784..2fbe4d3 100644 --- a/acouchbase_analytics/protocol/_core/client_adapter.py +++ b/acouchbase_analytics/protocol/_core/client_adapter.py @@ -57,15 +57,15 @@ def analytics_path(self) -> str: """ **INTERNAL** """ - return self._ANALYTICS_PATH - + return self._ANALYTICS_PATH + @property def client(self) -> AsyncClient: """ **INTERNAL** """ return self._client - + @property def client_id(self) -> str: """ @@ -86,7 +86,7 @@ def default_deserializer(self) -> Deserializer: **INTERNAL** """ return self._conn_details.default_deserializer - + @property def has_client(self) -> bool: """ @@ -137,7 +137,7 @@ async def send_request(self, request: QueryRequest) -> Response: """ if not hasattr(self, '_client'): raise RuntimeError('Client not created yet') - + # if request.url is None: # raise ValueError('Request URL cannot be None') @@ -161,4 +161,3 @@ def reset_client(self) -> None: """ if hasattr(self, '_client'): del self._client - diff --git a/acouchbase_analytics/protocol/_core/net_utils.py b/acouchbase_analytics/protocol/_core/net_utils.py index 54c2b4b..03b2184 100644 --- a/acouchbase_analytics/protocol/_core/net_utils.py +++ b/acouchbase_analytics/protocol/_core/net_utils.py @@ -54,4 +54,4 @@ async def get_request_ip_async(host: str, ip_str = str(ip) if not isinstance(ip, str) else ip ip = None if ip_str in previous_ips else ip_str - return ip \ No newline at end of file + return ip diff --git a/acouchbase_analytics/protocol/_core/request_context.py b/acouchbase_analytics/protocol/_core/request_context.py index 674a9ca..96a2f8c 100644 --- a/acouchbase_analytics/protocol/_core/request_context.py +++ b/acouchbase_analytics/protocol/_core/request_context.py @@ -71,7 +71,7 @@ def is_shutdown(self) -> bool: def okay_to_iterate(self) -> bool: self._check_cancelled_or_timed_out() return StreamingState.okay_to_iterate(self._request_state) - + @property def okay_to_stream(self) -> bool: self._check_cancelled_or_timed_out() @@ -97,7 +97,7 @@ def timed_out(self) -> bool: def _check_cancelled_or_timed_out(self) -> None: if self._request_state in [StreamingState.Timeout, StreamingState.Cancelled, StreamingState.Error]: return - + if hasattr(self, '_request_deadline') is False: return @@ -220,21 +220,21 @@ def create_response_task(self, fn: Callable[..., Coroutine[Any, Any, Any]], *arg # task.add_done_callback(task_done) self._response_task = task return task - + def deserialize_result(self, result: bytes) -> Any: return self._request.deserializer.deserialize(result) async def finish_processing_stream(self) -> None: if not self.has_stage_completed: await self._wait_for_stage_to_complete() - + while not self._json_stream.token_stream_exhausted: self._start_next_stage(self._json_stream.continue_parsing, reset_previous_stage=True) await self._wait_for_stage_to_complete() async def get_result_from_stream(self) -> ParsedResult: return await self._json_stream.get_result() - + async def initialize(self) -> None: # TODO: Add useful logging messages if self._request_state == StreamingState.ResetAndNotStarted: @@ -243,7 +243,7 @@ async def initialize(self) -> None: return await self.__aenter__() self._request_state = StreamingState.Started - # we set the request timeout once the context is initialized in order to create the deadline + # we set the request timeout once the context is initialized in order to create the deadline # closer to when the upstream logic will begin to use the request context timeouts = self._request.get_request_timeouts() or {} current_time = get_time() @@ -257,7 +257,7 @@ def maybe_continue_to_process_stream(self) -> None: if self._json_stream.token_stream_exhausted: return - + self._start_next_stage(self._json_stream.continue_parsing, reset_previous_stage=True) def okay_to_delay_and_retry(self, delay: float) -> bool: @@ -265,7 +265,7 @@ def okay_to_delay_and_retry(self, delay: float) -> bool: self._check_cancelled_or_timed_out() if self._request_state in [StreamingState.Timeout, StreamingState.Cancelled]: return False - + current_time = get_time() delay_time = current_time + delay will_time_out = self._request_deadline < delay_time @@ -287,7 +287,7 @@ async def process_response(self, await close_handler() raise AnalyticsError(message='Received unexpected empty result from JsonStream.', context=str(self._error_ctx)) - + if raw_response.value is None: await close_handler() raise AnalyticsError(message='Received unexpected empty result from JsonStream.', @@ -300,9 +300,9 @@ async def process_response(self, if 'errors' in json_response: await self._process_error(json_response['errors'], handle_context_shutdown=handle_context_shutdown) return json_response - + async def reraise_after_shutdown(self, err: Exception) -> None: - try: + try: raise err except Exception as ex: await self.shutdown(type(ex), ex, ex.__traceback__) @@ -314,7 +314,7 @@ async def send_request(self, enable_trace_handling: Optional[bool]=False) -> Htt attempted_ips = ', '.join(self._request.previous_ips or []) raise AnalyticsError(message=f'Connect failure. Unable to connect to any resolved IPs: {attempted_ips}.', context=str(self._error_ctx)) - + if enable_trace_handling is True: (self._request.update_url(ip, self._client_adapter.analytics_path) .add_trace_to_extensions(self._trace_handler) @@ -348,7 +348,7 @@ def start_stream(self, core_response: HttpCoreResponse) -> None: if hasattr(self, '_json_stream'): # TODO: logging; I don't think this is an error... return - + self._json_stream = AsyncJsonStream(core_response.aiter_bytes(), stream_config=self._stream_config) self._start_next_stage(self._json_stream.start_parsing) diff --git a/acouchbase_analytics/protocol/_core/retries.py b/acouchbase_analytics/protocol/_core/retries.py index 0c253e8..0bd4a5e 100644 --- a/acouchbase_analytics/protocol/_core/retries.py +++ b/acouchbase_analytics/protocol/_core/retries.py @@ -90,10 +90,10 @@ async def wrapped_fn(self: AsyncHttpStreamingResponse) -> None: # noqa: C901 await self.close() return wrapped_fn - + def calc_backoff(retry_count: int) -> float: min_ms = 100 max_ms = 60000 delay_ms = min_ms * pow(2, retry_count) capped_ms = min(max_ms, delay_ms) - return uniform(0, capped_ms / 1000.0) \ No newline at end of file + return uniform(0, capped_ms / 1000.0) diff --git a/acouchbase_analytics/protocol/cluster.py b/acouchbase_analytics/protocol/cluster.py index be2c446..5dc1a58 100644 --- a/acouchbase_analytics/protocol/cluster.py +++ b/acouchbase_analytics/protocol/cluster.py @@ -55,7 +55,7 @@ def client_adapter(self) -> _AsyncClientAdapter: **INTERNAL** """ return self._client_adapter - + @property def cluster_id(self) -> str: """ diff --git a/acouchbase_analytics/protocol/errors.py b/acouchbase_analytics/protocol/errors.py index d31aede..13aa5eb 100644 --- a/acouchbase_analytics/protocol/errors.py +++ b/acouchbase_analytics/protocol/errors.py @@ -38,5 +38,5 @@ async def wrapped_fn(host: str, msg='Connection error occurred while sending request.' raise WrappedError(AnalyticsError(cause=ex, message=msg), retriable=(ex.errno not in _NON_RETRYABLE_SOCKET_ERRORS)) from None - - return wrapped_fn \ No newline at end of file + + return wrapped_fn diff --git a/acouchbase_analytics/protocol/scope.py b/acouchbase_analytics/protocol/scope.py index b95e707..86c8231 100644 --- a/acouchbase_analytics/protocol/scope.py +++ b/acouchbase_analytics/protocol/scope.py @@ -55,13 +55,13 @@ def name(self) -> str: str: The name of this :class:`~acouchbase_analytics.protocol.scope.Scope` instance. """ return self._scope_name - + async def _create_client(self) -> None: """ **INTERNAL** """ await self.client_adapter.create_client() - + async def _execute_query(self, http_resp: AsyncHttpStreamingResponse) -> AsyncQueryResult: if not self.client_adapter.has_client: # TODO: add log message?? diff --git a/acouchbase_analytics/protocol/streaming.py b/acouchbase_analytics/protocol/streaming.py index dc2b536..d9419b7 100644 --- a/acouchbase_analytics/protocol/streaming.py +++ b/acouchbase_analytics/protocol/streaming.py @@ -55,7 +55,7 @@ async def _handle_iteration_abort(self) -> None: else: await self._request_context.shutdown() raise StopAsyncIteration - + async def _process_response(self, raw_response: Optional[ParsedResult]=None, @@ -75,7 +75,7 @@ async def close(self) -> None: if hasattr(self, '_core_response'): await self._core_response.aclose() del self._core_response - + def cancel(self) -> None: """ **INTERNAL** @@ -125,7 +125,7 @@ async def get_next_row(self) -> Any: and self._core_response is not None and self._request_context.okay_to_iterate): await self._handle_iteration_abort() - + self._request_context.maybe_continue_to_process_stream() raw_response = await self._request_context.get_result_from_stream() if raw_response.result_type == ParsedResultType.ROW: @@ -141,7 +141,7 @@ async def get_next_row(self) -> Any: raise StopAsyncIteration else: await self._process_response(raw_response=raw_response, handle_context_shutdown=True) - + @AsyncRetryHandler.with_retries async def send_request(self) -> None: """ @@ -165,4 +165,4 @@ async def shutdown(self) -> None: **INTERNAL** """ await self.close() - await self._request_context.shutdown() \ No newline at end of file + await self._request_context.shutdown() diff --git a/acouchbase_analytics/tests/json_parsing_t.py b/acouchbase_analytics/tests/json_parsing_t.py index 3d4599b..4256319 100644 --- a/acouchbase_analytics/tests/json_parsing_t.py +++ b/acouchbase_analytics/tests/json_parsing_t.py @@ -210,7 +210,7 @@ async def _run_async(idx: int) -> Dict[float, int]: row_idx += 1 return {time(): idx} - + async with TaskGroupResultCollector() as tg: for idx in range(10): tg.start_soon(_run_async, idx) @@ -492,4 +492,4 @@ def valid_test_method(meth: str) -> bool: @pytest.fixture(scope='class', name='async_test_env') def acouchbase_test_environment(self, simple_async_test_env: AsyncSimpleEnvironment) -> AsyncSimpleEnvironment: - return simple_async_test_env \ No newline at end of file + return simple_async_test_env diff --git a/acouchbase_analytics/tests/options_t.py b/acouchbase_analytics/tests/options_t.py index 12f9840..8cfc79f 100644 --- a/acouchbase_analytics/tests/options_t.py +++ b/acouchbase_analytics/tests/options_t.py @@ -221,4 +221,4 @@ def valid_test_method(meth: str) -> bool: method_list = [meth for meth in dir(ClusterOptionsTests) if valid_test_method(meth)] test_list = set(ClusterOptionsTestSuite.TEST_MANIFEST).symmetric_difference(method_list) if test_list: - pytest.fail(f'Test manifest invalid. Missing/extra tests: {test_list}.') \ No newline at end of file + pytest.fail(f'Test manifest invalid. Missing/extra tests: {test_list}.') diff --git a/acouchbase_analytics/tests/query_integration_t.py b/acouchbase_analytics/tests/query_integration_t.py index 24e06fc..5ab18c6 100644 --- a/acouchbase_analytics/tests/query_integration_t.py +++ b/acouchbase_analytics/tests/query_integration_t.py @@ -282,7 +282,7 @@ async def test_query_raw_options(self, async def test_query_timeout(self, test_env: AsyncTestEnvironment) -> None: statement = 'SELECT sleep("some value", 10000) AS some_field;' - + with pytest.raises(TimeoutError): await test_env.cluster_or_scope.execute_query(statement, QueryOptions(timeout=timedelta(seconds=2))) @@ -347,4 +347,4 @@ async def couchbase_test_environment(self, test_env = async_test_env.enable_scope() yield test_env test_env.disable_scope() - await test_env.teardown() \ No newline at end of file + await test_env.teardown() diff --git a/acouchbase_analytics/tests/query_options_t.py b/acouchbase_analytics/tests/query_options_t.py index 4ab489e..12c3629 100644 --- a/acouchbase_analytics/tests/query_options_t.py +++ b/acouchbase_analytics/tests/query_options_t.py @@ -65,7 +65,7 @@ class QueryOptionsTestSuite: @pytest.fixture(scope='class') def query_statment(self) -> str: return 'SELECT * FROM default' - + def test_options_deserializer(self, query_statment: str, request_builder: _RequestBuilder, @@ -250,7 +250,7 @@ def test_options_timeout_must_be_positive_kwargs(self, with pytest.raises(ValueError): request_builder.build_base_query_request(query_statment, **kwargs) - + class ClusterQueryOptionsTests(QueryOptionsTestSuite): @pytest.fixture(scope='class', autouse=True) @@ -294,4 +294,4 @@ def request_builder(self) -> _RequestBuilder: cred = Credential.from_username_and_password('Administrator', 'password') return _RequestBuilder(_AsyncClientAdapter('https://localhost', cred), 'test-database', - 'test-scope') \ No newline at end of file + 'test-scope') diff --git a/acouchbase_analytics/tests/test_server_t.py b/acouchbase_analytics/tests/test_server_t.py index 0780c48..d10d62e 100644 --- a/acouchbase_analytics/tests/test_server_t.py +++ b/acouchbase_analytics/tests/test_server_t.py @@ -93,7 +93,7 @@ async def test_error_retriable_response(self, test_env: AsyncTestEnvironment) -> statement = 'SELECT "Hello, data!" AS greeting' with pytest.raises(TimeoutError) as ex: await test_env.cluster_or_scope.execute_query(statement, QueryOptions(timeout=timedelta(seconds=2))) - + test_env.assert_error_context_num_attempts(4 , ex.value._context, exact=False) test_env.assert_error_context_contains_last_dispatch(ex.value._context) @@ -182,4 +182,4 @@ async def couchbase_test_environment(self, test_env = await async_test_env_with_server.enable_test_server() test_env.enable_scope() yield test_env - test_env.disable_scope().disable_test_server() \ No newline at end of file + test_env.disable_scope().disable_test_server() diff --git a/conftest.py b/conftest.py index 81a06db..417471e 100644 --- a/conftest.py +++ b/conftest.py @@ -20,7 +20,7 @@ pytest_plugins = [ 'tests.analytics_config', 'tests.environments.base_environment', - 'tests.environments.simple_environment' + 'tests.environments.simple_environment', ] _UNIT_TESTS = [ @@ -48,14 +48,14 @@ 'couchbase_analytics/tests/query_integration_t.py::ScopeQueryTests', ] + @pytest.fixture(scope='class') def anyio_backend() -> str: return 'asyncio' + # https://docs.pytest.org/en/stable/reference/reference.html#std-hook-pytest_collection_modifyitems -def pytest_collection_modifyitems(session: pytest.Session, - config: pytest.Config, - items: List[pytest.Item]) -> None: # noqa: C901 +def pytest_collection_modifyitems(session: pytest.Session, config: pytest.Config, items: List[pytest.Item]) -> None: # noqa: C901 for item in items: item_details = item.nodeid.split('::') @@ -69,4 +69,4 @@ def pytest_collection_modifyitems(session: pytest.Session, if test_class_path in _UNIT_TESTS: item.add_marker(pytest.mark.pycbac_unit) elif test_class_path in _INTEGRATRION_TESTS: - item.add_marker(pytest.mark.pycbac_integration) \ No newline at end of file + item.add_marker(pytest.mark.pycbac_integration) diff --git a/couchbase_analytics/common/_core/__init__.py b/couchbase_analytics/common/_core/__init__.py index e2303b3..04b9ded 100644 --- a/couchbase_analytics/common/_core/__init__.py +++ b/couchbase_analytics/common/_core/__init__.py @@ -16,4 +16,4 @@ from .json_parsing import JsonParsingError as JsonParsingError # noqa: F401 from .json_parsing import JsonStreamConfig as JsonStreamConfig # noqa: F401 from .json_parsing import ParsedResult as ParsedResult # noqa: F401 -from .json_parsing import ParsedResultType as ParsedResultType # noqa: F401 \ No newline at end of file +from .json_parsing import ParsedResultType as ParsedResultType # noqa: F401 diff --git a/couchbase_analytics/common/_core/_capella_certificates/_capella.pem b/couchbase_analytics/common/_core/_capella_certificates/_capella.pem index 32d3977..87984fe 100644 --- a/couchbase_analytics/common/_core/_capella_certificates/_capella.pem +++ b/couchbase_analytics/common/_core/_capella_certificates/_capella.pem @@ -16,4 +16,4 @@ xdzlTP/Z+qr0cnVbGBSZ+fbXstSiRaaAVcqQyv3BRvBadKBkCyPwo+7svQnScQ5P Js7HEHKVms5tZTgKIw1fbmgR2XHleah1AcANB+MAPBCcTgqurqr5G7W2aPSBLLGA fRIiVzm7VFLc7kWbp7ENH39HVG6TZzKnfl9zJYeiklo5vQQhGSMhzBsO70z4RRzi DPFAN/4qZAgD5q3AFNIq2WWADFQGSwVJhg== ------END CERTIFICATE----- \ No newline at end of file +-----END CERTIFICATE----- diff --git a/couchbase_analytics/common/_core/_nonprod_certificates/_nonprod.pem b/couchbase_analytics/common/_core/_nonprod_certificates/_nonprod.pem index 57fc342..2bd5744 100644 --- a/couchbase_analytics/common/_core/_nonprod_certificates/_nonprod.pem +++ b/couchbase_analytics/common/_core/_nonprod_certificates/_nonprod.pem @@ -16,4 +16,4 @@ UcinTaT68lVzkTc0D8T+gkRzwXIqxjML2ZdruD1foHNzCgeGHzKzdsjYqrnHv17b J+f5tqoa5CKbnyWl3HP0k7r3HHQP0GQequoqXcL3XlERX3Ne20Chck9mftNnHhKw Dby7ylZaP97sphqOZQ/W/gza7x1JYylrLXvjfdv3Nmu7oSMKO/2cDyWwcbVGkpbk 8JOQtFENWmr9u2S0cQfwoCSYBWaK0ofivA== ------END CERTIFICATE----- \ No newline at end of file +-----END CERTIFICATE----- diff --git a/couchbase_analytics/common/_core/duration_str_utils.py b/couchbase_analytics/common/_core/duration_str_utils.py index f87159d..9cd8055 100644 --- a/couchbase_analytics/common/_core/duration_str_utils.py +++ b/couchbase_analytics/common/_core/duration_str_utils.py @@ -26,19 +26,19 @@ def check_valid_duration_str(duration_str: str) -> None: """ Validates if the given string is a valid duration string. - + :param value: The duration string to validate. :return: True if valid, False otherwise. """ if not isinstance(duration_str, str): raise ValueError(f'Expected a string, got {type(duration_str).__name__} instead.') - + if is_null_or_empty(duration_str): raise ValueError('Duration string cannot be empty.') - + if duration_str.startswith('-'): raise ValueError('Negative durations are not supported.') - + # Special case: "0" duration if duration_str == '0': return @@ -47,7 +47,7 @@ def check_valid_duration_str(duration_str: str) -> None: if not match: raise ValueError('Duration string has invalid format') - + def parse_duration_str(duration_str: str, in_millis: Optional[bool]=False) -> float: check_valid_duration_str(duration_str) @@ -82,7 +82,7 @@ def parse_duration_str(duration_str: str, in_millis: Optional[bool]=False) -> fl f'Error details: {e}')) from None except KeyError: raise ValueError(f'Invalid duration. Unknown unit "{unit_str}"') from None - + if in_millis: total_seconds *= 1e3 - return total_seconds \ No newline at end of file + return total_seconds diff --git a/couchbase_analytics/common/_core/error_context.py b/couchbase_analytics/common/_core/error_context.py index ad8a62c..4865cbc 100644 --- a/couchbase_analytics/common/_core/error_context.py +++ b/couchbase_analytics/common/_core/error_context.py @@ -82,9 +82,9 @@ def _ctx_details(self) -> Dict[str, str]: errors = ', '.join(str(e) for e in self.errors) details['errors'] = f'[{errors}]' return details - + def __repr__(self) -> str: return f'{type(self).__name__}({self._ctx_details()})' - + def __str__(self) -> str: - return str(self._ctx_details()) \ No newline at end of file + return str(self._ctx_details()) diff --git a/couchbase_analytics/common/_core/json_parsing.py b/couchbase_analytics/common/_core/json_parsing.py index 4d49834..7cc99e7 100644 --- a/couchbase_analytics/common/_core/json_parsing.py +++ b/couchbase_analytics/common/_core/json_parsing.py @@ -31,10 +31,10 @@ def cause(self) -> Optional[Exception]: def __repr__(self) -> str: return f'JsonParsingError(cause={self._cause})' - + def __str__(self) -> str: return self.__repr__() - + # buffer size in httpcore is 2 ** 16 (65kiB) which matches the default buffer size in ijson # passing in a chunk_size is only applying an abstraction over the httpcore stream @@ -63,4 +63,4 @@ class ParsedResult(NamedTuple): **INTERNAL** """ value: Optional[bytes] - result_type: ParsedResultType \ No newline at end of file + result_type: ParsedResultType diff --git a/couchbase_analytics/common/_core/json_token_parser_base.py b/couchbase_analytics/common/_core/json_token_parser_base.py index a1be359..baf3a01 100644 --- a/couchbase_analytics/common/_core/json_token_parser_base.py +++ b/couchbase_analytics/common/_core/json_token_parser_base.py @@ -42,7 +42,7 @@ def should_pop_results_key(state: ParsingState, previous_state: ParsingState) -> def __str__(self) -> str: return self.value - + class TokenState(Enum): RESULTS_START = 'results_start' @@ -120,11 +120,11 @@ def __init__(self, emit_results_enabled: bool) -> None: @property def has_errors(self) -> bool: return self._has_errors - + @property def results_type(self) -> TokenType: return self._results_type - + def _check_results_in_raw_array(self) -> None: if self._results_type != TokenType.UNKNOWN: return @@ -167,7 +167,7 @@ def _handle_pop_transition(self, token_state: Optional[TokenState]=None) -> bool self._state = ParsingState.PROCESSING_RESULTS return True return False - + def _handle_push_transition(self) -> Optional[TokenState]: if self._state == ParsingState.START_RESULTS_PROCESSING: self._previous_state = self._state @@ -233,7 +233,7 @@ def _pop(self) -> Token: if self._stack: return self._stack.pop() raise ValueError('Stack is empty') - + def _should_push_pair(self, token: Token) -> bool: # when a results object is complete, the state will have transactioned back to PROCESSING # if we are not emitting rows or errors, we want to keep the results/errors object on the stack @@ -247,4 +247,4 @@ def _should_push_pair(self, token: Token) -> bool: if token.state != TokenState.RESULTS_START: return True - return False \ No newline at end of file + return False diff --git a/couchbase_analytics/common/_core/query.py b/couchbase_analytics/common/_core/query.py index eae4a8a..f0aab96 100644 --- a/couchbase_analytics/common/_core/query.py +++ b/couchbase_analytics/common/_core/query.py @@ -85,7 +85,7 @@ def build_query_metadata(json_data: Optional[Any]=None, metadata: QueryMetadataCore = {'request_id':json_data.get('requestID', ''), 'client_context_id':json_data.get('clientContextID', ''), 'warnings':warnings} - + # TODO: include status in metadata?? Seems to only be populated in error scenario if 'status' in json_data: metadata['status'] = json_data.get('status', '') diff --git a/couchbase_analytics/common/_core/utils.py b/couchbase_analytics/common/_core/utils.py index 0e47cac..1e4117a 100644 --- a/couchbase_analytics/common/_core/utils.py +++ b/couchbase_analytics/common/_core/utils.py @@ -143,4 +143,4 @@ def __call__(self, value: Any) -> List[T]: VALIDATE_FLOAT = ValidateType[float]() VALIDATE_STR = ValidateType[str]() VALIDATE_DESERIALIZER = ValidateBaseClass[Deserializer]() -VALIDATE_STR_LIST = ValidateList[str]() \ No newline at end of file +VALIDATE_STR_LIST = ValidateList[str]() diff --git a/couchbase_analytics/common/backoff_calculator.py b/couchbase_analytics/common/backoff_calculator.py new file mode 100644 index 0000000..0a31a5c --- /dev/null +++ b/couchbase_analytics/common/backoff_calculator.py @@ -0,0 +1,42 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from abc import ABC, abstractmethod +from random import uniform +from typing import Optional + + +class BackoffCalculator(ABC): + @abstractmethod + def calculate_backoff(self, retry_count: int) -> float: + raise NotImplementedError + +class DefaultBackoffCalculator(BackoffCalculator): + MIN = 100 + MAX = 60 * 1000 + EXPONENT_BASE = 2 + + def __init__(self, + min: Optional[int]=None, + max: Optional[int]=None, + exponent_base: Optional[int]=None) -> None: + self._min = min or self.MIN + self._max = max or self.MAX + self._exp = exponent_base or self.EXPONENT_BASE + + def calculate_backoff(self, retry_count: int) -> float: + delay_ms = self._min * self._exp ** (retry_count - 1) + capped_ms = min(self._max, delay_ms) + return uniform(0, capped_ms) diff --git a/couchbase_analytics/common/credential.py b/couchbase_analytics/common/credential.py index a8c53a6..8fb1b73 100644 --- a/couchbase_analytics/common/credential.py +++ b/couchbase_analytics/common/credential.py @@ -97,9 +97,9 @@ def _cred_from_env() -> Credential: """ return Credential(**callback().asdict()) - + def __repr__(self) -> str: return f'Credential(username={self._username}, password=****)' - + def __str__(self) -> str: return self.__repr__() diff --git a/couchbase_analytics/common/request.py b/couchbase_analytics/common/request.py index 489505c..f9948b4 100644 --- a/couchbase_analytics/common/request.py +++ b/couchbase_analytics/common/request.py @@ -40,6 +40,6 @@ def __repr__(self) -> str: 'path': self.path if self.path else '' } return f'{type(self).__name__}({details})' - + def __str__(self) -> str: - return self.__repr__() \ No newline at end of file + return self.__repr__() diff --git a/couchbase_analytics/common/result.py b/couchbase_analytics/common/result.py index d9010f2..9b2dd3a 100644 --- a/couchbase_analytics/common/result.py +++ b/couchbase_analytics/common/result.py @@ -134,7 +134,7 @@ def rows(self) -> AsyncIterator: An async iterator for iterating over query results. """ return AsyncIterator(self._http_response) - + async def shutdown(self) -> None: """Shutdown the streaming connection.""" await self._http_response.shutdown() diff --git a/couchbase_analytics/common/streaming.py b/couchbase_analytics/common/streaming.py index 3a85e22..95e7d3b 100644 --- a/couchbase_analytics/common/streaming.py +++ b/couchbase_analytics/common/streaming.py @@ -64,7 +64,7 @@ def is_okay(state: StreamingState) -> bool: return state not in [StreamingState.Cancelled, StreamingState.Error, StreamingState.Timeout] - + @staticmethod def is_timeout_or_cancelled(state: StreamingState) -> bool: """ @@ -159,7 +159,3 @@ class ParsedResult(NamedTuple): """ result: str result_type: HttpResponseType - - - - diff --git a/couchbase_analytics/protocol/_core/client_adapter.py b/couchbase_analytics/protocol/_core/client_adapter.py index dbd8552..fc5eebd 100644 --- a/couchbase_analytics/protocol/_core/client_adapter.py +++ b/couchbase_analytics/protocol/_core/client_adapter.py @@ -61,15 +61,15 @@ def analytics_path(self) -> str: """ **INTERNAL** """ - return self._ANALYTICS_PATH - + return self._ANALYTICS_PATH + @property def client(self) -> Client: """ **INTERNAL** """ return self._client - + @property def client_id(self) -> str: """ @@ -90,7 +90,7 @@ def default_deserializer(self) -> Deserializer: **INTERNAL** """ return self._conn_details.default_deserializer - + @property def has_client(self) -> bool: """ @@ -141,10 +141,10 @@ def send_request(self, request: QueryRequest) -> Response: """ if not hasattr(self, '_client'): raise RuntimeError('Client not created yet') - + # if request.url is None: # raise ValueError('Request URL cannot be None') - + url = URL(scheme=request.url.scheme, host=request.url.host, port=request.url.port, @@ -165,4 +165,3 @@ def reset_client(self) -> None: """ if hasattr(self, '_client'): del self._client - diff --git a/couchbase_analytics/protocol/_core/http_transport.py b/couchbase_analytics/protocol/_core/http_transport.py index 0469f19..0241b8d 100644 --- a/couchbase_analytics/protocol/_core/http_transport.py +++ b/couchbase_analytics/protocol/_core/http_transport.py @@ -255,4 +255,4 @@ def handle_request( ) def close(self) -> None: - self._pool.close() \ No newline at end of file + self._pool.close() diff --git a/couchbase_analytics/protocol/_core/json_stream.py b/couchbase_analytics/protocol/_core/json_stream.py index de1d9f7..43bfd53 100644 --- a/couchbase_analytics/protocol/_core/json_stream.py +++ b/couchbase_analytics/protocol/_core/json_stream.py @@ -109,7 +109,7 @@ def _handle_json_result(self, row: bytes) -> None: """ if self._notify_on_results_or_error is not None and not self._notify_on_results_or_error.done(): self._handle_notification(ParsedResultType.ROW) - + self._put(ParsedResult(row, ParsedResultType.ROW)) def _handle_notification(self, result_type: ParsedResultType) -> None: @@ -145,7 +145,7 @@ def read(self, size: Optional[int]=-1) -> bytes: """ if size is None or size == 0 or self._http_stream_exhausted: return b'' - + while not self._http_stream_exhausted: if size >= 0 and len(self._http_response_buffer) > size: break @@ -164,14 +164,14 @@ def read(self, size: Optional[int]=-1) -> bytes: data = bytes(self._http_response_buffer[:end]) del self._http_response_buffer[:end] return data - + def get_result(self, timeout: float) -> Optional[ParsedResult]: try: return self._results_queue.get(timeout=timeout) except QueueEmpty: # TODO: log a message here as indication the stream is slow return None - + def start_parsing(self, request_context: Optional[RequestContext]=None, notify_on_results_or_error: Optional[Future[ParsedResultType]]=None) -> None: @@ -182,4 +182,4 @@ def start_parsing(self, self._process_token_stream(request_context=request_context) def continue_parsing(self, request_context: Optional[RequestContext]=None,) -> None: - self._process_token_stream(request_context=request_context) \ No newline at end of file + self._process_token_stream(request_context=request_context) diff --git a/couchbase_analytics/protocol/_core/json_token_parser.py b/couchbase_analytics/protocol/_core/json_token_parser.py index 0177108..43ae354 100644 --- a/couchbase_analytics/protocol/_core/json_token_parser.py +++ b/couchbase_analytics/protocol/_core/json_token_parser.py @@ -41,7 +41,7 @@ def _handle_obj_emit(self, obj: str) -> bool: self._result_handler(bytes(obj, 'utf-8')) return True return False - + def _handle_pop_event(self, token_type: TokenType) -> None: matching_token = self._get_matching_token(token_type) obj_pairs: List[str] = [] @@ -60,7 +60,7 @@ def _handle_pop_event(self, token_type: TokenType) -> None: if len(self._stack) > 0 and self._stack[-1].type == TokenType.MAP_KEY: map_key = self._pop() # If we are emitting rows and/or errors, - # we don't keep them in the stack and therefore don't need to return the results + # we don't keep them in the stack and therefore don't need to return the results if self._should_push_pair(next_token): self._push(TokenType.PAIR, f'{map_key.value}:{obj}') else: diff --git a/couchbase_analytics/protocol/_core/net_utils.py b/couchbase_analytics/protocol/_core/net_utils.py index f4e18e9..1e2cb13 100644 --- a/couchbase_analytics/protocol/_core/net_utils.py +++ b/couchbase_analytics/protocol/_core/net_utils.py @@ -51,4 +51,4 @@ def get_request_ip(host: str, ip_str = str(ip) if not isinstance(ip, str) else ip ip = None if ip_str in previous_ips else ip_str - return ip \ No newline at end of file + return ip diff --git a/couchbase_analytics/protocol/_core/request.py b/couchbase_analytics/protocol/_core/request.py index e419d7d..a523865 100644 --- a/couchbase_analytics/protocol/_core/request.py +++ b/couchbase_analytics/protocol/_core/request.py @@ -48,7 +48,7 @@ class QueryRequest: body: Dict[str, Union[str, object]] extensions: RequestExtensions method: str = 'POST' - + options: Optional[QueryOptionsTransformedKwargs] = None previous_ips: Optional[Set[str]] = None enable_cancel: Optional[bool] = None @@ -108,7 +108,7 @@ def __init__(self, self._opts_builder = client.options_builder self._database_name = database_name self._scope_name = scope_name - + connect_timeout = self._conn_details.get_connect_timeout() self._default_query_timeout = self._conn_details.get_query_timeout() self._extensions: RequestExtensions = { @@ -132,7 +132,7 @@ def build_base_query_request(self, # noqa: C901 cancel_kwarg_token = kwargs.pop('enable_cancel', None) if isinstance(cancel_kwarg_token, bool): enable_cancel = cancel_kwarg_token - + # default if no options provided opts = QueryOptions() args_list = list(args) @@ -168,7 +168,7 @@ def build_base_query_request(self, # noqa: C901 'statement': statement, 'client_context_id': q_opts.get('client_context_id', None) or str(uuid4()) } - + if self._database_name is not None and self._scope_name is not None: body['query_context'] = f'default:`{self._database_name}`.`{self._scope_name}`' @@ -199,7 +199,7 @@ def build_base_query_request(self, # noqa: C901 key = f'${k}' if not k.startswith('$') else k body[key] = v elif opt_key == 'readonly': - body['readonly'] = opt_val + body['readonly'] = opt_val elif opt_key == 'scan_consistency': if isinstance(opt_val, QueryScanConsistency): body['scan_consistency'] = opt_val.value diff --git a/couchbase_analytics/protocol/_core/request_context.py b/couchbase_analytics/protocol/_core/request_context.py index 59888b3..e662881 100644 --- a/couchbase_analytics/protocol/_core/request_context.py +++ b/couchbase_analytics/protocol/_core/request_context.py @@ -53,11 +53,11 @@ def __init__(self, bg_future: Future[BlockingQueryResult], self._cancel_event = cancel_event self._background_work_ft.add_done_callback(self._background_work_done) self._user_ft.add_done_callback(self._user_done) - + @property def user_cancelled(self) -> bool: return self._user_ft.cancelled() - + def _background_work_done(self, ft: Future[BlockingQueryResult]) -> None: """ Callback to handle when the background work future is done. @@ -85,7 +85,7 @@ def _user_done(self, ft: Future[BlockingQueryResult]) -> None: self._background_work_ft.cancel() return - + class RequestContext: @@ -115,7 +115,7 @@ def __init__(self, @property def cancel_enabled(self) -> Optional[bool]: return self._request.enable_cancel - + @property def cancelled(self) -> bool: self._check_cancelled_or_timed_out() @@ -128,7 +128,7 @@ def error_context(self) -> ErrorContext: @property def has_stage_completed(self) -> bool: return self._stage_completed_ft is not None and self._stage_completed_ft.done() - + @property def is_shutdown(self) -> bool: return self._shutdown @@ -138,7 +138,7 @@ def okay_to_iterate(self) -> bool: # NOTE: Called prior to upstream logic attempting to iterate over results from HTTP client self._check_cancelled_or_timed_out() return StreamingState.okay_to_iterate(self._request_state) - + @property def okay_to_stream(self) -> bool: # NOTE: Called prior to upstream logic attempting to send request to HTTP client @@ -152,7 +152,7 @@ def request_error(self) -> Optional[Exception]: @property def request_state(self) -> StreamingState: return self._request_state - + @property def timed_out(self) -> bool: self._check_cancelled_or_timed_out() @@ -161,7 +161,7 @@ def timed_out(self) -> bool: def _check_cancelled_or_timed_out(self) -> None: if self._request_state in [StreamingState.Timeout, StreamingState.Cancelled, StreamingState.Error]: return - + if (self._cancel_event.is_set() or (self._background_request is not None and self._background_request.user_cancelled)): @@ -212,7 +212,7 @@ def _start_next_stage(self, self._stage_completed_ft = None elif self._stage_completed_ft is not None and not self._stage_completed_ft.done(): raise RuntimeError('Future already running in this context.') - + kwargs: Dict[str, Union[RequestContext, Future[ParsedResultType]]] = {'request_context': self} if create_notification is True: self._create_stage_notification_future() @@ -242,7 +242,7 @@ def deserialize_result(self, result: bytes) -> Any: def finish_processing_stream(self) -> None: if not self.has_stage_completed: self._wait_for_stage_completed() - + if self.cancelled: return @@ -266,10 +266,10 @@ def initialize(self) -> None: def maybe_continue_to_process_stream(self) -> None: if not self.has_stage_completed: return - + if self._json_stream.token_stream_exhausted: return - + if self.cancelled: return @@ -280,7 +280,7 @@ def okay_to_delay_and_retry(self, delay: float) -> bool: self._check_cancelled_or_timed_out() if self._request_state in [StreamingState.Timeout, StreamingState.Cancelled]: return False - + current_time = time.monotonic() delay_time = current_time + delay will_time_out = self._request_deadline < delay_time @@ -291,7 +291,7 @@ def okay_to_delay_and_retry(self, delay: float) -> bool: else: self._reset_stream() return True - + def process_response(self, close_handler: Callable[[], None], raw_response: Optional[ParsedResult]=None, @@ -302,7 +302,7 @@ def process_response(self, close_handler() raise AnalyticsError(message='Received unexpected empty result from JsonStream.', context=str(self._error_ctx)) - + if raw_response.value is None: close_handler() raise AnalyticsError(message='Received unexpected empty response value from JsonStream.', @@ -321,7 +321,7 @@ def send_request(self, enable_trace_handling: Optional[bool]=False) -> HttpCoreR attempted_ips = ', '.join(self._request.previous_ips or []) raise AnalyticsError(message=f'Connect failure. Unable to connect to any resolved IPs: {attempted_ips}.', context=str(self._error_ctx)) - + if enable_trace_handling is True: (self._request.update_url(ip, self._client_adapter.analytics_path) .add_trace_to_extensions(self._trace_handler) @@ -334,13 +334,13 @@ def send_request(self, enable_trace_handling: Optional[bool]=False) -> HttpCoreR # print(f'Response received: {response.status_code} for request {self._id}, body={self._request.body}.') if response.status_code == 401: raise InvalidCredentialError(context=str(self._error_ctx)) - + return response - + def send_request_in_background(self, fn: Callable[..., BlockingQueryResult], *args: object,) -> Future[BlockingQueryResult]: - + if self._background_request is not None: raise RuntimeError('Background reqeust already created for this context.') # TODO: custom ThreadPoolExecutor, to get a "plain" future @@ -372,7 +372,7 @@ def start_stream(self, core_response: HttpCoreResponse) -> None: if hasattr(self, '_json_stream'): # TODO: logging; I don't think this is an error... return - + # TODO: need to confirm if the httpx Response iterator is thread-safe self._json_stream = JsonStream(core_response.iter_bytes(), stream_config=self._stream_config) self._start_next_stage(self._json_stream.start_parsing, create_notification=True) @@ -386,4 +386,4 @@ def wait_for_stage_notification(self) -> None: result_type = self._stage_notification_ft.result(timeout=deadline) if result_type == ParsedResultType.ROW: # we move to iterating rows - self._request_state = StreamingState.StreamingResults \ No newline at end of file + self._request_state = StreamingState.StreamingResults diff --git a/couchbase_analytics/protocol/_core/retries.py b/couchbase_analytics/protocol/_core/retries.py index 607b34f..254742a 100644 --- a/couchbase_analytics/protocol/_core/retries.py +++ b/couchbase_analytics/protocol/_core/retries.py @@ -88,12 +88,10 @@ def wrapped_fn(self: HttpStreamingResponse) -> None: # noqa: C901 self.close() return wrapped_fn - + def calc_backoff(retry_count: int) -> float: min_ms = 100 max_ms = 60000 delay_ms = min_ms * pow(2, retry_count) capped_ms = min(max_ms, delay_ms) return uniform(0, capped_ms / 1000.0) - - diff --git a/couchbase_analytics/protocol/cluster.py b/couchbase_analytics/protocol/cluster.py index c8dc73f..6411cb3 100644 --- a/couchbase_analytics/protocol/cluster.py +++ b/couchbase_analytics/protocol/cluster.py @@ -38,7 +38,7 @@ def __init__(self, credential: Credential, options: Optional[ClusterOptions] = None, **kwargs: object) -> None: - + self._client_adapter = _ClientAdapter(http_endpoint, credential, options, **kwargs) self._request_builder = _RequestBuilder(self._client_adapter) self._cluster_id = str(uuid4()) @@ -58,7 +58,7 @@ def client_adapter(self) -> _ClientAdapter: **INTERNAL** """ return self._client_adapter - + @property def cluster_id(self) -> str: """ diff --git a/couchbase_analytics/protocol/connection.py b/couchbase_analytics/protocol/connection.py index 9a60dbe..d868540 100644 --- a/couchbase_analytics/protocol/connection.py +++ b/couchbase_analytics/protocol/connection.py @@ -84,7 +84,7 @@ def parse_http_endpoint(http_endpoint: str) -> Tuple[RequestURL, Dict[str, List[ if port is None: raise ValueError('The URL must have a port specified.') - + if not is_null_or_empty(parsed_endpoint.path): raise ValueError('The SDK does not currently support HTTP endpoint paths.') @@ -131,7 +131,7 @@ def parse_query_str_options(query_str_opts: Dict[str, List[str]]) -> Dict[str, Q else: print('Warning: Unrecognized query string option:', k) # TODO: exceptions -- this means the user passed in an invalid option - pass + pass else: if k in SecurityOptions.VALID_OPTION_KEYS: msg = f'Invalid query string option: {k}.' @@ -156,7 +156,7 @@ class _ConnectionDetails: default_deserializer: Deserializer ssl_context: Optional[ssl.SSLContext] = None sni_hostname: Optional[str] = None - + def get_connect_timeout(self) -> float: timeout_opts: Optional[TimeoutOptionsTransformedKwargs] = self.cluster_options.get('timeout_options') if timeout_opts is not None: @@ -164,7 +164,7 @@ def get_connect_timeout(self) -> float: if connect_timeout is not None: return connect_timeout return DEFAULT_TIMEOUTS['connect_timeout'] - + def get_query_timeout(self) -> float: timeout_opts: Optional[TimeoutOptionsTransformedKwargs] = self.cluster_options.get('timeout_options') if timeout_opts is not None: @@ -187,10 +187,10 @@ def validate_security_options(self) -> None: if security_opt_count > 1 or (security_opt_count == 1 and trust_capella is True): raise ValueError(('Can only set one of the following options: ' f'[{", ".join(["trust_only_capella"] + solo_security_opts)}]')) - + if not self.is_secure(): return - + self.ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) self.sni_hostname = self.url.host @@ -219,7 +219,7 @@ def validate_security_options(self) -> None: else: self.ssl_context.check_hostname = True self.ssl_context.verify_mode = ssl.CERT_REQUIRED - + @classmethod def create(cls, diff --git a/couchbase_analytics/protocol/errors.py b/couchbase_analytics/protocol/errors.py index 7694cf6..3d07b66 100644 --- a/couchbase_analytics/protocol/errors.py +++ b/couchbase_analytics/protocol/errors.py @@ -64,7 +64,7 @@ def __eq__(self, other: object) -> bool: def __repr__(self) -> str: return f'ServerQueryError(code={self.code}, message={self.message}, retriable={self.retriable})' - + @classmethod def from_json(cls, json_data: Dict[str, Any]) -> ServerQueryError: """ @@ -86,7 +86,7 @@ def __init__(self, @property def retriable(self) -> bool: return self._retriable - + @retriable.setter def retriable(self, value: bool) -> None: self._retriable = value @@ -94,7 +94,7 @@ def retriable(self, value: bool) -> None: def maybe_set_cause_context(self, context: ErrorContext) -> None: if not isinstance(self._cause, (AnalyticsError, InvalidCredentialError, QueryError, TimeoutError)): return - + if hasattr(self._cause, '_context') and self._cause._context is None: self._cause._context = str(context) @@ -106,7 +106,7 @@ def unwrap(self) -> Union[BaseException, Exception]: def __repr__(self) -> str: return f'{type(self).__name__}(cause={self._cause!r}, retriable={self._retriable})' - + def __str__(self) -> str: return self.__repr__() @@ -124,7 +124,7 @@ def __str__(self) -> str: socket.EAI_SYSTEM, socket.EAI_BADHINTS, socket.EAI_PROTOCOL, - socket.EAI_MAX + socket.EAI_MAX ] @@ -136,7 +136,7 @@ def build_error_from_json(json_data: List[Dict[str, Any]], context: ErrorContext return WrappedError(AnalyticsError(context=str(context), message='Unknown error occurred.')) if context.status_code == 401: return WrappedError(InvalidCredentialError(context=str(context), message='Invalid credentials provided.')) - + first_non_retriable_error: Optional[ServerQueryError] = None first_retriable_error: Optional[ServerQueryError] = None errs: List[ServerQueryError] = [] @@ -146,7 +146,7 @@ def build_error_from_json(json_data: List[Dict[str, Any]], context: ErrorContext retriable = bool(err_data.get('retriable', False)) or False if not retriable and first_non_retriable_error is None: first_non_retriable_error = err - + if retriable and first_retriable_error is None: first_retriable_error = err @@ -155,19 +155,19 @@ def build_error_from_json(json_data: List[Dict[str, Any]], context: ErrorContext if first_err is None: err_msg = 'Could not parse errors from server response (expected JSON array).' return WrappedError(AnalyticsError(context=str(context), message=err_msg)) - + if first_err.code == 20000: return WrappedError(InvalidCredentialError(context=str(context))) if first_err.code == 21002: return WrappedError(TimeoutError(context=str(context), message='Received timeout error from server.')) - + retriable = first_non_retriable_error is None and first_retriable_error is not None return WrappedError(QueryError(code=first_err.code, server_message=first_err.message, context=str(context)), retriable=retriable) - @staticmethod + @staticmethod def handle_socket_error(fn: Callable[[str, int, Optional[Set[str]]], Optional[str]] ) -> Callable[[str, int, Optional[Set[str]]], Optional[str]]: @wraps(fn) @@ -181,10 +181,5 @@ def wrapped_fn(host: str, msg='Connection error occurred while sending request.' raise WrappedError(AnalyticsError(cause=ex, message=msg), retriable=(ex.errno not in _NON_RETRYABLE_SOCKET_ERRORS)) from None - - return wrapped_fn - - - - + return wrapped_fn diff --git a/couchbase_analytics/protocol/scope.py b/couchbase_analytics/protocol/scope.py index 5aa06a1..650bb6c 100644 --- a/couchbase_analytics/protocol/scope.py +++ b/couchbase_analytics/protocol/scope.py @@ -69,7 +69,7 @@ def execute_query(self, stream_config=stream_config) resp = HttpStreamingResponse(request_context, lazy_execute=lazy_execute) - + def _execute_query(http_response: HttpStreamingResponse) -> BlockingQueryResult: http_response.send_request() return BlockingQueryResult(http_response) diff --git a/couchbase_analytics/protocol/streaming.py b/couchbase_analytics/protocol/streaming.py index aa22402..d12dcc6 100644 --- a/couchbase_analytics/protocol/streaming.py +++ b/couchbase_analytics/protocol/streaming.py @@ -68,7 +68,7 @@ def _process_response(self, raw_response=raw_response, handle_context_shutdown=handle_context_shutdown) self.set_metadata(json_data=json_response) - + def close(self) -> None: """ **INTERNAL** @@ -76,7 +76,7 @@ def close(self) -> None: if hasattr(self,'_core_response'): self._core_response.close() del self._core_response - + def cancel(self) -> None: """ **INTERNAL** @@ -84,7 +84,7 @@ def cancel(self) -> None: self.close() self._request_context.cancel_request() self._request_context.shutdown() - + def get_metadata(self) -> QueryMetadata: if self._metadata is None: @@ -116,7 +116,7 @@ def get_next_row(self) -> Any: and self._core_response is not None and self._request_context.okay_to_iterate): self._handle_iteration_abort() - + self._request_context.maybe_continue_to_process_stream() check_state = False while True: @@ -140,7 +140,7 @@ def get_next_row(self) -> Any: elif raw_response.result_type == ParsedResultType.END: self.set_metadata(raw_metadata=raw_response.value) raise StopIteration - + @RetryHandler.with_retries def send_request(self) -> None: if not self._request_context.okay_to_stream: @@ -156,4 +156,3 @@ def send_request(self) -> None: if not self._request_context.okay_to_iterate: self._request_context.finish_processing_stream() self._process_response() - diff --git a/couchbase_analytics/tests/backoff_calc_t.py b/couchbase_analytics/tests/backoff_calc_t.py new file mode 100644 index 0000000..b46f709 --- /dev/null +++ b/couchbase_analytics/tests/backoff_calc_t.py @@ -0,0 +1,61 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import pytest + +from couchbase_analytics.common.backoff_calculator import DefaultBackoffCalculator + +MIN = 100 +MAX = 60 * 1000 +EXPONENT_BASE = 2 + +class BackoffCalcTestSuite: + TEST_MANIFEST = [ + 'test_backoff_calcs', + ] + + @pytest.mark.parametrize('retry_count, max_expected', + [(1, MIN * EXPONENT_BASE ** 0), + (2, MIN * EXPONENT_BASE ** 1), + (3, MIN * EXPONENT_BASE ** 2), + (4, MIN * EXPONENT_BASE ** 3), + (5, MIN * EXPONENT_BASE ** 4), + (6, MIN * EXPONENT_BASE ** 5), + (7, MIN * EXPONENT_BASE ** 6), + (8, MIN * EXPONENT_BASE ** 7), + (9, MIN * EXPONENT_BASE ** 8), + (10, MIN * EXPONENT_BASE ** 9), + (1000, MAX), + ]) + def test_backoff_calcs(self, retry_count: int, max_expected: float) -> None: + calc = DefaultBackoffCalculator() + for _ in range(10): + delay = calc.calculate_backoff(retry_count) + assert delay <= max_expected + + +class BackoffCalcTests(BackoffCalcTestSuite): + + @pytest.fixture(scope='class', autouse=True) + def validate_test_manifest(self) -> None: + def valid_test_method(meth: str) -> bool: + attr = getattr(BackoffCalcTests, meth) + return callable(attr) and not meth.startswith('__') and meth.startswith('test') + method_list = [meth for meth in dir(BackoffCalcTests) if valid_test_method(meth)] + test_list = set(BackoffCalcTestSuite.TEST_MANIFEST).symmetric_difference(method_list) + if test_list: + pytest.fail(f'Test manifest invalid. Missing/extra tests: {test_list}.') diff --git a/couchbase_analytics/tests/duration_parsing_t.py b/couchbase_analytics/tests/duration_parsing_t.py index f0c7f3e..dfff54e 100644 --- a/couchbase_analytics/tests/duration_parsing_t.py +++ b/couchbase_analytics/tests/duration_parsing_t.py @@ -58,7 +58,7 @@ def test_invalid_durations(self, duration: str) -> None: ('0.1h10m', 9.6e5), # TODO: apparently this is invalid in Go, but was okay w/ C++ implementation ('.1h10m', 9.6e5), - ('0001h00010m', 4.2e6), + ('0001h00010m', 4.2e6), ('100ns', 1e-4), ('100us', 0.1), ('100μs', 0.1), @@ -92,4 +92,4 @@ def valid_test_method(meth: str) -> bool: method_list = [meth for meth in dir(DurationParsingTests) if valid_test_method(meth)] test_list = set(DurationParsingTestSuite.TEST_MANIFEST).symmetric_difference(method_list) if test_list: - pytest.fail(f'Test manifest invalid. Missing/extra tests: {test_list}.') \ No newline at end of file + pytest.fail(f'Test manifest invalid. Missing/extra tests: {test_list}.') diff --git a/couchbase_analytics/tests/json_parsing_t.py b/couchbase_analytics/tests/json_parsing_t.py index a1d4ae4..5aaec94 100644 --- a/couchbase_analytics/tests/json_parsing_t.py +++ b/couchbase_analytics/tests/json_parsing_t.py @@ -427,4 +427,4 @@ def valid_test_method(meth: str) -> bool: @pytest.fixture(scope='class', name='test_env') def couchbase_test_environment(self, simple_test_env: SimpleEnvironment) -> SimpleEnvironment: - return simple_test_env \ No newline at end of file + return simple_test_env diff --git a/couchbase_analytics/tests/options_t.py b/couchbase_analytics/tests/options_t.py index 967bb9c..ff35c97 100644 --- a/couchbase_analytics/tests/options_t.py +++ b/couchbase_analytics/tests/options_t.py @@ -221,4 +221,4 @@ def valid_test_method(meth: str) -> bool: method_list = [meth for meth in dir(ClusterOptionsTests) if valid_test_method(meth)] test_list = set(ClusterOptionsTestSuite.TEST_MANIFEST).symmetric_difference(method_list) if test_list: - pytest.fail(f'Test manifest invalid. Missing/extra tests: {test_list}.') \ No newline at end of file + pytest.fail(f'Test manifest invalid. Missing/extra tests: {test_list}.') diff --git a/couchbase_analytics/tests/query_integration_t.py b/couchbase_analytics/tests/query_integration_t.py index 5a9fe6d..e8f8837 100644 --- a/couchbase_analytics/tests/query_integration_t.py +++ b/couchbase_analytics/tests/query_integration_t.py @@ -59,7 +59,7 @@ class QueryTestSuite: 'test_query_timeout_while_streaming', 'test_simple_query', 'test_query_with_lazy_execution', - 'test_query_with_lazy_execution_raises_exception', + 'test_query_with_lazy_execution_raises_exception', ] @pytest.fixture(scope='class') @@ -112,7 +112,7 @@ def test_cancel_prior_iterating(self, test_env: BlockingTestEnvironment, cancel_ assert isinstance(res, BlockingQueryResult) assert res._http_response._request_context.request_state == StreamingState.Cancelled - + for row in res.rows(): rows.append(row) @@ -147,7 +147,7 @@ def test_cancel_prior_iterating_positional_params(self, assert isinstance(res, BlockingQueryResult) assert res._http_response._request_context.request_state == StreamingState.Cancelled - + for row in res.rows(): rows.append(row) @@ -182,13 +182,13 @@ def test_cancel_prior_iterating_with_kwargs(self, assert isinstance(res, BlockingQueryResult) assert res._http_response._request_context.request_state == StreamingState.Cancelled - + for row in res.rows(): rows.append(row) with pytest.raises(RuntimeError): res.metadata() - + test_env.assert_streaming_response_state(res) @pytest.mark.parametrize('cancel_via_future', [False, True]) @@ -217,7 +217,7 @@ def test_cancel_prior_iterating_with_options(self, assert isinstance(res, BlockingQueryResult) assert res._http_response._request_context.request_state == StreamingState.Cancelled - + for row in res.rows(): rows.append(row) @@ -252,7 +252,7 @@ def test_cancel_prior_iterating_with_opts_and_kwargs(self, assert isinstance(res, BlockingQueryResult) assert res._http_response._request_context.request_state == StreamingState.Cancelled - + for row in res.rows(): rows.append(row) @@ -452,7 +452,7 @@ def test_query_passthrough_deserializer(self, query_type: SyncQueryType) -> None: statement = 'FROM range(0, 10) AS num SELECT *' - + if query_type == SyncQueryType.NORMAL: result = test_env.cluster_or_scope.execute_query(statement, QueryOptions(deserializer=PassthroughDeserializer())) @@ -498,7 +498,7 @@ def test_query_positional_params_no_option(self, test_env: BlockingTestEnvironment, query_statement_pos_params_limit2: str, query_type: SyncQueryType) -> None: - + if query_type == SyncQueryType.NORMAL: result = test_env.cluster_or_scope.execute_query(query_statement_pos_params_limit2, 'United States') elif query_type == SyncQueryType.LAZY: @@ -570,7 +570,7 @@ def test_query_raw_options(self, else: statement = f'SELECT * FROM {test_env.fqdn} WHERE country = $country LIMIT $1;' - + if query_type == SyncQueryType.NORMAL: result = test_env.cluster_or_scope.execute_query(statement, QueryOptions(raw={'$country': 'United States', @@ -589,7 +589,7 @@ def test_query_raw_options(self, result = res.result() test_env.assert_rows(result, 2) - + if query_type == SyncQueryType.NORMAL: result = test_env.cluster_or_scope.execute_query(query_statement_pos_params_limit2, QueryOptions(raw={'args': ['United States']})) @@ -609,7 +609,7 @@ def test_query_raw_options(self, @pytest.mark.parametrize('query_type', [SyncQueryType.NORMAL, SyncQueryType.LAZY, SyncQueryType.CANCELLABLE]) def test_query_timeout(self, test_env: BlockingTestEnvironment, query_type: SyncQueryType) -> None: statement = 'SELECT sleep("some value", 10000) AS some_field;' - + if query_type == SyncQueryType.NORMAL: with pytest.raises(TimeoutError): result = test_env.cluster_or_scope.execute_query(statement, @@ -657,7 +657,7 @@ def test_simple_query(self, test_env: BlockingTestEnvironment, query_statement_limit2: str, query_type: SyncQueryType) -> None: - + if query_type == SyncQueryType.NORMAL: result = test_env.cluster_or_scope.execute_query(query_statement_limit2) elif query_type == SyncQueryType.LAZY: @@ -733,4 +733,4 @@ def couchbase_test_environment(self, test_env = sync_test_env.enable_scope() yield test_env test_env.disable_scope() - test_env.teardown() \ No newline at end of file + test_env.teardown() diff --git a/couchbase_analytics/tests/query_options_t.py b/couchbase_analytics/tests/query_options_t.py index be272c3..1c69de8 100644 --- a/couchbase_analytics/tests/query_options_t.py +++ b/couchbase_analytics/tests/query_options_t.py @@ -65,7 +65,7 @@ class QueryOptionsTestSuite: @pytest.fixture(scope='class') def query_statment(self) -> str: return 'SELECT * FROM default' - + def test_options_deserializer(self, query_statment: str, request_builder: _RequestBuilder, @@ -250,7 +250,7 @@ def test_options_timeout_must_be_positive_kwargs(self, with pytest.raises(ValueError): request_builder.build_base_query_request(query_statment, **kwargs) - + class ClusterQueryOptionsTests(QueryOptionsTestSuite): @pytest.fixture(scope='class', autouse=True) @@ -294,4 +294,4 @@ def request_builder(self) -> _RequestBuilder: cred = Credential.from_username_and_password('Administrator', 'password') return _RequestBuilder(_ClientAdapter('https://localhost', cred), 'test-database', - 'test-scope') \ No newline at end of file + 'test-scope') diff --git a/couchbase_analytics/tests/test_server_t.py b/couchbase_analytics/tests/test_server_t.py index ca6c9c1..e9aa673 100644 --- a/couchbase_analytics/tests/test_server_t.py +++ b/couchbase_analytics/tests/test_server_t.py @@ -93,7 +93,7 @@ def test_error_retriable_response(self, test_env: BlockingTestEnvironment) -> No statement = 'SELECT "Hello, data!" AS greeting' with pytest.raises(TimeoutError) as ex: test_env.cluster_or_scope.execute_query(statement, QueryOptions(timeout=timedelta(seconds=2))) - + test_env.assert_error_context_num_attempts(4 , ex.value._context, exact=False) test_env.assert_error_context_contains_last_dispatch(ex.value._context) @@ -203,4 +203,4 @@ def couchbase_test_environment(self, test_env = sync_test_env_with_server.enable_test_server() test_env.enable_scope() yield test_env - test_env.disable_scope().disable_test_server() \ No newline at end of file + test_env.disable_scope().disable_test_server() diff --git a/couchbase_analytics_version.py b/couchbase_analytics_version.py index ef0ff57..80e71d9 100644 --- a/couchbase_analytics_version.py +++ b/couchbase_analytics_version.py @@ -64,8 +64,8 @@ def __init__(self, rawtext: str): self.ver_extra = re.sub(r'^beta', 'b', self.ver_extra, count=1) m = re.search(r'^([ab]|dev|rc|post)\.?(\d+)?', self.ver_extra) if m is not None: - if m.group(1) in ["dev", "post"]: - self.ver_extra = "." + self.ver_extra.replace('.', '') + if m.group(1) in ['dev', 'post']: + self.ver_extra = '.' + self.ver_extra.replace('.', '') if m.group(2) is None: # No suffix, then add the number first = self.ver_extra[0] @@ -109,32 +109,32 @@ def get_version() -> str: loading it (and thus trying to load the extension module). """ if not os.path.exists(VERSION_FILE): - raise VersionNotFound(VERSION_FILE + " does not exist") - fp = open(VERSION_FILE, "r") + raise VersionNotFound(VERSION_FILE + ' does not exist') + fp = open(VERSION_FILE, 'r') vline = None for x in fp.readlines(): x = x.rstrip() if not x: continue - if not x.startswith("__version__"): + if not x.startswith('__version__'): continue vline = x.split('=')[1] break if not vline: - raise VersionNotFound("version file present but has no contents") + raise VersionNotFound('version file present but has no contents') return vline.strip().rstrip().replace("'", '') def get_git_describe() -> str: - if not os.path.exists(os.path.join(os.path.dirname(__file__), ".git")): - raise CantInvokeGit("Not a git build") + if not os.path.exists(os.path.join(os.path.dirname(__file__), '.git')): + raise CantInvokeGit('Not a git build') try: po = subprocess.Popen( - ("git", "describe", "--tags", "--long", "--always"), - stdout=subprocess.PIPE, stderr=subprocess.PIPE) + ('git', 'describe', '--tags', '--long', '--always'), stdout=subprocess.PIPE, stderr=subprocess.PIPE + ) except OSError as e: raise CantInvokeGit(e) from None @@ -179,18 +179,18 @@ def gen_version(do_write: Optional[bool] = True, txt: Optional[str] = None) -> N '# at', '# {0}'.format(datetime.datetime.now().isoformat(' ')), "__version__ = '{0}'".format(vstr), - "" + '', ) - with open(VERSION_FILE, "w") as fp: - fp.write("\n".join(lines)) + with open(VERSION_FILE, 'w') as fp: + fp.write('\n'.join(lines)) if __name__ == '__main__': from argparse import ArgumentParser + ap = ArgumentParser(description='Parse git version to PEP-440 version') ap.add_argument('-c', '--mode', choices=('show', 'make', 'parse')) - ap.add_argument('-i', '--input', - help='Sample input string (instead of git)') + ap.add_argument('-i', '--input', help='Sample input string (instead of git)') options = ap.parse_args() cmd = options.mode diff --git a/dev_requirements.txt b/dev_requirements.txt deleted file mode 100644 index a3de3d8..0000000 --- a/dev_requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -aiohttp~=3.11.10 -pytest~=8.3.5 \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 80c74b6..e08534f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta" [project] name = "couchbase-analytics" -version = "0.0.1" +version = "1.0.0-dev.1" dependencies = [ "anyio~=4.9.0", "httpx~=0.28.1", @@ -54,9 +54,10 @@ Repository = "https://github.com/couchbase/analytics-python-client" [dependency-groups] dev = [ "aiohttp~=3.11.10", - "mypy>=1.16.1", + "mypy~=1.16.1", + "pre-commit~=4.2.0", "pytest~=8.3.5", - "ruff>=0.12.0", + "ruff~=0.12.0", ] sphinx = [ "Sphinx~=7.4.7", diff --git a/requirements-dev.in b/requirements-dev.in new file mode 100644 index 0000000..16bf6d7 --- /dev/null +++ b/requirements-dev.in @@ -0,0 +1,5 @@ +aiohttp~=3.11.10 +mypy~=1.16.1 +pre-commit~=4.2.0 +pytest~=8.3.5 +ruff~=0.12.0 diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..864bf25 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,75 @@ +# This file was autogenerated by uv via the following command: +# uv pip compile requirements-dev.in --python-version 3.9 --universal -o requirements-dev.txt +aiohappyeyeballs==2.6.1 + # via aiohttp +aiohttp==3.11.18 + # via -r requirements-dev.in +aiosignal==1.3.2 + # via aiohttp +async-timeout==5.0.1 ; python_full_version < '3.11' + # via aiohttp +attrs==25.3.0 + # via aiohttp +cfgv==3.4.0 + # via pre-commit +colorama==0.4.6 ; sys_platform == 'win32' + # via pytest +distlib==0.3.9 + # via virtualenv +exceptiongroup==1.3.0 ; python_full_version < '3.11' + # via pytest +filelock==3.18.0 + # via virtualenv +frozenlist==1.7.0 + # via + # aiohttp + # aiosignal +identify==2.6.12 + # via pre-commit +idna==3.10 + # via yarl +iniconfig==2.1.0 + # via pytest +multidict==6.6.3 + # via + # aiohttp + # yarl +mypy==1.16.1 + # via -r requirements-dev.in +mypy-extensions==1.1.0 + # via mypy +nodeenv==1.9.1 + # via pre-commit +packaging==25.0 + # via pytest +pathspec==0.12.1 + # via mypy +platformdirs==4.3.8 + # via virtualenv +pluggy==1.6.0 + # via pytest +pre-commit==4.2.0 + # via -r requirements-dev.in +propcache==0.3.2 + # via + # aiohttp + # yarl +pytest==8.3.5 + # via -r requirements-dev.in +pyyaml==6.0.2 + # via pre-commit +ruff==0.12.2 + # via -r requirements-dev.in +tomli==2.2.1 ; python_full_version < '3.11' + # via + # mypy + # pytest +typing-extensions==4.14.0 + # via + # exceptiongroup + # multidict + # mypy +virtualenv==20.31.2 + # via pre-commit +yarl==1.20.1 + # via aiohttp diff --git a/sphinx_requirements.txt b/requirements-sphinx.in similarity index 79% rename from sphinx_requirements.txt rename to requirements-sphinx.in index f394b7b..8733811 100644 --- a/sphinx_requirements.txt +++ b/requirements-sphinx.in @@ -2,4 +2,4 @@ Sphinx~=7.4.7 sphinx-rtd-theme~=2.0 sphinx-copybutton~=0.5 enum-tools~=0.12 -sphinx-toolbox~=3.7 \ No newline at end of file +sphinx-toolbox~=3.7 diff --git a/requirements-sphinx.txt b/requirements-sphinx.txt new file mode 100644 index 0000000..0c15c0b --- /dev/null +++ b/requirements-sphinx.txt @@ -0,0 +1,160 @@ +# This file was autogenerated by uv via the following command: +# uv pip compile requirements-sphinx.in --python-version 3.9 --universal -o requirements-sphinx.txt +alabaster==0.7.16 + # via sphinx +apeye==1.4.1 + # via sphinx-toolbox +apeye-core==1.1.5 + # via apeye +autodocsumm==0.2.14 + # via sphinx-toolbox +babel==2.17.0 + # via sphinx +beautifulsoup4==4.13.4 + # via sphinx-toolbox +cachecontrol==0.14.3 + # via sphinx-toolbox +certifi==2025.6.15 + # via + # requests + # sphinx-prompt +charset-normalizer==3.4.2 + # via requests +colorama==0.4.6 ; sys_platform == 'win32' + # via sphinx +cssutils==2.11.1 + # via dict2css +dict2css==0.3.0.post1 + # via sphinx-toolbox +docutils==0.20.1 + # via + # sphinx + # sphinx-prompt + # sphinx-rtd-theme + # sphinx-tabs + # sphinx-toolbox +domdf-python-tools==3.10.0 + # via + # apeye + # apeye-core + # dict2css + # sphinx-toolbox +enum-tools==0.13.0 + # via -r requirements-sphinx.in +filelock==3.18.0 + # via + # cachecontrol + # sphinx-toolbox +html5lib==1.1 + # via sphinx-toolbox +idna==3.10 + # via + # apeye-core + # requests + # sphinx-prompt +imagesize==1.4.1 + # via sphinx +importlib-metadata==8.7.0 ; python_full_version < '3.10' + # via sphinx +jinja2==3.1.6 + # via + # sphinx + # sphinx-jinja2-compat + # sphinx-prompt +markupsafe==3.0.2 + # via + # jinja2 + # sphinx-jinja2-compat +more-itertools==10.7.0 + # via cssutils +msgpack==1.1.1 + # via cachecontrol +natsort==8.4.0 + # via domdf-python-tools +packaging==25.0 + # via sphinx +platformdirs==4.3.8 + # via apeye +pygments==2.19.2 + # via + # enum-tools + # sphinx + # sphinx-prompt + # sphinx-tabs +requests==2.32.4 + # via + # apeye + # cachecontrol + # sphinx + # sphinx-prompt +ruamel-yaml==0.18.14 + # via sphinx-toolbox +ruamel-yaml-clib==0.2.12 ; python_full_version < '3.14' and platform_python_implementation == 'CPython' + # via ruamel-yaml +six==1.17.0 + # via html5lib +snowballstemmer==3.0.1 + # via sphinx +soupsieve==2.7 + # via beautifulsoup4 +sphinx==7.4.7 + # via + # -r requirements-sphinx.in + # autodocsumm + # sphinx-autodoc-typehints + # sphinx-copybutton + # sphinx-prompt + # sphinx-rtd-theme + # sphinx-tabs + # sphinx-toolbox + # sphinxcontrib-jquery +sphinx-autodoc-typehints==2.3.0 + # via sphinx-toolbox +sphinx-copybutton==0.5.2 + # via -r requirements-sphinx.in +sphinx-jinja2-compat==0.3.0 + # via sphinx-toolbox +sphinx-prompt==1.8.0 ; python_full_version < '3.11' + # via sphinx-toolbox +sphinx-prompt==1.10.0 ; python_full_version >= '3.11' + # via sphinx-toolbox +sphinx-rtd-theme==2.0.0 + # via -r requirements-sphinx.in +sphinx-tabs==3.4.5 + # via sphinx-toolbox +sphinx-toolbox==3.10.0 + # via -r requirements-sphinx.in +sphinxcontrib-applehelp==2.0.0 + # via sphinx +sphinxcontrib-devhelp==2.0.0 + # via sphinx +sphinxcontrib-htmlhelp==2.1.0 + # via sphinx +sphinxcontrib-jquery==4.1 + # via sphinx-rtd-theme +sphinxcontrib-jsmath==1.0.1 + # via sphinx +sphinxcontrib-qthelp==2.0.0 + # via sphinx +sphinxcontrib-serializinghtml==2.0.0 + # via sphinx +standard-imghdr==3.10.14 ; python_full_version >= '3.13' + # via sphinx-jinja2-compat +tabulate==0.9.0 + # via sphinx-toolbox +tomli==2.2.1 ; python_full_version < '3.11' + # via sphinx +typing-extensions==4.14.0 + # via + # beautifulsoup4 + # domdf-python-tools + # enum-tools + # sphinx-toolbox +urllib3==2.5.0 + # via + # requests + # sphinx-prompt +webencodings==0.5.1 + # via html5lib +zipp==3.23.0 ; python_full_version < '3.10' + # via importlib-metadata diff --git a/requirements.in b/requirements.in new file mode 100644 index 0000000..8ebee13 --- /dev/null +++ b/requirements.in @@ -0,0 +1,6 @@ +anyio~=4.9.0 +sniffio~=1.3.1 +httpx~=0.28.1 +ijson~=3.3.0 +# Typing support +typing-extensions~=4.11; python_version<"3.11.0" diff --git a/requirements.txt b/requirements.txt index 68d5447..35d292a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,33 @@ -anyio~=4.9.0 -sniffio~=1.3.1 -httpx~=0.28.1 -ijson~=3.3.0 -# Typing support -typing-extensions~=4.11; python_version<"3.11" +# This file was autogenerated by uv via the following command: +# uv pip compile requirements.in --python-version 3.9 --universal -o requirements.txt +anyio==4.9.0 + # via + # -r requirements.in + # httpx +certifi==2025.6.15 + # via + # httpcore + # httpx +exceptiongroup==1.3.0 ; python_full_version < '3.11' + # via anyio +h11==0.16.0 + # via httpcore +httpcore==1.0.9 + # via httpx +httpx==0.28.1 + # via -r requirements.in +idna==3.10 + # via + # anyio + # httpx +ijson==3.3.0 + # via -r requirements.in +sniffio==1.3.1 + # via + # -r requirements.in + # anyio +typing-extensions==4.14.0 ; python_full_version < '3.13' + # via + # -r requirements.in + # anyio + # exceptiongroup diff --git a/setup.py b/setup.py index d849054..5813aed 100644 --- a/setup.py +++ b/setup.py @@ -30,23 +30,23 @@ PYCBAC_VERSION = couchbase_analytics_version.get_version() -package_data = {'couchbase_analytics.common.core._nonprod_certificates': ['*.pem'], - 'couchbase_analytics.common.core._capella_certificates': ['*.pem']} +package_data = { + 'couchbase_analytics.common.core._nonprod_certificates': ['*.pem'], + 'couchbase_analytics.common.core._capella_certificates': ['*.pem'], +} print(f'Python Analytics SDK version: {PYCBAC_VERSION}') -setup(name='couchbase-analytics', - version=PYCBAC_VERSION, - python_requires='>=3.9', - install_requires=[ - 'httpx~=0.28.1', - 'ijson~=3.3.0', - 'typing-extensions~=4.11; python_version<"3.11"' - ], - packages=find_packages( - include=['acouchbase_analytics', 'couchbase_analytics', 'acouchbase_analytics.*', 'couchbase_analytics.*'], - exclude=['acouchbase_analytics.tests', 'couchbase_analytics.tests']), - package_data=package_data, - long_description=open(PYCBAC_README, "r").read(), - long_description_content_type='text/markdown', - ) +setup( + name='couchbase-analytics', + version=PYCBAC_VERSION, + python_requires='>=3.9', + install_requires=['httpx~=0.28.1', 'ijson~=3.3.0', 'typing-extensions~=4.11; python_version<"3.11"'], + packages=find_packages( + include=['acouchbase_analytics', 'couchbase_analytics', 'acouchbase_analytics.*', 'couchbase_analytics.*'], + exclude=['acouchbase_analytics.tests', 'couchbase_analytics.tests'], + ), + package_data=package_data, + long_description=open(PYCBAC_README, 'r').read(), + long_description_content_type='text/markdown', +) diff --git a/tests/environments/__init__.py b/tests/environments/__init__.py index b38b903..72df2de 100644 --- a/tests/environments/__init__.py +++ b/tests/environments/__init__.py @@ -12,4 +12,3 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. - diff --git a/tests/environments/base_environment.py b/tests/environments/base_environment.py index 0907464..2ec0906 100644 --- a/tests/environments/base_environment.py +++ b/tests/environments/base_environment.py @@ -74,7 +74,7 @@ def __init__(self, config: AnalyticsConfig, **kwargs: Unpack[TestEnvironmentOpti @property def config(self) -> AnalyticsConfig: return self._config - + @property def fqdn(self) -> str: return self.config.fqdn @@ -130,7 +130,7 @@ def load_collection_data_from_file(self, file_path: str, limit: Optional[int] = if limit is not None and len(json_data) > limit: return json_data[:limit] return json_data - + class BlockingTestEnvironment(TestEnvironment): def __init__(self, config: AnalyticsConfig, **kwargs: Unpack[TestEnvironmentOptionsKwargs]) -> None: super().__init__(config, **kwargs) @@ -140,7 +140,7 @@ def cluster(self) -> Cluster: if self._cluster is None: raise AnalyticsTestEnvironmentError('No cluster available.') return self._cluster - + @property def scope(self) -> Scope: if self._scope is None: @@ -169,7 +169,7 @@ def disable_scope(self) -> BlockingTestEnvironment: self._scope = None self._use_scope = False return self - + def disable_test_server(self) -> BlockingTestEnvironment: if self._server_handler is not None: self._server_handler.stop_server() @@ -208,7 +208,7 @@ def enable_test_server(self) -> BlockingTestEnvironment: print(f'Connecting to test server at {url}') self._server_handler.start_server() return self - + def setup(self) -> None: if self.config.create_keyspace is False: return @@ -318,9 +318,9 @@ def get_environment(cls, env_opts['scope_name'] = config.scope_name env_opts['collection_name'] = config.collection_name return cls(config, **env_opts) - - + + class AsyncTestEnvironment(TestEnvironment): def __init__(self, config: AnalyticsConfig, **kwargs: Unpack[TestEnvironmentOptionsKwargs]) -> None: self._backend = kwargs.pop('backend', None) @@ -337,7 +337,7 @@ def cluster_or_scope(self) -> Union[AsyncCluster, AsyncScope]: if self._async_scope is not None: return self.scope return self.cluster - + @property def scope(self) -> AsyncScope: if self._async_scope is None: @@ -360,7 +360,7 @@ def disable_scope(self) -> AsyncTestEnvironment: self._async_scope = None self._use_scope = False return self - + def disable_test_server(self) -> AsyncTestEnvironment: if self._server_handler is not None: self._server_handler.stop_server() @@ -399,7 +399,7 @@ async def enable_test_server(self) -> AsyncTestEnvironment: print(f'Connecting to test server at {url}') self._server_handler.start_server() return self - + async def setup(self) -> None: if self.config.create_keyspace is False: return @@ -433,7 +433,7 @@ async def setup(self) -> None: await self.cluster.execute_query(statement) except Exception as ex: raise AnalyticsTestEnvironmentError(f'Unable to load collection data. Error: {ex}') - + def set_url_path(self, url_path: str) -> None: if self._server_handler is None: raise AnalyticsTestEnvironmentError('No server handler provided, cannot set URL path.') @@ -516,7 +516,7 @@ def get_environment(cls, env_opts['scope_name'] = config.scope_name env_opts['collection_name'] = config.collection_name return cls(config, **env_opts) - + @pytest.fixture(scope='class', name='sync_test_env') def base_test_environment(analytics_config: AnalyticsConfig) -> BlockingTestEnvironment: print("Creating sync test environment") @@ -539,4 +539,4 @@ def base_async_test_environment_with_server(analytics_config: AnalyticsConfig, a server_handler = WebServerHandler() return AsyncTestEnvironment.get_environment(analytics_config, server_handler=server_handler, - backend=anyio_backend) \ No newline at end of file + backend=anyio_backend) diff --git a/tests/environments/simple_environment.py b/tests/environments/simple_environment.py index d4449dd..27646eb 100644 --- a/tests/environments/simple_environment.py +++ b/tests/environments/simple_environment.py @@ -232,4 +232,4 @@ def simple_async_test_environment(anyio_backend: str) -> AsyncSimpleEnvironment: @pytest.fixture(scope='class', name='simple_test_env') def simple_test_environment(anyio_backend: str) -> SimpleEnvironment: - return SimpleEnvironment(anyio_backend) \ No newline at end of file + return SimpleEnvironment(anyio_backend) diff --git a/tests/test_server/__init__.py b/tests/test_server/__init__.py index 0071373..67b2a6b 100644 --- a/tests/test_server/__init__.py +++ b/tests/test_server/__init__.py @@ -70,4 +70,4 @@ def from_str(nrst: str) -> NonRetriableSpecificationType: if not match: raise ValueError(f'Invalid non-retriable specification type: {nrst}. ' f'Valid options are: {[e.value for e in NonRetriableSpecificationType]}') - return match \ No newline at end of file + return match diff --git a/tests/test_server/request.py b/tests/test_server/request.py index d2fcb62..16cf9c5 100644 --- a/tests/test_server/request.py +++ b/tests/test_server/request.py @@ -8,13 +8,13 @@ from tests.test_server import ErrorType, NonRetriableSpecificationType, ResultType, RetriableGroupType -@dataclass +@dataclass class ServerErrorRequest: error_type: ErrorType retry_group_type: Optional[RetriableGroupType] = None non_retriable_spec: Optional[NonRetriableSpecificationType] = None error_count: Optional[int] = None - + @classmethod def from_json(cls, json_data: Dict[str, Any]) -> ServerErrorRequest: error_type = json_data.get('error_type', None) @@ -34,7 +34,7 @@ def from_json(cls, json_data: Dict[str, Any]) -> ServerErrorRequest: non_retriable_spec=nrst, error_count=json_data.get('error_count', None)) -@dataclass +@dataclass class ServerResultsRequest: result_type: ResultType row_count: Optional[int] = None @@ -44,7 +44,7 @@ class ServerResultsRequest: @classmethod def from_json(cls, json_data: Dict[str, Any]) -> ServerResultsRequest: - + until_raw = json_data.get('until', None) if until_raw is not None and not isinstance(until_raw, (float, int)): raise ValueError(f'Invalid "until" value: {until_raw}. Must be a number.') @@ -63,14 +63,14 @@ def from_json(cls, json_data: Dict[str, Any]) -> ServerResultsRequest: if chunk_raw is not None and not isinstance(chunk_raw, int): raise ValueError(f'Invalid "chunk_size" value: {chunk_raw}. Must be an integer.') chunk_size = int(chunk_raw) if chunk_raw is not None else None - + return cls(result_type=result_type, row_count=row_count, chunk_size=chunk_size, stream=json_data.get('stream', False), until=until) -@dataclass +@dataclass class ServerSlowResultsRequest: row_count: int result_type: Optional[ResultType] = ResultType.Object @@ -93,4 +93,4 @@ def from_json(cls, json_data: Dict[str, Any]) -> ServerTimeoutRequest: raise ValueError(f'Invalid "timeout" value: {timeout}. Must be a number.') return cls(error_type=ErrorType.Timeout, timeout=float(timeout), - server_side=json_data.get('server_side', False)) \ No newline at end of file + server_side=json_data.get('server_side', False)) diff --git a/tests/test_server/response.py b/tests/test_server/response.py index 37b946c..cc5d835 100644 --- a/tests/test_server/response.py +++ b/tests/test_server/response.py @@ -155,7 +155,7 @@ def to_json_repr(self) -> Dict[str, Union[str, int]]: 'bufferCachePageReadCount': self.buffer_cache_page_read_count, 'errorCount': self.error_count } - + @staticmethod def create() -> ServerResponseMetrics: return ServerResponseMetrics(0.0, 0.0, 0.0, 0.0, 0, 0, 0, 0.0, 0, 0) @@ -194,7 +194,7 @@ def _build_retry_group(retry_specification: NonRetriableSpecificationType, errors.append(ServerResponseError(24040, 'Some unknown error occurred')) else: raise RuntimeError('Unrecognized retry specification type.') - + return errors @staticmethod @@ -207,7 +207,7 @@ def build_retry_group(group_type: RetriableGroupType, return [ServerResponseError(24045, 'Some unknown retriable error occurred', True) for _ in range(err_count)] - + if retry_specification is None: raise RuntimeError('No non-retriable specification type provided.') if group_type == RetriableGroupType.Zero: @@ -220,7 +220,7 @@ def build_retry_group(group_type: RetriableGroupType, return ServerResponseError._build_retry_group(retry_specification, err_count, retriable_idx=err_count-1) else: raise RuntimeError('Unrecognized retriable group type.') - + @staticmethod def build_errors(resp: ServerResponse, @@ -264,7 +264,7 @@ class ServerResponseResults: def to_json_repr(self) -> Union[List[str], List[Dict[str, Any]]]: return self.results - + @staticmethod def build_results(resp: ServerResponse, row_count: int, @@ -284,7 +284,7 @@ def build_results(resp: ServerResponse, resp.metrics.result_size = row_count * 10 else: raise RuntimeError(f'Unrecognized result type. Got type: {result_type}') - + @staticmethod def get_result_genetaotr(result_type: ResultType) -> Callable[[], Union[Generator[bytes, None, None]]]: if result_type == ResultType.Object: @@ -330,11 +330,11 @@ def to_json_repr(self) -> Dict[str, Any]: if self.errors is not None: output['errors'] = [e.to_json_repr() for e in self.errors] return output - + def update_elapsed_time(self, t: float) -> None: self.metrics.elapsed_time = t self.metrics.execution_time = t @staticmethod def create() -> ServerResponse: - return ServerResponse(200, 'success', ServerResponseMetrics.create()) \ No newline at end of file + return ServerResponse(200, 'success', ServerResponseMetrics.create()) diff --git a/tests/test_server/web_server.py b/tests/test_server/web_server.py index 3bf724c..0a2e43f 100644 --- a/tests/test_server/web_server.py +++ b/tests/test_server/web_server.py @@ -39,7 +39,7 @@ from tests.utils import AsyncBytesIterator, AsyncInfiniteBytesIterator logging.basicConfig(level=logging.INFO, - stream=sys.stderr, + stream=sys.stderr, format='%(asctime)s - %(levelname)s - (PID:%(process)d) - %(message)s') logger = logging.getLogger(__name__) @@ -51,7 +51,7 @@ def __init__(self, host: Optional[str]='0.0.0.0', port:Optional[int]=8080) -> No self._app.add_routes([web.post('/test_error', self.handle_error_request), web.post('/test_results', self.handle_results_request), web.post('/test_slow_results', self.handle_slow_results_request)]) - + async def _handle_timeout_error_request(self, request: ServerTimeoutRequest) -> web.Response: timeout = request.timeout start = perf_counter() @@ -71,7 +71,7 @@ async def _handle_timeout_error_request(self, request: ServerTimeoutRequest) -> 'elapsedTime': f'{elapsed}s', 'message': f'Request timed out after {timeout} seconds.' }) - + def _handle_auth_error_request(self, error_type: ErrorType) -> web.Response: start = perf_counter() resp = ServerResponse.create() @@ -80,7 +80,7 @@ def _handle_auth_error_request(self, error_type: ErrorType) -> web.Response: elapsed = end - start resp.update_elapsed_time(elapsed) return web.json_response(resp.to_json_repr()) - + async def _handle_retry_error_request(self, request: ServerErrorRequest) -> web.Response: start = perf_counter() resp = ServerResponse.create() @@ -94,7 +94,7 @@ async def _handle_retry_error_request(self, request: ServerErrorRequest) -> web. resp.update_elapsed_time(elapsed) res = resp.to_json_repr() return web.json_response(resp.to_json_repr()) - + async def _handle_results_request(self, request: ServerResultsRequest, web_request: web.Request) -> Union[web.Response, web.StreamResponse]: resp = ServerResponse.create() start = perf_counter() @@ -122,7 +122,7 @@ async def _handle_results_request(self, request: ServerResultsRequest, web_reque await response.write(chunk) await response.write_eof() return response - + if request.row_count is None: raise ValueError('Missing "row_count" in JSON data.') @@ -148,7 +148,7 @@ async def handle_error_request(self, request: web.Request) -> web.Response: received_json = await request.json() if 'error_type' not in received_json: raise ValueError('Missing "error_type" in JSON data.') - + error_req = ServerErrorRequest.from_json(received_json) if error_req.error_type == ErrorType.Timeout: timeout_req = ServerTimeoutRequest.from_json(received_json) @@ -171,7 +171,7 @@ async def handle_error_request(self, request: web.Request) -> web.Response: except Exception as e: logger.error(f'An error occurred: {e}', exc_info=True) return web.Response(status=400, text="Bad Request") - + async def handle_results_request(self, request: web.Request) -> Union[web.Response, web.StreamResponse]: try: received_json = await request.json() @@ -186,13 +186,13 @@ async def handle_results_request(self, request: web.Request) -> Union[web.Respon except Exception as e: logger.error(f'An error occurred: {e}', exc_info=True) return web.Response(status=400, text="Bad Request") - + async def handle_slow_results_request(self, request: web.Request) -> web.StreamResponse: try: received_json = await request.json() if 'request_type' not in received_json: raise ValueError('Missing "request_type" in JSON data.') - + logger.info(f"Received JSON: {received_json}") return web.json_response({ 'status': 'success', @@ -254,4 +254,4 @@ async def run_server(host: str, port: int) -> None: except KeyboardInterrupt: pass except Exception as e: - logger.critical(f'Critical error: {e}', exc_info=True) \ No newline at end of file + logger.critical(f'Critical error: {e}', exc_info=True) diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py index 39555ed..0b33344 100644 --- a/tests/utils/__init__.py +++ b/tests/utils/__init__.py @@ -62,7 +62,7 @@ def stop_iterating(self, end_data: Optional[Union[bytes, str]] = None) -> None: def __aiter__(self) -> AsyncInfiniteBytesIterator: return self - + async def __anext__(self) -> bytes: if self._simulate_delay: delay = random.uniform(*self._simulate_delay_range) @@ -96,7 +96,7 @@ async def __anext__(self) -> bytes: chunk = bytes(self._data[:self._stop]) del self._data[:self._stop] self._stop += self._chunk_size - + return chunk class AsyncBytesIterator(PyAsyncIterator[bytes]): @@ -115,7 +115,7 @@ def __init__(self, def __aiter__(self) -> AsyncBytesIterator: return self - + async def __anext__(self) -> bytes: if self._simulate_delay: delay = random.uniform(*self._simulate_delay_range) @@ -147,7 +147,7 @@ def __init__(self, data: Union[bytes, str], chunk_size: Optional[int] = 100) -> def __iter__(self) -> BytesIterator: return self - + def __next__(self) -> bytes: if not self._data: raise StopIteration @@ -165,7 +165,7 @@ def __next__(self) -> bytes: self._start = self._stop self._stop += self._chunk_size return chunk - + def get_test_cert_path() -> str: return os.path.join(pathlib.Path(__file__).parent, 'certs', 'dinocluster.pem') @@ -187,4 +187,4 @@ def to_query_str(params: Dict[str, Any]) -> str: else: encoded_params.append(f'{quote(k)}={quote(str(v))}') - return '&'.join(encoded_params) \ No newline at end of file + return '&'.join(encoded_params) diff --git a/tests/utils/_async_client_adapter.py b/tests/utils/_async_client_adapter.py index 5757788..eb38dab 100644 --- a/tests/utils/_async_client_adapter.py +++ b/tests/utils/_async_client_adapter.py @@ -42,7 +42,7 @@ def client_adapter_init_override(self, *args, **kwargs) -> None: # type: ignore async def send_request_override(self: _AsyncClientAdapter, request: QueryRequest) -> Response: if not hasattr(self, '_client'): raise RuntimeError('Client not created yet') - + # if request.url is None: # raise ValueError('Request URL cannot be None') @@ -96,4 +96,4 @@ class _TestAsyncClientAdapter(_AsyncClientAdapter): setattr(_TestAsyncClientAdapter, 'update_request_extensions', update_request_extensions) setattr(_TestAsyncClientAdapter, 'PYCBAC_TESTING', True) -__all__ = ["_TestAsyncClientAdapter"] \ No newline at end of file +__all__ = ["_TestAsyncClientAdapter"] diff --git a/tests/utils/_async_utils.py b/tests/utils/_async_utils.py index b000294..394daf9 100644 --- a/tests/utils/_async_utils.py +++ b/tests/utils/_async_utils.py @@ -36,7 +36,7 @@ async def _execute(self, fn: Callable[..., Any], *args: object) -> None: def start_soon(self, fn: Callable[..., Any], *args: object) -> None: self._taskgroup.start_soon(self._execute, fn, *args) - + async def __aenter__(self) -> TaskGroupResultCollector: self._taskgroup = anyio.create_task_group() await self._taskgroup.__aenter__() @@ -50,4 +50,4 @@ async def __aexit__(self, res = await self._taskgroup.__aexit__(exc_type= exc_type, exc_val=exc_val, exc_tb=exc_tb) return res finally: - del self._taskgroup \ No newline at end of file + del self._taskgroup diff --git a/tests/utils/_client_adapter.py b/tests/utils/_client_adapter.py index e2818c6..4b6ee83 100644 --- a/tests/utils/_client_adapter.py +++ b/tests/utils/_client_adapter.py @@ -20,7 +20,7 @@ def client_adapter_init_override(self, *args, **kwargs) -> None: # type: ignore self._conn_details = adapter._conn_details if self._http_transport_cls is None: self._http_transport_cls = adapter._http_transport_cls - + # def create_client_override(self) -> None: # if not hasattr(self, '_client'): @@ -37,11 +37,11 @@ def client_adapter_init_override(self, *args, **kwargs) -> None: # type: ignore # transport = self._http_transport_cls() # self._client = Client(auth=BasicAuth(*self._conn_details.credential), # transport=transport) - + def send_request_override(self: _ClientAdapter, request: QueryRequest) -> Response: if not hasattr(self, '_client'): raise RuntimeError('Client not created yet') - + # if request.url is None: # raise ValueError('Request URL cannot be None') @@ -73,7 +73,7 @@ def send_request_override(self: _ClientAdapter, request: QueryRequest) -> Respon except socket.gaierror as err: req_url = self._conn_details.url.get_formatted_url() raise RuntimeError(f'Unable to connect to {req_url}') from err - + def set_request_path(self: _ClientAdapter, path: str) -> None: self._ANALYTICS_PATH = path @@ -94,4 +94,4 @@ class _TestClientAdapter(_ClientAdapter): setattr(_TestClientAdapter, 'update_request_extensions', update_request_extensions) setattr(_TestClientAdapter, 'PYCBAC_TESTING', True) -__all__ = ["_TestClientAdapter"] \ No newline at end of file +__all__ = ["_TestClientAdapter"] diff --git a/tests/utils/_run_web_server.py b/tests/utils/_run_web_server.py index 0aa9935..def7823 100644 --- a/tests/utils/_run_web_server.py +++ b/tests/utils/_run_web_server.py @@ -27,7 +27,7 @@ print(f'Web server script path: {WEB_SERVER_PATH}') logging.basicConfig(level=logging.INFO, - stream=sys.stderr, + stream=sys.stderr, format='%(asctime)s - %(levelname)s - (PID:%(process)d) - %(message)s') logger = logging.getLogger(__name__) @@ -47,7 +47,7 @@ def start_server(self) -> None: if self._server_process and self._server_process.poll() is None: logger.info(f'Web server is already running (PID: {self._server_process.pid}).') return - + if not path.exists(WEB_SERVER_PATH): msg = f'Web server script not found at {WEB_SERVER_PATH}.' logger.error(msg) @@ -83,7 +83,7 @@ def stop_server(self) -> None: if self._server_process.poll() is not None: self._server_process = None return - + try: self._server_process.terminate() try: @@ -97,4 +97,4 @@ def stop_server(self) -> None: logger.error(f'Error stopping web server: {e}', exc_info=True) raise finally: - self._server_process = None \ No newline at end of file + self._server_process = None diff --git a/tests/utils/_test_async_httpx.py b/tests/utils/_test_async_httpx.py index a5e1441..9f59dde 100644 --- a/tests/utils/_test_async_httpx.py +++ b/tests/utils/_test_async_httpx.py @@ -245,4 +245,4 @@ def async_http_transport_init_override(self, *args, **kwargs) -> None: # type: TestAsyncHTTPTransport = AsyncHTTPTransport -__all__ = ["TestAsyncHTTPTransport"] \ No newline at end of file +__all__ = ["TestAsyncHTTPTransport"] diff --git a/tests/utils/_test_httpx.py b/tests/utils/_test_httpx.py index 0421590..3cd390e 100644 --- a/tests/utils/_test_httpx.py +++ b/tests/utils/_test_httpx.py @@ -149,7 +149,7 @@ def create_connection(self, origin: Origin) -> ConnectionInterface: network_backend=self._network_backend, socket_options=self._socket_options, ) - + def handle_request(self, request: Request) -> Response: """ Send an HTTP request, and return an HTTP response. @@ -238,7 +238,7 @@ def handle_request(self, request: Request) -> Response: ), extensions=response.extensions, ) - + def http_transport_init_override(self, *args, **kwargs) -> None: # type: ignore verify = kwargs.get('verify') cert = kwargs.get('cert') @@ -272,4 +272,4 @@ def http_transport_init_override(self, *args, **kwargs) -> None: # type: ignore TestHTTPTransport = HTTPTransport -__all__ = ["TestHTTPTransport"] \ No newline at end of file +__all__ = ["TestHTTPTransport"] diff --git a/tests/utils/certs/dinoca.pem b/tests/utils/certs/dinoca.pem index c63a645..8f5285d 100644 --- a/tests/utils/certs/dinoca.pem +++ b/tests/utils/certs/dinoca.pem @@ -28,4 +28,4 @@ wQdKIkezcQ7OoPmpIsuEQvnCoPdVFsNA4eHdRp5u877olE/iDmljsu3sa0Y6xxnv wmK1E44EciNQ7aj5lzeLrSP0/uFZRTDP4h7B4jlkFWqgPpE6uSYTNVZgwVwQCcA7 ZlQIVK3aOQvact80pqXn8Zu2MHlvVR6L3po+FIBsa8ha2rGTsOasVFkXQLnKBO7r Og2yKTFaMcFFhK2+PVFxQQ== ------END CERTIFICATE----- \ No newline at end of file +-----END CERTIFICATE----- diff --git a/tests/utils/certs/dinocluster.pem b/tests/utils/certs/dinocluster.pem index dd46e08..ae4790a 100644 --- a/tests/utils/certs/dinocluster.pem +++ b/tests/utils/certs/dinocluster.pem @@ -28,4 +28,4 @@ c9Yc230EF3ZXiar9voqBZmQ7P4S6Pkud4tllo4yotNS1TLn7UvzpudOeEwMroi+n ATY0+MXcgOd21yKVgk833TuYzl3Alj9RK1jeJY+GVualZzTqyLeaEvbGN0fmZbj6 OwNoRoZIknh6pLvsup/XZjluuLj7+8atZLfsa/Dd+pVTNjWkDdMljiHQQ6nQWo/P KMfDlOlfgAsxPTY9XPrb6HWFfLEeHN7FTkLK2ZccvA== ------END CERTIFICATE----- \ No newline at end of file +-----END CERTIFICATE----- From eca989f6c7a257996ff62963e9ef1294e277962b Mon Sep 17 00:00:00 2001 From: Jared Casey Date: Wed, 9 Jul 2025 12:34:25 -0600 Subject: [PATCH 07/18] Updates to move SDK closer to initial developer preview. Changes ======= * Add retry logic * Cleaned up error handling * Reorganized RequestContext logic * Updated tests * Updated pre-commit configuration and ensured all stages are passing --- .pre-commit-config.yaml | 48 ++++++++++ acouchbase_analytics/cluster.py | 4 +- acouchbase_analytics/cluster.pyi | 5 +- acouchbase_analytics/database.py | 1 - .../protocol/_core/async_json_stream.py | 19 ++-- .../protocol/_core/async_json_token_parser.py | 20 ++-- .../protocol/_core/client_adapter.py | 10 +- .../protocol/_core/net_utils.py | 23 ++--- .../protocol/_core/request_context.py | 68 ++++++++----- .../protocol/_core/retries.py | 96 +++++++++++++------ acouchbase_analytics/protocol/cluster.py | 5 +- acouchbase_analytics/protocol/cluster.pyi | 5 +- acouchbase_analytics/protocol/database.py | 1 - acouchbase_analytics/protocol/errors.py | 19 ++-- acouchbase_analytics/protocol/streaming.py | 4 +- acouchbase_analytics/tests/connection_t.py | 13 ++- acouchbase_analytics/tests/json_parsing_t.py | 89 +++++++++-------- acouchbase_analytics/tests/options_t.py | 49 +++++++--- acouchbase_analytics/tests/query_options_t.py | 40 +++++++- acouchbase_analytics/tests/test_server_t.py | 55 +++++++++-- couchbase_analytics/__init__.py | 1 + couchbase_analytics/_version.py | 2 +- couchbase_analytics/cluster.py | 4 +- couchbase_analytics/cluster.pyi | 5 +- couchbase_analytics/common/__init__.py | 5 +- couchbase_analytics/common/_core/__init__.py | 1 - .../common/_core/error_context.py | 9 +- .../common/_core/json_parsing.py | 17 ---- .../common/_core/json_token_parser_base.py | 4 +- couchbase_analytics/common/_core/query.py | 5 +- couchbase_analytics/common/_core/result.py | 6 +- couchbase_analytics/common/_core/utils.py | 8 +- .../common/backoff_calculator.py | 4 +- couchbase_analytics/common/credential.py | 4 +- couchbase_analytics/common/errors.py | 12 ++- couchbase_analytics/common/options.py | 16 ++-- couchbase_analytics/common/options_base.py | 15 ++- couchbase_analytics/common/query.py | 4 +- couchbase_analytics/common/request.py | 6 +- couchbase_analytics/common/result.py | 5 +- couchbase_analytics/common/streaming.py | 5 +- .../protocol/_core/client_adapter.py | 10 +- .../protocol/_core/http_transport.py | 42 ++++---- .../protocol/_core/json_stream.py | 19 ++-- .../protocol/_core/json_token_parser.py | 18 ++-- .../protocol/_core/net_utils.py | 22 ++--- couchbase_analytics/protocol/_core/request.py | 27 +++--- .../protocol/_core/request_context.py | 83 +++++++++------- couchbase_analytics/protocol/_core/retries.py | 92 ++++++++++++------ couchbase_analytics/protocol/cluster.py | 4 +- couchbase_analytics/protocol/cluster.pyi | 5 +- couchbase_analytics/protocol/connection.py | 27 ++++-- couchbase_analytics/protocol/errors.py | 73 +++++++------- couchbase_analytics/protocol/options.py | 58 ++++++----- couchbase_analytics/protocol/streaming.py | 4 +- couchbase_analytics/tests/connection_t.py | 10 ++ couchbase_analytics/tests/json_parsing_t.py | 94 +++++++++--------- couchbase_analytics/tests/options_t.py | 49 +++++++--- .../tests/query_integration_t.py | 5 +- couchbase_analytics/tests/query_options_t.py | 40 +++++++- couchbase_analytics/tests/test_server_t.py | 55 +++++++++-- pyproject.toml | 10 +- tests/__init__.py | 5 +- tests/environments/base_environment.py | 9 +- tests/test_config.ini | 2 +- tests/test_server/__init__.py | 1 + tests/test_server/request.py | 15 ++- tests/test_server/response.py | 18 +++- tests/test_server/web_server.py | 15 +++ tests/utils/__init__.py | 8 +- tests/utils/_async_utils.py | 6 +- tests/utils/_test_async_httpx.py | 19 +++- tests/utils/_test_httpx.py | 20 +++- 73 files changed, 1069 insertions(+), 508 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 72ffd1d..1e3d079 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -20,6 +20,54 @@ repos: # - id: ruff-format # types_or: [ python, pyi ] # Compile requirements + - repo: https://github.com/PyCQA/bandit + rev: 1.8.6 + hooks: + - id: bandit + exclude: | + (?x)^( + acouchbase_analytics/tests/| + couchbase_analytics/tests/| + tests/| + couchbase_analytics_version.py + ) + args: + [ + --quiet + ] + - repo: https://github.com/PyCQA/isort + rev: 5.13.2 + hooks: + - id: isort + exclude: | + (?x)^( + deps/| + src/ + ) + args: + [ + "--multi-line 1", + "--force-grid-wrap 3", + "--use-parentheses True", + "--ensure-newline-before-comments True", + "--line-length 120", + "--order-by-type True" + ] + - repo: local + hooks: + - id: mypy + name: mypy + entry: "./run-mypy" + language: python + additional_dependencies: + - mypy~=1.16.1 + - pytest~=8.3.5 + - httpx~=0.28.1 + - aiohttp~=3.11.10 + types: + - python + require_serial: true + verbose: true - repo: https://github.com/astral-sh/uv-pre-commit # uv version. rev: 0.7.19 diff --git a/acouchbase_analytics/cluster.py b/acouchbase_analytics/cluster.py index ed1bc1f..ddac3c2 100644 --- a/acouchbase_analytics/cluster.py +++ b/acouchbase_analytics/cluster.py @@ -16,7 +16,9 @@ from __future__ import annotations import sys -from typing import TYPE_CHECKING, Awaitable, Optional +from typing import (TYPE_CHECKING, + Awaitable, + Optional) if sys.version_info < (3, 10): from typing_extensions import TypeAlias diff --git a/acouchbase_analytics/cluster.pyi b/acouchbase_analytics/cluster.pyi index eb4b88c..325d679 100644 --- a/acouchbase_analytics/cluster.pyi +++ b/acouchbase_analytics/cluster.pyi @@ -23,7 +23,10 @@ else: from acouchbase_analytics.database import AsyncDatabase from couchbase_analytics.credential import Credential -from couchbase_analytics.options import ClusterOptions, ClusterOptionsKwargs, QueryOptions, QueryOptionsKwargs +from couchbase_analytics.options import (ClusterOptions, + ClusterOptionsKwargs, + QueryOptions, + QueryOptionsKwargs) from couchbase_analytics.result import AsyncQueryResult class AsyncCluster: diff --git a/acouchbase_analytics/database.py b/acouchbase_analytics/database.py index 4cb8300..3cfca28 100644 --- a/acouchbase_analytics/database.py +++ b/acouchbase_analytics/database.py @@ -23,7 +23,6 @@ else: from typing import TypeAlias - from acouchbase_analytics.scope import AsyncScope if TYPE_CHECKING: diff --git a/acouchbase_analytics/protocol/_core/async_json_stream.py b/acouchbase_analytics/protocol/_core/async_json_stream.py index 3318541..dad42fa 100644 --- a/acouchbase_analytics/protocol/_core/async_json_stream.py +++ b/acouchbase_analytics/protocol/_core/async_json_stream.py @@ -18,15 +18,14 @@ from typing import AsyncIterator, Optional import ijson -from anyio import EndOfStream, Event, create_memory_object_stream +from anyio import (EndOfStream, + Event, + create_memory_object_stream) from acouchbase_analytics.protocol._core.async_json_token_parser import AsyncJsonTokenParser -from couchbase_analytics.common._core.json_parsing import ( - JsonParsingError, - JsonStreamConfig, - ParsedResult, - ParsedResultType, -) +from couchbase_analytics.common._core.json_parsing import (JsonStreamConfig, + ParsedResult, + ParsedResultType) from couchbase_analytics.common.errors import AnalyticsError @@ -133,7 +132,11 @@ async def _process_token_stream(self) -> None: except StopAsyncIteration: self._token_stream_exhausted = True except ijson.common.IncompleteJSONError as ex: - raise JsonParsingError(cause=ex) from None + # TODO: log this error + self._token_stream_exhausted = True + await self._send_to_stream(ParsedResult(str(ex).encode('utf-8'), ParsedResultType.ERROR), close=True) + self._handle_notification(ParsedResultType.ERROR) + return if self._token_stream_exhausted: diff --git a/acouchbase_analytics/protocol/_core/async_json_token_parser.py b/acouchbase_analytics/protocol/_core/async_json_token_parser.py index e131a4c..d809162 100644 --- a/acouchbase_analytics/protocol/_core/async_json_token_parser.py +++ b/acouchbase_analytics/protocol/_core/async_json_token_parser.py @@ -15,16 +15,18 @@ from __future__ import annotations -from typing import Any, Callable, Coroutine, List, Optional +from typing import (Any, + Callable, + Coroutine, + List, + Optional) -from couchbase_analytics.common._core.json_token_parser_base import ( - POP_EVENTS, - START_EVENTS, - VALUE_TOKENS, - JsonTokenParserBase, - ParsingState, - TokenType, -) +from couchbase_analytics.common._core.json_token_parser_base import (POP_EVENTS, + START_EVENTS, + VALUE_TOKENS, + JsonTokenParserBase, + ParsingState, + TokenType) class AsyncJsonTokenParser(JsonTokenParserBase): diff --git a/acouchbase_analytics/protocol/_core/client_adapter.py b/acouchbase_analytics/protocol/_core/client_adapter.py index 2fbe4d3..22fb71d 100644 --- a/acouchbase_analytics/protocol/_core/client_adapter.py +++ b/acouchbase_analytics/protocol/_core/client_adapter.py @@ -19,7 +19,10 @@ from typing import TYPE_CHECKING, Optional from uuid import uuid4 -from httpx import URL, AsyncClient, BasicAuth, Response +from httpx import (URL, + AsyncClient, + BasicAuth, + Response) from couchbase_analytics.common.credential import Credential from couchbase_analytics.common.deserializer import Deserializer @@ -138,11 +141,8 @@ async def send_request(self, request: QueryRequest) -> Response: if not hasattr(self, '_client'): raise RuntimeError('Client not created yet') - # if request.url is None: - # raise ValueError('Request URL cannot be None') - url = URL(scheme=request.url.scheme, - host=request.url.host, + host=request.url.ip, port=request.url.port, path=request.url.path,) req = self._client.build_request(request.method, diff --git a/acouchbase_analytics/protocol/_core/net_utils.py b/acouchbase_analytics/protocol/_core/net_utils.py index 03b2184..495ed9a 100644 --- a/acouchbase_analytics/protocol/_core/net_utils.py +++ b/acouchbase_analytics/protocol/_core/net_utils.py @@ -16,9 +16,11 @@ from __future__ import annotations import socket -from ipaddress import IPv4Address, IPv6Address, ip_address +from ipaddress import (IPv4Address, + IPv6Address, + ip_address) from random import choice -from typing import Optional, Set, Union +from typing import Optional, Union import anyio @@ -26,9 +28,7 @@ @ErrorMapper.handle_socket_error_async -async def get_request_ip_async(host: str, - port: int, - previous_ips: Optional[Set[str]]=None) -> Optional[str]: +async def get_request_ip_async(host: str, port: int) -> str: # Lets not call getaddrinfo, if the host is already an IP address try: ip: Optional[Union[IPv4Address, IPv6Address, str]] = ip_address(host) @@ -40,18 +40,11 @@ async def get_request_ip_async(host: str, if host == 'localhost': ip = '127.0.0.1' - if previous_ips is None: - previous_ips = set() - if not ip: result = await anyio.getaddrinfo(host, port, type=socket.SOCK_STREAM, family=socket.AF_UNSPEC) - try: - res_ip = choice([addr[4][0] for addr in result if addr[4][0] not in previous_ips]) - ip = str(res_ip) - except IndexError: - ip = None + res_ip = choice([addr[4][0] for addr in result]) # nosec B311 + ip = str(res_ip) else: - ip_str = str(ip) if not isinstance(ip, str) else ip - ip = None if ip_str in previous_ips else ip_str + ip = str(ip) return ip diff --git a/acouchbase_analytics/protocol/_core/request_context.py b/acouchbase_analytics/protocol/_core/request_context.py index 96a2f8c..d020cef 100644 --- a/acouchbase_analytics/protocol/_core/request_context.py +++ b/acouchbase_analytics/protocol/_core/request_context.py @@ -3,19 +3,33 @@ import json from asyncio import CancelledError, Task from types import TracebackType -from typing import TYPE_CHECKING, Any, Awaitable, Callable, Coroutine, Dict, List, Optional, Type, Union +from typing import (TYPE_CHECKING, + Any, + Awaitable, + Callable, + Coroutine, + Dict, + List, + Optional, + Type, + Union) from uuid import uuid4 import anyio from httpx import Response as HttpCoreResponse from httpx import TimeoutException -from acouchbase_analytics.protocol._core.anyio_utils import AsyncBackend, current_async_library, get_time +from acouchbase_analytics.protocol._core.anyio_utils import (AsyncBackend, + current_async_library, + get_time) from acouchbase_analytics.protocol._core.async_json_stream import AsyncJsonStream from acouchbase_analytics.protocol._core.net_utils import get_request_ip_async -from couchbase_analytics.common._core import JsonStreamConfig, ParsedResult, ParsedResultType +from couchbase_analytics.common._core import (JsonStreamConfig, + ParsedResult, + ParsedResultType) from couchbase_analytics.common._core.error_context import ErrorContext -from couchbase_analytics.common.errors import AnalyticsError, InvalidCredentialError +from couchbase_analytics.common.backoff_calculator import DefaultBackoffCalculator +from couchbase_analytics.common.errors import AnalyticsError from couchbase_analytics.common.streaming import StreamingState from couchbase_analytics.protocol.connection import DEFAULT_TIMEOUTS from couchbase_analytics.protocol.errors import ErrorMapper @@ -37,6 +51,7 @@ def __init__(self, self._client_adapter = client_adapter self._request = request self._backend = backend or current_async_library() + self._backoff_calc = DefaultBackoffCalculator() self._error_ctx = ErrorContext(num_attempts=0, method=request.method, statement=request.get_request_statement()) @@ -85,6 +100,10 @@ def request_error(self) -> Optional[Union[BaseException, Exception]]: def request_state(self) -> StreamingState: return self._request_state + @property + def retry_limit_exceeded(self) -> bool: + return self.error_context.num_attempts > self._request.max_retries + @property def results_or_errors_type(self) -> ParsedResultType: return self._json_stream.results_or_errors_type @@ -137,10 +156,12 @@ def _maybe_set_request_error(self, self._request_error = exc_val async def _process_error(self, - json_data: List[Dict[str, Any]], + json_data: Union[str, List[Dict[str, Any]]], handle_context_shutdown: Optional[bool]=False) -> None: self._request_state = StreamingState.Error - if not isinstance(json_data, list): + if isinstance(json_data, str): + self._request_error = ErrorMapper.build_error_from_http_status_code(json_data, self._error_ctx) + elif not isinstance(json_data, list): self._request_error = AnalyticsError('Cannot parse error response; expected JSON array', context=str(self._error_ctx)) else: @@ -154,7 +175,6 @@ def _reset_stream(self) -> None: if hasattr(self, '_json_stream'): del self._json_stream self._request_state = StreamingState.ResetAndNotStarted - self._request.previous_ips = set() self._stage_completed = None self._cancel_scope_deadline_updated = False @@ -197,6 +217,9 @@ async def _wait_for_stage_to_complete(self) -> None: return await self._stage_completed.wait() + def calculate_backoff(self) -> float: + return self._backoff_calc.calculate_backoff(self._error_ctx.num_attempts) / 1000 + def cancel_request(self, fn: Optional[Callable[..., Awaitable[Any]]]=None, *args: object) -> None: @@ -273,6 +296,9 @@ def okay_to_delay_and_retry(self, delay: float) -> bool: if will_time_out: self._request_state = StreamingState.Timeout return False + elif self.retry_limit_exceeded: + self._request_state = StreamingState.Error + return False else: self._reset_stream() return True @@ -296,10 +322,15 @@ async def process_response(self, # we have all the data, close the core response/stream await close_handler() - json_response = json.loads(raw_response.value) - if 'errors' in json_response: - await self._process_error(json_response['errors'], handle_context_shutdown=handle_context_shutdown) - return json_response + try: + json_response = json.loads(raw_response.value) + except json.JSONDecodeError: + await self._process_error(str(raw_response.value), + handle_context_shutdown=handle_context_shutdown) + else: + if 'errors' in json_response: + await self._process_error(json_response['errors'], handle_context_shutdown=handle_context_shutdown) + return json_response async def reraise_after_shutdown(self, err: Exception) -> None: try: @@ -309,24 +340,17 @@ async def reraise_after_shutdown(self, err: Exception) -> None: raise ex from None async def send_request(self, enable_trace_handling: Optional[bool]=False) -> HttpCoreResponse: - ip = await get_request_ip_async(self._request.url.host, self._request.url.port, self._request.previous_ips) - if ip is None: - attempted_ips = ', '.join(self._request.previous_ips or []) - raise AnalyticsError(message=f'Connect failure. Unable to connect to any resolved IPs: {attempted_ips}.', - context=str(self._error_ctx)) - + self._error_ctx.update_num_attempts() + ip = await get_request_ip_async(self._request.url.host, self._request.url.port) if enable_trace_handling is True: (self._request.update_url(ip, self._client_adapter.analytics_path) - .add_trace_to_extensions(self._trace_handler) - .update_previous_ips(ip)) + .add_trace_to_extensions(self._trace_handler)) else: - self._request.update_url(ip, self._client_adapter.analytics_path).update_previous_ips(ip) + self._request.update_url(ip, self._client_adapter.analytics_path) # TODO: add logging; provide request details (to/from, deadlines, etc.) self._error_ctx.update_request_context(self._request) response = await self._client_adapter.send_request(self._request) self._error_ctx.update_response_context(response) - if response.status_code == 401: - raise InvalidCredentialError(context=str(self._error_ctx)) return response async def shutdown(self, diff --git a/acouchbase_analytics/protocol/_core/retries.py b/acouchbase_analytics/protocol/_core/retries.py index 0bd4a5e..047916f 100644 --- a/acouchbase_analytics/protocol/_core/retries.py +++ b/acouchbase_analytics/protocol/_core/retries.py @@ -17,17 +17,24 @@ from asyncio import CancelledError from functools import wraps -from random import uniform -from typing import TYPE_CHECKING, Any, Callable, Coroutine +from typing import (TYPE_CHECKING, + Any, + Callable, + Coroutine, + Optional, + Union) from httpx import ConnectError, ConnectTimeout from acouchbase_analytics.protocol._core.anyio_utils import sleep -from couchbase_analytics.common.errors import AnalyticsError, InternalSDKError, TimeoutError +from couchbase_analytics.common.errors import (AnalyticsError, + InternalSDKError, + TimeoutError) from couchbase_analytics.common.streaming import StreamingState from couchbase_analytics.protocol.errors import WrappedError if TYPE_CHECKING: + from acouchbase_analytics.protocol._core.request_context import AsyncRequestContext from acouchbase_analytics.protocol.streaming import AsyncHttpStreamingResponse @@ -36,6 +43,54 @@ class AsyncRetryHandler: **INTERNAL** """ + @staticmethod + async def handle_httpx_retry(ex: Union[ConnectError, ConnectTimeout], + ctx: AsyncRequestContext + ) -> Optional[Exception]: + err_str = str(ex) + if 'SSL:' in err_str: + message = 'TLS connection error occurred.' + return AnalyticsError(cause=ex, message=message, context=str(ctx.error_context)) + delay = ctx.calculate_backoff() + err: Optional[Exception] = None + if not ctx.okay_to_delay_and_retry(delay): + if ctx.retry_limit_exceeded: + err = AnalyticsError(cause=ex, message='Retry limit exceeded.', context=str(ctx.error_context)) + else: + err = TimeoutError(message='Request timed out during retry delay.', context=str(ctx.error_context)) + if err: + return err + await sleep(delay) + return None + + @staticmethod + async def handle_retry(ex: WrappedError, + ctx: AsyncRequestContext + ) -> Optional[Union[BaseException, Exception]]: + if ex.retriable is True: + delay = ctx.calculate_backoff() + err: Optional[Union[BaseException, Exception]] = None + if not ctx.okay_to_delay_and_retry(delay): + if ctx.retry_limit_exceeded: + if ex.is_cause_query_err: + ex.maybe_set_cause_context(ctx.error_context) + err = ex.unwrap() + else: + err = AnalyticsError(cause=ex.unwrap(), + message='Retry limit exceeded.', + context=str(ctx.error_context)) + else: + err = TimeoutError(message='Request timed out during retry delay.', + context=str(ctx.error_context)) + + if err: + return err + await sleep(delay) + return None + ex.maybe_set_cause_context(ctx.error_context) + return ex.unwrap() + + @staticmethod def with_retries(fn: Callable[[AsyncHttpStreamingResponse], Coroutine[Any, Any, None]] # noqa: C901 ) -> Callable[[AsyncHttpStreamingResponse], Coroutine[Any, Any, None]]: @@ -46,33 +101,23 @@ async def wrapped_fn(self: AsyncHttpStreamingResponse) -> None: # noqa: C901 await fn(self) break except WrappedError as ex: - if ex.retriable is True: - delay = calc_backoff(self._request_context.error_context.num_attempts) - if not self._request_context.okay_to_delay_and_retry(delay): - await self._request_context.shutdown(type(ex), ex, ex.__traceback__) - raise TimeoutError(message='Request timed out during retry delay.', - context=str(self._request_context.error_context)) from None - await sleep(delay) + err = await AsyncRetryHandler.handle_retry(ex, self._request_context) + if err is None: continue await self._request_context.shutdown(type(ex), ex, ex.__traceback__) - ex.maybe_set_cause_context(self._request_context.error_context) - raise ex.unwrap() from None + raise err from None + except (ConnectError, ConnectTimeout) as ex: + err = await AsyncRetryHandler.handle_httpx_retry(ex, self._request_context) + if err is None: + continue + await self._request_context.shutdown(type(ex), ex, ex.__traceback__) + raise err from None except AnalyticsError: # if an AnalyticsError is raised, we have already shut down the request context raise except RuntimeError as ex: await self._request_context.shutdown(type(ex), ex, ex.__traceback__) raise ex - except ConnectError as ex: - await self._request_context.shutdown(type(ex), ex, ex.__traceback__) - raise AnalyticsError(cause=ex, - message='Unable to establish connection for request.', - context=str(self._request_context.error_context)) from None - except ConnectTimeout as ex: - await self._request_context.shutdown(type(ex), ex, ex.__traceback__) - raise TimeoutError(cause=ex, - message='Request timed out trying to establish connection.', - context=str(self._request_context.error_context)) from None except BaseException as ex: await self._request_context.shutdown(type(ex), ex, ex.__traceback__) if self._request_context.timed_out: @@ -90,10 +135,3 @@ async def wrapped_fn(self: AsyncHttpStreamingResponse) -> None: # noqa: C901 await self.close() return wrapped_fn - -def calc_backoff(retry_count: int) -> float: - min_ms = 100 - max_ms = 60000 - delay_ms = min_ms * pow(2, retry_count) - capped_ms = min(max_ms, delay_ms) - return uniform(0, capped_ms / 1000.0) diff --git a/acouchbase_analytics/protocol/cluster.py b/acouchbase_analytics/protocol/cluster.py index 5dc1a58..ecce654 100644 --- a/acouchbase_analytics/protocol/cluster.py +++ b/acouchbase_analytics/protocol/cluster.py @@ -16,7 +16,9 @@ from __future__ import annotations import sys -from typing import TYPE_CHECKING, Awaitable, Optional +from typing import (TYPE_CHECKING, + Awaitable, + Optional) from uuid import uuid4 if sys.version_info < (3, 10): @@ -43,7 +45,6 @@ def __init__(self, credential: Credential, options: Optional[ClusterOptions] = None, **kwargs: object) -> None: - print(f'Adapter module: {_AsyncClientAdapter.__module__}') self._client_adapter = _AsyncClientAdapter(connstr, credential, options, **kwargs) self._cluster_id = str(uuid4()) self._request_builder = _RequestBuilder(self._client_adapter) diff --git a/acouchbase_analytics/protocol/cluster.pyi b/acouchbase_analytics/protocol/cluster.pyi index f273183..baa0680 100644 --- a/acouchbase_analytics/protocol/cluster.pyi +++ b/acouchbase_analytics/protocol/cluster.pyi @@ -25,7 +25,10 @@ from acouchbase_analytics.protocol._core.client_adapter import _AsyncClientAdapt from acouchbase_analytics.protocol.database import AsyncDatabase from couchbase_analytics.common.credential import Credential from couchbase_analytics.common.result import AsyncQueryResult -from couchbase_analytics.options import ClusterOptions, ClusterOptionsKwargs, QueryOptions, QueryOptionsKwargs +from couchbase_analytics.options import (ClusterOptions, + ClusterOptionsKwargs, + QueryOptions, + QueryOptionsKwargs) class AsyncCluster: @overload diff --git a/acouchbase_analytics/protocol/database.py b/acouchbase_analytics/protocol/database.py index e2270a3..b91892d 100644 --- a/acouchbase_analytics/protocol/database.py +++ b/acouchbase_analytics/protocol/database.py @@ -23,7 +23,6 @@ else: from typing import TypeAlias - from acouchbase_analytics.protocol._core.client_adapter import _AsyncClientAdapter from acouchbase_analytics.protocol.scope import AsyncScope diff --git a/acouchbase_analytics/protocol/errors.py b/acouchbase_analytics/protocol/errors.py index 13aa5eb..cb33238 100644 --- a/acouchbase_analytics/protocol/errors.py +++ b/acouchbase_analytics/protocol/errors.py @@ -17,26 +17,25 @@ import socket from functools import wraps -from typing import Any, Callable, Coroutine, Optional, Set +from typing import (Any, + Callable, + Coroutine) from couchbase_analytics.common.errors import AnalyticsError -from couchbase_analytics.protocol.errors import _NON_RETRYABLE_SOCKET_ERRORS, WrappedError +from couchbase_analytics.protocol.errors import WrappedError class ErrorMapper: @staticmethod - def handle_socket_error_async(fn: Callable[[str, int, Optional[Set[str]]], Coroutine[Any, Any, Optional[str]]] - ) -> Callable[[str, int, Optional[Set[str]]], Coroutine[Any, Any, Optional[str]]]: + def handle_socket_error_async(fn: Callable[[str, int], Coroutine[Any, Any, str]] + ) -> Callable[[str, int], Coroutine[Any, Any, str]]: @wraps(fn) - async def wrapped_fn(host: str, - port: int, - previous_ips: Optional[Set[str]]=None) -> Optional[str]: + async def wrapped_fn(host: str, port: int) -> str: try: - return await fn(host, port, previous_ips) + return await fn(host, port) except socket.gaierror as ex: # print(f'getaddrinfo failed for {host}:{port} with error: {ex}') msg='Connection error occurred while sending request.' - raise WrappedError(AnalyticsError(cause=ex, message=msg), - retriable=(ex.errno not in _NON_RETRYABLE_SOCKET_ERRORS)) from None + raise WrappedError(AnalyticsError(cause=ex, message=msg), retriable=True) from None return wrapped_fn diff --git a/acouchbase_analytics/protocol/streaming.py b/acouchbase_analytics/protocol/streaming.py index d9419b7..88c26f3 100644 --- a/acouchbase_analytics/protocol/streaming.py +++ b/acouchbase_analytics/protocol/streaming.py @@ -23,7 +23,9 @@ from acouchbase_analytics.protocol._core.retries import AsyncRetryHandler from couchbase_analytics.common._core import ParsedResult, ParsedResultType from couchbase_analytics.common._core.query import build_query_metadata -from couchbase_analytics.common.errors import AnalyticsError, InternalSDKError, TimeoutError +from couchbase_analytics.common.errors import (AnalyticsError, + InternalSDKError, + TimeoutError) from couchbase_analytics.common.query import QueryMetadata diff --git a/acouchbase_analytics/tests/connection_t.py b/acouchbase_analytics/tests/connection_t.py index ccb13a4..4f2413c 100644 --- a/acouchbase_analytics/tests/connection_t.py +++ b/acouchbase_analytics/tests/connection_t.py @@ -32,6 +32,7 @@ class ConnectionTestSuite: TEST_MANIFEST = [ 'test_connstr_options_fail', + 'test_connstr_options_max_retries', 'test_connstr_options_timeout', 'test_connstr_options_timeout_fail', 'test_connstr_options_timeout_invalid_duration', @@ -50,13 +51,21 @@ class ConnectionTestSuite: 'trust_only_pem_file=/path/to/file', 'disable_server_certificate_verification=True' ]) - def test_connstr_options_fail(self, - connstr_opt: str) -> None: + def test_connstr_options_fail(self, connstr_opt: str) -> None: cred = Credential.from_username_and_password('Administrator', 'password') connstr = f'https://localhost?{connstr_opt}' with pytest.raises(ValueError): _AsyncClientAdapter(connstr, cred) + def test_connstr_options_max_retries(self) -> None: + cred = Credential.from_username_and_password('Administrator', 'password') + max_retries = 10 + connstr = f'https://localhost?max_retries={max_retries}' + client = _AsyncClientAdapter(connstr, cred) + req_builder = _RequestBuilder(client) + req = req_builder.build_base_query_request('SELECT 1=1') + assert req.max_retries == max_retries + @pytest.mark.parametrize('duration, expected_seconds', [('1h', '3600'), ('+1h', '3600'), diff --git a/acouchbase_analytics/tests/json_parsing_t.py b/acouchbase_analytics/tests/json_parsing_t.py index 4256319..e46ab73 100644 --- a/acouchbase_analytics/tests/json_parsing_t.py +++ b/acouchbase_analytics/tests/json_parsing_t.py @@ -22,7 +22,9 @@ import pytest from acouchbase_analytics.protocol._core.async_json_stream import AsyncJsonStream -from couchbase_analytics.common._core import JsonParsingError, JsonStreamConfig, ParsedResult, ParsedResultType +from couchbase_analytics.common._core import (JsonStreamConfig, + ParsedResult, + ParsedResultType) from couchbase_analytics.common.errors import AnalyticsError from tests.environments.simple_environment import JsonDataType from tests.utils import AsyncBytesIterator @@ -305,63 +307,58 @@ async def test_array_of_objects(self) -> None: @pytest.mark.anyio async def test_invalid_empty(self) -> None: - try: - data = '' - parser = AsyncJsonStream(AsyncBytesIterator(bytes(data, 'utf-8')), - stream_config=JsonStreamConfig(buffer_entire_result=True)) - await parser.start_parsing() - except JsonParsingError as err: - assert isinstance(err, JsonParsingError) - assert err.cause is not None - assert 'parse error' in str(err.cause) + data = '' + parser = AsyncJsonStream(AsyncBytesIterator(bytes(data, 'utf-8')), + stream_config=JsonStreamConfig(buffer_entire_result=True)) + await parser.start_parsing() + res = await parser.get_result() + assert res.result_type == ParsedResultType.ERROR + assert res.value is not None + assert 'parse error' in str(res.value.decode('utf-8')) @pytest.mark.anyio async def test_invalid_garbage_between_objects(self) -> None: - try: - data = '[{"id":1,"name":"Alice"},garbage,{"id":2,"name":"Bob"}]' - parser = AsyncJsonStream(AsyncBytesIterator(bytes(data, 'utf-8')), - stream_config=JsonStreamConfig(buffer_entire_result=True)) - await parser.start_parsing() - except JsonParsingError as err: - assert isinstance(err, JsonParsingError) - assert err.cause is not None - assert 'lexical error' in str(err.cause) + data = '[{"id":1,"name":"Alice"},garbage,{"id":2,"name":"Bob"}]' + parser = AsyncJsonStream(AsyncBytesIterator(bytes(data, 'utf-8')), + stream_config=JsonStreamConfig(buffer_entire_result=True)) + await parser.start_parsing() + res = await parser.get_result() + assert res.result_type == ParsedResultType.ERROR + assert res.value is not None + assert 'lexical error' in str(res.value.decode('utf-8')) @pytest.mark.anyio async def test_invalid_leading_garbage(self) -> None: - try: - data = 'garbage{"key":"value"}' - parser = AsyncJsonStream(AsyncBytesIterator(bytes(data, 'utf-8')), - stream_config=JsonStreamConfig(buffer_entire_result=True)) - await parser.start_parsing() - except JsonParsingError as err: - assert isinstance(err, JsonParsingError) - assert err.cause is not None - assert 'lexical error' in str(err.cause) + data = 'garbage{"key":"value"}' + parser = AsyncJsonStream(AsyncBytesIterator(bytes(data, 'utf-8')), + stream_config=JsonStreamConfig(buffer_entire_result=True)) + await parser.start_parsing() + res = await parser.get_result() + assert res.result_type == ParsedResultType.ERROR + assert res.value is not None + assert 'lexical error' in str(res.value.decode('utf-8')) @pytest.mark.anyio async def test_invalid_trailing_garbage(self) -> None: - try: - data = '{"key":"value"}garbage' - parser = AsyncJsonStream(AsyncBytesIterator(bytes(data, 'utf-8')), - stream_config=JsonStreamConfig(buffer_entire_result=True)) - await parser.start_parsing() - except JsonParsingError as err: - assert isinstance(err, JsonParsingError) - assert err.cause is not None - assert 'parse error' in str(err.cause) + data = '{"key":"value"}garbage' + parser = AsyncJsonStream(AsyncBytesIterator(bytes(data, 'utf-8')), + stream_config=JsonStreamConfig(buffer_entire_result=True)) + await parser.start_parsing() + res = await parser.get_result() + assert res.result_type == ParsedResultType.ERROR + assert res.value is not None + assert 'parse error' in str(res.value.decode('utf-8')) @pytest.mark.anyio async def test_invalid_whitespace_only(self) -> None: - try: - data = ' \n\t ' - parser = AsyncJsonStream(AsyncBytesIterator(bytes(data, 'utf-8')), - stream_config=JsonStreamConfig(buffer_entire_result=True)) - await parser.start_parsing() - except JsonParsingError as err: - assert isinstance(err, JsonParsingError) - assert err.cause is not None - assert 'parse error' in str(err.cause) + data = ' \n\t ' + parser = AsyncJsonStream(AsyncBytesIterator(bytes(data, 'utf-8')), + stream_config=JsonStreamConfig(buffer_entire_result=True)) + await parser.start_parsing() + res = await parser.get_result() + assert res.result_type == ParsedResultType.ERROR + assert res.value is not None + assert 'parse error' in str(res.value.decode('utf-8')) @pytest.mark.anyio async def test_value_bool(self) -> None: diff --git a/acouchbase_analytics/tests/options_t.py b/acouchbase_analytics/tests/options_t.py index 8cfc79f..113ab4c 100644 --- a/acouchbase_analytics/tests/options_t.py +++ b/acouchbase_analytics/tests/options_t.py @@ -16,21 +16,25 @@ from __future__ import annotations from datetime import timedelta -from typing import Dict, Type +from typing import (Dict, + Optional, + Type) import pytest from acouchbase_analytics.protocol._core.client_adapter import _AsyncClientAdapter from couchbase_analytics.credential import Credential -from couchbase_analytics.deserializer import DefaultJsonDeserializer, Deserializer, PassthroughDeserializer -from couchbase_analytics.options import ( - ClusterOptions, - SecurityOptions, - SecurityOptionsKwargs, - TimeoutOptions, - TimeoutOptionsKwargs, -) -from tests.utils import get_test_cert_list, get_test_cert_path, get_test_cert_str +from couchbase_analytics.deserializer import (DefaultJsonDeserializer, + Deserializer, + PassthroughDeserializer) +from couchbase_analytics.options import (ClusterOptions, + SecurityOptions, + SecurityOptionsKwargs, + TimeoutOptions, + TimeoutOptionsKwargs) +from tests.utils import (get_test_cert_list, + get_test_cert_path, + get_test_cert_str) TEST_CERT_PATH = get_test_cert_path() TEST_CERT_LIST = get_test_cert_list() @@ -42,6 +46,8 @@ class ClusterOptionsTestSuite: TEST_MANIFEST = [ 'test_options_deserializer', 'test_options_deserializer_kwargs', + 'test_options_max_retries', + 'test_options_max_retries_kwargs', 'test_security_options', 'test_security_options_classmethods', 'test_security_options_kwargs', @@ -54,19 +60,38 @@ class ClusterOptionsTestSuite: ] @pytest.mark.parametrize('deserializer_cls', [DefaultJsonDeserializer, PassthroughDeserializer]) - def test_options_deserializer(self, deserializer_cls:Type[Deserializer]) -> None: + def test_options_deserializer(self, deserializer_cls: Type[Deserializer]) -> None: cred = Credential.from_username_and_password('Administrator', 'password') deserializer_instance = deserializer_cls() client = _AsyncClientAdapter('https://localhost', cred, ClusterOptions(deserializer=deserializer_instance)) assert isinstance(client.connection_details.default_deserializer, deserializer_cls) @pytest.mark.parametrize('deserializer_cls', [DefaultJsonDeserializer, PassthroughDeserializer]) - def test_options_deserializer_kwargs(self, deserializer_cls:Type[Deserializer]) -> None: + def test_options_deserializer_kwargs(self, deserializer_cls: Type[Deserializer]) -> None: cred = Credential.from_username_and_password('Administrator', 'password') deserializer_instance = deserializer_cls() client = _AsyncClientAdapter('https://localhost', cred, **{'deserializer': deserializer_instance}) assert isinstance(client.connection_details.default_deserializer, deserializer_cls) + @pytest.mark.parametrize('max_retries', [5, 10, None]) + def test_options_max_retries(self, max_retries: Optional[int]) -> None: + cred = Credential.from_username_and_password('Administrator', 'password') + client = _AsyncClientAdapter('https://localhost', cred, ClusterOptions(max_retries=max_retries)) + if max_retries is None: + assert client.connection_details.get_max_retries() == 7 + else: + assert client.connection_details.get_max_retries() == max_retries + + @pytest.mark.parametrize('max_retries', [5, 10, None]) + def test_options_max_retries_kwargs(self, max_retries: Optional[int]) -> None: + cred = Credential.from_username_and_password('Administrator', 'password') + if max_retries is None: + client = _AsyncClientAdapter('https://localhost', cred) + assert client.connection_details.get_max_retries() == 7 + else: + client = _AsyncClientAdapter('https://localhost', cred, **{'max_retries': max_retries}) + assert client.connection_details.get_max_retries() == max_retries + @pytest.mark.parametrize('opts, expected_opts', [({}, None), ({'trust_only_capella': True}, diff --git a/acouchbase_analytics/tests/query_options_t.py b/acouchbase_analytics/tests/query_options_t.py index 12c3629..ca2e7f5 100644 --- a/acouchbase_analytics/tests/query_options_t.py +++ b/acouchbase_analytics/tests/query_options_t.py @@ -17,7 +17,11 @@ from dataclasses import dataclass from datetime import timedelta -from typing import Any, Dict, List, Optional, Union +from typing import (Any, + Dict, + List, + Optional, + Union) import pytest @@ -46,6 +50,8 @@ class QueryOptionsTestSuite: TEST_MANIFEST = [ 'test_options_deserializer', 'test_options_deserializer_kwargs', + 'test_options_max_retries', + 'test_options_max_retries_kwargs', 'test_options_named_parameters', 'test_options_named_parameters_kwargs', 'test_options_positional_parameters', @@ -92,6 +98,38 @@ def test_options_deserializer_kwargs(self, assert req.deserializer == deserializer query_ctx.validate_query_context(req.body) + @pytest.mark.parametrize('max_retries', [5, 10, None]) + def test_options_max_retries(self, + query_statment: str, + request_builder: _RequestBuilder, + query_ctx: QueryContext, + max_retries: Optional[int]) -> None: + if max_retries is not None: + q_opts = QueryOptions(max_retries=max_retries) + req = request_builder.build_base_query_request(query_statment, q_opts) + else: + req = request_builder.build_base_query_request(query_statment) + exp_opts: QueryOptionsTransformedKwargs = {} + assert req.options == exp_opts + assert req.max_retries == (max_retries if max_retries is not None else 7) + query_ctx.validate_query_context(req.body) + + @pytest.mark.parametrize('max_retries', [5, 10, None]) + def test_options_max_retries_kwargs(self, + query_statment: str, + request_builder: _RequestBuilder, + query_ctx: QueryContext, + max_retries: Optional[int]) -> None: + if max_retries is not None: + kwargs: QueryOptionsKwargs = {'max_retries': max_retries} + req = request_builder.build_base_query_request(query_statment, **kwargs) + else: + req = request_builder.build_base_query_request(query_statment) + exp_opts: QueryOptionsTransformedKwargs = {} + assert req.options == exp_opts + assert req.max_retries == (max_retries if max_retries is not None else 7) + query_ctx.validate_query_context(req.body) + def test_options_named_parameters(self, query_statment: str, request_builder: _RequestBuilder, diff --git a/acouchbase_analytics/tests/test_server_t.py b/acouchbase_analytics/tests/test_server_t.py index d10d62e..bb5da8d 100644 --- a/acouchbase_analytics/tests/test_server_t.py +++ b/acouchbase_analytics/tests/test_server_t.py @@ -15,17 +15,22 @@ from __future__ import annotations -from asyncio import Task from datetime import timedelta -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Union import pytest -from acouchbase_analytics.errors import InvalidCredentialError, QueryError, TimeoutError +from acouchbase_analytics.errors import (AnalyticsError, + InvalidCredentialError, + QueryError, + TimeoutError) from acouchbase_analytics.options import QueryOptions from acouchbase_analytics.result import AsyncQueryResult from tests import AsyncYieldFixture -from tests.test_server import ErrorType, NonRetriableSpecificationType, ResultType, RetriableGroupType +from tests.test_server import (ErrorType, + NonRetriableSpecificationType, + ResultType, + RetriableGroupType) if TYPE_CHECKING: from tests.environments.base_environment import AsyncTestEnvironment @@ -37,7 +42,9 @@ class TestServerTestSuite: 'test_auth_error_unauthorized', 'test_auth_error_insufficient_permissions', 'test_error_non_retriable_response', - 'test_error_retriable_response', + 'test_error_retriable_response_timeout', + 'test_error_retriable_response_retries_exceeded', + 'test_error_retriable_http503', 'test_error_timeout', 'test_results_object_values', 'test_results_raw_values' @@ -86,17 +93,51 @@ async def test_error_non_retriable_response(self, test_env.assert_error_context_num_attempts(1, ex.value._context) test_env.assert_error_context_contains_last_dispatch(ex.value._context) - async def test_error_retriable_response(self, test_env: AsyncTestEnvironment) -> None: + async def test_error_retriable_response_timeout(self, test_env: AsyncTestEnvironment) -> None: test_env.set_url_path('/test_error') test_env.update_request_json({'error_type': ErrorType.Retriable.value, 'retry_group_type': RetriableGroupType.All.value}) statement = 'SELECT "Hello, data!" AS greeting' with pytest.raises(TimeoutError) as ex: - await test_env.cluster_or_scope.execute_query(statement, QueryOptions(timeout=timedelta(seconds=2))) + # just-in-case, increase the max_retries to ensure we hit the timeout + await test_env.cluster_or_scope.execute_query(statement, QueryOptions(max_retries=10, timeout=timedelta(seconds=1.5))) test_env.assert_error_context_num_attempts(4 , ex.value._context, exact=False) test_env.assert_error_context_contains_last_dispatch(ex.value._context) + async def test_error_retriable_response_retries_exceeded(self, test_env: AsyncTestEnvironment) -> None: + test_env.set_url_path('/test_error') + test_env.update_request_json({'error_type': ErrorType.Retriable.value, + 'retry_group_type': RetriableGroupType.All.value}) + statement = 'SELECT "Hello, data!" AS greeting' + allowed_retries = 5 + q_opts = QueryOptions(max_retries=allowed_retries, timeout=timedelta(seconds=10)) + with pytest.raises(QueryError) as ex: + await test_env.cluster_or_scope.execute_query(statement, q_opts) + + print(ex.value) + test_env.assert_error_context_num_attempts(allowed_retries+1 , ex.value._context) + test_env.assert_error_context_contains_last_dispatch(ex.value._context) + + @pytest.mark.parametrize('analytics_error', [False, True]) + async def test_error_retriable_http503(self, test_env: AsyncTestEnvironment, analytics_error: bool) -> None: + test_env.set_url_path('/test_error') + test_env.update_request_json({'error_type': ErrorType.Http503.value, + 'analytics_error': analytics_error}) + statement = 'SELECT "Hello, data!" AS greeting' + allowed_retries = 5 + q_opts = QueryOptions(max_retries=allowed_retries, timeout=timedelta(seconds=10)) + ex: Union[pytest.ExceptionInfo[AnalyticsError], pytest.ExceptionInfo[QueryError]] + if analytics_error: + with pytest.raises(QueryError) as ex: + await test_env.cluster_or_scope.execute_query(statement, q_opts) + else: + with pytest.raises(AnalyticsError) as ex: + await test_env.cluster_or_scope.execute_query(statement, q_opts) + + test_env.assert_error_context_num_attempts(allowed_retries+1 , ex.value._context) + test_env.assert_error_context_contains_last_dispatch(ex.value._context) + @pytest.mark.parametrize('server_side', [False, True]) async def test_error_timeout(self, test_env: AsyncTestEnvironment, server_side: bool) -> None: test_env.set_url_path('/test_error') diff --git a/couchbase_analytics/__init__.py b/couchbase_analytics/__init__.py index a133438..a2d88e4 100644 --- a/couchbase_analytics/__init__.py +++ b/couchbase_analytics/__init__.py @@ -14,5 +14,6 @@ # limitations under the License. from couchbase_analytics.common import JSONType as JSONType # noqa: F401 + # TODO: logging # from couchbase_analytics.protocol import configure_logging as configure_logging # noqa: F401 diff --git a/couchbase_analytics/_version.py b/couchbase_analytics/_version.py index 3f9c4a7..a34413d 100644 --- a/couchbase_analytics/_version.py +++ b/couchbase_analytics/_version.py @@ -1,5 +1,5 @@ # This file automatically generated by # /Users/jaredcasey/GIT/couchbase/clients/python/analytics-python-client/./couchbase_analytics_version.py # at -# 2025-07-02 15:23:28.150595 +# 2025-07-09 12:11:48.524648 __version__ = '0.0.1' diff --git a/couchbase_analytics/cluster.py b/couchbase_analytics/cluster.py index 056f917..100f08e 100644 --- a/couchbase_analytics/cluster.py +++ b/couchbase_analytics/cluster.py @@ -16,7 +16,9 @@ from __future__ import annotations from concurrent.futures import Future -from typing import TYPE_CHECKING, Optional, Union +from typing import (TYPE_CHECKING, + Optional, + Union) from couchbase_analytics.database import Database from couchbase_analytics.result import BlockingQueryResult diff --git a/couchbase_analytics/cluster.pyi b/couchbase_analytics/cluster.pyi index 379aa0e..6d61e43 100644 --- a/couchbase_analytics/cluster.pyi +++ b/couchbase_analytics/cluster.pyi @@ -25,7 +25,10 @@ else: from couchbase_analytics import JSONType from couchbase_analytics.credential import Credential from couchbase_analytics.database import Database -from couchbase_analytics.options import ClusterOptions, ClusterOptionsKwargs, QueryOptions, QueryOptionsKwargs +from couchbase_analytics.options import (ClusterOptions, + ClusterOptionsKwargs, + QueryOptions, + QueryOptionsKwargs) from couchbase_analytics.result import BlockingQueryResult class Cluster: diff --git a/couchbase_analytics/common/__init__.py b/couchbase_analytics/common/__init__.py index 962f59f..04ab2ad 100644 --- a/couchbase_analytics/common/__init__.py +++ b/couchbase_analytics/common/__init__.py @@ -13,6 +13,9 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import Any, Dict, List, Union +from typing import (Any, + Dict, + List, + Union) JSONType = Union[str, int, float, bool, None, Dict[str, Any], List[Any]] diff --git a/couchbase_analytics/common/_core/__init__.py b/couchbase_analytics/common/_core/__init__.py index 04b9ded..d1d6ccb 100644 --- a/couchbase_analytics/common/_core/__init__.py +++ b/couchbase_analytics/common/_core/__init__.py @@ -13,7 +13,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -from .json_parsing import JsonParsingError as JsonParsingError # noqa: F401 from .json_parsing import JsonStreamConfig as JsonStreamConfig # noqa: F401 from .json_parsing import ParsedResult as ParsedResult # noqa: F401 from .json_parsing import ParsedResultType as ParsedResultType # noqa: F401 diff --git a/couchbase_analytics/common/_core/error_context.py b/couchbase_analytics/common/_core/error_context.py index 4865cbc..b24bd09 100644 --- a/couchbase_analytics/common/_core/error_context.py +++ b/couchbase_analytics/common/_core/error_context.py @@ -16,7 +16,10 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Any, Dict, List, Optional +from typing import (Any, + Dict, + List, + Optional) from httpx import Response as HttpCoreResponse @@ -47,8 +50,10 @@ def maybe_update_errors(self) -> None: if self.first_error is not None: self.errors = [self.first_error] - def update_request_context(self, request: QueryRequest) -> None: + def update_num_attempts(self) -> None: self.num_attempts += 1 + + def update_request_context(self, request: QueryRequest) -> None: self.path = request.url.path def update_response_context(self, response: HttpCoreResponse) -> None: diff --git a/couchbase_analytics/common/_core/json_parsing.py b/couchbase_analytics/common/_core/json_parsing.py index 7cc99e7..0c17179 100644 --- a/couchbase_analytics/common/_core/json_parsing.py +++ b/couchbase_analytics/common/_core/json_parsing.py @@ -19,23 +19,6 @@ from enum import IntEnum from typing import NamedTuple, Optional - -class JsonParsingError(Exception): - def __init__(self, cause: Optional[Exception]=None) -> None: - super().__init__(cause) - self._cause = cause - - @property - def cause(self) -> Optional[Exception]: - return self._cause - - def __repr__(self) -> str: - return f'JsonParsingError(cause={self._cause})' - - def __str__(self) -> str: - return self.__repr__() - - # buffer size in httpcore is 2 ** 16 (65kiB) which matches the default buffer size in ijson # passing in a chunk_size is only applying an abstraction over the httpcore stream DEFAULT_HTTP_STREAM_BUFFER_SIZE = 2**16 diff --git a/couchbase_analytics/common/_core/json_token_parser_base.py b/couchbase_analytics/common/_core/json_token_parser_base.py index baf3a01..97a17db 100644 --- a/couchbase_analytics/common/_core/json_token_parser_base.py +++ b/couchbase_analytics/common/_core/json_token_parser_base.py @@ -17,7 +17,9 @@ from collections import deque from enum import Enum -from typing import Deque, NamedTuple, Optional +from typing import (Deque, + NamedTuple, + Optional) class ParsingState(Enum): diff --git a/couchbase_analytics/common/_core/query.py b/couchbase_analytics/common/_core/query.py index f0aab96..7849580 100644 --- a/couchbase_analytics/common/_core/query.py +++ b/couchbase_analytics/common/_core/query.py @@ -16,7 +16,10 @@ from __future__ import annotations import json -from typing import Any, List, Optional, TypedDict +from typing import (Any, + List, + Optional, + TypedDict) from couchbase_analytics.common._core.duration_str_utils import parse_duration_str diff --git a/couchbase_analytics/common/_core/result.py b/couchbase_analytics/common/_core/result.py index 018b8c5..8cbd521 100644 --- a/couchbase_analytics/common/_core/result.py +++ b/couchbase_analytics/common/_core/result.py @@ -17,7 +17,11 @@ import sys from abc import ABC, abstractmethod -from typing import Any, Coroutine, List, Optional, Union +from typing import (Any, + Coroutine, + List, + Optional, + Union) if sys.version_info < (3, 9): from typing import AsyncIterator as PyAsyncIterator diff --git a/couchbase_analytics/common/_core/utils.py b/couchbase_analytics/common/_core/utils.py index 1e4117a..fa53189 100644 --- a/couchbase_analytics/common/_core/utils.py +++ b/couchbase_analytics/common/_core/utils.py @@ -18,7 +18,13 @@ from datetime import timedelta from enum import Enum from os import path -from typing import Any, Dict, Generic, List, Optional, TypeVar, Union +from typing import (Any, + Dict, + Generic, + List, + Optional, + TypeVar, + Union) from couchbase_analytics.common.deserializer import Deserializer diff --git a/couchbase_analytics/common/backoff_calculator.py b/couchbase_analytics/common/backoff_calculator.py index 0a31a5c..f2c1c81 100644 --- a/couchbase_analytics/common/backoff_calculator.py +++ b/couchbase_analytics/common/backoff_calculator.py @@ -26,7 +26,7 @@ def calculate_backoff(self, retry_count: int) -> float: class DefaultBackoffCalculator(BackoffCalculator): MIN = 100 MAX = 60 * 1000 - EXPONENT_BASE = 2 + EXPONENT_BASE = 1.5 def __init__(self, min: Optional[int]=None, @@ -39,4 +39,4 @@ def __init__(self, def calculate_backoff(self, retry_count: int) -> float: delay_ms = self._min * self._exp ** (retry_count - 1) capped_ms = min(self._max, delay_ms) - return uniform(0, capped_ms) + return uniform(0, capped_ms) # nosec B311 diff --git a/couchbase_analytics/common/credential.py b/couchbase_analytics/common/credential.py index 8fb1b73..c6afb99 100644 --- a/couchbase_analytics/common/credential.py +++ b/couchbase_analytics/common/credential.py @@ -15,7 +15,9 @@ from __future__ import annotations -from typing import Callable, Dict, Tuple +from typing import (Callable, + Dict, + Tuple) class Credential: diff --git a/couchbase_analytics/common/errors.py b/couchbase_analytics/common/errors.py index 4a0402a..6dba21f 100644 --- a/couchbase_analytics/common/errors.py +++ b/couchbase_analytics/common/errors.py @@ -15,7 +15,9 @@ from __future__ import annotations -from typing import Dict, Optional, Union +from typing import (Dict, + Optional, + Union) """ @@ -40,12 +42,12 @@ def __init__(self, def _err_details(self) -> Dict[str, str]: details: Dict[str, str] = {} + if self._message is not None and not self._message.isspace(): + details['message'] = self._message if self._context is not None: details['context'] = self._context if self._cause is not None: details['cause'] = self._cause.__repr__() - if self._message is not None and not self._message.isspace(): - details['message'] = self._message return details def __repr__(self) -> str: @@ -171,12 +173,12 @@ def __init__(self, def __repr__(self) -> str: details: Dict[str, str] = {} + if self._message is not None and not self._message.isspace(): + details['message'] = self._message if self._context is not None: details['context'] = self._context if self._cause is not None: details['cause'] = self._cause.__repr__() - if self._message is not None and not self._message.isspace(): - details['message'] = self._message if details: return f'{type(self).__name__}({details})' return f'{type(self).__name__}()' diff --git a/couchbase_analytics/common/options.py b/couchbase_analytics/common/options.py index ba35bdd..7de1e00 100644 --- a/couchbase_analytics/common/options.py +++ b/couchbase_analytics/common/options.py @@ -24,15 +24,13 @@ else: from typing import TypeAlias -from couchbase_analytics.common.options_base import ( - ClusterOptionsBase, - QueryOptionsBase, - SecurityOptionsBase, - TimeoutOptionsBase, -) +from couchbase_analytics.common.options_base import ClusterOptionsBase from couchbase_analytics.common.options_base import ClusterOptionsKwargs as ClusterOptionsKwargs # noqa: F401 +from couchbase_analytics.common.options_base import QueryOptionsBase from couchbase_analytics.common.options_base import QueryOptionsKwargs as QueryOptionsKwargs # noqa: F401 +from couchbase_analytics.common.options_base import SecurityOptionsBase from couchbase_analytics.common.options_base import SecurityOptionsKwargs as SecurityOptionsKwargs # noqa: F401 +from couchbase_analytics.common.options_base import TimeoutOptionsBase from couchbase_analytics.common.options_base import TimeoutOptionsKwargs as TimeoutOptionsKwargs # noqa: F401 """ @@ -44,15 +42,16 @@ class ClusterOptions(ClusterOptionsBase): """Available options to set when creating a cluster. Cluster options enable the configuration of various global cluster settings. - Some options can be set globally for the cluster, but overridden for specific operations (i.e. :class:`.TimeoutOptions`). + Some options can be set globally for the cluster, but overridden for a specific request (i.e. :class:`.TimeoutOptions`). .. note:: Options and methods marked **VOLATILE** are subject to change at any time. Args: deserializer (Optional[Deserializer]): Set to configure global serializer to translate JSON to Python objects. Defaults to `None` (:class:`~couchbase_analytics.deserializer.DefaultJsonDeserializer`). + max_retries (Optional[int]): Set to configure the maximum number of retries for a request. Defaults to 7. security_options (Optional[:class:`.SecurityOptions`]): Security options for SDK connection. - timeout_options (Optional[:class:`.TimeoutOptions`]): Timeout options for various SDK operations. See :class:`.TimeoutOptions` for details. + timeout_options (Optional[:class:`.TimeoutOptions`]): Timeout options for the various stages of a request. See :class:`.TimeoutOptions` for details. """ # noqa: E501 @@ -149,6 +148,7 @@ class QueryOptions(QueryOptionsBase): client_context_id (Optional[str]): Set to configure a unique identifier for this query request. Defaults to `None` (autogenerated by client). deserializer (Optional[Deserializer]): Specifies a :class:`~couchbase_analytics.deserializer.Deserializer` to apply to results. Defaults to `None` (:class:`~couchbase_analytics.deserializer.DefaultJsonDeserializer`). lazy_execute (Optional[bool]): **VOLATILE** If enabled, the query will not execute until the application begins to iterate over results. Defaulst to `None` (disabled). + max_retries (Optional[int]): Set to configure the maximum number of retries for a request. named_parameters (Optional[Dict[str, :py:type:`~couchbase_analytics.JSONType`]]): Values to use for positional placeholders in query. positional_parameters (Optional[List[:py:type:`~couchbase_analytics.JSONType`]]):, optional): Values to use for named placeholders in query. query_context (Optional[str]): Specifies the context within which this query should be executed. diff --git a/couchbase_analytics/common/options_base.py b/couchbase_analytics/common/options_base.py index ae5912b..7430b25 100644 --- a/couchbase_analytics/common/options_base.py +++ b/couchbase_analytics/common/options_base.py @@ -18,7 +18,14 @@ import sys from datetime import timedelta -from typing import Any, Dict, Iterable, List, Literal, Optional, TypedDict, Union +from typing import (Any, + Dict, + Iterable, + List, + Literal, + Optional, + TypedDict, + Union) if sys.version_info < (3, 10): from typing_extensions import TypeAlias, Unpack @@ -42,12 +49,14 @@ class ClusterOptionsKwargs(TypedDict, total=False): deserializer: Optional[Deserializer] + max_retries: Optional[int] security_options: Optional[SecurityOptionsBase] timeout_options: Optional[TimeoutOptionsBase] ClusterOptionsValidKeys: TypeAlias = Literal[ 'deserializer', + 'max_retries', 'security_options', 'timeout_options', ] @@ -60,6 +69,7 @@ class ClusterOptionsBase(Dict[str, Any]): VALID_OPTION_KEYS: List[ClusterOptionsValidKeys] = [ 'deserializer', + 'max_retries', 'security_options', 'timeout_options', ] @@ -134,6 +144,7 @@ class QueryOptionsKwargs(TypedDict, total=False): client_context_id: Optional[str] deserializer: Optional[Deserializer] lazy_execute: Optional[bool] + max_retries: Optional[int] named_parameters: Optional[Dict[str, JSONType]] positional_parameters: Optional[Iterable[JSONType]] query_context: Optional[str] @@ -148,6 +159,7 @@ class QueryOptionsKwargs(TypedDict, total=False): 'client_context_id', 'deserializer', 'lazy_execute', + 'max_retries', 'named_parameters', 'positional_parameters', 'query_context', @@ -165,6 +177,7 @@ class QueryOptionsBase(Dict[str, object]): 'client_context_id', 'deserializer', 'lazy_execute', + 'max_retries', 'named_parameters', 'positional_parameters', 'query_context', diff --git a/couchbase_analytics/common/query.py b/couchbase_analytics/common/query.py index e580c4b..c0ef918 100644 --- a/couchbase_analytics/common/query.py +++ b/couchbase_analytics/common/query.py @@ -18,7 +18,9 @@ from datetime import timedelta from typing import List, Optional -from couchbase_analytics.common._core.query import QueryMetadataCore, QueryMetricsCore, QueryWarningCore +from couchbase_analytics.common._core.query import (QueryMetadataCore, + QueryMetricsCore, + QueryWarningCore) class QueryWarning: diff --git a/couchbase_analytics/common/request.py b/couchbase_analytics/common/request.py index f9948b4..6c2d73b 100644 --- a/couchbase_analytics/common/request.py +++ b/couchbase_analytics/common/request.py @@ -24,13 +24,15 @@ class RequestURL: scheme: str host: str port: int + ip: Optional[str] = None path: Optional[str] = None def get_formatted_url(self) -> str: """Get the formatted URL for this request.""" if self.path is None: - return f'{self.scheme}://{self.host}:{self.port}' - return f'{self.scheme}://{self.host}:{self.port}{self.path}' + host = self.ip if self.ip else self.host + return f'{self.scheme}://{host}:{self.port}' + return f'{self.scheme}://{host}:{self.port}{self.path}' def __repr__(self) -> str: details: Dict[str, str] = { diff --git a/couchbase_analytics/common/result.py b/couchbase_analytics/common/result.py index 9b2dd3a..df8216b 100644 --- a/couchbase_analytics/common/result.py +++ b/couchbase_analytics/common/result.py @@ -15,7 +15,10 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, List, Optional +from typing import (TYPE_CHECKING, + Any, + List, + Optional) from couchbase_analytics.common._core.result import QueryResult as QueryResult from couchbase_analytics.common.query import QueryMetadata diff --git a/couchbase_analytics/common/streaming.py b/couchbase_analytics/common/streaming.py index 95e7d3b..880dc1f 100644 --- a/couchbase_analytics/common/streaming.py +++ b/couchbase_analytics/common/streaming.py @@ -18,7 +18,10 @@ from collections.abc import AsyncIterator as PyAsyncIterator from collections.abc import Iterator from enum import IntEnum -from typing import TYPE_CHECKING, Any, List, NamedTuple +from typing import (TYPE_CHECKING, + Any, + List, + NamedTuple) from couchbase_analytics.common.errors import AnalyticsError, InternalSDKError diff --git a/couchbase_analytics/protocol/_core/client_adapter.py b/couchbase_analytics/protocol/_core/client_adapter.py index fc5eebd..78009ea 100644 --- a/couchbase_analytics/protocol/_core/client_adapter.py +++ b/couchbase_analytics/protocol/_core/client_adapter.py @@ -19,7 +19,10 @@ from typing import TYPE_CHECKING, Optional from uuid import uuid4 -from httpx import URL, BasicAuth, Client, Response +from httpx import (URL, + BasicAuth, + Client, + Response) from couchbase_analytics.common.credential import Credential from couchbase_analytics.common.deserializer import Deserializer @@ -142,11 +145,8 @@ def send_request(self, request: QueryRequest) -> Response: if not hasattr(self, '_client'): raise RuntimeError('Client not created yet') - # if request.url is None: - # raise ValueError('Request URL cannot be None') - url = URL(scheme=request.url.scheme, - host=request.url.host, + host=request.url.ip, port=request.url.port, path=request.url.path) req = self._client.build_request(request.method, diff --git a/couchbase_analytics/protocol/_core/http_transport.py b/couchbase_analytics/protocol/_core/http_transport.py index 0241b8d..89e8566 100644 --- a/couchbase_analytics/protocol/_core/http_transport.py +++ b/couchbase_analytics/protocol/_core/http_transport.py @@ -2,22 +2,32 @@ import ssl import time from types import TracebackType -from typing import Iterable, Optional, TypeVar, Union - -from httpcore import ( - ConnectionInterface, - ConnectionPool, - HTTP2Connection, - HTTP11Connection, - HTTPConnection, - Origin, - Request, -) +from typing import (Iterable, + Optional, + TypeVar, + Union) + +from httpcore import (ConnectionInterface, + ConnectionPool, + HTTP2Connection, + HTTP11Connection, + HTTPConnection, + Origin, + Request) from httpcore import Response as CoreResponse from httpcore._exceptions import ConnectionNotAvailable, UnsupportedProtocol from httpcore._sync.connection_pool import PoolByteStream, PoolRequest -from httpx import URL, BaseTransport, HTTPTransport, Limits, Proxy, Response, SyncByteStream, create_ssl_context -from httpx._transports.default import SOCKET_OPTION, ResponseStream, map_httpcore_exceptions +from httpx import (URL, + BaseTransport, + HTTPTransport, + Limits, + Proxy, + Response, + SyncByteStream, + create_ssl_context) +from httpx._transports.default import (SOCKET_OPTION, + ResponseStream, + map_httpcore_exceptions) from httpx._types import CertTypes, ProxyTypes # httpx._transports.default.py @@ -152,7 +162,7 @@ def handle_request(self, request: Request) -> CoreResponse: # Return the response. Note that in this case we still have to manage # the point at which the response is closed. - assert isinstance(response.stream, Iterable) + assert isinstance(response.stream, Iterable) # nosec B101 return CoreResponse( status=response.status, headers=response.headers, @@ -227,7 +237,7 @@ def handle_request( self, request: Request, # type: ignore ) -> Response: - assert isinstance(request.stream, SyncByteStream) + assert isinstance(request.stream, SyncByteStream) # nosec B101 import httpcore req = httpcore.Request( @@ -245,7 +255,7 @@ def handle_request( with map_httpcore_exceptions(): resp = self._pool.handle_request(req) - assert isinstance(resp.stream, Iterable) + assert isinstance(resp.stream, Iterable) # nosec B101 return Response( status_code=resp.status, diff --git a/couchbase_analytics/protocol/_core/json_stream.py b/couchbase_analytics/protocol/_core/json_stream.py index 43bfd53..eb79d06 100644 --- a/couchbase_analytics/protocol/_core/json_stream.py +++ b/couchbase_analytics/protocol/_core/json_stream.py @@ -19,16 +19,15 @@ from queue import Empty as QueueEmpty from queue import Full as QueueFull from queue import Queue -from typing import TYPE_CHECKING, Iterator, Optional +from typing import (TYPE_CHECKING, + Iterator, + Optional) import ijson -from couchbase_analytics.common._core.json_parsing import ( - JsonParsingError, - JsonStreamConfig, - ParsedResult, - ParsedResultType, -) +from couchbase_analytics.common._core.json_parsing import (JsonStreamConfig, + ParsedResult, + ParsedResultType) from couchbase_analytics.protocol._core.json_token_parser import JsonTokenParser if TYPE_CHECKING: @@ -132,7 +131,11 @@ def _process_token_stream(self, request_context: Optional[RequestContext]=None) except StopIteration: self._token_stream_exhausted = True except ijson.common.IncompleteJSONError as ex: - raise JsonParsingError(cause=ex) from None + # TODO: log this error + self._token_stream_exhausted = True + self._put(ParsedResult(str(ex).encode('utf-8'), ParsedResultType.ERROR)) + self._handle_notification(ParsedResultType.ERROR) + return if self._token_stream_exhausted: result_type = ParsedResultType.ERROR if self._json_token_parser.has_errors else ParsedResultType.END diff --git a/couchbase_analytics/protocol/_core/json_token_parser.py b/couchbase_analytics/protocol/_core/json_token_parser.py index 43ae354..1c38e3d 100644 --- a/couchbase_analytics/protocol/_core/json_token_parser.py +++ b/couchbase_analytics/protocol/_core/json_token_parser.py @@ -15,16 +15,16 @@ from __future__ import annotations -from typing import Callable, List, Optional +from typing import (Callable, + List, + Optional) -from couchbase_analytics.common._core.json_token_parser_base import ( - POP_EVENTS, - START_EVENTS, - VALUE_TOKENS, - JsonTokenParserBase, - ParsingState, - TokenType, -) +from couchbase_analytics.common._core.json_token_parser_base import (POP_EVENTS, + START_EVENTS, + VALUE_TOKENS, + JsonTokenParserBase, + ParsingState, + TokenType) class JsonTokenParser(JsonTokenParserBase): diff --git a/couchbase_analytics/protocol/_core/net_utils.py b/couchbase_analytics/protocol/_core/net_utils.py index 1e2cb13..6042b73 100644 --- a/couchbase_analytics/protocol/_core/net_utils.py +++ b/couchbase_analytics/protocol/_core/net_utils.py @@ -16,17 +16,17 @@ from __future__ import annotations import socket -from ipaddress import IPv4Address, IPv6Address, ip_address +from ipaddress import (IPv4Address, + IPv6Address, + ip_address) from random import choice -from typing import Optional, Set, Union +from typing import Optional, Union from couchbase_analytics.protocol.errors import ErrorMapper @ErrorMapper.handle_socket_error -def get_request_ip(host: str, - port: int, - previous_ips: Optional[Set[str]]=None) -> Optional[str]: +def get_request_ip(host: str, port: int) -> str: # Lets not call getaddrinfo, if the host is already an IP address try: ip: Optional[Union[IPv4Address, IPv6Address, str]] = ip_address(host) @@ -38,17 +38,11 @@ def get_request_ip(host: str, if host == 'localhost': ip = '127.0.0.1' - if previous_ips is None: - previous_ips = set() if not ip: result = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM, family=socket.AF_UNSPEC) - try: - res_ip = choice([addr[4][0] for addr in result if addr[4][0] not in previous_ips]) - ip = str(res_ip) - except IndexError: - ip = None + res_ip = choice([addr[4][0] for addr in result]) # nosec B311 + ip = str(res_ip) else: - ip_str = str(ip) if not isinstance(ip, str) else ip - ip = None if ip_str in previous_ips else ip_str + ip = str(ip) return ip diff --git a/couchbase_analytics/protocol/_core/request.py b/couchbase_analytics/protocol/_core/request.py index a523865..133e3b6 100644 --- a/couchbase_analytics/protocol/_core/request.py +++ b/couchbase_analytics/protocol/_core/request.py @@ -17,7 +17,15 @@ from copy import deepcopy from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, Callable, Coroutine, Dict, Optional, Set, TypedDict, Union, cast +from typing import (TYPE_CHECKING, + Any, + Callable, + Coroutine, + Dict, + Optional, + TypedDict, + Union, + cast) from uuid import uuid4 from couchbase_analytics.common.deserializer import Deserializer @@ -47,10 +55,10 @@ class QueryRequest: deserializer: Deserializer body: Dict[str, Union[str, object]] extensions: RequestExtensions + max_retries: int method: str = 'POST' options: Optional[QueryOptionsTransformedKwargs] = None - previous_ips: Optional[Set[str]] = None enable_cancel: Optional[bool] = None def add_trace_to_extensions(self, handler: Callable[[str, str], @@ -79,20 +87,11 @@ def get_request_timeouts(self) -> Optional[RequestTimeoutExtensions]: return {} return self.extensions['timeout'] - def update_previous_ips(self, ip: str) -> QueryRequest: - """ - **INTERNAL** - """ - if self.previous_ips is None: - self.previous_ips = set() - self.previous_ips.add(ip) - return self - def update_url(self, ip: str, path: str) -> QueryRequest: """ **INTERNAL** """ - self.url.host = ip + self.url.ip = ip self.url.path = path return self @@ -161,8 +160,9 @@ def build_base_query_request(self, # noqa: C901 q_opts['positional_parameters'] = parsed_args_list if named_params and len(named_params) > 0: q_opts['named_parameters'] = named_params - # add the default serializer if one does not exist + # handle deserializer and max_retries deserializer = q_opts.pop('deserializer', None) or self._conn_details.default_deserializer + max_retries = q_opts.pop('max_retries', None) or self._conn_details.get_max_retries() body: Dict[str, Union[str, object]] = { 'statement': statement, @@ -210,5 +210,6 @@ def build_base_query_request(self, # noqa: C901 deserializer, body, extensions=extensions, + max_retries=max_retries, options=q_opts, enable_cancel=enable_cancel) diff --git a/couchbase_analytics/protocol/_core/request_context.py b/couchbase_analytics/protocol/_core/request_context.py index e662881..5e3c7cb 100644 --- a/couchbase_analytics/protocol/_core/request_context.py +++ b/couchbase_analytics/protocol/_core/request_context.py @@ -3,22 +3,34 @@ import json import math import time -from concurrent.futures import CancelledError, Future, ThreadPoolExecutor +from concurrent.futures import (CancelledError, + Future, + ThreadPoolExecutor) from threading import Event, Lock -from typing import TYPE_CHECKING, Any, Callable, Dict, Iterator, List, Optional, Union +from typing import (TYPE_CHECKING, + Any, + Callable, + Dict, + Iterator, + List, + Optional, + Union) from uuid import uuid4 from httpx import Response as HttpCoreResponse -from couchbase_analytics.common._core import JsonStreamConfig, ParsedResult, ParsedResultType +from couchbase_analytics.common._core import (JsonStreamConfig, + ParsedResult, + ParsedResultType) from couchbase_analytics.common._core.error_context import ErrorContext -from couchbase_analytics.common.errors import AnalyticsError, InvalidCredentialError, TimeoutError +from couchbase_analytics.common.backoff_calculator import DefaultBackoffCalculator +from couchbase_analytics.common.errors import AnalyticsError, TimeoutError from couchbase_analytics.common.result import BlockingQueryResult from couchbase_analytics.common.streaming import StreamingState from couchbase_analytics.protocol._core.json_stream import JsonStream from couchbase_analytics.protocol._core.net_utils import get_request_ip from couchbase_analytics.protocol.connection import DEFAULT_TIMEOUTS -from couchbase_analytics.protocol.errors import ErrorMapper +from couchbase_analytics.protocol.errors import ErrorMapper, WrappedError if TYPE_CHECKING: from couchbase_analytics.protocol._core.client_adapter import _ClientAdapter @@ -97,6 +109,7 @@ def __init__(self, self._id = str(uuid4()) self._client_adapter = client_adapter self._request = request + self._backoff_calc = DefaultBackoffCalculator() self._error_ctx = ErrorContext(num_attempts=0, method=request.method, statement=request.get_request_statement()) @@ -104,7 +117,6 @@ def __init__(self, self._stream_config = stream_config or JsonStreamConfig() self._json_stream: JsonStream self._cancel_event = Event() - self._request_error: Optional[Exception] = None self._tp_executor = tp_executor self._stage_completed_ft: Optional[Future[Any]] = None self._stage_notification_ft: Optional[Future[ParsedResultType]] = None @@ -145,14 +157,14 @@ def okay_to_stream(self) -> bool: self._check_cancelled_or_timed_out() return StreamingState.okay_to_stream(self._request_state) - @property - def request_error(self) -> Optional[Exception]: - return self._request_error - @property def request_state(self) -> StreamingState: return self._request_state + @property + def retry_limit_exceeded(self) -> bool: + return self.error_context.num_attempts > self._request.max_retries + @property def timed_out(self) -> bool: self._check_cancelled_or_timed_out() @@ -183,23 +195,25 @@ def _create_stage_notification_future(self) -> None: self._stage_notification_ft = Future[ParsedResultType]() def _process_error(self, - json_data: List[Dict[str, Any]], + json_data: Union[str, List[Dict[str, Any]]], handle_context_shutdown: Optional[bool]=False) -> None: self._request_state = StreamingState.Error - if not isinstance(json_data, list): - self._request_error = AnalyticsError(message='Cannot parse error response; expected JSON array', - context=str(self._request_context.error_context)) + request_error: Union[AnalyticsError, WrappedError] + if isinstance(json_data, str): + request_error = ErrorMapper.build_error_from_http_status_code(json_data, self._error_ctx) + elif not isinstance(json_data, list): + request_error = AnalyticsError(message='Cannot parse error response; expected JSON array', + context=str(self._error_ctx)) else: - self._request_error = ErrorMapper.build_error_from_json(json_data, self._error_ctx) + request_error = ErrorMapper.build_error_from_json(json_data, self._error_ctx) if handle_context_shutdown is True: self.shutdown() - raise self._request_error + raise request_error def _reset_stream(self) -> None: if hasattr(self, '_json_stream'): del self._json_stream self._request_state = StreamingState.ResetAndNotStarted - self._request.previous_ips = set() self._stage_notification_ft = None def _start_next_stage(self, @@ -231,6 +245,9 @@ def _wait_for_stage_completed(self) -> None: raise RuntimeError('Stage completed future not created for this context.') self._stage_completed_ft.result() + def calculate_backoff(self) -> float: + return self._backoff_calc.calculate_backoff(self._error_ctx.num_attempts) / 1000 + def cancel_request(self) -> None: if self._request_state == StreamingState.Timeout: return @@ -288,6 +305,9 @@ def okay_to_delay_and_retry(self, delay: float) -> bool: if will_time_out: self._request_state = StreamingState.Timeout return False + elif self.retry_limit_exceeded: + self._request_state = StreamingState.Error + return False else: self._reset_stream() return True @@ -310,31 +330,28 @@ def process_response(self, # we have all the data, close the core response/stream close_handler() - json_response = json.loads(raw_response.value) - if 'errors' in json_response: - self._process_error(json_response['errors'], handle_context_shutdown=handle_context_shutdown) - return json_response + try: + json_response = json.loads(raw_response.value) + except json.JSONDecodeError: + self._process_error(str(raw_response.value), + handle_context_shutdown=handle_context_shutdown) + else: + if 'errors' in json_response: + self._process_error(json_response['errors'], handle_context_shutdown=handle_context_shutdown) + return json_response def send_request(self, enable_trace_handling: Optional[bool]=False) -> HttpCoreResponse: - ip = get_request_ip(self._request.url.host, self._request.url.port, self._request.previous_ips) - if ip is None: - attempted_ips = ', '.join(self._request.previous_ips or []) - raise AnalyticsError(message=f'Connect failure. Unable to connect to any resolved IPs: {attempted_ips}.', - context=str(self._error_ctx)) - + self._error_ctx.update_num_attempts() + ip = get_request_ip(self._request.url.host, self._request.url.port) if enable_trace_handling is True: (self._request.update_url(ip, self._client_adapter.analytics_path) - .add_trace_to_extensions(self._trace_handler) - .update_previous_ips(ip)) + .add_trace_to_extensions(self._trace_handler)) else: - self._request.update_url(ip, self._client_adapter.analytics_path).update_previous_ips(ip) + self._request.update_url(ip, self._client_adapter.analytics_path) self._error_ctx.update_request_context(self._request) response = self._client_adapter.send_request(self._request) self._error_ctx.update_response_context(response) # print(f'Response received: {response.status_code} for request {self._id}, body={self._request.body}.') - if response.status_code == 401: - raise InvalidCredentialError(context=str(self._error_ctx)) - return response def send_request_in_background(self, diff --git a/couchbase_analytics/protocol/_core/retries.py b/couchbase_analytics/protocol/_core/retries.py index 254742a..30ba5bc 100644 --- a/couchbase_analytics/protocol/_core/retries.py +++ b/couchbase_analytics/protocol/_core/retries.py @@ -17,24 +17,73 @@ from concurrent.futures import CancelledError from functools import wraps -from random import uniform from time import sleep -from typing import TYPE_CHECKING, Callable +from typing import (TYPE_CHECKING, + Callable, + Optional, + Union) from httpx import ConnectError, ConnectTimeout -from couchbase_analytics.common.errors import AnalyticsError, InternalSDKError, TimeoutError +from couchbase_analytics.common.errors import (AnalyticsError, + InternalSDKError, + TimeoutError) from couchbase_analytics.common.streaming import StreamingState from couchbase_analytics.protocol.errors import WrappedError if TYPE_CHECKING: + from couchbase_analytics.protocol._core.request_context import RequestContext from couchbase_analytics.protocol.streaming import HttpStreamingResponse + class RetryHandler: """ **INTERNAL** """ + @staticmethod + def handle_httpx_retry(ex: Union[ConnectError, ConnectTimeout], ctx: RequestContext) -> Optional[Exception]: + err_str = str(ex) + if 'SSL:' in err_str: + message = 'TLS connection error occurred.' + return AnalyticsError(cause=ex, message=message, context=str(ctx.error_context)) + delay = ctx.calculate_backoff() + err: Optional[Exception] = None + if not ctx.okay_to_delay_and_retry(delay): + if ctx.retry_limit_exceeded: + err = AnalyticsError(cause=ex, message='Retry limit exceeded.', context=str(ctx.error_context)) + else: + err = TimeoutError(message='Request timed out during retry delay.', context=str(ctx.error_context)) + if err: + return err + sleep(delay) + return None + + @staticmethod + def handle_retry(ex: WrappedError, ctx: RequestContext) -> Optional[Union[BaseException, Exception]]: + if ex.retriable is True: + delay = ctx.calculate_backoff() + err: Optional[Union[BaseException, Exception]] = None + if not ctx.okay_to_delay_and_retry(delay): + if ctx.retry_limit_exceeded: + if ex.is_cause_query_err: + ex.maybe_set_cause_context(ctx.error_context) + err = ex.unwrap() + else: + err = AnalyticsError(cause=ex.unwrap(), + message='Retry limit exceeded.', + context=str(ctx.error_context)) + else: + err = TimeoutError(message='Request timed out during retry delay.', + context=str(ctx.error_context)) + + if err: + return err + sleep(delay) + return None + ex.maybe_set_cause_context(ctx.error_context) + return ex.unwrap() + @staticmethod def with_retries(fn: Callable[[HttpStreamingResponse], None]) -> Callable[[HttpStreamingResponse], None]: # noqa: C901 @wraps(fn) @@ -44,37 +93,25 @@ def wrapped_fn(self: HttpStreamingResponse) -> None: # noqa: C901 fn(self) break except WrappedError as ex: - if ex.retriable is True: - delay = calc_backoff(self._request_context.error_context.num_attempts) - if not self._request_context.okay_to_delay_and_retry(delay): - self._request_context.shutdown(ex) - raise TimeoutError(message='Request timed out during retry delay.', - context=str(self._request_context.error_context)) from None - sleep(delay) + err = RetryHandler.handle_retry(ex, self._request_context) + if err is None: continue self._request_context.shutdown(ex) - ex.maybe_set_cause_context(self._request_context.error_context) - raise ex.unwrap() from None + raise err from None + except (ConnectError, ConnectTimeout) as ex: + err = RetryHandler.handle_httpx_retry(ex, self._request_context) + if err is None: + continue + self._request_context.shutdown(ex) + raise err from None except AnalyticsError: # if an AnalyticsError is raised, we have already shut down the request context raise except RuntimeError as ex: self._request_context.shutdown(ex) raise ex - except ConnectError as ex: - self._request_context.shutdown(ex) - raise AnalyticsError(cause=ex, - message='Unable to establish connection for request.', - context=str(self._request_context.error_context)) from None - except ConnectTimeout as ex: - self._request_context.shutdown(ex) - raise TimeoutError(cause=ex, - message='Request timed out trying to establish connection.', - context=str(self._request_context.error_context)) from None except BaseException as ex: self._request_context.shutdown(ex) - if self._request_context.request_error is not None: - raise self._request_context.request_error from None if self._request_context.timed_out: raise TimeoutError(message='Request timeout.', context=str(self._request_context.error_context)) from None @@ -88,10 +125,3 @@ def wrapped_fn(self: HttpStreamingResponse) -> None: # noqa: C901 self.close() return wrapped_fn - -def calc_backoff(retry_count: int) -> float: - min_ms = 100 - max_ms = 60000 - delay_ms = min_ms * pow(2, retry_count) - capped_ms = min(max_ms, delay_ms) - return uniform(0, capped_ms / 1000.0) diff --git a/couchbase_analytics/protocol/cluster.py b/couchbase_analytics/protocol/cluster.py index 6411cb3..0eef0ef 100644 --- a/couchbase_analytics/protocol/cluster.py +++ b/couchbase_analytics/protocol/cluster.py @@ -17,7 +17,9 @@ import atexit from concurrent.futures import Future, ThreadPoolExecutor -from typing import TYPE_CHECKING, Optional, Union +from typing import (TYPE_CHECKING, + Optional, + Union) from uuid import uuid4 from couchbase_analytics.common.result import BlockingQueryResult diff --git a/couchbase_analytics/protocol/cluster.pyi b/couchbase_analytics/protocol/cluster.pyi index d6ac5de..0be0060 100644 --- a/couchbase_analytics/protocol/cluster.pyi +++ b/couchbase_analytics/protocol/cluster.pyi @@ -25,7 +25,10 @@ else: from couchbase_analytics import JSONType from couchbase_analytics.common.credential import Credential from couchbase_analytics.common.result import BlockingQueryResult -from couchbase_analytics.options import ClusterOptions, ClusterOptionsKwargs, QueryOptions, QueryOptionsKwargs +from couchbase_analytics.options import (ClusterOptions, + ClusterOptionsKwargs, + QueryOptions, + QueryOptionsKwargs) from couchbase_analytics.protocol._core.client_adapter import _ClientAdapter class Cluster: diff --git a/couchbase_analytics/protocol/connection.py b/couchbase_analytics/protocol/connection.py index d868540..f950688 100644 --- a/couchbase_analytics/protocol/connection.py +++ b/couchbase_analytics/protocol/connection.py @@ -17,7 +17,13 @@ import ssl from dataclasses import dataclass -from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, TypedDict, cast +from typing import (TYPE_CHECKING, + Dict, + List, + Optional, + Tuple, + TypedDict, + cast) from urllib.parse import parse_qs, urlparse from couchbase_analytics.common._core._certificates import _Certificates @@ -25,14 +31,14 @@ from couchbase_analytics.common._core.utils import is_null_or_empty from couchbase_analytics.common.credential import Credential from couchbase_analytics.common.deserializer import DefaultJsonDeserializer, Deserializer -from couchbase_analytics.common.options import ClusterOptions, SecurityOptions, TimeoutOptions +from couchbase_analytics.common.options import (ClusterOptions, + SecurityOptions, + TimeoutOptions) from couchbase_analytics.common.request import RequestURL -from couchbase_analytics.protocol.options import ( - ClusterOptionsTransformedKwargs, - QueryStrVal, - SecurityOptionsTransformedKwargs, - TimeoutOptionsTransformedKwargs, -) +from couchbase_analytics.protocol.options import (ClusterOptionsTransformedKwargs, + QueryStrVal, + SecurityOptionsTransformedKwargs, + TimeoutOptionsTransformedKwargs) if TYPE_CHECKING: from couchbase_analytics.protocol.options import OptionsBuilder @@ -52,6 +58,8 @@ class DefaultTimeouts(TypedDict): 'query_timeout': 60 * 10, } +DEFAULT_MAX_RETRIES: int = 7 + def parse_http_endpoint(http_endpoint: str) -> Tuple[RequestURL, Dict[str, List[str]]]: """ **INTERNAL** @@ -165,6 +173,9 @@ def get_connect_timeout(self) -> float: return connect_timeout return DEFAULT_TIMEOUTS['connect_timeout'] + def get_max_retries(self) -> int: + return self.cluster_options.get('max_retries', None) or DEFAULT_MAX_RETRIES + def get_query_timeout(self) -> float: timeout_opts: Optional[TimeoutOptionsTransformedKwargs] = self.cluster_options.get('timeout_options') if timeout_opts is not None: diff --git a/couchbase_analytics/protocol/errors.py b/couchbase_analytics/protocol/errors.py index 3d07b66..901325f 100644 --- a/couchbase_analytics/protocol/errors.py +++ b/couchbase_analytics/protocol/errors.py @@ -18,7 +18,13 @@ import socket import sys from functools import wraps -from typing import Any, Callable, Dict, List, NamedTuple, Optional, Set, Union +from typing import (Any, + Callable, + Dict, + List, + NamedTuple, + Optional, + Union) if sys.version_info < (3, 10): from typing_extensions import TypeAlias @@ -26,13 +32,11 @@ from typing import TypeAlias from couchbase_analytics.common._core.error_context import ErrorContext -from couchbase_analytics.common.errors import ( - AnalyticsError, - InternalSDKError, - InvalidCredentialError, - QueryError, - TimeoutError, -) +from couchbase_analytics.common.errors import (AnalyticsError, + InternalSDKError, + InvalidCredentialError, + QueryError, + TimeoutError) AnalyticsClientError: TypeAlias = Union[AnalyticsError, InternalSDKError, @@ -83,6 +87,10 @@ def __init__(self, self._cause = cause self._retriable = retriable + @property + def is_cause_query_err(self) -> bool: + return isinstance(self._cause, QueryError) + @property def retriable(self) -> bool: return self._retriable @@ -110,26 +118,20 @@ def __repr__(self) -> str: def __str__(self) -> str: return self.__repr__() +# Python does not specify which socket errors are retriable or not, although there is a EAI_AGAIN error +# that is commented to be temporary. The current version of the RFC has connect failures as retriable. # https://github.com/python/cpython/blob/0f866cbfefd797b4dae25962457c5579bb90dde5/Modules/addrinfo.h#L58-L71 -_NON_RETRYABLE_SOCKET_ERRORS: List[int] = [ - socket.EAI_ADDRFAMILY, - socket.EAI_BADFLAGS, - socket.EAI_FAIL, - socket.EAI_FAMILY, - socket.EAI_MEMORY, - socket.EAI_NODATA, - socket.EAI_NONAME, - socket.EAI_SERVICE, - socket.EAI_SOCKTYPE, - socket.EAI_SYSTEM, - socket.EAI_BADHINTS, - socket.EAI_PROTOCOL, - socket.EAI_MAX -] - class ErrorMapper: + @staticmethod + def build_error_from_http_status_code(message: str, context: ErrorContext) -> WrappedError: + + if context.status_code == 503: + return WrappedError(AnalyticsError(context=str(context), message=message), retriable=True) + + return WrappedError(AnalyticsError(context=str(context), message=message)) + @staticmethod # noqa: C901 def build_error_from_json(json_data: List[Dict[str, Any]], context: ErrorContext) -> WrappedError: if context.status_code is None: @@ -161,25 +163,22 @@ def build_error_from_json(json_data: List[Dict[str, Any]], context: ErrorContext if first_err.code == 21002: return WrappedError(TimeoutError(context=str(context), message='Received timeout error from server.')) + q_err = QueryError(code=first_err.code, server_message=first_err.message, context=str(context)) + if context.status_code == 503: + return WrappedError(q_err, retriable=True) + retriable = first_non_retriable_error is None and first_retriable_error is not None - return WrappedError(QueryError(code=first_err.code, - server_message=first_err.message, - context=str(context)), - retriable=retriable) + return WrappedError(q_err, retriable=retriable) @staticmethod - def handle_socket_error(fn: Callable[[str, int, Optional[Set[str]]], Optional[str]] - ) -> Callable[[str, int, Optional[Set[str]]], Optional[str]]: + def handle_socket_error(fn: Callable[[str, int], str]) -> Callable[[str, int], str]: @wraps(fn) - def wrapped_fn(host: str, - port: int, - previous_ips: Optional[Set[str]]=None) -> Optional[str]: + def wrapped_fn(host: str, port: int) -> str: try: - return fn(host, port, previous_ips) + return fn(host, port) except socket.gaierror as ex: - # print(f'getaddrinfo failed for {host}:{port} with error: {ex}') + print(f'getaddrinfo failed for {host}:{port} with error: {ex}') msg='Connection error occurred while sending request.' - raise WrappedError(AnalyticsError(cause=ex, message=msg), - retriable=(ex.errno not in _NON_RETRYABLE_SOCKET_ERRORS)) from None + raise WrappedError(AnalyticsError(cause=ex, message=msg), retriable=True) from None return wrapped_fn diff --git a/couchbase_analytics/protocol/options.py b/couchbase_analytics/protocol/options.py index ecc83df..955edf9 100644 --- a/couchbase_analytics/protocol/options.py +++ b/couchbase_analytics/protocol/options.py @@ -16,34 +16,38 @@ from __future__ import annotations from copy import copy -from typing import Any, Callable, Dict, List, Literal, Optional, Tuple, TypedDict, TypeVar, Union +from typing import (Any, + Callable, + Dict, + List, + Literal, + Optional, + Tuple, + TypedDict, + TypeVar, + Union) from couchbase_analytics.common._core import JsonStreamConfig -from couchbase_analytics.common._core.utils import ( - VALIDATE_BOOL, - VALIDATE_DESERIALIZER, - VALIDATE_STR, - VALIDATE_STR_LIST, - EnumToStr, - to_seconds, - validate_path, - validate_raw_dict, -) +from couchbase_analytics.common._core.utils import (VALIDATE_BOOL, + VALIDATE_DESERIALIZER, + VALIDATE_INT, + VALIDATE_STR, + VALIDATE_STR_LIST, + EnumToStr, + to_seconds, + validate_path, + validate_raw_dict) from couchbase_analytics.common.deserializer import Deserializer from couchbase_analytics.common.enums import QueryScanConsistency -from couchbase_analytics.common.options import ( - ClusterOptions, - OptionsClass, - QueryOptions, - SecurityOptions, - TimeoutOptions, -) -from couchbase_analytics.common.options_base import ( - ClusterOptionsValidKeys, - QueryOptionsValidKeys, - SecurityOptionsValidKeys, - TimeoutOptionsValidKeys, -) +from couchbase_analytics.common.options import (ClusterOptions, + OptionsClass, + QueryOptions, + SecurityOptions, + TimeoutOptions) +from couchbase_analytics.common.options_base import (ClusterOptionsValidKeys, + QueryOptionsValidKeys, + SecurityOptionsValidKeys, + TimeoutOptionsValidKeys) QUERY_CONSISTENCY_TO_STR = EnumToStr[QueryScanConsistency]() @@ -52,12 +56,14 @@ class ClusterOptionsTransforms(TypedDict): deserializer: Dict[Literal['deserializer'], Callable[[Any], Deserializer]] + max_retries: Dict[Literal['max_retries'], Callable[[Any], int]] security_options: Dict[Literal['security_options'], Callable[[Any], Any]] timeout_options: Dict[Literal['timeout_options'], Callable[[Any], Any]] CLUSTER_OPTIONS_TRANSFORMS: ClusterOptionsTransforms = { 'deserializer': {'deserializer': VALIDATE_DESERIALIZER}, + 'max_retries': {'max_retries': VALIDATE_INT}, 'security_options': {'security_options': lambda x: x}, 'timeout_options': {'timeout_options': lambda x: x}, } @@ -65,6 +71,7 @@ class ClusterOptionsTransforms(TypedDict): class ClusterOptionsTransformedKwargs(TypedDict, total=False): deserializer: Optional[Deserializer] + max_retries: Optional[int] security_options: Optional[SecurityOptionsTransformedKwargs] timeout_options: Optional[TimeoutOptionsTransformedKwargs] @@ -115,6 +122,7 @@ class QueryOptionsTransforms(TypedDict): client_context_id: Dict[Literal['client_context_id'], Callable[[Any], str]] deserializer: Dict[Literal['deserializer'], Callable[[Any], Deserializer]] lazy_execute: Dict[Literal['lazy_execute'], Callable[[Any], bool]] + max_retries: Dict[Literal['max_retries'], Callable[[Any], int]] named_parameters: Dict[Literal['named_parameters'], Callable[[Any], Any]] positional_parameters: Dict[Literal['positional_parameters'], Callable[[Any], Any]] query_context: Dict[Literal['query_context'], Callable[[Any], str]] @@ -129,6 +137,7 @@ class QueryOptionsTransforms(TypedDict): 'client_context_id': {'client_context_id': VALIDATE_STR}, 'deserializer': {'deserializer': VALIDATE_DESERIALIZER}, 'lazy_execute': {'lazy_execute': VALIDATE_BOOL}, + 'max_retries': {'max_retries': VALIDATE_INT}, 'named_parameters': {'named_parameters': lambda x: x}, 'positional_parameters': {'positional_parameters': lambda x: x}, 'query_context': {'query_context': VALIDATE_STR}, @@ -144,6 +153,7 @@ class QueryOptionsTransformedKwargs(TypedDict, total=False): client_context_id: Optional[str] deserializer: Optional[Deserializer] lazy_execute: Optional[bool] + max_retries: Optional[int] named_parameters: Optional[Any] positional_parameters: Optional[Any] priority: Optional[bool] diff --git a/couchbase_analytics/protocol/streaming.py b/couchbase_analytics/protocol/streaming.py index d12dcc6..09b4541 100644 --- a/couchbase_analytics/protocol/streaming.py +++ b/couchbase_analytics/protocol/streaming.py @@ -22,7 +22,9 @@ from couchbase_analytics.common._core import ParsedResult, ParsedResultType from couchbase_analytics.common._core.query import build_query_metadata -from couchbase_analytics.common.errors import AnalyticsError, InternalSDKError, TimeoutError +from couchbase_analytics.common.errors import (AnalyticsError, + InternalSDKError, + TimeoutError) from couchbase_analytics.common.query import QueryMetadata from couchbase_analytics.protocol._core.request_context import RequestContext from couchbase_analytics.protocol._core.retries import RetryHandler diff --git a/couchbase_analytics/tests/connection_t.py b/couchbase_analytics/tests/connection_t.py index e598f0a..23b1875 100644 --- a/couchbase_analytics/tests/connection_t.py +++ b/couchbase_analytics/tests/connection_t.py @@ -32,6 +32,7 @@ class ConnectionTestSuite: TEST_MANIFEST = [ 'test_connstr_options_fail', + 'test_connstr_options_max_retries', 'test_connstr_options_timeout', 'test_connstr_options_timeout_fail', 'test_connstr_options_timeout_invalid_duration', @@ -57,6 +58,15 @@ def test_connstr_options_fail(self, with pytest.raises(ValueError): _ClientAdapter(connstr, cred) + def test_connstr_options_max_retries(self) -> None: + cred = Credential.from_username_and_password('Administrator', 'password') + max_retries = 10 + connstr = f'https://localhost?max_retries={max_retries}' + client = _ClientAdapter(connstr, cred) + req_builder = _RequestBuilder(client) + req = req_builder.build_base_query_request('SELECT 1=1') + assert req.max_retries == max_retries + @pytest.mark.parametrize('duration, expected_seconds', [('1h', '3600'), ('+1h', '3600'), diff --git a/couchbase_analytics/tests/json_parsing_t.py b/couchbase_analytics/tests/json_parsing_t.py index 5aaec94..8174eb6 100644 --- a/couchbase_analytics/tests/json_parsing_t.py +++ b/couchbase_analytics/tests/json_parsing_t.py @@ -20,7 +20,9 @@ import pytest -from couchbase_analytics.common._core import JsonParsingError, JsonStreamConfig, ParsedResult, ParsedResultType +from couchbase_analytics.common._core import (JsonStreamConfig, + ParsedResult, + ParsedResultType) from couchbase_analytics.protocol._core.json_stream import JsonStream from tests.environments.simple_environment import JsonDataType from tests.utils import BytesIterator @@ -260,59 +262,59 @@ def test_array_of_objects(self) -> None: assert parser.get_result(0.01) is None def test_invalid_empty(self) -> None: - try: - data = '' - parser = JsonStream(BytesIterator(bytes(data, 'utf-8')), - stream_config=JsonStreamConfig(buffer_entire_result=True)) - parser.start_parsing() - except JsonParsingError as err: - assert isinstance(err, JsonParsingError) - assert err.cause is not None - assert 'parse error' in str(err.cause) + data = '' + parser = JsonStream(BytesIterator(bytes(data, 'utf-8')), + stream_config=JsonStreamConfig(buffer_entire_result=True)) + parser.start_parsing() + res = parser.get_result(0.01) + assert isinstance(res, ParsedResult) + assert res.result_type == ParsedResultType.ERROR + assert res.value is not None + assert 'parse error' in str(res.value.decode('utf-8')) def test_invalid_garbage_between_objects(self) -> None: - try: - data = '[{"id":1,"name":"Alice"},garbage,{"id":2,"name":"Bob"}]' - parser = JsonStream(BytesIterator(bytes(data, 'utf-8')), - stream_config=JsonStreamConfig(buffer_entire_result=True)) - parser.start_parsing() - except JsonParsingError as err: - assert isinstance(err, JsonParsingError) - assert err.cause is not None - assert 'lexical error' in str(err.cause) + data = '[{"id":1,"name":"Alice"},garbage,{"id":2,"name":"Bob"}]' + parser = JsonStream(BytesIterator(bytes(data, 'utf-8')), + stream_config=JsonStreamConfig(buffer_entire_result=True)) + parser.start_parsing() + res = parser.get_result(0.01) + assert isinstance(res, ParsedResult) + assert res.result_type == ParsedResultType.ERROR + assert res.value is not None + assert 'lexical error' in str(res.value.decode('utf-8')) def test_invalid_leading_garbage(self) -> None: - try: - data = 'garbage{"key":"value"}' - parser = JsonStream(BytesIterator(bytes(data, 'utf-8')), - stream_config=JsonStreamConfig(buffer_entire_result=True)) - parser.start_parsing() - except JsonParsingError as err: - assert isinstance(err, JsonParsingError) - assert err.cause is not None - assert 'lexical error' in str(err.cause) + data = 'garbage{"key":"value"}' + parser = JsonStream(BytesIterator(bytes(data, 'utf-8')), + stream_config=JsonStreamConfig(buffer_entire_result=True)) + parser.start_parsing() + res = parser.get_result(0.01) + assert isinstance(res, ParsedResult) + assert res.result_type == ParsedResultType.ERROR + assert res.value is not None + assert 'lexical error' in str(res.value.decode('utf-8')) def test_invalid_trailing_garbage(self) -> None: - try: - data = '{"key":"value"}garbage' - parser = JsonStream(BytesIterator(bytes(data, 'utf-8')), - stream_config=JsonStreamConfig(buffer_entire_result=True)) - parser.start_parsing() - except JsonParsingError as err: - assert isinstance(err, JsonParsingError) - assert err.cause is not None - assert 'parse error' in str(err.cause) + data = '{"key":"value"}garbage' + parser = JsonStream(BytesIterator(bytes(data, 'utf-8')), + stream_config=JsonStreamConfig(buffer_entire_result=True)) + parser.start_parsing() + res = parser.get_result(0.01) + assert isinstance(res, ParsedResult) + assert res.result_type == ParsedResultType.ERROR + assert res.value is not None + assert 'parse error' in str(res.value.decode('utf-8')) def test_invalid_whitespace_only(self) -> None: - try: - data = ' \n\t ' - parser = JsonStream(BytesIterator(bytes(data, 'utf-8')), - stream_config=JsonStreamConfig(buffer_entire_result=True)) - parser.start_parsing() - except JsonParsingError as err: - assert isinstance(err, JsonParsingError) - assert err.cause is not None - assert 'parse error' in str(err.cause) + data = ' \n\t ' + parser = JsonStream(BytesIterator(bytes(data, 'utf-8')), + stream_config=JsonStreamConfig(buffer_entire_result=True)) + parser.start_parsing() + res = parser.get_result(0.01) + assert isinstance(res, ParsedResult) + assert res.result_type == ParsedResultType.ERROR + assert res.value is not None + assert 'parse error' in str(res.value.decode('utf-8')) def test_object(self) -> None: data = '{"name":"John","age":30,"city":"New York"}' diff --git a/couchbase_analytics/tests/options_t.py b/couchbase_analytics/tests/options_t.py index ff35c97..f05e87a 100644 --- a/couchbase_analytics/tests/options_t.py +++ b/couchbase_analytics/tests/options_t.py @@ -16,21 +16,25 @@ from __future__ import annotations from datetime import timedelta -from typing import Dict, Type +from typing import (Dict, + Optional, + Type) import pytest from couchbase_analytics.credential import Credential -from couchbase_analytics.deserializer import DefaultJsonDeserializer, Deserializer, PassthroughDeserializer -from couchbase_analytics.options import ( - ClusterOptions, - SecurityOptions, - SecurityOptionsKwargs, - TimeoutOptions, - TimeoutOptionsKwargs, -) +from couchbase_analytics.deserializer import (DefaultJsonDeserializer, + Deserializer, + PassthroughDeserializer) +from couchbase_analytics.options import (ClusterOptions, + SecurityOptions, + SecurityOptionsKwargs, + TimeoutOptions, + TimeoutOptionsKwargs) from couchbase_analytics.protocol._core.client_adapter import _ClientAdapter -from tests.utils import get_test_cert_list, get_test_cert_path, get_test_cert_str +from tests.utils import (get_test_cert_list, + get_test_cert_path, + get_test_cert_str) TEST_CERT_PATH = get_test_cert_path() TEST_CERT_LIST = get_test_cert_list() @@ -42,6 +46,8 @@ class ClusterOptionsTestSuite: TEST_MANIFEST = [ 'test_options_deserializer', 'test_options_deserializer_kwargs', + 'test_options_max_retries', + 'test_options_max_retries_kwargs', 'test_security_options', 'test_security_options_classmethods', 'test_security_options_kwargs', @@ -54,19 +60,38 @@ class ClusterOptionsTestSuite: ] @pytest.mark.parametrize('deserializer_cls', [DefaultJsonDeserializer, PassthroughDeserializer]) - def test_options_deserializer(self, deserializer_cls:Type[Deserializer]) -> None: + def test_options_deserializer(self, deserializer_cls: Type[Deserializer]) -> None: cred = Credential.from_username_and_password('Administrator', 'password') deserializer_instance = deserializer_cls() client = _ClientAdapter('https://localhost', cred, ClusterOptions(deserializer=deserializer_instance)) assert isinstance(client.connection_details.default_deserializer, deserializer_cls) @pytest.mark.parametrize('deserializer_cls', [DefaultJsonDeserializer, PassthroughDeserializer]) - def test_options_deserializer_kwargs(self, deserializer_cls:Type[Deserializer]) -> None: + def test_options_deserializer_kwargs(self, deserializer_cls: Type[Deserializer]) -> None: cred = Credential.from_username_and_password('Administrator', 'password') deserializer_instance = deserializer_cls() client = _ClientAdapter('https://localhost', cred, **{'deserializer': deserializer_instance}) assert isinstance(client.connection_details.default_deserializer, deserializer_cls) + @pytest.mark.parametrize('max_retries', [5, 10, None]) + def test_options_max_retries(self, max_retries: Optional[int]) -> None: + cred = Credential.from_username_and_password('Administrator', 'password') + client = _ClientAdapter('https://localhost', cred, ClusterOptions(max_retries=max_retries)) + if max_retries is None: + assert client.connection_details.get_max_retries() == 7 + else: + assert client.connection_details.get_max_retries() == max_retries + + @pytest.mark.parametrize('max_retries', [5, 10, None]) + def test_options_max_retries_kwargs(self, max_retries: Optional[int]) -> None: + cred = Credential.from_username_and_password('Administrator', 'password') + if max_retries is None: + client = _ClientAdapter('https://localhost', cred) + assert client.connection_details.get_max_retries() == 7 + else: + client = _ClientAdapter('https://localhost', cred, **{'max_retries': max_retries}) + assert client.connection_details.get_max_retries() == max_retries + @pytest.mark.parametrize('opts, expected_opts', [({}, None), ({'trust_only_capella': True}, diff --git a/couchbase_analytics/tests/query_integration_t.py b/couchbase_analytics/tests/query_integration_t.py index e8f8837..f47ad7f 100644 --- a/couchbase_analytics/tests/query_integration_t.py +++ b/couchbase_analytics/tests/query_integration_t.py @@ -18,7 +18,10 @@ import json from concurrent.futures import CancelledError, Future from datetime import timedelta -from typing import TYPE_CHECKING, Any, Dict, Optional +from typing import (TYPE_CHECKING, + Any, + Dict, + Optional) import pytest diff --git a/couchbase_analytics/tests/query_options_t.py b/couchbase_analytics/tests/query_options_t.py index 1c69de8..17486ec 100644 --- a/couchbase_analytics/tests/query_options_t.py +++ b/couchbase_analytics/tests/query_options_t.py @@ -17,7 +17,11 @@ from dataclasses import dataclass from datetime import timedelta -from typing import Any, Dict, List, Optional, Union +from typing import (Any, + Dict, + List, + Optional, + Union) import pytest @@ -46,6 +50,8 @@ class QueryOptionsTestSuite: TEST_MANIFEST = [ 'test_options_deserializer', 'test_options_deserializer_kwargs', + 'test_options_max_retries', + 'test_options_max_retries_kwargs', 'test_options_named_parameters', 'test_options_named_parameters_kwargs', 'test_options_positional_parameters', @@ -92,6 +98,38 @@ def test_options_deserializer_kwargs(self, assert req.deserializer == deserializer query_ctx.validate_query_context(req.body) + @pytest.mark.parametrize('max_retries', [5, 10, None]) + def test_options_max_retries(self, + query_statment: str, + request_builder: _RequestBuilder, + query_ctx: QueryContext, + max_retries: Optional[int]) -> None: + if max_retries is not None: + q_opts = QueryOptions(max_retries=max_retries) + req = request_builder.build_base_query_request(query_statment, q_opts) + else: + req = request_builder.build_base_query_request(query_statment) + exp_opts: QueryOptionsTransformedKwargs = {} + assert req.options == exp_opts + assert req.max_retries == (max_retries if max_retries is not None else 7) + query_ctx.validate_query_context(req.body) + + @pytest.mark.parametrize('max_retries', [5, 10, None]) + def test_options_max_retries_kwargs(self, + query_statment: str, + request_builder: _RequestBuilder, + query_ctx: QueryContext, + max_retries: Optional[int]) -> None: + if max_retries is not None: + kwargs: QueryOptionsKwargs = {'max_retries': max_retries} + req = request_builder.build_base_query_request(query_statment, **kwargs) + else: + req = request_builder.build_base_query_request(query_statment) + exp_opts: QueryOptionsTransformedKwargs = {} + assert req.options == exp_opts + assert req.max_retries == (max_retries if max_retries is not None else 7) + query_ctx.validate_query_context(req.body) + def test_options_named_parameters(self, query_statment: str, request_builder: _RequestBuilder, diff --git a/couchbase_analytics/tests/test_server_t.py b/couchbase_analytics/tests/test_server_t.py index e9aa673..fc4e5d9 100644 --- a/couchbase_analytics/tests/test_server_t.py +++ b/couchbase_analytics/tests/test_server_t.py @@ -17,15 +17,21 @@ from concurrent.futures import Future from datetime import timedelta -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Union import pytest -from couchbase_analytics.errors import InvalidCredentialError, QueryError, TimeoutError +from couchbase_analytics.errors import (AnalyticsError, + InvalidCredentialError, + QueryError, + TimeoutError) from couchbase_analytics.options import QueryOptions from couchbase_analytics.result import BlockingQueryResult from tests import SyncQueryType, YieldFixture -from tests.test_server import ErrorType, NonRetriableSpecificationType, ResultType, RetriableGroupType +from tests.test_server import (ErrorType, + NonRetriableSpecificationType, + ResultType, + RetriableGroupType) if TYPE_CHECKING: from tests.environments.base_environment import BlockingTestEnvironment @@ -37,7 +43,9 @@ class TestServerTestSuite: 'test_auth_error_unauthorized', 'test_auth_error_insufficient_permissions', 'test_error_non_retriable_response', - 'test_error_retriable_response', + 'test_error_retriable_response_timeout', + 'test_error_retriable_response_retries_exceeded', + 'test_error_retriable_http503', 'test_error_timeout', 'test_results_object_values', 'test_results_raw_values' @@ -86,17 +94,52 @@ def test_error_non_retriable_response(self, test_env.assert_error_context_num_attempts(1, ex.value._context) test_env.assert_error_context_contains_last_dispatch(ex.value._context) - def test_error_retriable_response(self, test_env: BlockingTestEnvironment) -> None: + def test_error_retriable_response_timeout(self, test_env: BlockingTestEnvironment) -> None: test_env.set_url_path('/test_error') test_env.update_request_json({'error_type': ErrorType.Retriable.value, 'retry_group_type': RetriableGroupType.All.value}) statement = 'SELECT "Hello, data!" AS greeting' with pytest.raises(TimeoutError) as ex: - test_env.cluster_or_scope.execute_query(statement, QueryOptions(timeout=timedelta(seconds=2))) + # just-in-case, increase the max_retries to ensure we hit the timeout + test_env.cluster_or_scope.execute_query(statement, QueryOptions(max_retries=10, timeout=timedelta(seconds=1.5))) test_env.assert_error_context_num_attempts(4 , ex.value._context, exact=False) test_env.assert_error_context_contains_last_dispatch(ex.value._context) + def test_error_retriable_response_retries_exceeded(self, test_env: BlockingTestEnvironment) -> None: + test_env.set_url_path('/test_error') + test_env.update_request_json({'error_type': ErrorType.Retriable.value, + 'retry_group_type': RetriableGroupType.All.value}) + statement = 'SELECT "Hello, data!" AS greeting' + allowed_retries = 5 + q_opts = QueryOptions(max_retries=allowed_retries, timeout=timedelta(seconds=10)) + with pytest.raises(QueryError) as ex: + test_env.cluster_or_scope.execute_query(statement, q_opts) + + print(ex.value) + test_env.assert_error_context_num_attempts(allowed_retries+1 , ex.value._context) + test_env.assert_error_context_contains_last_dispatch(ex.value._context) + + @pytest.mark.parametrize('analytics_error', [False, True]) + def test_error_retriable_http503(self, test_env: BlockingTestEnvironment, analytics_error: bool) -> None: + test_env.set_url_path('/test_error') + test_env.update_request_json({'error_type': ErrorType.Http503.value, + 'analytics_error': analytics_error}) + statement = 'SELECT "Hello, data!" AS greeting' + allowed_retries = 5 + q_opts = QueryOptions(max_retries=allowed_retries, timeout=timedelta(seconds=10)) + ex: Union[pytest.ExceptionInfo[AnalyticsError], pytest.ExceptionInfo[QueryError]] + if analytics_error: + with pytest.raises(QueryError) as ex: + test_env.cluster_or_scope.execute_query(statement, q_opts) + else: + with pytest.raises(AnalyticsError) as ex: + test_env.cluster_or_scope.execute_query(statement, q_opts) + + test_env.assert_error_context_num_attempts(allowed_retries+1 , ex.value._context) + test_env.assert_error_context_contains_last_dispatch(ex.value._context) + + @pytest.mark.parametrize('server_side', [False, True]) def test_error_timeout(self, test_env: BlockingTestEnvironment, server_side: bool) -> None: test_env.set_url_path('/test_error') diff --git a/pyproject.toml b/pyproject.toml index e08534f..c4e8acf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -93,8 +93,16 @@ line-length = 120 extend-exclude = ["tests/test_config.ini", "test*.py", "*_tests.py"] [tool.ruff.lint] -select = ["E", "F", "B", "C", "I"] +select = ["E", "F", "B", "C"] [tool.ruff.format] quote-style = "single" docstring-code-format = false + +[tool.isort] +multi_line_output = 1 +force_grid_wrap = 3 +use_parentheses = true +ensure_newline_before_comments = true +line_length = 120 +order_by_type = true diff --git a/tests/__init__.py b/tests/__init__.py index d3f974a..3c294b1 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -14,7 +14,10 @@ # limitations under the License. from enum import Enum -from typing import AsyncGenerator, Generator, Optional, TypeVar +from typing import (AsyncGenerator, + Generator, + Optional, + TypeVar) T = TypeVar('T') AsyncYieldFixture = AsyncGenerator[T, None] diff --git a/tests/environments/base_environment.py b/tests/environments/base_environment.py index 2ec0906..4155561 100644 --- a/tests/environments/base_environment.py +++ b/tests/environments/base_environment.py @@ -19,7 +19,13 @@ import pathlib import sys from os import path -from typing import TYPE_CHECKING, Any, Dict, List, Optional, TypedDict, Union +from typing import (TYPE_CHECKING, + Any, + Dict, + List, + Optional, + TypedDict, + Union) if sys.version_info < (3, 11): from typing_extensions import Unpack @@ -389,6 +395,7 @@ async def enable_test_server(self) -> AsyncTestEnvironment: raise AnalyticsTestEnvironmentError('No cluster available, cannot enable test server.') from tests.utils._async_client_adapter import _TestAsyncClientAdapter from tests.utils._test_async_httpx import TestAsyncHTTPTransport + # close the adapter here b/c we need to await await self._async_cluster._impl._client_adapter.close_client() new_adapter = _TestAsyncClientAdapter(adapter=self._async_cluster._impl._client_adapter, # type: ignore[call-arg] diff --git a/tests/test_config.ini b/tests/test_config.ini index d65e657..82d35de 100644 --- a/tests/test_config.ini +++ b/tests/test_config.ini @@ -1,6 +1,6 @@ [analytics] scheme = http -host = e19f1e4e-20250626.cb-sdk.bemdas.com +host = 3585106b-20250708.cb-sdk.bemdas.com port = 8095 username = Administrator password = password diff --git a/tests/test_server/__init__.py b/tests/test_server/__init__.py index 67b2a6b..e779751 100644 --- a/tests/test_server/__init__.py +++ b/tests/test_server/__init__.py @@ -23,6 +23,7 @@ class ErrorType(Enum): Unauthorized = 'unauthorized' InsufficientPermissions = 'insufficient_permissions' Retriable = 'retriable' + Http503 = 'http_503' @staticmethod def from_str(error_type: str) -> ErrorType: diff --git a/tests/test_server/request.py b/tests/test_server/request.py index 16cf9c5..78f2661 100644 --- a/tests/test_server/request.py +++ b/tests/test_server/request.py @@ -5,7 +5,10 @@ Dict, Optional) -from tests.test_server import ErrorType, NonRetriableSpecificationType, ResultType, RetriableGroupType +from tests.test_server import (ErrorType, + NonRetriableSpecificationType, + ResultType, + RetriableGroupType) @dataclass @@ -94,3 +97,13 @@ def from_json(cls, json_data: Dict[str, Any]) -> ServerTimeoutRequest: return cls(error_type=ErrorType.Timeout, timeout=float(timeout), server_side=json_data.get('server_side', False)) + +@dataclass +class ServerHttp503Request: + error_type: ErrorType + analytics_error: Optional[bool] = False + + @classmethod + def from_json(cls, json_data: Dict[str, Any]) -> ServerHttp503Request: + return cls(error_type=ErrorType.Http503, + analytics_error=json_data.get('analytics_error', False)) diff --git a/tests/test_server/response.py b/tests/test_server/response.py index cc5d835..83a9562 100644 --- a/tests/test_server/response.py +++ b/tests/test_server/response.py @@ -18,10 +18,19 @@ import json from dataclasses import dataclass, field from random import choice -from typing import Any, Callable, Dict, Generator, List, Optional, Union +from typing import (Any, + Callable, + Dict, + Generator, + List, + Optional, + Union) from uuid import uuid4 -from tests.test_server import ErrorType, NonRetriableSpecificationType, ResultType, RetriableGroupType +from tests.test_server import (ErrorType, + NonRetriableSpecificationType, + ResultType, + RetriableGroupType) US_CITIES = [ "New York City", @@ -233,6 +242,11 @@ def build_errors(resp: ServerResponse, resp.status = 'timeout' resp.metrics.error_count = 1 resp.errors = [ServerResponseError(21002, 'Request timed out and will be cancelled.', True)] + elif error_type == ErrorType.Http503: + resp.http_status = 503 + resp.status = 'fatal' + resp.metrics.error_count = 1 + resp.errors = [ServerResponseError(23000, 'Service is currently unavailable.')] elif error_type == ErrorType.InsufficientPermissions: resp.http_status = 403 resp.status = 'fatal' diff --git a/tests/test_server/web_server.py b/tests/test_server/web_server.py index 0a2e43f..6017c6a 100644 --- a/tests/test_server/web_server.py +++ b/tests/test_server/web_server.py @@ -31,6 +31,7 @@ from tests.test_server import ErrorType from tests.test_server.request import (ServerErrorRequest, + ServerHttp503Request, ServerResultsRequest, ServerTimeoutRequest) from tests.test_server.response import (ServerResponse, @@ -81,6 +82,17 @@ def _handle_auth_error_request(self, error_type: ErrorType) -> web.Response: resp.update_elapsed_time(elapsed) return web.json_response(resp.to_json_repr()) + def _handle_http503_error_request(self, request: ServerHttp503Request) -> web.Response: + if request.analytics_error is False: + return web.Response(status=503, text='Service Unavailable') + start = perf_counter() + resp = ServerResponse.create() + ServerResponseError.build_errors(resp, request.error_type) + end = perf_counter() + elapsed = end - start + resp.update_elapsed_time(elapsed) + return web.json_response(resp.to_json_repr(), status=resp.http_status) + async def _handle_retry_error_request(self, request: ServerErrorRequest) -> web.Response: start = perf_counter() resp = ServerResponse.create() @@ -157,6 +169,9 @@ async def handle_error_request(self, request: web.Request) -> web.Response: return self._handle_auth_error_request(error_req.error_type) elif error_req.error_type == ErrorType.Retriable: return await self._handle_retry_error_request(error_req) + elif error_req.error_type == ErrorType.Http503: + http503_req = ServerHttp503Request.from_json(received_json) + return self._handle_http503_error_request(http503_req) logger.info(f"Received JSON: {received_json}") return web.json_response({ 'status': 'success', diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py index 0b33344..bd6633e 100644 --- a/tests/utils/__init__.py +++ b/tests/utils/__init__.py @@ -22,7 +22,13 @@ import random from collections.abc import AsyncIterator as PyAsyncIterator from collections.abc import Iterator -from typing import Any, Dict, Generator, List, Optional, Tuple, Union +from typing import (Any, + Dict, + Generator, + List, + Optional, + Tuple, + Union) from urllib.parse import quote import anyio diff --git a/tests/utils/_async_utils.py b/tests/utils/_async_utils.py index 394daf9..6215cbf 100644 --- a/tests/utils/_async_utils.py +++ b/tests/utils/_async_utils.py @@ -16,7 +16,11 @@ from __future__ import annotations from types import TracebackType -from typing import Any, Callable, List, Optional, Type +from typing import (Any, + Callable, + List, + Optional, + Type) import anyio diff --git a/tests/utils/_test_async_httpx.py b/tests/utils/_test_async_httpx.py index 9f59dde..5238f07 100644 --- a/tests/utils/_test_async_httpx.py +++ b/tests/utils/_test_async_httpx.py @@ -1,14 +1,25 @@ import typing -from httpcore import AsyncConnectionPool, Origin, Request, Response -from httpcore._async.connection import RETRIES_BACKOFF_FACTOR, AsyncHTTPConnection, exponential_backoff, logger +from httpcore import (AsyncConnectionPool, + Origin, + Request, + Response) +from httpcore._async.connection import (RETRIES_BACKOFF_FACTOR, + AsyncHTTPConnection, + exponential_backoff, + logger) from httpcore._async.connection_pool import AsyncPoolRequest, PoolByteStream from httpcore._async.interfaces import AsyncConnectionInterface from httpcore._backends.base import AsyncNetworkStream -from httpcore._exceptions import ConnectError, ConnectionNotAvailable, ConnectTimeout, UnsupportedProtocol +from httpcore._exceptions import (ConnectError, + ConnectionNotAvailable, + ConnectTimeout, + UnsupportedProtocol) from httpcore._ssl import default_ssl_context from httpcore._trace import Trace -from httpx import AsyncHTTPTransport, Limits, create_ssl_context +from httpx import (AsyncHTTPTransport, + Limits, + create_ssl_context) class TestAsyncHTTPConnection(AsyncHTTPConnection): diff --git a/tests/utils/_test_httpx.py b/tests/utils/_test_httpx.py index 3cd390e..ed3c243 100644 --- a/tests/utils/_test_httpx.py +++ b/tests/utils/_test_httpx.py @@ -1,15 +1,27 @@ import time import typing -from httpcore import ConnectionPool, Origin, Request, Response +from httpcore import (ConnectionPool, + Origin, + Request, + Response) from httpcore._backends.base import NetworkStream -from httpcore._exceptions import ConnectError, ConnectionNotAvailable, ConnectTimeout, PoolTimeout, UnsupportedProtocol +from httpcore._exceptions import (ConnectError, + ConnectionNotAvailable, + ConnectTimeout, + PoolTimeout, + UnsupportedProtocol) from httpcore._ssl import default_ssl_context -from httpcore._sync.connection import RETRIES_BACKOFF_FACTOR, HTTPConnection, exponential_backoff, logger +from httpcore._sync.connection import (RETRIES_BACKOFF_FACTOR, + HTTPConnection, + exponential_backoff, + logger) from httpcore._sync.connection_pool import PoolByteStream, PoolRequest from httpcore._sync.interfaces import ConnectionInterface from httpcore._trace import Trace -from httpx import HTTPTransport, Limits, create_ssl_context +from httpx import (HTTPTransport, + Limits, + create_ssl_context) class TestHTTPConnection(HTTPConnection): From a4b4f615e124dcb05f2521c4a387841a1e6ede47 Mon Sep 17 00:00:00 2001 From: Jared Casey Date: Wed, 9 Jul 2025 14:24:55 -0600 Subject: [PATCH 08/18] ruff linting/formatting updates --- .pre-commit-config.yaml | 23 +- acouchbase_analytics/__init__.py | 6 +- acouchbase_analytics/cluster.py | 21 +- acouchbase_analytics/cluster.pyi | 91 ++-- acouchbase_analytics/database.py | 3 +- acouchbase_analytics/database.pyi | 2 - .../protocol/_core/anyio_utils.py | 6 +- .../protocol/_core/async_json_stream.py | 32 +- .../protocol/_core/async_json_token_parser.py | 31 +- .../protocol/_core/client_adapter.py | 70 ++-- .../protocol/_core/net_utils.py | 4 +- .../protocol/_core/request_context.py | 117 +++--- .../protocol/_core/retries.py | 49 +-- acouchbase_analytics/protocol/cluster.py | 39 +- acouchbase_analytics/protocol/cluster.pyi | 89 +--- acouchbase_analytics/protocol/database.py | 4 +- acouchbase_analytics/protocol/database.pyi | 3 - acouchbase_analytics/protocol/errors.py | 11 +- acouchbase_analytics/protocol/scope.py | 14 +- acouchbase_analytics/protocol/scope.pyi | 37 +- acouchbase_analytics/protocol/streaming.py | 45 +- acouchbase_analytics/scope.py | 3 +- acouchbase_analytics/scope.pyi | 36 +- acouchbase_analytics/tests/connection_t.py | 223 +++++----- acouchbase_analytics/tests/json_parsing_t.py | 148 ++++--- acouchbase_analytics/tests/options_t.py | 244 ++++++----- .../tests/query_integration_t.py | 121 +++--- acouchbase_analytics/tests/query_options_t.py | 160 +++---- acouchbase_analytics/tests/test_server_t.py | 115 ++--- couchbase_analytics/_version.py | 4 +- couchbase_analytics/cluster.py | 28 +- couchbase_analytics/cluster.pyi | 205 +++------ couchbase_analytics/common/__init__.py | 5 +- .../common/_core/_certificates.py | 1 + .../common/_core/duration_str_utils.py | 20 +- .../common/_core/error_context.py | 5 +- .../common/_core/json_parsing.py | 4 + .../common/_core/json_token_parser_base.py | 42 +- couchbase_analytics/common/_core/query.py | 26 +- couchbase_analytics/common/_core/result.py | 6 +- couchbase_analytics/common/_core/utils.py | 44 +- .../common/backoff_calculator.py | 8 +- couchbase_analytics/common/credential.py | 9 +- couchbase_analytics/common/deserializer.py | 3 +- couchbase_analytics/common/enums.py | 20 +- couchbase_analytics/common/errors.py | 56 +-- couchbase_analytics/common/options.py | 10 +- couchbase_analytics/common/options_base.py | 16 +- couchbase_analytics/common/query.py | 10 +- couchbase_analytics/common/request.py | 2 +- couchbase_analytics/common/result.py | 9 +- couchbase_analytics/common/streaming.py | 25 +- couchbase_analytics/database.py | 3 +- couchbase_analytics/database.pyi | 2 - .../protocol/_core/client_adapter.py | 59 +-- .../protocol/_core/http_transport.py | 94 ++--- .../protocol/_core/json_stream.py | 40 +- .../protocol/_core/json_token_parser.py | 30 +- .../protocol/_core/net_utils.py | 4 +- couchbase_analytics/protocol/_core/request.py | 75 ++-- .../protocol/_core/request_context.py | 129 +++--- couchbase_analytics/protocol/_core/retries.py | 31 +- couchbase_analytics/protocol/cluster.py | 59 ++- couchbase_analytics/protocol/cluster.pyi | 207 +++------ couchbase_analytics/protocol/connection.py | 76 ++-- couchbase_analytics/protocol/database.py | 6 +- couchbase_analytics/protocol/database.pyi | 4 - couchbase_analytics/protocol/errors.py | 41 +- couchbase_analytics/protocol/options.py | 160 ++++--- couchbase_analytics/protocol/scope.py | 32 +- couchbase_analytics/protocol/scope.pyi | 152 +++---- couchbase_analytics/protocol/streaming.py | 53 ++- couchbase_analytics/scope.py | 10 +- couchbase_analytics/scope.pyi | 150 +++---- couchbase_analytics/tests/backoff_calc_t.py | 32 +- couchbase_analytics/tests/connection_t.py | 226 +++++----- .../tests/duration_parsing_t.py | 95 +++-- couchbase_analytics/tests/json_parsing_t.py | 132 +++--- couchbase_analytics/tests/options_t.py | 242 ++++++----- .../tests/query_integration_t.py | 394 +++++++++--------- couchbase_analytics/tests/query_options_t.py | 160 +++---- couchbase_analytics/tests/test_server_t.py | 125 +++--- couchbase_analytics_version.py | 48 ++- mypy.ini | 4 + pyproject.toml | 80 ++-- requirements-dev.in | 2 + requirements-dev.txt | 5 +- tests/__init__.py | 8 +- tests/analytics_config.py | 53 ++- tests/environments/base_environment.py | 148 ++++--- tests/environments/simple_environment.py | 21 +- tests/test_server/__init__.py | 20 +- tests/test_server/request.py | 45 +- tests/test_server/response.py | 278 ++++++------ tests/test_server/web_server.py | 116 +++--- tests/utils/__init__.py | 60 +-- tests/utils/_async_client_adapter.py | 26 +- tests/utils/_async_utils.py | 16 +- tests/utils/_client_adapter.py | 26 +- tests/utils/_run_web_server.py | 24 +- tests/utils/_test_async_httpx.py | 115 ++--- tests/utils/_test_httpx.py | 126 +++--- 102 files changed, 2928 insertions(+), 3421 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1e3d079..6edfbdc 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -17,9 +17,8 @@ repos: - id: ruff-check types_or: [ python, pyi ] # Run the formatter. - # - id: ruff-format - # types_or: [ python, pyi ] - # Compile requirements + - id: ruff-format + types_or: [ python, pyi ] - repo: https://github.com/PyCQA/bandit rev: 1.8.6 hooks: @@ -35,24 +34,6 @@ repos: [ --quiet ] - - repo: https://github.com/PyCQA/isort - rev: 5.13.2 - hooks: - - id: isort - exclude: | - (?x)^( - deps/| - src/ - ) - args: - [ - "--multi-line 1", - "--force-grid-wrap 3", - "--use-parentheses True", - "--ensure-newline-before-comments True", - "--line-length 120", - "--order-by-type True" - ] - repo: local hooks: - id: mypy diff --git a/acouchbase_analytics/__init__.py b/acouchbase_analytics/__init__.py index 1d3fbe9..06d8d05 100644 --- a/acouchbase_analytics/__init__.py +++ b/acouchbase_analytics/__init__.py @@ -26,8 +26,7 @@ class _LoopValidator: **INTERNAL** """ - REQUIRED_METHODS = {'add_reader', 'remove_reader', - 'add_writer', 'remove_writer'} + REQUIRED_METHODS = {'add_reader', 'remove_reader', 'add_writer', 'remove_writer'} @staticmethod def _get_working_loop() -> AbstractEventLoop: @@ -53,8 +52,7 @@ def _is_valid_loop(evloop: Optional[AbstractEventLoop] = None) -> bool: if not evloop: return False for meth in _LoopValidator.REQUIRED_METHODS: - abs_meth, actual_meth = ( - getattr(asyncio.AbstractEventLoop, meth), getattr(evloop.__class__, meth)) + abs_meth, actual_meth = (getattr(asyncio.AbstractEventLoop, meth), getattr(evloop.__class__, meth)) if abs_meth == actual_meth: return False return True diff --git a/acouchbase_analytics/cluster.py b/acouchbase_analytics/cluster.py index ddac3c2..02d869f 100644 --- a/acouchbase_analytics/cluster.py +++ b/acouchbase_analytics/cluster.py @@ -16,9 +16,7 @@ from __future__ import annotations import sys -from typing import (TYPE_CHECKING, - Awaitable, - Optional) +from typing import TYPE_CHECKING, Awaitable, Optional if sys.version_info < (3, 10): from typing_extensions import TypeAlias @@ -56,12 +54,11 @@ class AsyncCluster: """ # noqa: E501 - def __init__(self, - connstr: str, - credential: Credential, - options: Optional[ClusterOptions] = None, - **kwargs: object) -> None: + def __init__( + self, connstr: str, credential: Credential, options: Optional[ClusterOptions] = None, **kwargs: object + ) -> None: from acouchbase_analytics.protocol.cluster import AsyncCluster as _AsyncCluster + self._impl = _AsyncCluster(connstr, credential, options, **kwargs) def database(self, name: str) -> AsyncDatabase: @@ -157,11 +154,9 @@ def shutdown(self) -> None: return self._impl.shutdown() @classmethod - def create_instance(cls, - connstr: str, - credential: Credential, - options: Optional[ClusterOptions] = None, - **kwargs: object) -> AsyncCluster: + def create_instance( + cls, connstr: str, credential: Credential, options: Optional[ClusterOptions] = None, **kwargs: object + ) -> AsyncCluster: """Create an AsyncCluster instance Args: diff --git a/acouchbase_analytics/cluster.pyi b/acouchbase_analytics/cluster.pyi index 325d679..3969716 100644 --- a/acouchbase_analytics/cluster.pyi +++ b/acouchbase_analytics/cluster.pyi @@ -23,96 +23,59 @@ else: from acouchbase_analytics.database import AsyncDatabase from couchbase_analytics.credential import Credential -from couchbase_analytics.options import (ClusterOptions, - ClusterOptionsKwargs, - QueryOptions, - QueryOptionsKwargs) +from couchbase_analytics.options import ClusterOptions, ClusterOptionsKwargs, QueryOptions, QueryOptionsKwargs from couchbase_analytics.result import AsyncQueryResult class AsyncCluster: @overload def __init__(self, http_endpoint: str, credential: Credential) -> None: ... - @overload - def __init__(self, - http_endpoint: str, - credential: Credential, - options: ClusterOptions) -> None: ... - + def __init__(self, http_endpoint: str, credential: Credential, options: ClusterOptions) -> None: ... @overload - def __init__(self, - http_endpoint: str, - credential: Credential, - **kwargs: Unpack[ClusterOptionsKwargs]) -> None: ... - + def __init__(self, http_endpoint: str, credential: Credential, **kwargs: Unpack[ClusterOptionsKwargs]) -> None: ... @overload - def __init__(self, - http_endpoint: str, - credential: Credential, - options: ClusterOptions, - **kwargs: Unpack[ClusterOptionsKwargs]) -> None: ... - + def __init__( + self, + http_endpoint: str, + credential: Credential, + options: ClusterOptions, + **kwargs: Unpack[ClusterOptionsKwargs], + ) -> None: ... def database(self, database_name: str) -> AsyncDatabase: ... - @overload def execute_query(self, statement: str) -> Awaitable[AsyncQueryResult]: ... - @overload def execute_query(self, statement: str, options: QueryOptions) -> Awaitable[AsyncQueryResult]: ... - @overload def execute_query(self, statement: str, **kwargs: Unpack[QueryOptionsKwargs]) -> Awaitable[AsyncQueryResult]: ... - @overload - def execute_query(self, - statement: str, - options: QueryOptions, - **kwargs: Unpack[QueryOptionsKwargs]) -> Awaitable[AsyncQueryResult]: ... - + def execute_query( + self, statement: str, options: QueryOptions, **kwargs: Unpack[QueryOptionsKwargs] + ) -> Awaitable[AsyncQueryResult]: ... @overload - def execute_query(self, - statement: str, - options: QueryOptions, - *args: str, - **kwargs: Unpack[QueryOptionsKwargs]) -> Awaitable[AsyncQueryResult]: ... - + def execute_query( + self, statement: str, options: QueryOptions, *args: str, **kwargs: Unpack[QueryOptionsKwargs] + ) -> Awaitable[AsyncQueryResult]: ... @overload - def execute_query(self, - statement: str, - options: QueryOptions, - *args: str, - **kwargs: str) -> Awaitable[AsyncQueryResult]: ... - + def execute_query( + self, statement: str, options: QueryOptions, *args: str, **kwargs: str + ) -> Awaitable[AsyncQueryResult]: ... @overload - def execute_query(self, - statement: str, - *args: str, - **kwargs: str) -> Awaitable[AsyncQueryResult]: ... - + def execute_query(self, statement: str, *args: str, **kwargs: str) -> Awaitable[AsyncQueryResult]: ... def shutdown(self) -> None: ... - @overload @classmethod def create_instance(cls, http_endpoint: str, credential: Credential) -> AsyncCluster: ... - @overload @classmethod - def create_instance(cls, - http_endpoint: str, - credential: Credential, - options: ClusterOptions) -> AsyncCluster: ... - + def create_instance(cls, http_endpoint: str, credential: Credential, options: ClusterOptions) -> AsyncCluster: ... @overload @classmethod - def create_instance(cls, - http_endpoint: str, - credential: Credential, - **kwargs: Unpack[ClusterOptionsKwargs]) -> AsyncCluster: ... - + def create_instance( + cls, http_endpoint: str, credential: Credential, **kwargs: Unpack[ClusterOptionsKwargs] + ) -> AsyncCluster: ... @overload @classmethod - def create_instance(cls, - http_endpoint: str, - credential: Credential, - options: ClusterOptions, - **kwargs: Unpack[ClusterOptionsKwargs]) -> AsyncCluster: ... + def create_instance( + cls, http_endpoint: str, credential: Credential, options: ClusterOptions, **kwargs: Unpack[ClusterOptionsKwargs] + ) -> AsyncCluster: ... diff --git a/acouchbase_analytics/database.py b/acouchbase_analytics/database.py index 3cfca28..8cb1935 100644 --- a/acouchbase_analytics/database.py +++ b/acouchbase_analytics/database.py @@ -32,12 +32,13 @@ class AsyncDatabase: def __init__(self, cluster: AsyncCluster, database_name: str) -> None: from acouchbase_analytics.protocol.database import AsyncDatabase as _AsyncDatabase + self._impl = _AsyncDatabase(cluster, database_name) @property def name(self) -> str: """ - str: The name of this :class:`~acouchbase_analytics.database.AsyncDatabase` instance. + str: The name of this :class:`~acouchbase_analytics.database.AsyncDatabase` instance. """ return self._impl.name diff --git a/acouchbase_analytics/database.pyi b/acouchbase_analytics/database.pyi index 7960bd6..90fd597 100644 --- a/acouchbase_analytics/database.pyi +++ b/acouchbase_analytics/database.pyi @@ -18,8 +18,6 @@ from acouchbase_analytics.scope import AsyncScope class AsyncDatabase: def __init__(self, cluster: AsyncCluster, database_name: str) -> None: ... - @property def name(self) -> str: ... - def scope(self, scope_name: str) -> AsyncScope: ... diff --git a/acouchbase_analytics/protocol/_core/anyio_utils.py b/acouchbase_analytics/protocol/_core/anyio_utils.py index e21e8f5..3a29499 100644 --- a/acouchbase_analytics/protocol/_core/anyio_utils.py +++ b/acouchbase_analytics/protocol/_core/anyio_utils.py @@ -12,9 +12,11 @@ def get_time() -> float: """ return anyio.current_time() + async def sleep(delay: float) -> None: await anyio.sleep(delay) + class AsyncBackend: def __init__(self, backend_lib: str) -> None: """ @@ -35,13 +37,15 @@ def loop(self) -> Optional[AbstractEventLoop]: Get the event loop for the async backend, if it exists """ if not hasattr(self, '_loop'): - if self._backend_lib == 'asyncio': + if self._backend_lib == 'asyncio': import asyncio + self._loop = asyncio.get_event_loop() else: raise RuntimeError('Unsupported async backend library.') return self._loop + def current_async_library() -> Optional[AsyncBackend]: try: import sniffio diff --git a/acouchbase_analytics/protocol/_core/async_json_stream.py b/acouchbase_analytics/protocol/_core/async_json_stream.py index dad42fa..6e2c9f9 100644 --- a/acouchbase_analytics/protocol/_core/async_json_stream.py +++ b/acouchbase_analytics/protocol/_core/async_json_stream.py @@ -18,23 +18,20 @@ from typing import AsyncIterator, Optional import ijson -from anyio import (EndOfStream, - Event, - create_memory_object_stream) +from anyio import EndOfStream, Event, create_memory_object_stream from acouchbase_analytics.protocol._core.async_json_token_parser import AsyncJsonTokenParser -from couchbase_analytics.common._core.json_parsing import (JsonStreamConfig, - ParsedResult, - ParsedResultType) +from couchbase_analytics.common._core.json_parsing import JsonStreamConfig, ParsedResult, ParsedResultType from couchbase_analytics.common.errors import AnalyticsError class AsyncJsonStream: - def __init__(self, - http_stream_iter: AsyncIterator[bytes], - *, - stream_config: Optional[JsonStreamConfig]=None, - ) -> None: + def __init__( + self, + http_stream_iter: AsyncIterator[bytes], + *, + stream_config: Optional[JsonStreamConfig] = None, + ) -> None: # HTTP stream handling if stream_config is None: stream_config = JsonStreamConfig() @@ -44,7 +41,9 @@ def __init__(self, self._http_stream_exhausted = False # results handling - self._send_stream, self._receive_stream = create_memory_object_stream[ParsedResult](max_buffer_size=stream_config.buffered_row_max) # noqa: E501 + self._send_stream, self._receive_stream = create_memory_object_stream[ParsedResult]( + max_buffer_size=stream_config.buffered_row_max + ) # noqa: E501 self._json_stream_parser = None self._buffer_entire_result = stream_config.buffer_entire_result handler = None if self._buffer_entire_result is True else self._handle_json_result @@ -88,7 +87,7 @@ def _continue_processing(self) -> bool: return False return True - async def _send_to_stream(self, result: ParsedResult, close: Optional[bool]=False) -> None: + async def _send_to_stream(self, result: ParsedResult, close: Optional[bool] = False) -> None: """ **INTERNAL** """ @@ -104,7 +103,7 @@ async def _handle_json_result(self, row: bytes) -> None: self._handle_notification(ParsedResultType.ROW) await self._send_to_stream(ParsedResult(row, ParsedResultType.ROW)) - def _handle_notification(self, result_type: Optional[ParsedResultType]=None) -> None: + def _handle_notification(self, result_type: Optional[ParsedResultType] = None) -> None: if self._has_results_or_errors_evt.is_set(): return @@ -121,7 +120,7 @@ async def _process_token_stream(self) -> None: **INTERNAL** """ if self._json_stream_parser is None: - self._json_stream_parser = ijson.parse_async(self, buf_size=self._http_stream_buffer_size) + self._json_stream_parser = ijson.parse_async(self, buf_size=self._http_stream_buffer_size) while self._continue_processing(): try: @@ -138,13 +137,12 @@ async def _process_token_stream(self) -> None: self._handle_notification(ParsedResultType.ERROR) return - if self._token_stream_exhausted: result_type = ParsedResultType.ERROR if self._json_token_parser.has_errors else ParsedResultType.END await self._send_to_stream(ParsedResult(self._json_token_parser.get_result(), result_type), close=True) self._handle_notification(result_type) - async def read(self, size: Optional[int]=-1) -> bytes: + async def read(self, size: Optional[int] = -1) -> bytes: """ **INTERNAL** """ diff --git a/acouchbase_analytics/protocol/_core/async_json_token_parser.py b/acouchbase_analytics/protocol/_core/async_json_token_parser.py index d809162..3a75c28 100644 --- a/acouchbase_analytics/protocol/_core/async_json_token_parser.py +++ b/acouchbase_analytics/protocol/_core/async_json_token_parser.py @@ -15,30 +15,29 @@ from __future__ import annotations -from typing import (Any, - Callable, - Coroutine, - List, - Optional) +from typing import Any, Callable, Coroutine, List, Optional -from couchbase_analytics.common._core.json_token_parser_base import (POP_EVENTS, - START_EVENTS, - VALUE_TOKENS, - JsonTokenParserBase, - ParsingState, - TokenType) +from couchbase_analytics.common._core.json_token_parser_base import ( + POP_EVENTS, + START_EVENTS, + VALUE_TOKENS, + JsonTokenParserBase, + ParsingState, + TokenType, +) class AsyncJsonTokenParser(JsonTokenParserBase): - def __init__(self, - results_handler: Optional[Callable[[bytes], Coroutine[Any, Any, None]]]=None) -> None: + def __init__(self, results_handler: Optional[Callable[[bytes], Coroutine[Any, Any, None]]] = None) -> None: self._results_handler = results_handler super().__init__(emit_results_enabled=results_handler is not None) async def _handle_obj_emit(self, obj: str) -> bool: - if (self._emit_results_enabled + if ( + self._emit_results_enabled and self._results_handler is not None - and ParsingState.okay_to_emit(self._state, self._previous_state)): + and ParsingState.okay_to_emit(self._state, self._previous_state) + ): await self._results_handler(bytes(obj, 'utf-8')) return True return False @@ -59,7 +58,7 @@ async def _handle_pop_event(self, token_type: TokenType) -> None: if should_emit: object_emitted = await self._handle_obj_emit(obj) if object_emitted: - break # this means we emiited the result/error, so stop processing the stack + break # this means we emiited the result/error, so stop processing the stack if len(self._stack) > 0 and self._stack[-1].type == TokenType.MAP_KEY: map_key = self._pop() diff --git a/acouchbase_analytics/protocol/_core/client_adapter.py b/acouchbase_analytics/protocol/_core/client_adapter.py index 22fb71d..5cdcf31 100644 --- a/acouchbase_analytics/protocol/_core/client_adapter.py +++ b/acouchbase_analytics/protocol/_core/client_adapter.py @@ -19,10 +19,7 @@ from typing import TYPE_CHECKING, Optional from uuid import uuid4 -from httpx import (URL, - AsyncClient, - BasicAuth, - Response) +from httpx import URL, AsyncClient, BasicAuth, Response from couchbase_analytics.common.credential import Credential from couchbase_analytics.common.deserializer import Deserializer @@ -35,85 +32,79 @@ class _AsyncClientAdapter: """ - **INTERNAL** + **INTERNAL** """ _ANALYTICS_PATH = '/api/v1/request' - def __init__(self, - http_endpoint: str, - credential: Credential, - options: Optional[object] = None, - **kwargs: object) -> None: + def __init__( + self, http_endpoint: str, credential: Credential, options: Optional[object] = None, **kwargs: object + ) -> None: self._client_id = str(uuid4()) self._opts_builder = OptionsBuilder() - self._conn_details = _ConnectionDetails.create(self._opts_builder, - http_endpoint, - credential, - options, - **kwargs) + self._conn_details = _ConnectionDetails.create(self._opts_builder, http_endpoint, credential, options, **kwargs) # TODO: do we want to support custom HTTP transports for the async client? self._http_transport_cls = None @property def analytics_path(self) -> str: """ - **INTERNAL** + **INTERNAL** """ return self._ANALYTICS_PATH @property def client(self) -> AsyncClient: """ - **INTERNAL** + **INTERNAL** """ return self._client @property def client_id(self) -> str: """ - **INTERNAL** + **INTERNAL** """ return self._client_id @property def connection_details(self) -> _ConnectionDetails: """ - **INTERNAL** + **INTERNAL** """ return self._conn_details @property def default_deserializer(self) -> Deserializer: """ - **INTERNAL** + **INTERNAL** """ return self._conn_details.default_deserializer @property def has_client(self) -> bool: """ - **INTERNAL** + **INTERNAL** """ return hasattr(self, '_client') @property def options_builder(self) -> OptionsBuilder: """ - **INTERNAL** + **INTERNAL** """ return self._opts_builder async def close_client(self) -> None: """ - **INTERNAL** + **INTERNAL** """ if hasattr(self, '_client'): await self._client.aclose() async def create_client(self) -> None: """ - **INTERNAL** + **INTERNAL** """ if not hasattr(self, '_client'): if self._conn_details.is_secure(): @@ -122,33 +113,32 @@ async def create_client(self) -> None: transport = None if self._http_transport_cls is not None: transport = self._http_transport_cls(verify=self._conn_details.ssl_context) - self._client = AsyncClient(verify=self._conn_details.ssl_context, - auth=BasicAuth(*self._conn_details.credential), - transport=transport) + self._client = AsyncClient( + verify=self._conn_details.ssl_context, + auth=BasicAuth(*self._conn_details.credential), + transport=transport, + ) else: transport = None if self._http_transport_cls is not None: transport = self._http_transport_cls() - self._client = AsyncClient(auth=BasicAuth(*self._conn_details.credential), - transport=transport) + self._client = AsyncClient(auth=BasicAuth(*self._conn_details.credential), transport=transport) # TODO: log message - async def send_request(self, request: QueryRequest) -> Response: """ - **INTERNAL** + **INTERNAL** """ if not hasattr(self, '_client'): raise RuntimeError('Client not created yet') - url = URL(scheme=request.url.scheme, - host=request.url.ip, - port=request.url.port, - path=request.url.path,) - req = self._client.build_request(request.method, - url, - json=request.body, - extensions=request.extensions) + url = URL( + scheme=request.url.scheme, + host=request.url.ip, + port=request.url.port, + path=request.url.path, + ) + req = self._client.build_request(request.method, url, json=request.body, extensions=request.extensions) try: return await self._client.send(req, stream=True) except socket.gaierror as err: @@ -157,7 +147,7 @@ async def send_request(self, request: QueryRequest) -> Response: def reset_client(self) -> None: """ - **INTERNAL** + **INTERNAL** """ if hasattr(self, '_client'): del self._client diff --git a/acouchbase_analytics/protocol/_core/net_utils.py b/acouchbase_analytics/protocol/_core/net_utils.py index 495ed9a..079b229 100644 --- a/acouchbase_analytics/protocol/_core/net_utils.py +++ b/acouchbase_analytics/protocol/_core/net_utils.py @@ -16,9 +16,7 @@ from __future__ import annotations import socket -from ipaddress import (IPv4Address, - IPv6Address, - ip_address) +from ipaddress import IPv4Address, IPv6Address, ip_address from random import choice from typing import Optional, Union diff --git a/acouchbase_analytics/protocol/_core/request_context.py b/acouchbase_analytics/protocol/_core/request_context.py index d020cef..3056c53 100644 --- a/acouchbase_analytics/protocol/_core/request_context.py +++ b/acouchbase_analytics/protocol/_core/request_context.py @@ -3,30 +3,17 @@ import json from asyncio import CancelledError, Task from types import TracebackType -from typing import (TYPE_CHECKING, - Any, - Awaitable, - Callable, - Coroutine, - Dict, - List, - Optional, - Type, - Union) +from typing import TYPE_CHECKING, Any, Awaitable, Callable, Coroutine, Dict, List, Optional, Type, Union from uuid import uuid4 import anyio from httpx import Response as HttpCoreResponse from httpx import TimeoutException -from acouchbase_analytics.protocol._core.anyio_utils import (AsyncBackend, - current_async_library, - get_time) +from acouchbase_analytics.protocol._core.anyio_utils import AsyncBackend, current_async_library, get_time from acouchbase_analytics.protocol._core.async_json_stream import AsyncJsonStream from acouchbase_analytics.protocol._core.net_utils import get_request_ip_async -from couchbase_analytics.common._core import (JsonStreamConfig, - ParsedResult, - ParsedResultType) +from couchbase_analytics.common._core import JsonStreamConfig, ParsedResult, ParsedResultType from couchbase_analytics.common._core.error_context import ErrorContext from couchbase_analytics.common.backoff_calculator import DefaultBackoffCalculator from couchbase_analytics.common.errors import AnalyticsError @@ -38,23 +25,24 @@ from acouchbase_analytics.protocol._core.client_adapter import _AsyncClientAdapter from couchbase_analytics.protocol._core.request import QueryRequest + class AsyncRequestContext: # TODO: AsyncExitStack?? # https://anyio.readthedocs.io/en/stable/cancellation.html - def __init__(self, - client_adapter: _AsyncClientAdapter, - request: QueryRequest, - stream_config: Optional[JsonStreamConfig]=None, - backend: Optional[AsyncBackend]=None) -> None: + def __init__( + self, + client_adapter: _AsyncClientAdapter, + request: QueryRequest, + stream_config: Optional[JsonStreamConfig] = None, + backend: Optional[AsyncBackend] = None, + ) -> None: self._id = str(uuid4()) self._client_adapter = client_adapter self._request = request self._backend = backend or current_async_library() self._backoff_calc = DefaultBackoffCalculator() - self._error_ctx = ErrorContext(num_attempts=0, - method=request.method, - statement=request.get_request_statement()) + self._error_ctx = ErrorContext(num_attempts=0, method=request.method, statement=request.get_request_statement()) self._request_state = StreamingState.NotStarted self._stream_config = stream_config or JsonStreamConfig() self._json_stream: AsyncJsonStream @@ -137,9 +125,9 @@ async def _execute(self, fn: Callable[..., Awaitable[Any]], *args: object) -> No if self._stage_completed is not None: self._stage_completed.set() - def _maybe_set_request_error(self, - exc_type: Optional[Type[BaseException]]=None, - exc_val: Optional[BaseException]=None) -> None: + def _maybe_set_request_error( + self, exc_type: Optional[Type[BaseException]] = None, exc_val: Optional[BaseException] = None + ) -> None: self._check_cancelled_or_timed_out() if exc_val is None: return @@ -155,15 +143,16 @@ def _maybe_set_request_error(self, self._request_state = StreamingState.Error self._request_error = exc_val - async def _process_error(self, - json_data: Union[str, List[Dict[str, Any]]], - handle_context_shutdown: Optional[bool]=False) -> None: + async def _process_error( + self, json_data: Union[str, List[Dict[str, Any]]], handle_context_shutdown: Optional[bool] = False + ) -> None: self._request_state = StreamingState.Error if isinstance(json_data, str): self._request_error = ErrorMapper.build_error_from_http_status_code(json_data, self._error_ctx) elif not isinstance(json_data, list): - self._request_error = AnalyticsError('Cannot parse error response; expected JSON array', - context=str(self._error_ctx)) + self._request_error = AnalyticsError( + 'Cannot parse error response; expected JSON array', context=str(self._error_ctx) + ) else: self._request_error = ErrorMapper.build_error_from_json(json_data, self._error_ctx) if handle_context_shutdown is True: @@ -178,10 +167,9 @@ def _reset_stream(self) -> None: self._stage_completed = None self._cancel_scope_deadline_updated = False - def _start_next_stage(self, - fn: Callable[..., Awaitable[Any]], - *args: object, - reset_previous_stage: Optional[bool]=False) -> None: + def _start_next_stage( + self, fn: Callable[..., Awaitable[Any]], *args: object, reset_previous_stage: Optional[bool] = False + ) -> None: if self._stage_completed is not None: if reset_previous_stage is True: self._stage_completed = None @@ -202,7 +190,7 @@ async def _trace_handler(self, event_name: str, _: str) -> None: self._update_cancel_scope_deadline(self._request_deadline, is_absolute=True) self._cancel_scope_deadline_updated = True - def _update_cancel_scope_deadline(self, deadline: float, is_absolute: Optional[bool]=False) -> None: + def _update_cancel_scope_deadline(self, deadline: float, is_absolute: Optional[bool] = False) -> None: # TODO: confirm scenario of get_time() < self._taskgroup.cancel_scope.deadline is handled by anyio new_deadline = deadline if is_absolute else get_time() + deadline # TODO: Useful debug log message @@ -220,9 +208,7 @@ async def _wait_for_stage_to_complete(self) -> None: def calculate_backoff(self) -> float: return self._backoff_calc.calculate_backoff(self._error_ctx.num_attempts) / 1000 - def cancel_request(self, - fn: Optional[Callable[..., Awaitable[Any]]]=None, - *args: object) -> None: + def cancel_request(self, fn: Optional[Callable[..., Awaitable[Any]]] = None, *args: object) -> None: if fn is not None: self._taskgroup.start_soon(fn, *args) if self._request_state == StreamingState.Timeout: @@ -303,21 +289,25 @@ def okay_to_delay_and_retry(self, delay: float) -> bool: self._reset_stream() return True - async def process_response(self, - close_handler: Callable[[], Coroutine[Any, Any, None]], - raw_response: Optional[ParsedResult]=None, - handle_context_shutdown: Optional[bool]=False) -> Any: + async def process_response( + self, + close_handler: Callable[[], Coroutine[Any, Any, None]], + raw_response: Optional[ParsedResult] = None, + handle_context_shutdown: Optional[bool] = False, + ) -> Any: if raw_response is None: raw_response = await self._json_stream.get_result() if raw_response is None: await close_handler() - raise AnalyticsError(message='Received unexpected empty result from JsonStream.', - context=str(self._error_ctx)) + raise AnalyticsError( + message='Received unexpected empty result from JsonStream.', context=str(self._error_ctx) + ) if raw_response.value is None: await close_handler() - raise AnalyticsError(message='Received unexpected empty result from JsonStream.', - context=str(self._error_ctx)) + raise AnalyticsError( + message='Received unexpected empty result from JsonStream.', context=str(self._error_ctx) + ) # we have all the data, close the core response/stream await close_handler() @@ -325,8 +315,7 @@ async def process_response(self, try: json_response = json.loads(raw_response.value) except json.JSONDecodeError: - await self._process_error(str(raw_response.value), - handle_context_shutdown=handle_context_shutdown) + await self._process_error(str(raw_response.value), handle_context_shutdown=handle_context_shutdown) else: if 'errors' in json_response: await self._process_error(json_response['errors'], handle_context_shutdown=handle_context_shutdown) @@ -339,12 +328,15 @@ async def reraise_after_shutdown(self, err: Exception) -> None: await self.shutdown(type(ex), ex, ex.__traceback__) raise ex from None - async def send_request(self, enable_trace_handling: Optional[bool]=False) -> HttpCoreResponse: + async def send_request(self, enable_trace_handling: Optional[bool] = False) -> HttpCoreResponse: self._error_ctx.update_num_attempts() ip = await get_request_ip_async(self._request.url.host, self._request.url.port) if enable_trace_handling is True: - (self._request.update_url(ip, self._client_adapter.analytics_path) - .add_trace_to_extensions(self._trace_handler)) + ( + self._request.update_url(ip, self._client_adapter.analytics_path).add_trace_to_extensions( + self._trace_handler + ) + ) else: self._request.update_url(ip, self._client_adapter.analytics_path) # TODO: add logging; provide request details (to/from, deadlines, etc.) @@ -353,10 +345,12 @@ async def send_request(self, enable_trace_handling: Optional[bool]=False) -> Htt self._error_ctx.update_response_context(response) return response - async def shutdown(self, - exc_type: Optional[Type[BaseException]]=None, - exc_val: Optional[BaseException]=None, - exc_tb: Optional[TracebackType]=None) -> None: + async def shutdown( + self, + exc_type: Optional[Type[BaseException]] = None, + exc_val: Optional[BaseException] = None, + exc_tb: Optional[TracebackType] = None, + ) -> None: if self.is_shutdown: return if hasattr(self, '_taskgroup'): @@ -387,14 +381,13 @@ async def __aenter__(self) -> AsyncRequestContext: await self._taskgroup.__aenter__() return self - async def __aexit__(self, - exc_type: Optional[Type[BaseException]], - exc_val: Optional[BaseException], - exc_tb: Optional[TracebackType]) -> Optional[bool]: + async def __aexit__( + self, exc_type: Optional[Type[BaseException]], exc_val: Optional[BaseException], exc_tb: Optional[TracebackType] + ) -> Optional[bool]: try: await self._taskgroup.__aexit__(exc_type, exc_val, exc_tb) except BaseException: - pass # we handle the error when the context is shutdown (which is what calls __aexit__()) + pass # we handle the error when the context is shutdown (which is what calls __aexit__()) finally: self._maybe_set_request_error(exc_type, exc_val) del self._taskgroup diff --git a/acouchbase_analytics/protocol/_core/retries.py b/acouchbase_analytics/protocol/_core/retries.py index 047916f..e1c465c 100644 --- a/acouchbase_analytics/protocol/_core/retries.py +++ b/acouchbase_analytics/protocol/_core/retries.py @@ -17,19 +17,12 @@ from asyncio import CancelledError from functools import wraps -from typing import (TYPE_CHECKING, - Any, - Callable, - Coroutine, - Optional, - Union) +from typing import TYPE_CHECKING, Any, Callable, Coroutine, Optional, Union from httpx import ConnectError, ConnectTimeout from acouchbase_analytics.protocol._core.anyio_utils import sleep -from couchbase_analytics.common.errors import (AnalyticsError, - InternalSDKError, - TimeoutError) +from couchbase_analytics.common.errors import AnalyticsError, InternalSDKError, TimeoutError from couchbase_analytics.common.streaming import StreamingState from couchbase_analytics.protocol.errors import WrappedError @@ -40,13 +33,13 @@ class AsyncRetryHandler: """ - **INTERNAL** + **INTERNAL** """ @staticmethod - async def handle_httpx_retry(ex: Union[ConnectError, ConnectTimeout], - ctx: AsyncRequestContext - ) -> Optional[Exception]: + async def handle_httpx_retry( + ex: Union[ConnectError, ConnectTimeout], ctx: AsyncRequestContext + ) -> Optional[Exception]: err_str = str(ex) if 'SSL:' in err_str: message = 'TLS connection error occurred.' @@ -64,9 +57,7 @@ async def handle_httpx_retry(ex: Union[ConnectError, ConnectTimeout], return None @staticmethod - async def handle_retry(ex: WrappedError, - ctx: AsyncRequestContext - ) -> Optional[Union[BaseException, Exception]]: + async def handle_retry(ex: WrappedError, ctx: AsyncRequestContext) -> Optional[Union[BaseException, Exception]]: if ex.retriable is True: delay = ctx.calculate_backoff() err: Optional[Union[BaseException, Exception]] = None @@ -76,12 +67,11 @@ async def handle_retry(ex: WrappedError, ex.maybe_set_cause_context(ctx.error_context) err = ex.unwrap() else: - err = AnalyticsError(cause=ex.unwrap(), - message='Retry limit exceeded.', - context=str(ctx.error_context)) + err = AnalyticsError( + cause=ex.unwrap(), message='Retry limit exceeded.', context=str(ctx.error_context) + ) else: - err = TimeoutError(message='Request timed out during retry delay.', - context=str(ctx.error_context)) + err = TimeoutError(message='Request timed out during retry delay.', context=str(ctx.error_context)) if err: return err @@ -90,10 +80,10 @@ async def handle_retry(ex: WrappedError, ex.maybe_set_cause_context(ctx.error_context) return ex.unwrap() - @staticmethod - def with_retries(fn: Callable[[AsyncHttpStreamingResponse], Coroutine[Any, Any, None]] # noqa: C901 - ) -> Callable[[AsyncHttpStreamingResponse], Coroutine[Any, Any, None]]: + def with_retries( # noqa: C901 + fn: Callable[[AsyncHttpStreamingResponse], Coroutine[Any, Any, None]], + ) -> Callable[[AsyncHttpStreamingResponse], Coroutine[Any, Any, None]]: @wraps(fn) async def wrapped_fn(self: AsyncHttpStreamingResponse) -> None: # noqa: C901 while True: @@ -121,15 +111,16 @@ async def wrapped_fn(self: AsyncHttpStreamingResponse) -> None: # noqa: C901 except BaseException as ex: await self._request_context.shutdown(type(ex), ex, ex.__traceback__) if self._request_context.timed_out: - raise TimeoutError(message='Request timed out.', - context=str(self._request_context.error_context)) from None + raise TimeoutError( + message='Request timed out.', context=str(self._request_context.error_context) + ) from None if self._request_context.cancelled: raise CancelledError('Request was cancelled.') from None if self._request_context.request_error is not None: raise self._request_context.request_error from None - raise InternalSDKError(cause=ex, - message=str(ex), - context=str(self._request_context.error_context)) from None + raise InternalSDKError( + cause=ex, message=str(ex), context=str(self._request_context.error_context) + ) from None finally: if not StreamingState.is_okay(self._request_context.request_state): await self.close() diff --git a/acouchbase_analytics/protocol/cluster.py b/acouchbase_analytics/protocol/cluster.py index ecce654..5a3851c 100644 --- a/acouchbase_analytics/protocol/cluster.py +++ b/acouchbase_analytics/protocol/cluster.py @@ -16,9 +16,7 @@ from __future__ import annotations import sys -from typing import (TYPE_CHECKING, - Awaitable, - Optional) +from typing import TYPE_CHECKING, Awaitable, Optional from uuid import uuid4 if sys.version_info < (3, 10): @@ -39,12 +37,9 @@ class AsyncCluster: - - def __init__(self, - connstr: str, - credential: Credential, - options: Optional[ClusterOptions] = None, - **kwargs: object) -> None: + def __init__( + self, connstr: str, credential: Credential, options: Optional[ClusterOptions] = None, **kwargs: object + ) -> None: self._client_adapter = _AsyncClientAdapter(connstr, credential, options, **kwargs) self._cluster_id = str(uuid4()) self._request_builder = _RequestBuilder(self._client_adapter) @@ -53,34 +48,34 @@ def __init__(self, @property def client_adapter(self) -> _AsyncClientAdapter: """ - **INTERNAL** + **INTERNAL** """ return self._client_adapter @property def cluster_id(self) -> str: """ - **INTERNAL** + **INTERNAL** """ return self._cluster_id @property def has_client(self) -> bool: """ - bool: Indicator on if the cluster HTTP client has been created or not. + bool: Indicator on if the cluster HTTP client has been created or not. """ return self._client_adapter.has_client async def _shutdown(self) -> None: """ - **INTERNAL** + **INTERNAL** """ await self._client_adapter.close_client() self._client_adapter.reset_client() async def _create_client(self) -> None: """ - **INTERNAL** + **INTERNAL** """ await self._client_adapter.create_client() @@ -109,22 +104,18 @@ async def _execute_query(self, http_resp: AsyncHttpStreamingResponse) -> AsyncQu def execute_query(self, statement: str, *args: object, **kwargs: object) -> Awaitable[AsyncQueryResult]: base_req = self._request_builder.build_base_query_request(statement, *args, is_async=True, **kwargs) stream_config = base_req.options.pop('stream_config', None) - request_context = AsyncRequestContext(client_adapter=self.client_adapter, - request=base_req, - stream_config=stream_config, - backend=self._backend) + request_context = AsyncRequestContext( + client_adapter=self.client_adapter, request=base_req, stream_config=stream_config, backend=self._backend + ) resp = AsyncHttpStreamingResponse(request_context) if self._backend.backend_lib == 'asyncio': return request_context.create_response_task(self._execute_query, resp) return self._execute_query(resp) - @classmethod - def create_instance(cls, - connstr: str, - credential: Credential, - options: Optional[ClusterOptions] = None, - **kwargs: object) -> AsyncCluster: + def create_instance( + cls, connstr: str, credential: Credential, options: Optional[ClusterOptions] = None, **kwargs: object + ) -> AsyncCluster: return cls(connstr, credential, options, **kwargs) diff --git a/acouchbase_analytics/protocol/cluster.pyi b/acouchbase_analytics/protocol/cluster.pyi index baa0680..46d9767 100644 --- a/acouchbase_analytics/protocol/cluster.pyi +++ b/acouchbase_analytics/protocol/cluster.pyi @@ -25,101 +25,58 @@ from acouchbase_analytics.protocol._core.client_adapter import _AsyncClientAdapt from acouchbase_analytics.protocol.database import AsyncDatabase from couchbase_analytics.common.credential import Credential from couchbase_analytics.common.result import AsyncQueryResult -from couchbase_analytics.options import (ClusterOptions, - ClusterOptionsKwargs, - QueryOptions, - QueryOptionsKwargs) +from couchbase_analytics.options import ClusterOptions, ClusterOptionsKwargs, QueryOptions, QueryOptionsKwargs class AsyncCluster: @overload def __init__(self, connstr: str, credential: Credential) -> None: ... - @overload - def __init__(self, - connstr: str, - credential: Credential, - options: ClusterOptions) -> None: ... - + def __init__(self, connstr: str, credential: Credential, options: ClusterOptions) -> None: ... @overload - def __init__(self, - connstr: str, - credential: Credential, - **kwargs: Unpack[ClusterOptionsKwargs]) -> None: ... - + def __init__(self, connstr: str, credential: Credential, **kwargs: Unpack[ClusterOptionsKwargs]) -> None: ... @overload - def __init__(self, - connstr: str, - credential: Credential, - options: ClusterOptions, - **kwargs: Unpack[ClusterOptionsKwargs]) -> None: ... - + def __init__( + self, connstr: str, credential: Credential, options: ClusterOptions, **kwargs: Unpack[ClusterOptionsKwargs] + ) -> None: ... @property def client_adapter(self) -> _AsyncClientAdapter: ... - @property def connected(self) -> bool: ... - def shutdown(self) -> None: ... - def database(self, name: str) -> AsyncDatabase: ... - @overload def execute_query(self, statement: str) -> Awaitable[AsyncQueryResult]: ... - @overload def execute_query(self, statement: str, options: QueryOptions) -> Awaitable[AsyncQueryResult]: ... - @overload def execute_query(self, statement: str, **kwargs: Unpack[QueryOptionsKwargs]) -> Awaitable[AsyncQueryResult]: ... - @overload - def execute_query(self, - statement: str, - options: QueryOptions, - **kwargs: Unpack[QueryOptionsKwargs]) -> Awaitable[AsyncQueryResult]: ... - + def execute_query( + self, statement: str, options: QueryOptions, **kwargs: Unpack[QueryOptionsKwargs] + ) -> Awaitable[AsyncQueryResult]: ... @overload - def execute_query(self, - statement: str, - options: QueryOptions, - *args: str, - **kwargs: Unpack[QueryOptionsKwargs]) -> Awaitable[AsyncQueryResult]: ... - + def execute_query( + self, statement: str, options: QueryOptions, *args: str, **kwargs: Unpack[QueryOptionsKwargs] + ) -> Awaitable[AsyncQueryResult]: ... @overload - def execute_query(self, - statement: str, - options: QueryOptions, - *args: str, - **kwargs: str) -> Awaitable[AsyncQueryResult]: ... - + def execute_query( + self, statement: str, options: QueryOptions, *args: str, **kwargs: str + ) -> Awaitable[AsyncQueryResult]: ... @overload - def execute_query(self, - statement: str, - *args: str, - **kwargs: str) -> Awaitable[AsyncQueryResult]: ... - + def execute_query(self, statement: str, *args: str, **kwargs: str) -> Awaitable[AsyncQueryResult]: ... @overload @classmethod def create_instance(cls, connstr: str, credential: Credential) -> AsyncCluster: ... - @overload @classmethod - def create_instance(cls, - connstr: str, - credential: Credential, - options: ClusterOptions) -> AsyncCluster: ... - + def create_instance(cls, connstr: str, credential: Credential, options: ClusterOptions) -> AsyncCluster: ... @overload @classmethod - def create_instance(cls, - connstr: str, - credential: Credential, - **kwargs: Unpack[ClusterOptionsKwargs]) -> AsyncCluster: ... - + def create_instance( + cls, connstr: str, credential: Credential, **kwargs: Unpack[ClusterOptionsKwargs] + ) -> AsyncCluster: ... @overload @classmethod - def create_instance(cls, - connstr: str, - credential: Credential, - options: ClusterOptions, - **kwargs: Unpack[ClusterOptionsKwargs]) -> AsyncCluster: ... + def create_instance( + cls, connstr: str, credential: Credential, options: ClusterOptions, **kwargs: Unpack[ClusterOptionsKwargs] + ) -> AsyncCluster: ... diff --git a/acouchbase_analytics/protocol/database.py b/acouchbase_analytics/protocol/database.py index b91892d..5d653f4 100644 --- a/acouchbase_analytics/protocol/database.py +++ b/acouchbase_analytics/protocol/database.py @@ -38,14 +38,14 @@ def __init__(self, cluster: AsyncCluster, database_name: str) -> None: @property def client_adapter(self) -> _AsyncClientAdapter: """ - **INTERNAL** + **INTERNAL** """ return self._cluster.client_adapter @property def name(self) -> str: """ - str: The name of this :class:`~acouchbase_analytics.protocol.database.Database` instance. + str: The name of this :class:`~acouchbase_analytics.protocol.database.Database` instance. """ return self._database_name diff --git a/acouchbase_analytics/protocol/database.pyi b/acouchbase_analytics/protocol/database.pyi index 3d8560c..14d555b 100644 --- a/acouchbase_analytics/protocol/database.pyi +++ b/acouchbase_analytics/protocol/database.pyi @@ -19,11 +19,8 @@ from couchbase_analytics.protocol.scope import Scope class AsyncDatabase: def __init__(self, cluster: AsyncCluster, database_name: str) -> None: ... - @property def client_adapter(self) -> _AsyncClientAdapter: ... - @property def name(self) -> str: ... - def scope(self, scope_name: str) -> Scope: ... diff --git a/acouchbase_analytics/protocol/errors.py b/acouchbase_analytics/protocol/errors.py index cb33238..f3b1443 100644 --- a/acouchbase_analytics/protocol/errors.py +++ b/acouchbase_analytics/protocol/errors.py @@ -17,9 +17,7 @@ import socket from functools import wraps -from typing import (Any, - Callable, - Coroutine) +from typing import Any, Callable, Coroutine from couchbase_analytics.common.errors import AnalyticsError from couchbase_analytics.protocol.errors import WrappedError @@ -27,15 +25,16 @@ class ErrorMapper: @staticmethod - def handle_socket_error_async(fn: Callable[[str, int], Coroutine[Any, Any, str]] - ) -> Callable[[str, int], Coroutine[Any, Any, str]]: + def handle_socket_error_async( + fn: Callable[[str, int], Coroutine[Any, Any, str]], + ) -> Callable[[str, int], Coroutine[Any, Any, str]]: @wraps(fn) async def wrapped_fn(host: str, port: int) -> str: try: return await fn(host, port) except socket.gaierror as ex: # print(f'getaddrinfo failed for {host}:{port} with error: {ex}') - msg='Connection error occurred while sending request.' + msg = 'Connection error occurred while sending request.' raise WrappedError(AnalyticsError(cause=ex, message=msg), retriable=True) from None return wrapped_fn diff --git a/acouchbase_analytics/protocol/scope.py b/acouchbase_analytics/protocol/scope.py index 86c8231..631f32e 100644 --- a/acouchbase_analytics/protocol/scope.py +++ b/acouchbase_analytics/protocol/scope.py @@ -35,7 +35,6 @@ class AsyncScope: - def __init__(self, database: AsyncDatabase, scope_name: str) -> None: self._database = database self._scope_name = scope_name @@ -45,20 +44,20 @@ def __init__(self, database: AsyncDatabase, scope_name: str) -> None: @property def client_adapter(self) -> _AsyncClientAdapter: """ - **INTERNAL** + **INTERNAL** """ return self._database.client_adapter @property def name(self) -> str: """ - str: The name of this :class:`~acouchbase_analytics.protocol.scope.Scope` instance. + str: The name of this :class:`~acouchbase_analytics.protocol.scope.Scope` instance. """ return self._scope_name async def _create_client(self) -> None: """ - **INTERNAL** + **INTERNAL** """ await self.client_adapter.create_client() @@ -72,10 +71,9 @@ async def _execute_query(self, http_resp: AsyncHttpStreamingResponse) -> AsyncQu def execute_query(self, statement: str, *args: object, **kwargs: object) -> Awaitable[AsyncQueryResult]: base_req = self._request_builder.build_base_query_request(statement, *args, is_async=True, **kwargs) stream_config = base_req.options.pop('stream_config', None) - request_context = AsyncRequestContext(client_adapter=self.client_adapter, - request=base_req, - stream_config=stream_config, - backend=self._backend) + request_context = AsyncRequestContext( + client_adapter=self.client_adapter, request=base_req, stream_config=stream_config, backend=self._backend + ) resp = AsyncHttpStreamingResponse(request_context) if self._backend.backend_lib == 'asyncio': return request_context.create_response_task(self._execute_query, resp) diff --git a/acouchbase_analytics/protocol/scope.pyi b/acouchbase_analytics/protocol/scope.pyi index b08b0d7..d86a2ea 100644 --- a/acouchbase_analytics/protocol/scope.pyi +++ b/acouchbase_analytics/protocol/scope.pyi @@ -28,44 +28,27 @@ from couchbase_analytics.result import AsyncQueryResult class AsyncScope: def __init__(self, database: AsyncDatabase, scope_name: str) -> None: ... - @property def client_adapter(self) -> _AsyncClientAdapter: ... - @property def name(self) -> str: ... - @overload def execute_query(self, statement: str) -> Awaitable[AsyncQueryResult]: ... - @overload def execute_query(self, statement: str, options: QueryOptions) -> Awaitable[AsyncQueryResult]: ... - @overload def execute_query(self, statement: str, **kwargs: Unpack[QueryOptionsKwargs]) -> Awaitable[AsyncQueryResult]: ... - @overload - def execute_query(self, - statement: str, - options: QueryOptions, - **kwargs: Unpack[QueryOptionsKwargs]) -> Awaitable[AsyncQueryResult]: ... - + def execute_query( + self, statement: str, options: QueryOptions, **kwargs: Unpack[QueryOptionsKwargs] + ) -> Awaitable[AsyncQueryResult]: ... @overload - def execute_query(self, - statement: str, - options: QueryOptions, - *args: str, - **kwargs: Unpack[QueryOptionsKwargs]) -> Awaitable[AsyncQueryResult]: ... - + def execute_query( + self, statement: str, options: QueryOptions, *args: str, **kwargs: Unpack[QueryOptionsKwargs] + ) -> Awaitable[AsyncQueryResult]: ... @overload - def execute_query(self, - statement: str, - options: QueryOptions, - *args: str, - **kwargs: str) -> Awaitable[AsyncQueryResult]: ... - + def execute_query( + self, statement: str, options: QueryOptions, *args: str, **kwargs: str + ) -> Awaitable[AsyncQueryResult]: ... @overload - def execute_query(self, - statement: str, - *args: str, - **kwargs: str) -> Awaitable[AsyncQueryResult]: ... + def execute_query(self, statement: str, *args: str, **kwargs: str) -> Awaitable[AsyncQueryResult]: ... diff --git a/acouchbase_analytics/protocol/streaming.py b/acouchbase_analytics/protocol/streaming.py index 88c26f3..b931a60 100644 --- a/acouchbase_analytics/protocol/streaming.py +++ b/acouchbase_analytics/protocol/streaming.py @@ -23,9 +23,7 @@ from acouchbase_analytics.protocol._core.retries import AsyncRetryHandler from couchbase_analytics.common._core import ParsedResult, ParsedResultType from couchbase_analytics.common._core.query import build_query_metadata -from couchbase_analytics.common.errors import (AnalyticsError, - InternalSDKError, - TimeoutError) +from couchbase_analytics.common.errors import AnalyticsError, InternalSDKError, TimeoutError from couchbase_analytics.common.query import QueryMetadata @@ -51,23 +49,24 @@ async def _handle_iteration_abort(self) -> None: await self._request_context.shutdown() raise StopAsyncIteration elif self._request_context.timed_out: - err = TimeoutError(message='Unable to complete iteration. Request timed out.', - context=str(self._request_context.error_context)) + err = TimeoutError( + message='Unable to complete iteration. Request timed out.', + context=str(self._request_context.error_context), + ) await self._request_context.reraise_after_shutdown(err) else: await self._request_context.shutdown() raise StopAsyncIteration - - async def _process_response(self, - raw_response: Optional[ParsedResult]=None, - handle_context_shutdown: Optional[bool]=False) -> None: + async def _process_response( + self, raw_response: Optional[ParsedResult] = None, handle_context_shutdown: Optional[bool] = False + ) -> None: """ **INTERNAL** """ - json_response = await self._request_context.process_response(self.close, - raw_response=raw_response, - handle_context_shutdown=handle_context_shutdown) + json_response = await self._request_context.process_response( + self.close, raw_response=raw_response, handle_context_shutdown=handle_context_shutdown + ) await self.set_metadata(json_data=json_response) async def close(self) -> None: @@ -100,9 +99,7 @@ def get_metadata(self) -> QueryMetadata: raise RuntimeError('Query metadata is only available after all rows have been iterated.') return self._metadata - async def set_metadata(self, - json_data: Optional[Any]=None, - raw_metadata: Optional[bytes]=None) -> None: + async def set_metadata(self, json_data: Optional[Any] = None, raw_metadata: Optional[bytes] = None) -> None: """ **INTERNAL** """ @@ -112,9 +109,7 @@ async def set_metadata(self, except (AnalyticsError, ValueError) as err: await self._request_context.reraise_after_shutdown(err) except Exception as ex: - internal_err = InternalSDKError(cause=ex, - message=str(ex), - context=str(self._request_context.error_context)) + internal_err = InternalSDKError(cause=ex, message=str(ex), context=str(self._request_context.error_context)) await self._request_context.reraise_after_shutdown(internal_err) finally: await self.close() @@ -123,9 +118,11 @@ async def get_next_row(self) -> Any: """ **INTERNAL** """ - if not (hasattr(self, '_core_response') - and self._core_response is not None - and self._request_context.okay_to_iterate): + if not ( + hasattr(self, '_core_response') + and self._core_response is not None + and self._request_context.okay_to_iterate + ): await self._handle_iteration_abort() self._request_context.maybe_continue_to_process_stream() @@ -133,8 +130,10 @@ async def get_next_row(self) -> Any: if raw_response.result_type == ParsedResultType.ROW: if raw_response.value is None: await self.close() - raise AnalyticsError(message='Unexpected empty row response while streaming.', - context=str(self._request_context.error_context)) + raise AnalyticsError( + message='Unexpected empty row response while streaming.', + context=str(self._request_context.error_context), + ) return self._request_context.deserialize_result(raw_response.value) elif raw_response.result_type in [ParsedResultType.ERROR, ParsedResultType.UNKNOWN]: await self._process_response(raw_response=raw_response, handle_context_shutdown=True) diff --git a/acouchbase_analytics/scope.py b/acouchbase_analytics/scope.py index b14ad37..7febf95 100644 --- a/acouchbase_analytics/scope.py +++ b/acouchbase_analytics/scope.py @@ -33,12 +33,13 @@ class AsyncScope: def __init__(self, database: AsyncDatabase, scope_name: str) -> None: from acouchbase_analytics.protocol.scope import AsyncScope as _AsyncScope + self._impl = _AsyncScope(database, scope_name) @property def name(self) -> str: """ - str: The name of this :class:`~acouchbase_analytics.scope.AsyncScope` instance. + str: The name of this :class:`~acouchbase_analytics.scope.AsyncScope` instance. """ return self._impl.name diff --git a/acouchbase_analytics/scope.pyi b/acouchbase_analytics/scope.pyi index fff196b..29c8a8b 100644 --- a/acouchbase_analytics/scope.pyi +++ b/acouchbase_analytics/scope.pyi @@ -27,41 +27,25 @@ from couchbase_analytics.result import AsyncQueryResult class AsyncScope: def __init__(self, database: AsyncDatabase, scope_name: str) -> None: ... - @property def name(self) -> str: ... - @overload def execute_query(self, statement: str) -> Awaitable[AsyncQueryResult]: ... - @overload def execute_query(self, statement: str, options: QueryOptions) -> Awaitable[AsyncQueryResult]: ... - @overload def execute_query(self, statement: str, **kwargs: Unpack[QueryOptionsKwargs]) -> Awaitable[AsyncQueryResult]: ... - @overload - def execute_query(self, - statement: str, - options: QueryOptions, - **kwargs: Unpack[QueryOptionsKwargs]) -> Awaitable[AsyncQueryResult]: ... - + def execute_query( + self, statement: str, options: QueryOptions, **kwargs: Unpack[QueryOptionsKwargs] + ) -> Awaitable[AsyncQueryResult]: ... @overload - def execute_query(self, - statement: str, - options: QueryOptions, - *args: str, - **kwargs: Unpack[QueryOptionsKwargs]) -> Awaitable[AsyncQueryResult]: ... - + def execute_query( + self, statement: str, options: QueryOptions, *args: str, **kwargs: Unpack[QueryOptionsKwargs] + ) -> Awaitable[AsyncQueryResult]: ... @overload - def execute_query(self, - statement: str, - options: QueryOptions, - *args: str, - **kwargs: str) -> Awaitable[AsyncQueryResult]: ... - + def execute_query( + self, statement: str, options: QueryOptions, *args: str, **kwargs: str + ) -> Awaitable[AsyncQueryResult]: ... @overload - def execute_query(self, - statement: str, - *args: str, - **kwargs: str) -> Awaitable[AsyncQueryResult]: ... + def execute_query(self, statement: str, *args: str, **kwargs: str) -> Awaitable[AsyncQueryResult]: ... diff --git a/acouchbase_analytics/tests/connection_t.py b/acouchbase_analytics/tests/connection_t.py index 4f2413c..ca13c38 100644 --- a/acouchbase_analytics/tests/connection_t.py +++ b/acouchbase_analytics/tests/connection_t.py @@ -42,15 +42,18 @@ class ConnectionTestSuite: 'test_valid_connection_strings', ] - @pytest.mark.parametrize('connstr_opt', - ['invalid_op=10', - 'connect_timeout=2500ms', - 'dispatch_timeout=2500ms', - 'query_timeout=2500ms', - 'socket_connect_timeout=2500ms', - 'trust_only_pem_file=/path/to/file', - 'disable_server_certificate_verification=True' - ]) + @pytest.mark.parametrize( + 'connstr_opt', + [ + 'invalid_op=10', + 'connect_timeout=2500ms', + 'dispatch_timeout=2500ms', + 'query_timeout=2500ms', + 'socket_connect_timeout=2500ms', + 'trust_only_pem_file=/path/to/file', + 'disable_server_certificate_verification=True', + ], + ) def test_connstr_options_fail(self, connstr_opt: str) -> None: cred = Credential.from_username_and_password('Administrator', 'password') connstr = f'https://localhost?{connstr_opt}' @@ -66,30 +69,30 @@ def test_connstr_options_max_retries(self) -> None: req = req_builder.build_base_query_request('SELECT 1=1') assert req.max_retries == max_retries - @pytest.mark.parametrize('duration, expected_seconds', - [('1h', '3600'), - ('+1h', '3600'), - ('+1h', '3600'), - ('1h10m', '4200'), - ('1.h10m', '4200'), - ('.1h10m', '960'), - ('0001h00010m', '4200'), - ('2m3s4ms', '123.004'), - (('100ns', '1e-7')), - (('100us', '1e-4')), - (('100μs', '1e-4')), - (('1000000ns', '.001')), - (('1000us', '.001')), - (('1000μs', '.001')), - ('4ms3s2m', '123.004'), - ('4ms3s2m5s', '128.004'), - ('2m3.125s', '123.125'), - ]) - def test_connstr_options_timeout(self, - duration: str, - expected_seconds: str) -> None: - opt_keys = ['timeout.connect_timeout', - 'timeout.query_timeout'] + @pytest.mark.parametrize( + 'duration, expected_seconds', + [ + ('1h', '3600'), + ('+1h', '3600'), + ('+1h', '3600'), + ('1h10m', '4200'), + ('1.h10m', '4200'), + ('.1h10m', '960'), + ('0001h00010m', '4200'), + ('2m3s4ms', '123.004'), + (('100ns', '1e-7')), + (('100us', '1e-4')), + (('100μs', '1e-4')), + (('1000000ns', '.001')), + (('1000us', '.001')), + (('1000μs', '.001')), + ('4ms3s2m', '123.004'), + ('4ms3s2m5s', '128.004'), + ('2m3.125s', '123.125'), + ], + ) + def test_connstr_options_timeout(self, duration: str, expected_seconds: str) -> None: + opt_keys = ['timeout.connect_timeout', 'timeout.query_timeout'] # opts = {k: duration for k in opt_keys} opts = dict.fromkeys(opt_keys, duration) cred = Credential.from_username_and_password('Administrator', 'password') @@ -114,12 +117,10 @@ def test_connstr_options_timeout(self, assert read_timeout is not None assert abs(read_timeout - expected) < 1e-9 - @pytest.mark.parametrize('invalid_opt_name', - ['connect_timeout', - 'dispatch_timeout', - 'query_timeout', - 'resolve_timeout', - 'socket_connect_timeout']) + @pytest.mark.parametrize( + 'invalid_opt_name', + ['connect_timeout', 'dispatch_timeout', 'query_timeout', 'resolve_timeout', 'socket_connect_timeout'], + ) def test_connstr_options_timeout_fail(self, invalid_opt_name: str) -> None: opts = {invalid_opt_name: '2500s'} cred = Credential.from_username_and_password('Administrator', 'password') @@ -127,30 +128,31 @@ def test_connstr_options_timeout_fail(self, invalid_opt_name: str) -> None: with pytest.raises(ValueError): _AsyncClientAdapter(connstr, cred) - @pytest.mark.parametrize('bad_duration', - ['123', - '00', - ' 1h', - '1h ', - '1h 2m' - '+-3h', - '-+3h', - '-', - '-.', - '.', - '.h', - '2.3.4h', - '3x', - '3', - '3h4x', - '1H', - '1h-2m', - '-1h', - '-1m', - '-1s' - ]) - def test_connstr_options_timeout_invalid_duration(self, - bad_duration: str) -> None: + @pytest.mark.parametrize( + 'bad_duration', + [ + '123', + '00', + ' 1h', + '1h ', + '1h 2m+-3h', + '-+3h', + '-', + '-.', + '.', + '.h', + '2.3.4h', + '3x', + '3', + '3h4x', + '1H', + '1h-2m', + '-1h', + '-1m', + '-1s', + ], + ) + def test_connstr_options_timeout_invalid_duration(self, bad_duration: str) -> None: opt_keys = ['timeout.connect_timeout', 'timeout.query_timeout'] for key in opt_keys: opts = {key: bad_duration} @@ -159,28 +161,36 @@ def test_connstr_options_timeout_invalid_duration(self, with pytest.raises(ValueError): _AsyncClientAdapter(connstr, cred) - @pytest.mark.parametrize('connstr_opts, expected_opts', - [({'security.trust_only_pem_file': TEST_CERT_PATH}, - {'trust_only_pem_file': TEST_CERT_PATH, - 'trust_only_capella': False}), - ({'security.disable_server_certificate_verification': 'true'}, - {'disable_server_certificate_verification': True}), - ]) - def test_connstr_options_security(self, - connstr_opts: Dict[str, object], - expected_opts: Dict[str, object]) -> None: + @pytest.mark.parametrize( + 'connstr_opts, expected_opts', + [ + ( + {'security.trust_only_pem_file': TEST_CERT_PATH}, + {'trust_only_pem_file': TEST_CERT_PATH, 'trust_only_capella': False}, + ), + ( + {'security.disable_server_certificate_verification': 'true'}, + {'disable_server_certificate_verification': True}, + ), + ], + ) + def test_connstr_options_security(self, connstr_opts: Dict[str, object], expected_opts: Dict[str, object]) -> None: cred = Credential.from_username_and_password('Administrator', 'password') connstr = f'https://localhost?{to_query_str(connstr_opts)}' client = _AsyncClientAdapter(connstr, cred) sec_opts = client.connection_details.cluster_options.get('security_options', {}) assert sec_opts == expected_opts - @pytest.mark.parametrize('invalid_opt_name', - ['trust_only_capella', - 'trust_only_pem_file', - 'trust_only_pem_str', - 'trust_only_certificates', - 'disable_server_certificate_verification']) + @pytest.mark.parametrize( + 'invalid_opt_name', + [ + 'trust_only_capella', + 'trust_only_pem_file', + 'trust_only_pem_str', + 'trust_only_certificates', + 'disable_server_certificate_verification', + ], + ) def test_connstr_options_security_fail(self, invalid_opt_name: str) -> None: opts = {invalid_opt_name: 'True'} cred = Credential.from_username_and_password('Administrator', 'password') @@ -188,33 +198,42 @@ def test_connstr_options_security_fail(self, invalid_opt_name: str) -> None: with pytest.raises(ValueError): _AsyncClientAdapter(connstr, cred) - @pytest.mark.parametrize('connstr', ['10.0.0.1:8091', - 'http://10.0.0.1:11222,10.0.0.2,10.0.0.3:11207', - 'http://10.0.0.1;10.0.0.2:11210;10.0.0.3', - 'http://[::ffff:192.168.0.1]:11207,[::ffff:192.168.0.2]:11207', - 'https://10.0.0.1:11222,10.0.0.2,10.0.0.3:11207', - 'https://10.0.0.1;10.0.0.2:11210;10.0.0.3', - 'https://[::ffff:192.168.0.1]:11207,[::ffff:192.168.0.2]:11207', - 'couchbase://10.0.0.1', - 'couchbases://10.0.0.1']) + @pytest.mark.parametrize( + 'connstr', + [ + '10.0.0.1:8091', + 'http://10.0.0.1:11222,10.0.0.2,10.0.0.3:11207', + 'http://10.0.0.1;10.0.0.2:11210;10.0.0.3', + 'http://[::ffff:192.168.0.1]:11207,[::ffff:192.168.0.2]:11207', + 'https://10.0.0.1:11222,10.0.0.2,10.0.0.3:11207', + 'https://10.0.0.1;10.0.0.2:11210;10.0.0.3', + 'https://[::ffff:192.168.0.1]:11207,[::ffff:192.168.0.2]:11207', + 'couchbase://10.0.0.1', + 'couchbases://10.0.0.1', + ], + ) def test_invalid_connection_strings(self, connstr: str) -> None: cred = Credential.from_username_and_password('Administrator', 'password') with pytest.raises(ValueError): AsyncCluster.create_instance(connstr, cred) - @pytest.mark.parametrize('connstr', ['http://10.0.0.1', - 'http://10.0.0.1:11222', - 'http://[3ffe:2a00:100:7031::1]', - 'http://[::ffff:192.168.0.1]:11207', - 'http://test.local:11210', - 'http://fqdn', - 'https://10.0.0.1', - 'https://10.0.0.1:11222', - 'https://[3ffe:2a00:100:7031::1]', - 'https://[::ffff:192.168.0.1]:11207', - 'https://test.local:11210', - 'https://fqdn' - ]) + @pytest.mark.parametrize( + 'connstr', + [ + 'http://10.0.0.1', + 'http://10.0.0.1:11222', + 'http://[3ffe:2a00:100:7031::1]', + 'http://[::ffff:192.168.0.1]:11207', + 'http://test.local:11210', + 'http://fqdn', + 'https://10.0.0.1', + 'https://10.0.0.1:11222', + 'https://[3ffe:2a00:100:7031::1]', + 'https://[::ffff:192.168.0.1]:11207', + 'https://test.local:11210', + 'https://fqdn', + ], + ) def test_valid_connection_strings(self, connstr: str) -> None: cred = Credential.from_username_and_password('Administrator', 'password') client = _AsyncClientAdapter(connstr, cred) @@ -227,12 +246,12 @@ def test_valid_connection_strings(self, connstr: str) -> None: class ConnectionTests(ConnectionTestSuite): - @pytest.fixture(scope='class', autouse=True) def validate_test_manifest(self) -> None: def valid_test_method(meth: str) -> bool: attr = getattr(ConnectionTests, meth) return callable(attr) and not meth.startswith('__') and meth.startswith('test') + method_list = [meth for meth in dir(ConnectionTests) if valid_test_method(meth)] test_list = set(ConnectionTestSuite.TEST_MANIFEST).symmetric_difference(method_list) if test_list: diff --git a/acouchbase_analytics/tests/json_parsing_t.py b/acouchbase_analytics/tests/json_parsing_t.py index e46ab73..5bace7d 100644 --- a/acouchbase_analytics/tests/json_parsing_t.py +++ b/acouchbase_analytics/tests/json_parsing_t.py @@ -22,9 +22,7 @@ import pytest from acouchbase_analytics.protocol._core.async_json_stream import AsyncJsonStream -from couchbase_analytics.common._core import (JsonStreamConfig, - ParsedResult, - ParsedResultType) +from couchbase_analytics.common._core import JsonStreamConfig, ParsedResult, ParsedResultType from couchbase_analytics.common.errors import AnalyticsError from tests.environments.simple_environment import JsonDataType from tests.utils import AsyncBytesIterator @@ -43,37 +41,32 @@ class JsonParsingTestSuite: 'test_analytics_multiple_errors', 'test_analytics_parses_async', 'test_analytics_simple_result', - 'test_array', 'test_array_empty', 'test_array_mixed_types', 'test_array_of_objects', - 'test_invalid_empty', 'test_invalid_garbage_between_objects', 'test_invalid_leading_garbage', 'test_invalid_trailing_garbage', 'test_invalid_whitespace_only', - 'test_object', 'test_object_complex_nested_structure', 'test_object_empty', 'test_object_simple_nested', 'test_object_with_empty_key_and_value', 'test_object_with_unicode', - 'test_value_bool', 'test_value_null', ] @pytest.mark.parametrize('buffered_result', [True, False]) - async def test_analytics_error(self, - async_test_env: AsyncSimpleEnvironment, - buffered_result: bool) -> None: + async def test_analytics_error(self, async_test_env: AsyncSimpleEnvironment, buffered_result: bool) -> None: json_object, bytes_data = async_test_env.get_json_data(JsonDataType.FAILED_REQUEST) if buffered_result: - parser = AsyncJsonStream(AsyncBytesIterator(bytes_data), - stream_config=JsonStreamConfig(buffer_entire_result=True)) + parser = AsyncJsonStream( + AsyncBytesIterator(bytes_data), stream_config=JsonStreamConfig(buffer_entire_result=True) + ) else: parser = AsyncJsonStream(AsyncBytesIterator(bytes_data)) await parser.start_parsing() @@ -138,13 +131,12 @@ async def test_analytics_many_rows(self, async_test_env: AsyncSimpleEnvironment) await parser.get_result() @pytest.mark.parametrize('buffered_result', [True, False]) - async def test_analytics_many_rows_raw(self, - async_test_env: AsyncSimpleEnvironment, - buffered_result: bool) -> None: + async def test_analytics_many_rows_raw(self, async_test_env: AsyncSimpleEnvironment, buffered_result: bool) -> None: json_object, bytes_data = async_test_env.get_json_data(JsonDataType.MULTIPLE_RESULTS_RAW) if buffered_result: - parser = AsyncJsonStream(AsyncBytesIterator(bytes_data), - stream_config=JsonStreamConfig(buffer_entire_result=True)) + parser = AsyncJsonStream( + AsyncBytesIterator(bytes_data), stream_config=JsonStreamConfig(buffer_entire_result=True) + ) else: parser = AsyncJsonStream(AsyncBytesIterator(bytes_data)) @@ -174,13 +166,14 @@ async def test_analytics_many_rows_raw(self, await parser.get_result() @pytest.mark.parametrize('buffered_result', [True, False]) - async def test_analytics_multiple_errors(self, - async_test_env: AsyncSimpleEnvironment, - buffered_result: bool) -> None: + async def test_analytics_multiple_errors( + self, async_test_env: AsyncSimpleEnvironment, buffered_result: bool + ) -> None: json_object, bytes_data = async_test_env.get_json_data(JsonDataType.FAILED_REQUEST_MULTI_ERRORS) if buffered_result: - parser = AsyncJsonStream(AsyncBytesIterator(bytes_data), - stream_config=JsonStreamConfig(buffer_entire_result=True)) + parser = AsyncJsonStream( + AsyncBytesIterator(bytes_data), stream_config=JsonStreamConfig(buffer_entire_result=True) + ) else: parser = AsyncJsonStream(AsyncBytesIterator(bytes_data)) await parser.start_parsing() @@ -194,10 +187,11 @@ async def test_analytics_multiple_errors(self, async def test_analytics_parses_async(self, async_test_env: AsyncSimpleEnvironment) -> None: json_object, bytes_data = async_test_env.get_json_data(JsonDataType.MULTIPLE_RESULTS) + async def _run_async(idx: int) -> Dict[float, int]: - parser = AsyncJsonStream(AsyncBytesIterator(bytes_data, - simulate_delay=True, - simulate_delay_range=(0.01, 0.1))) + parser = AsyncJsonStream( + AsyncBytesIterator(bytes_data, simulate_delay=True, simulate_delay_range=(0.01, 0.1)) + ) await parser.start_parsing() row_idx = 0 while row_idx < 36: @@ -220,13 +214,12 @@ async def _run_async(idx: int) -> Dict[float, int]: assert list(ordered_results.values()) != list(range(10)) @pytest.mark.parametrize('buffered_result', [True, False]) - async def test_analytics_simple_result(self, - async_test_env: AsyncSimpleEnvironment, - buffered_result: bool) -> None: + async def test_analytics_simple_result(self, async_test_env: AsyncSimpleEnvironment, buffered_result: bool) -> None: json_object, bytes_data = async_test_env.get_json_data(JsonDataType.SIMPLE_REQUEST) if buffered_result: - parser = AsyncJsonStream(AsyncBytesIterator(bytes_data), - stream_config=JsonStreamConfig(buffer_entire_result=True)) + parser = AsyncJsonStream( + AsyncBytesIterator(bytes_data), stream_config=JsonStreamConfig(buffer_entire_result=True) + ) else: parser = AsyncJsonStream(AsyncBytesIterator(bytes_data)) await parser.start_parsing() @@ -252,8 +245,9 @@ async def test_analytics_simple_result(self, @pytest.mark.anyio async def test_array(self) -> None: data = '[1,2,"three"]' - parser = AsyncJsonStream(AsyncBytesIterator(bytes(data, 'utf-8')), - stream_config=JsonStreamConfig(buffer_entire_result=True)) + parser = AsyncJsonStream( + AsyncBytesIterator(bytes(data, 'utf-8')), stream_config=JsonStreamConfig(buffer_entire_result=True) + ) await parser.start_parsing() result = await parser.get_result() assert isinstance(result, ParsedResult) @@ -266,8 +260,9 @@ async def test_array(self) -> None: @pytest.mark.anyio async def test_array_empty(self) -> None: data = '[]' - parser = AsyncJsonStream(AsyncBytesIterator(bytes(data, 'utf-8')), - stream_config=JsonStreamConfig(buffer_entire_result=True)) + parser = AsyncJsonStream( + AsyncBytesIterator(bytes(data, 'utf-8')), stream_config=JsonStreamConfig(buffer_entire_result=True) + ) await parser.start_parsing() result = await parser.get_result() assert isinstance(result, ParsedResult) @@ -280,8 +275,9 @@ async def test_array_empty(self) -> None: @pytest.mark.anyio async def test_array_mixed_types(self) -> None: data = '[123,"text",true,null,{"key":"value"}]' - parser = AsyncJsonStream(AsyncBytesIterator(bytes(data, 'utf-8')), - stream_config=JsonStreamConfig(buffer_entire_result=True)) + parser = AsyncJsonStream( + AsyncBytesIterator(bytes(data, 'utf-8')), stream_config=JsonStreamConfig(buffer_entire_result=True) + ) await parser.start_parsing() result = await parser.get_result() assert isinstance(result, ParsedResult) @@ -294,8 +290,9 @@ async def test_array_mixed_types(self) -> None: @pytest.mark.anyio async def test_array_of_objects(self) -> None: data = '[{"id":1,"name":"Alice"},{"id":2,"name":"Bob"}]' - parser = AsyncJsonStream(AsyncBytesIterator(bytes(data, 'utf-8')), - stream_config=JsonStreamConfig(buffer_entire_result=True)) + parser = AsyncJsonStream( + AsyncBytesIterator(bytes(data, 'utf-8')), stream_config=JsonStreamConfig(buffer_entire_result=True) + ) await parser.start_parsing() result = await parser.get_result() assert isinstance(result, ParsedResult) @@ -308,8 +305,9 @@ async def test_array_of_objects(self) -> None: @pytest.mark.anyio async def test_invalid_empty(self) -> None: data = '' - parser = AsyncJsonStream(AsyncBytesIterator(bytes(data, 'utf-8')), - stream_config=JsonStreamConfig(buffer_entire_result=True)) + parser = AsyncJsonStream( + AsyncBytesIterator(bytes(data, 'utf-8')), stream_config=JsonStreamConfig(buffer_entire_result=True) + ) await parser.start_parsing() res = await parser.get_result() assert res.result_type == ParsedResultType.ERROR @@ -319,8 +317,9 @@ async def test_invalid_empty(self) -> None: @pytest.mark.anyio async def test_invalid_garbage_between_objects(self) -> None: data = '[{"id":1,"name":"Alice"},garbage,{"id":2,"name":"Bob"}]' - parser = AsyncJsonStream(AsyncBytesIterator(bytes(data, 'utf-8')), - stream_config=JsonStreamConfig(buffer_entire_result=True)) + parser = AsyncJsonStream( + AsyncBytesIterator(bytes(data, 'utf-8')), stream_config=JsonStreamConfig(buffer_entire_result=True) + ) await parser.start_parsing() res = await parser.get_result() assert res.result_type == ParsedResultType.ERROR @@ -330,8 +329,9 @@ async def test_invalid_garbage_between_objects(self) -> None: @pytest.mark.anyio async def test_invalid_leading_garbage(self) -> None: data = 'garbage{"key":"value"}' - parser = AsyncJsonStream(AsyncBytesIterator(bytes(data, 'utf-8')), - stream_config=JsonStreamConfig(buffer_entire_result=True)) + parser = AsyncJsonStream( + AsyncBytesIterator(bytes(data, 'utf-8')), stream_config=JsonStreamConfig(buffer_entire_result=True) + ) await parser.start_parsing() res = await parser.get_result() assert res.result_type == ParsedResultType.ERROR @@ -341,8 +341,9 @@ async def test_invalid_leading_garbage(self) -> None: @pytest.mark.anyio async def test_invalid_trailing_garbage(self) -> None: data = '{"key":"value"}garbage' - parser = AsyncJsonStream(AsyncBytesIterator(bytes(data, 'utf-8')), - stream_config=JsonStreamConfig(buffer_entire_result=True)) + parser = AsyncJsonStream( + AsyncBytesIterator(bytes(data, 'utf-8')), stream_config=JsonStreamConfig(buffer_entire_result=True) + ) await parser.start_parsing() res = await parser.get_result() assert res.result_type == ParsedResultType.ERROR @@ -352,8 +353,9 @@ async def test_invalid_trailing_garbage(self) -> None: @pytest.mark.anyio async def test_invalid_whitespace_only(self) -> None: data = ' \n\t ' - parser = AsyncJsonStream(AsyncBytesIterator(bytes(data, 'utf-8')), - stream_config=JsonStreamConfig(buffer_entire_result=True)) + parser = AsyncJsonStream( + AsyncBytesIterator(bytes(data, 'utf-8')), stream_config=JsonStreamConfig(buffer_entire_result=True) + ) await parser.start_parsing() res = await parser.get_result() assert res.result_type == ParsedResultType.ERROR @@ -363,8 +365,9 @@ async def test_invalid_whitespace_only(self) -> None: @pytest.mark.anyio async def test_value_bool(self) -> None: data = 'true' - parser = AsyncJsonStream(AsyncBytesIterator(bytes(data, 'utf-8')), - stream_config=JsonStreamConfig(buffer_entire_result=True)) + parser = AsyncJsonStream( + AsyncBytesIterator(bytes(data, 'utf-8')), stream_config=JsonStreamConfig(buffer_entire_result=True) + ) await parser.start_parsing() result = await parser.get_result() assert isinstance(result, ParsedResult) @@ -377,8 +380,9 @@ async def test_value_bool(self) -> None: @pytest.mark.anyio async def test_value_null(self) -> None: data = 'null' - parser = AsyncJsonStream(AsyncBytesIterator(bytes(data, 'utf-8')), - stream_config=JsonStreamConfig(buffer_entire_result=True)) + parser = AsyncJsonStream( + AsyncBytesIterator(bytes(data, 'utf-8')), stream_config=JsonStreamConfig(buffer_entire_result=True) + ) await parser.start_parsing() result = await parser.get_result() assert isinstance(result, ParsedResult) @@ -391,8 +395,9 @@ async def test_value_null(self) -> None: @pytest.mark.anyio async def test_object(self) -> None: data = '{"name":"John","age":30,"city":"New York"}' - parser = AsyncJsonStream(AsyncBytesIterator(bytes(data, 'utf-8')), - stream_config=JsonStreamConfig(buffer_entire_result=True)) + parser = AsyncJsonStream( + AsyncBytesIterator(bytes(data, 'utf-8')), stream_config=JsonStreamConfig(buffer_entire_result=True) + ) await parser.start_parsing() result = await parser.get_result() assert isinstance(result, ParsedResult) @@ -404,12 +409,14 @@ async def test_object(self) -> None: @pytest.mark.anyio async def test_object_complex_nested_structure(self) -> None: - data_list = ['{"users":[{"id":1,"name":"Alice","roles":["admin","editor"]},' - '{"id":2,"name":"Bob","roles":["viewer"]}],', - '"meta":{"count":2,"status":"success"}}'] + data_list = [ + '{"users":[{"id":1,"name":"Alice","roles":["admin","editor"]},{"id":2,"name":"Bob","roles":["viewer"]}],', + '"meta":{"count":2,"status":"success"}}', + ] data = ''.join(data_list) - parser = AsyncJsonStream(AsyncBytesIterator(bytes(data, 'utf-8')), - stream_config=JsonStreamConfig(buffer_entire_result=True)) + parser = AsyncJsonStream( + AsyncBytesIterator(bytes(data, 'utf-8')), stream_config=JsonStreamConfig(buffer_entire_result=True) + ) await parser.start_parsing() result = await parser.get_result() assert isinstance(result, ParsedResult) @@ -422,8 +429,9 @@ async def test_object_complex_nested_structure(self) -> None: @pytest.mark.anyio async def test_object_empty(self) -> None: data = '{}' - parser = AsyncJsonStream(AsyncBytesIterator(bytes(data, 'utf-8')), - stream_config=JsonStreamConfig(buffer_entire_result=True)) + parser = AsyncJsonStream( + AsyncBytesIterator(bytes(data, 'utf-8')), stream_config=JsonStreamConfig(buffer_entire_result=True) + ) await parser.start_parsing() result = await parser.get_result() assert isinstance(result, ParsedResult) @@ -436,8 +444,9 @@ async def test_object_empty(self) -> None: @pytest.mark.anyio async def test_object_simple_nested(self) -> None: data = '{"outer":{"inner":{"key":"value"}}}' - parser = AsyncJsonStream(AsyncBytesIterator(bytes(data, 'utf-8')), - stream_config=JsonStreamConfig(buffer_entire_result=True)) + parser = AsyncJsonStream( + AsyncBytesIterator(bytes(data, 'utf-8')), stream_config=JsonStreamConfig(buffer_entire_result=True) + ) await parser.start_parsing() result = await parser.get_result() assert isinstance(result, ParsedResult) @@ -450,8 +459,9 @@ async def test_object_simple_nested(self) -> None: @pytest.mark.anyio async def test_object_with_empty_key_and_value(self) -> None: data = '{"":""}' - parser = AsyncJsonStream(AsyncBytesIterator(bytes(data, 'utf-8')), - stream_config=JsonStreamConfig(buffer_entire_result=True)) + parser = AsyncJsonStream( + AsyncBytesIterator(bytes(data, 'utf-8')), stream_config=JsonStreamConfig(buffer_entire_result=True) + ) await parser.start_parsing() result = await parser.get_result() assert isinstance(result, ParsedResult) @@ -464,8 +474,9 @@ async def test_object_with_empty_key_and_value(self) -> None: @pytest.mark.anyio async def test_object_with_unicode(self) -> None: data = '{"name":"你好","city":"Denver"}' - parser = AsyncJsonStream(AsyncBytesIterator(bytes(data, 'utf-8')), - stream_config=JsonStreamConfig(buffer_entire_result=True)) + parser = AsyncJsonStream( + AsyncBytesIterator(bytes(data, 'utf-8')), stream_config=JsonStreamConfig(buffer_entire_result=True) + ) await parser.start_parsing() result = await parser.get_result() assert isinstance(result, ParsedResult) @@ -475,13 +486,14 @@ async def test_object_with_unicode(self) -> None: with pytest.raises(AnalyticsError): await parser.get_result() -class JsonParsingTests(JsonParsingTestSuite): +class JsonParsingTests(JsonParsingTestSuite): @pytest.fixture(scope='class', autouse=True) def validate_test_manifest(self) -> None: def valid_test_method(meth: str) -> bool: attr = getattr(JsonParsingTests, meth) return callable(attr) and not meth.startswith('__') and meth.startswith('test') + method_list = [meth for meth in dir(JsonParsingTests) if valid_test_method(meth)] test_list = set(JsonParsingTestSuite.TEST_MANIFEST).symmetric_difference(method_list) if test_list: diff --git a/acouchbase_analytics/tests/options_t.py b/acouchbase_analytics/tests/options_t.py index 113ab4c..8fd67c5 100644 --- a/acouchbase_analytics/tests/options_t.py +++ b/acouchbase_analytics/tests/options_t.py @@ -16,25 +16,21 @@ from __future__ import annotations from datetime import timedelta -from typing import (Dict, - Optional, - Type) +from typing import Dict, Optional, Type import pytest from acouchbase_analytics.protocol._core.client_adapter import _AsyncClientAdapter from couchbase_analytics.credential import Credential -from couchbase_analytics.deserializer import (DefaultJsonDeserializer, - Deserializer, - PassthroughDeserializer) -from couchbase_analytics.options import (ClusterOptions, - SecurityOptions, - SecurityOptionsKwargs, - TimeoutOptions, - TimeoutOptionsKwargs) -from tests.utils import (get_test_cert_list, - get_test_cert_path, - get_test_cert_str) +from couchbase_analytics.deserializer import DefaultJsonDeserializer, Deserializer, PassthroughDeserializer +from couchbase_analytics.options import ( + ClusterOptions, + SecurityOptions, + SecurityOptionsKwargs, + TimeoutOptions, + TimeoutOptionsKwargs, +) +from tests.utils import get_test_cert_list, get_test_cert_path, get_test_cert_str TEST_CERT_PATH = get_test_cert_path() TEST_CERT_LIST = get_test_cert_list() @@ -42,7 +38,6 @@ class ClusterOptionsTestSuite: - TEST_MANIFEST = [ 'test_options_deserializer', 'test_options_deserializer_kwargs', @@ -80,7 +75,7 @@ def test_options_max_retries(self, max_retries: Optional[int]) -> None: if max_retries is None: assert client.connection_details.get_max_retries() == 7 else: - assert client.connection_details.get_max_retries() == max_retries + assert client.connection_details.get_max_retries() == max_retries @pytest.mark.parametrize('max_retries', [5, 10, None]) def test_options_max_retries_kwargs(self, max_retries: Optional[int]) -> None: @@ -89,147 +84,148 @@ def test_options_max_retries_kwargs(self, max_retries: Optional[int]) -> None: client = _AsyncClientAdapter('https://localhost', cred) assert client.connection_details.get_max_retries() == 7 else: - client = _AsyncClientAdapter('https://localhost', cred, **{'max_retries': max_retries}) - assert client.connection_details.get_max_retries() == max_retries - - @pytest.mark.parametrize('opts, expected_opts', - [({}, None), - ({'trust_only_capella': True}, - {'trust_only_capella': True}), - ({'trust_only_pem_file': TEST_CERT_PATH}, - {'trust_only_pem_file': TEST_CERT_PATH, - 'trust_only_capella': False}), - ({'trust_only_pem_str': TEST_CERT_STR}, - {'trust_only_pem_str': TEST_CERT_STR, - 'trust_only_capella': False}), - ({'trust_only_certificates': TEST_CERT_LIST}, - {'trust_only_certificates': TEST_CERT_LIST, - 'trust_only_capella': False}), - ({'disable_server_certificate_verification': True}, - {'disable_server_certificate_verification': True}), - ]) + client = _AsyncClientAdapter('https://localhost', cred, **{'max_retries': max_retries}) + assert client.connection_details.get_max_retries() == max_retries + + @pytest.mark.parametrize( + 'opts, expected_opts', + [ + ({}, None), + ({'trust_only_capella': True}, {'trust_only_capella': True}), + ( + {'trust_only_pem_file': TEST_CERT_PATH}, + {'trust_only_pem_file': TEST_CERT_PATH, 'trust_only_capella': False}, + ), + ({'trust_only_pem_str': TEST_CERT_STR}, {'trust_only_pem_str': TEST_CERT_STR, 'trust_only_capella': False}), + ( + {'trust_only_certificates': TEST_CERT_LIST}, + {'trust_only_certificates': TEST_CERT_LIST, 'trust_only_capella': False}, + ), + ({'disable_server_certificate_verification': True}, {'disable_server_certificate_verification': True}), + ], + ) def test_security_options(self, opts: SecurityOptionsKwargs, expected_opts: SecurityOptionsKwargs) -> None: cred = Credential.from_username_and_password('Administrator', 'password') - client = _AsyncClientAdapter('https://localhost', - cred, - ClusterOptions(security_options=SecurityOptions(**opts))) + client = _AsyncClientAdapter( + 'https://localhost', cred, ClusterOptions(security_options=SecurityOptions(**opts)) + ) assert expected_opts == client.connection_details.cluster_options.get('security_options') - @pytest.mark.parametrize('opts, expected_opts', - [(SecurityOptions.trust_only_capella(), - {'trust_only_capella': True}), - (SecurityOptions.trust_only_pem_file(TEST_CERT_PATH), - {'trust_only_pem_file': TEST_CERT_PATH, - 'trust_only_capella': False}), - (SecurityOptions.trust_only_pem_str(TEST_CERT_STR), - {'trust_only_pem_str': TEST_CERT_STR, - 'trust_only_capella': False}), - (SecurityOptions.trust_only_certificates(TEST_CERT_LIST), - {'trust_only_certificates': TEST_CERT_LIST, - 'trust_only_capella': False}), - ]) + @pytest.mark.parametrize( + 'opts, expected_opts', + [ + (SecurityOptions.trust_only_capella(), {'trust_only_capella': True}), + ( + SecurityOptions.trust_only_pem_file(TEST_CERT_PATH), + {'trust_only_pem_file': TEST_CERT_PATH, 'trust_only_capella': False}, + ), + ( + SecurityOptions.trust_only_pem_str(TEST_CERT_STR), + {'trust_only_pem_str': TEST_CERT_STR, 'trust_only_capella': False}, + ), + ( + SecurityOptions.trust_only_certificates(TEST_CERT_LIST), + {'trust_only_certificates': TEST_CERT_LIST, 'trust_only_capella': False}, + ), + ], + ) def test_security_options_classmethods(self, opts: SecurityOptions, expected_opts: Dict[str, object]) -> None: cred = Credential.from_username_and_password('Administrator', 'password') - client = _AsyncClientAdapter('https://localhost', - cred, - ClusterOptions(security_options=opts)) + client = _AsyncClientAdapter('https://localhost', cred, ClusterOptions(security_options=opts)) assert expected_opts == client.connection_details.cluster_options.get('security_options') - @pytest.mark.parametrize('opts, expected_opts', - [({}, None), - ({'trust_only_capella': True}, - {'trust_only_capella': True}), - ({'trust_only_pem_file': TEST_CERT_PATH}, - {'trust_only_pem_file': TEST_CERT_PATH, - 'trust_only_capella': False}), - ({'trust_only_pem_str': TEST_CERT_STR}, - {'trust_only_pem_str': TEST_CERT_STR, - 'trust_only_capella': False}), - ({'trust_only_certificates': TEST_CERT_LIST}, - {'trust_only_certificates': TEST_CERT_LIST, - 'trust_only_capella': False}), - ({'disable_server_certificate_verification': True}, - {'disable_server_certificate_verification': True}), - ]) + @pytest.mark.parametrize( + 'opts, expected_opts', + [ + ({}, None), + ({'trust_only_capella': True}, {'trust_only_capella': True}), + ( + {'trust_only_pem_file': TEST_CERT_PATH}, + {'trust_only_pem_file': TEST_CERT_PATH, 'trust_only_capella': False}, + ), + ({'trust_only_pem_str': TEST_CERT_STR}, {'trust_only_pem_str': TEST_CERT_STR, 'trust_only_capella': False}), + ( + {'trust_only_certificates': TEST_CERT_LIST}, + {'trust_only_certificates': TEST_CERT_LIST, 'trust_only_capella': False}, + ), + ({'disable_server_certificate_verification': True}, {'disable_server_certificate_verification': True}), + ], + ) def test_security_options_kwargs(self, opts: Dict[str, object], expected_opts: Dict[str, object]) -> None: cred = Credential.from_username_and_password('Administrator', 'password') client = _AsyncClientAdapter('https://localhost', cred, **opts) assert expected_opts == client.connection_details.cluster_options.get('security_options') - @pytest.mark.parametrize('opts', - [{'trust_only_capella': True, - 'trust_only_pem_file': TEST_CERT_PATH}, - {'trust_only_capella': True, - 'trust_only_pem_str': TEST_CERT_STR}, - {'trust_only_capella': True, - 'trust_only_certificates': TEST_CERT_LIST}, - ]) + @pytest.mark.parametrize( + 'opts', + [ + {'trust_only_capella': True, 'trust_only_pem_file': TEST_CERT_PATH}, + {'trust_only_capella': True, 'trust_only_pem_str': TEST_CERT_STR}, + {'trust_only_capella': True, 'trust_only_certificates': TEST_CERT_LIST}, + ], + ) def test_security_options_invalid(self, opts: SecurityOptionsKwargs) -> None: cred = Credential.from_username_and_password('Administrator', 'password') with pytest.raises(ValueError): - _AsyncClientAdapter('https://localhost', - cred, - ClusterOptions(security_options=SecurityOptions(**opts))) - - @pytest.mark.parametrize('opts', - [{'trust_only_capella': True, - 'trust_only_pem_file': TEST_CERT_PATH}, - {'trust_only_capella': True, - 'trust_only_pem_str': TEST_CERT_STR}, - {'trust_only_capella': True, - 'trust_only_certificates': TEST_CERT_LIST}, - ]) + _AsyncClientAdapter('https://localhost', cred, ClusterOptions(security_options=SecurityOptions(**opts))) + + @pytest.mark.parametrize( + 'opts', + [ + {'trust_only_capella': True, 'trust_only_pem_file': TEST_CERT_PATH}, + {'trust_only_capella': True, 'trust_only_pem_str': TEST_CERT_STR}, + {'trust_only_capella': True, 'trust_only_certificates': TEST_CERT_LIST}, + ], + ) def test_security_options_invalid_kwargs(self, opts: Dict[str, object]) -> None: cred = Credential.from_username_and_password('Administrator', 'password') with pytest.raises(ValueError): _AsyncClientAdapter('https://localhost', cred, **opts) - @pytest.mark.parametrize('opts, expected_opts', - [({}, None), - ({'connect_timeout': timedelta(seconds=30)}, - {'connect_timeout': 30}), - ({'query_timeout': timedelta(seconds=30)}, - {'query_timeout': 30}), - ({'connect_timeout': timedelta(seconds=60), - 'query_timeout': timedelta(seconds=30)}, - {'connect_timeout': 60, - 'query_timeout': 30}), - ]) + @pytest.mark.parametrize( + 'opts, expected_opts', + [ + ({}, None), + ({'connect_timeout': timedelta(seconds=30)}, {'connect_timeout': 30}), + ({'query_timeout': timedelta(seconds=30)}, {'query_timeout': 30}), + ( + {'connect_timeout': timedelta(seconds=60), 'query_timeout': timedelta(seconds=30)}, + {'connect_timeout': 60, 'query_timeout': 30}, + ), + ], + ) def test_timeout_options(self, opts: TimeoutOptionsKwargs, expected_opts: TimeoutOptionsKwargs) -> None: cred = Credential.from_username_and_password('Administrator', 'password') - client = _AsyncClientAdapter('https://localhost', - cred, - ClusterOptions(timeout_options=TimeoutOptions(**opts))) + client = _AsyncClientAdapter('https://localhost', cred, ClusterOptions(timeout_options=TimeoutOptions(**opts))) assert expected_opts == client.connection_details.cluster_options.get('timeout_options') - @pytest.mark.parametrize('opts, expected_opts', - [({'connect_timeout': timedelta(seconds=30)}, - {'connect_timeout': 30}), - ({'query_timeout': timedelta(seconds=30)}, - {'query_timeout': 30}), - ({'connect_timeout': timedelta(seconds=60), - 'query_timeout': timedelta(seconds=30)}, - {'connect_timeout': 60, - 'query_timeout': 30}), - ]) + @pytest.mark.parametrize( + 'opts, expected_opts', + [ + ({'connect_timeout': timedelta(seconds=30)}, {'connect_timeout': 30}), + ({'query_timeout': timedelta(seconds=30)}, {'query_timeout': 30}), + ( + {'connect_timeout': timedelta(seconds=60), 'query_timeout': timedelta(seconds=30)}, + {'connect_timeout': 60, 'query_timeout': 30}, + ), + ], + ) def test_timeout_options_kwargs(self, opts: Dict[str, object], expected_opts: Dict[str, object]) -> None: cred = Credential.from_username_and_password('Administrator', 'password') client = _AsyncClientAdapter('https://localhost', cred, **opts) assert expected_opts == client.connection_details.cluster_options.get('timeout_options') - @pytest.mark.parametrize('opts', - [{'connect_timeout': timedelta(seconds=-1)}, - {'query_timeout': timedelta(seconds=-1)}]) + @pytest.mark.parametrize( + 'opts', [{'connect_timeout': timedelta(seconds=-1)}, {'query_timeout': timedelta(seconds=-1)}] + ) def test_timeout_options_must_be_positive(self, opts: TimeoutOptionsKwargs) -> None: cred = Credential.from_username_and_password('Administrator', 'password') with pytest.raises(ValueError): - _AsyncClientAdapter('https://localhost', - cred, - ClusterOptions(timeout_options=TimeoutOptions(**opts))) + _AsyncClientAdapter('https://localhost', cred, ClusterOptions(timeout_options=TimeoutOptions(**opts))) - @pytest.mark.parametrize('opts', - [{'connect_timeout': timedelta(seconds=-1)}, - {'query_timeout': timedelta(seconds=-1)}]) + @pytest.mark.parametrize( + 'opts', [{'connect_timeout': timedelta(seconds=-1)}, {'query_timeout': timedelta(seconds=-1)}] + ) def test_timeout_options_must_be_positive_kwargs(self, opts: Dict[str, object]) -> None: cred = Credential.from_username_and_password('Administrator', 'password') with pytest.raises(ValueError): @@ -237,12 +233,12 @@ def test_timeout_options_must_be_positive_kwargs(self, opts: Dict[str, object]) class ClusterOptionsTests(ClusterOptionsTestSuite): - @pytest.fixture(scope='class', autouse=True) def validate_test_manifest(self) -> None: def valid_test_method(meth: str) -> bool: attr = getattr(ClusterOptionsTests, meth) return callable(attr) and not meth.startswith('__') and meth.startswith('test') + method_list = [meth for meth in dir(ClusterOptionsTests) if valid_test_method(meth)] test_list = set(ClusterOptionsTestSuite.TEST_MANIFEST).symmetric_difference(method_list) if test_list: diff --git a/acouchbase_analytics/tests/query_integration_t.py b/acouchbase_analytics/tests/query_integration_t.py index 5ab18c6..0b98cdf 100644 --- a/acouchbase_analytics/tests/query_integration_t.py +++ b/acouchbase_analytics/tests/query_integration_t.py @@ -34,7 +34,6 @@ class QueryTestSuite: - TEST_MANIFEST = [ 'test_query_cancel_prior_iterating', 'test_query_cancel_async_while_iterating', @@ -93,9 +92,9 @@ async def test_query_cancel_prior_iterating(self, test_env: AsyncTestEnvironment with pytest.raises(CancelledError): await qtask - async def test_query_cancel_async_while_iterating(self, - test_env: AsyncTestEnvironment, - query_statement_limit5: str) -> None: + async def test_query_cancel_async_while_iterating( + self, test_env: AsyncTestEnvironment, query_statement_limit5: str + ) -> None: qtask = test_env.cluster_or_scope.execute_query(query_statement_limit5) assert isinstance(qtask, Task) res = await qtask @@ -118,9 +117,9 @@ async def test_query_cancel_async_while_iterating(self, res.metadata() test_env.assert_streaming_response_state(res) - async def test_query_cancel_while_iterating(self, - test_env: AsyncTestEnvironment, - query_statement_limit5: str) -> None: + async def test_query_cancel_while_iterating( + self, test_env: AsyncTestEnvironment, query_statement_limit5: str + ) -> None: qtask = test_env.cluster_or_scope.execute_query(query_statement_limit5) assert isinstance(qtask, Task) res = await qtask @@ -145,9 +144,7 @@ async def test_query_cancel_while_iterating(self, await res.shutdown() test_env.assert_streaming_response_state(res) - async def test_query_metadata(self, - test_env: AsyncTestEnvironment, - query_statement_limit5: str) -> None: + async def test_query_metadata(self, test_env: AsyncTestEnvironment, query_statement_limit5: str) -> None: result = await test_env.cluster_or_scope.execute_query(query_statement_limit5) expected_count = 5 await test_env.assert_rows(result, expected_count) @@ -166,9 +163,9 @@ async def test_query_metadata(self, assert metrics.execution_time() > timedelta(0) test_env.assert_streaming_response_state(result) - async def test_query_metadata_not_available(self, - test_env: AsyncTestEnvironment, - query_statement_limit5: str) -> None: + async def test_query_metadata_not_available( + self, test_env: AsyncTestEnvironment, query_statement_limit5: str + ) -> None: result = await test_env.cluster_or_scope.execute_query(query_statement_limit5) with pytest.raises(RuntimeError): @@ -192,36 +189,40 @@ async def test_query_metadata_not_available(self, assert len(metadata.request_id()) > 0 test_env.assert_streaming_response_state(result) - async def test_query_named_parameters(self, - test_env: AsyncTestEnvironment, - query_statement_named_params_limit2: str,) -> None: + async def test_query_named_parameters( + self, + test_env: AsyncTestEnvironment, + query_statement_named_params_limit2: str, + ) -> None: q_opts = QueryOptions(named_parameters={'country': 'United States'}) result = await test_env.cluster_or_scope.execute_query(query_statement_named_params_limit2, q_opts) await test_env.assert_rows(result, 2) test_env.assert_streaming_response_state(result) - async def test_query_named_parameters_no_options(self, - test_env: AsyncTestEnvironment, - query_statement_named_params_limit2: str) -> None: - result = await test_env.cluster_or_scope.execute_query(query_statement_named_params_limit2, - country='United States') + async def test_query_named_parameters_no_options( + self, test_env: AsyncTestEnvironment, query_statement_named_params_limit2: str + ) -> None: + result = await test_env.cluster_or_scope.execute_query( + query_statement_named_params_limit2, country='United States' + ) await test_env.assert_rows(result, 2) test_env.assert_streaming_response_state(result) - async def test_query_named_parameters_override(self, - test_env: AsyncTestEnvironment, - query_statement_named_params_limit2: str) -> None: + async def test_query_named_parameters_override( + self, test_env: AsyncTestEnvironment, query_statement_named_params_limit2: str + ) -> None: q_opts = QueryOptions(named_parameters={'country': 'abcdefg'}) - result = await test_env.cluster_or_scope.execute_query(query_statement_named_params_limit2, - q_opts, - country='United States') + result = await test_env.cluster_or_scope.execute_query( + query_statement_named_params_limit2, q_opts, country='United States' + ) await test_env.assert_rows(result, 2) test_env.assert_streaming_response_state(result) async def test_query_passthrough_deserializer(self, test_env: AsyncTestEnvironment) -> None: statement = 'FROM range(0, 10) AS num SELECT *' - result = await test_env.cluster_or_scope.execute_query(statement, - QueryOptions(deserializer=PassthroughDeserializer())) + result = await test_env.cluster_or_scope.execute_query( + statement, QueryOptions(deserializer=PassthroughDeserializer()) + ) idx = 0 async for row in result.rows(): assert isinstance(row, bytes) @@ -229,28 +230,28 @@ async def test_query_passthrough_deserializer(self, test_env: AsyncTestEnvironme idx += 1 test_env.assert_streaming_response_state(result) - async def test_query_positional_params(self, - test_env: AsyncTestEnvironment, - query_statement_pos_params_limit2: str) -> None: + async def test_query_positional_params( + self, test_env: AsyncTestEnvironment, query_statement_pos_params_limit2: str + ) -> None: q_opts = QueryOptions(positional_parameters=['United States']) result = await test_env.cluster_or_scope.execute_query(query_statement_pos_params_limit2, q_opts) await test_env.assert_rows(result, 2) test_env.assert_streaming_response_state(result) - async def test_query_positional_params_no_option(self, - test_env: AsyncTestEnvironment, - query_statement_pos_params_limit2: str) -> None: + async def test_query_positional_params_no_option( + self, test_env: AsyncTestEnvironment, query_statement_pos_params_limit2: str + ) -> None: result = await test_env.cluster_or_scope.execute_query(query_statement_pos_params_limit2, 'United States') await test_env.assert_rows(result, 2) test_env.assert_streaming_response_state(result) - async def test_query_positional_params_override(self, - test_env: AsyncTestEnvironment, - query_statement_pos_params_limit2: str) -> None: + async def test_query_positional_params_override( + self, test_env: AsyncTestEnvironment, query_statement_pos_params_limit2: str + ) -> None: q_opts = QueryOptions(positional_parameters=['abcdefg']) - result = await test_env.cluster_or_scope.execute_query(query_statement_pos_params_limit2, - q_opts, - 'United States') + result = await test_env.cluster_or_scope.execute_query( + query_statement_pos_params_limit2, q_opts, 'United States' + ) await test_env.assert_rows(result, 2) test_env.assert_streaming_response_state(result) @@ -259,9 +260,9 @@ async def test_query_raises_exception_prior_to_iterating(self, test_env: AsyncTe with pytest.raises(QueryError): await test_env.cluster_or_scope.execute_query(statement) - async def test_query_raw_options(self, - test_env: AsyncTestEnvironment, - query_statement_pos_params_limit2: str) -> None: + async def test_query_raw_options( + self, test_env: AsyncTestEnvironment, query_statement_pos_params_limit2: str + ) -> None: # via raw, we should be able to pass any option # if using named params, need to match full name param in query # which is different for when we pass in name_parameters via their specific @@ -275,8 +276,9 @@ async def test_query_raw_options(self, result = await test_env.cluster_or_scope.execute_query(statement, q_opts) await test_env.assert_rows(result, 2) - result = await test_env.cluster_or_scope.execute_query(query_statement_pos_params_limit2, - QueryOptions(raw={'args': ['United States']})) + result = await test_env.cluster_or_scope.execute_query( + query_statement_pos_params_limit2, QueryOptions(raw={'args': ['United States']}) + ) await test_env.assert_rows(result, 2) test_env.assert_streaming_response_state(result) @@ -284,13 +286,11 @@ async def test_query_timeout(self, test_env: AsyncTestEnvironment) -> None: statement = 'SELECT sleep("some value", 10000) AS some_field;' with pytest.raises(TimeoutError): - await test_env.cluster_or_scope.execute_query(statement, - QueryOptions(timeout=timedelta(seconds=2))) + await test_env.cluster_or_scope.execute_query(statement, QueryOptions(timeout=timedelta(seconds=2))) async def test_query_timeout_while_streaming(self, test_env: AsyncTestEnvironment) -> None: statement = 'SELECT {"x1": 1, "x2": 2, "x3": 3} FROM range(1, 100000) r;' - res = test_env.cluster_or_scope.execute_query(statement, - QueryOptions(timeout=timedelta(seconds=2))) + res = test_env.cluster_or_scope.execute_query(statement, QueryOptions(timeout=timedelta(seconds=2))) assert isinstance(res, Task) result = await res @@ -299,50 +299,49 @@ async def test_query_timeout_while_streaming(self, test_env: AsyncTestEnvironmen pass test_env.assert_streaming_response_state(result) - async def test_simple_query(self, - test_env: AsyncTestEnvironment, - query_statement_limit2: str) -> None: + async def test_simple_query(self, test_env: AsyncTestEnvironment, query_statement_limit2: str) -> None: result = await test_env.cluster_or_scope.execute_query(query_statement_limit2) await test_env.assert_rows(result, 2) test_env.assert_streaming_response_state(result) -class ClusterQueryTests(QueryTestSuite): +class ClusterQueryTests(QueryTestSuite): @pytest.fixture(scope='class', autouse=True) def validate_test_manifest(self) -> None: def valid_test_method(meth: str) -> bool: attr = getattr(ClusterQueryTests, meth) return callable(attr) and not meth.startswith('__') and meth.startswith('test') + method_list = [meth for meth in dir(ClusterQueryTests) if valid_test_method(meth)] test_list = set(QueryTestSuite.TEST_MANIFEST).symmetric_difference(method_list) if test_list: pytest.fail(f'Test manifest invalid. Missing/extra tests: {test_list}.') @pytest.fixture(scope='class', name='test_env') - async def couchbase_test_environment(self, - async_test_env: AsyncTestEnvironment - ) -> AsyncYieldFixture[AsyncTestEnvironment]: + async def couchbase_test_environment( + self, async_test_env: AsyncTestEnvironment + ) -> AsyncYieldFixture[AsyncTestEnvironment]: await async_test_env.setup() yield async_test_env await async_test_env.teardown() class ScopeQueryTests(QueryTestSuite): - @pytest.fixture(scope='class', autouse=True) def validate_test_manifest(self) -> None: def valid_test_method(meth: str) -> bool: attr = getattr(ScopeQueryTests, meth) return callable(attr) and not meth.startswith('__') and meth.startswith('test') + method_list = [meth for meth in dir(ScopeQueryTests) if valid_test_method(meth)] test_list = set(QueryTestSuite.TEST_MANIFEST).symmetric_difference(method_list) if test_list: pytest.fail(f'Test manifest invalid. Missing/extra tests: {test_list}.') @pytest.fixture(scope='class', name='test_env') - async def couchbase_test_environment(self, - async_test_env: AsyncTestEnvironment - ) -> AsyncYieldFixture[AsyncTestEnvironment]: + async def couchbase_test_environment( + self, async_test_env: AsyncTestEnvironment + ) -> AsyncYieldFixture[AsyncTestEnvironment]: await async_test_env.setup() test_env = async_test_env.enable_scope() yield test_env diff --git a/acouchbase_analytics/tests/query_options_t.py b/acouchbase_analytics/tests/query_options_t.py index ca2e7f5..23b1de1 100644 --- a/acouchbase_analytics/tests/query_options_t.py +++ b/acouchbase_analytics/tests/query_options_t.py @@ -17,11 +17,7 @@ from dataclasses import dataclass from datetime import timedelta -from typing import (Any, - Dict, - List, - Optional, - Union) +from typing import Any, Dict, List, Optional, Union import pytest @@ -65,18 +61,18 @@ class QueryOptionsTestSuite: 'test_options_timeout', 'test_options_timeout_kwargs', 'test_options_timeout_must_be_positive', - 'test_options_timeout_must_be_positive_kwargs' + 'test_options_timeout_must_be_positive_kwargs', ] @pytest.fixture(scope='class') def query_statment(self) -> str: return 'SELECT * FROM default' - def test_options_deserializer(self, - query_statment: str, - request_builder: _RequestBuilder, - query_ctx: QueryContext) -> None: + def test_options_deserializer( + self, query_statment: str, request_builder: _RequestBuilder, query_ctx: QueryContext + ) -> None: from couchbase_analytics.deserializer import DefaultJsonDeserializer + deserializer = DefaultJsonDeserializer() q_opts = QueryOptions(deserializer=deserializer) req = request_builder.build_base_query_request(query_statment, q_opts) @@ -85,11 +81,11 @@ def test_options_deserializer(self, assert req.deserializer == deserializer query_ctx.validate_query_context(req.body) - def test_options_deserializer_kwargs(self, - query_statment: str, - request_builder: _RequestBuilder, - query_ctx: QueryContext) -> None: + def test_options_deserializer_kwargs( + self, query_statment: str, request_builder: _RequestBuilder, query_ctx: QueryContext + ) -> None: from couchbase_analytics.deserializer import DefaultJsonDeserializer + deserializer = DefaultJsonDeserializer() kwargs: QueryOptionsKwargs = {'deserializer': deserializer} req = request_builder.build_base_query_request(query_statment, **kwargs) @@ -99,11 +95,9 @@ def test_options_deserializer_kwargs(self, query_ctx.validate_query_context(req.body) @pytest.mark.parametrize('max_retries', [5, 10, None]) - def test_options_max_retries(self, - query_statment: str, - request_builder: _RequestBuilder, - query_ctx: QueryContext, - max_retries: Optional[int]) -> None: + def test_options_max_retries( + self, query_statment: str, request_builder: _RequestBuilder, query_ctx: QueryContext, max_retries: Optional[int] + ) -> None: if max_retries is not None: q_opts = QueryOptions(max_retries=max_retries) req = request_builder.build_base_query_request(query_statment, q_opts) @@ -115,11 +109,9 @@ def test_options_max_retries(self, query_ctx.validate_query_context(req.body) @pytest.mark.parametrize('max_retries', [5, 10, None]) - def test_options_max_retries_kwargs(self, - query_statment: str, - request_builder: _RequestBuilder, - query_ctx: QueryContext, - max_retries: Optional[int]) -> None: + def test_options_max_retries_kwargs( + self, query_statment: str, request_builder: _RequestBuilder, query_ctx: QueryContext, max_retries: Optional[int] + ) -> None: if max_retries is not None: kwargs: QueryOptionsKwargs = {'max_retries': max_retries} req = request_builder.build_base_query_request(query_statment, **kwargs) @@ -130,10 +122,9 @@ def test_options_max_retries_kwargs(self, assert req.max_retries == (max_retries if max_retries is not None else 7) query_ctx.validate_query_context(req.body) - def test_options_named_parameters(self, - query_statment: str, - request_builder: _RequestBuilder, - query_ctx: QueryContext) -> None: + def test_options_named_parameters( + self, query_statment: str, request_builder: _RequestBuilder, query_ctx: QueryContext + ) -> None: params: Dict[str, JSONType] = {'foo': 'bar', 'baz': 1, 'quz': False} q_opts = QueryOptions(named_parameters=params) req = request_builder.build_base_query_request(query_statment, q_opts) @@ -141,10 +132,9 @@ def test_options_named_parameters(self, assert req.options == exp_opts query_ctx.validate_query_context(req.body) - def test_options_named_parameters_kwargs(self, - query_statment: str, - request_builder: _RequestBuilder, - query_ctx: QueryContext) -> None: + def test_options_named_parameters_kwargs( + self, query_statment: str, request_builder: _RequestBuilder, query_ctx: QueryContext + ) -> None: params: Dict[str, JSONType] = {'foo': 'bar', 'baz': 1, 'quz': False} kwargs: QueryOptionsKwargs = {'named_parameters': params} req = request_builder.build_base_query_request(query_statment, **kwargs) @@ -152,10 +142,9 @@ def test_options_named_parameters_kwargs(self, assert req.options == exp_opts query_ctx.validate_query_context(req.body) - def test_options_positional_parameters(self, - query_statment: str, - request_builder: _RequestBuilder, - query_ctx: QueryContext) -> None: + def test_options_positional_parameters( + self, query_statment: str, request_builder: _RequestBuilder, query_ctx: QueryContext + ) -> None: params: List[JSONType] = ['foo', 'bar', 1, False] q_opts = QueryOptions(positional_parameters=params) req = request_builder.build_base_query_request(query_statment, q_opts) @@ -163,10 +152,9 @@ def test_options_positional_parameters(self, assert req.options == exp_opts query_ctx.validate_query_context(req.body) - def test_options_positional_parameters_kwargs(self, - query_statment: str, - request_builder: _RequestBuilder, - query_ctx: QueryContext) -> None: + def test_options_positional_parameters_kwargs( + self, query_statment: str, request_builder: _RequestBuilder, query_ctx: QueryContext + ) -> None: params: List[JSONType] = ['foo', 'bar', 1, False] kwargs: QueryOptionsKwargs = {'positional_parameters': params} req = request_builder.build_base_query_request(query_statment, **kwargs) @@ -174,10 +162,7 @@ def test_options_positional_parameters_kwargs(self, assert req.options == exp_opts query_ctx.validate_query_context(req.body) - def test_options_raw(self, - query_statment: str, - request_builder: _RequestBuilder, - query_ctx: QueryContext) -> None: + def test_options_raw(self, query_statment: str, request_builder: _RequestBuilder, query_ctx: QueryContext) -> None: pos_params: List[JSONType] = ['foo', 'bar', 1, False] params: Dict[str, Any] = {'readonly': True, 'positional_params': pos_params} q_opts = QueryOptions(raw=params) @@ -186,10 +171,9 @@ def test_options_raw(self, assert req.options == exp_opts query_ctx.validate_query_context(req.body) - def test_options_raw_kwargs(self, - query_statment: str, - request_builder: _RequestBuilder, - query_ctx: QueryContext) -> None: + def test_options_raw_kwargs( + self, query_statment: str, request_builder: _RequestBuilder, query_ctx: QueryContext + ) -> None: pos_params: List[JSONType] = ['foo', 'bar', 1, False] params: Dict[str, Any] = {'readonly': True, 'positional_params': pos_params} kwargs: QueryOptionsKwargs = {'raw': params} @@ -198,104 +182,88 @@ def test_options_raw_kwargs(self, assert req.options == exp_opts query_ctx.validate_query_context(req.body) - def test_options_readonly(self, - query_statment: str, - request_builder: _RequestBuilder, - query_ctx: QueryContext) -> None: + def test_options_readonly( + self, query_statment: str, request_builder: _RequestBuilder, query_ctx: QueryContext + ) -> None: q_opts = QueryOptions(readonly=True) req = request_builder.build_base_query_request(query_statment, q_opts) exp_opts: QueryOptionsTransformedKwargs = {'readonly': True} assert req.options == exp_opts query_ctx.validate_query_context(req.body) - def test_options_readonly_kwargs(self, - query_statment: str, - request_builder: _RequestBuilder, - query_ctx: QueryContext) -> None: + def test_options_readonly_kwargs( + self, query_statment: str, request_builder: _RequestBuilder, query_ctx: QueryContext + ) -> None: kwargs: QueryOptionsKwargs = {'readonly': True} req = request_builder.build_base_query_request(query_statment, **kwargs) exp_opts: QueryOptionsTransformedKwargs = {'readonly': True} assert req.options == exp_opts query_ctx.validate_query_context(req.body) - def test_options_scan_consistency(self, - query_statment: str, - request_builder: _RequestBuilder, - query_ctx: QueryContext) -> None: + def test_options_scan_consistency( + self, query_statment: str, request_builder: _RequestBuilder, query_ctx: QueryContext + ) -> None: from couchbase_analytics.query import QueryScanConsistency + q_opts = QueryOptions(scan_consistency=QueryScanConsistency.REQUEST_PLUS) req = request_builder.build_base_query_request(query_statment, q_opts) - exp_opts: QueryOptionsTransformedKwargs = { - 'scan_consistency': QueryScanConsistency.REQUEST_PLUS.value - } + exp_opts: QueryOptionsTransformedKwargs = {'scan_consistency': QueryScanConsistency.REQUEST_PLUS.value} assert req.options == exp_opts query_ctx.validate_query_context(req.body) - def test_options_scan_consistency_kwargs(self, - query_statment: str, - request_builder: _RequestBuilder, - query_ctx: QueryContext) -> None: + def test_options_scan_consistency_kwargs( + self, query_statment: str, request_builder: _RequestBuilder, query_ctx: QueryContext + ) -> None: from couchbase_analytics.query import QueryScanConsistency + kwargs: QueryOptionsKwargs = {'scan_consistency': QueryScanConsistency.REQUEST_PLUS} req = request_builder.build_base_query_request(query_statment, **kwargs) - exp_opts: QueryOptionsTransformedKwargs = { - 'scan_consistency': QueryScanConsistency.REQUEST_PLUS.value - } + exp_opts: QueryOptionsTransformedKwargs = {'scan_consistency': QueryScanConsistency.REQUEST_PLUS.value} assert req.options == exp_opts query_ctx.validate_query_context(req.body) - def test_options_timeout(self, - query_statment: str, - request_builder: _RequestBuilder, - query_ctx: QueryContext) -> None: + def test_options_timeout( + self, query_statment: str, request_builder: _RequestBuilder, query_ctx: QueryContext + ) -> None: q_opts = QueryOptions(timeout=timedelta(seconds=20)) req = request_builder.build_base_query_request(query_statment, q_opts) - exp_opts: QueryOptionsTransformedKwargs = { - 'timeout': 20.0 - } + exp_opts: QueryOptionsTransformedKwargs = {'timeout': 20.0} assert req.options == exp_opts # NOTE: we add time to the server timeout to ensure a client side timeout assert req.body['timeout'] == '25000.0ms' query_ctx.validate_query_context(req.body) - def test_options_timeout_kwargs(self, - query_statment: str, - request_builder: _RequestBuilder, - query_ctx: QueryContext) -> None: + def test_options_timeout_kwargs( + self, query_statment: str, request_builder: _RequestBuilder, query_ctx: QueryContext + ) -> None: kwargs: QueryOptionsKwargs = {'timeout': timedelta(seconds=20)} req = request_builder.build_base_query_request(query_statment, **kwargs) - exp_opts: QueryOptionsTransformedKwargs = { - 'timeout': 20.0 - } + exp_opts: QueryOptionsTransformedKwargs = {'timeout': 20.0} assert req.options == exp_opts # NOTE: we add time to the server timeout to ensure a client side timeout assert req.body['timeout'] == '25000.0ms' query_ctx.validate_query_context(req.body) - def test_options_timeout_must_be_positive(self, - query_statment: str, - request_builder: _RequestBuilder - ) -> None: + def test_options_timeout_must_be_positive(self, query_statment: str, request_builder: _RequestBuilder) -> None: q_opts = QueryOptions(timeout=timedelta(seconds=-1)) with pytest.raises(ValueError): request_builder.build_base_query_request(query_statment, q_opts) - def test_options_timeout_must_be_positive_kwargs(self, - query_statment: str, - request_builder: _RequestBuilder - ) -> None: + def test_options_timeout_must_be_positive_kwargs( + self, query_statment: str, request_builder: _RequestBuilder + ) -> None: kwargs: QueryOptionsKwargs = {'timeout': timedelta(seconds=-1)} with pytest.raises(ValueError): request_builder.build_base_query_request(query_statment, **kwargs) class ClusterQueryOptionsTests(QueryOptionsTestSuite): - @pytest.fixture(scope='class', autouse=True) def validate_test_manifest(self) -> None: def valid_test_method(meth: str) -> bool: attr = getattr(ClusterQueryOptionsTests, meth) return callable(attr) and not meth.startswith('__') and meth.startswith('test') + method_list = [meth for meth in dir(ClusterQueryOptionsTests) if valid_test_method(meth)] test_list = set(QueryOptionsTestSuite.TEST_MANIFEST).symmetric_difference(method_list) if test_list: @@ -312,12 +280,12 @@ def request_builder(self) -> _RequestBuilder: class ScopeQueryOptionsTests(QueryOptionsTestSuite): - @pytest.fixture(scope='class', autouse=True) def validate_test_manifest(self) -> None: def valid_test_method(meth: str) -> bool: attr = getattr(ScopeQueryOptionsTests, meth) return callable(attr) and not meth.startswith('__') and meth.startswith('test') + method_list = [meth for meth in dir(ScopeQueryOptionsTests) if valid_test_method(meth)] test_list = set(QueryOptionsTestSuite.TEST_MANIFEST).symmetric_difference(method_list) if test_list: @@ -330,6 +298,4 @@ def query_context(self) -> QueryContext: @pytest.fixture(scope='class') def request_builder(self) -> _RequestBuilder: cred = Credential.from_username_and_password('Administrator', 'password') - return _RequestBuilder(_AsyncClientAdapter('https://localhost', cred), - 'test-database', - 'test-scope') + return _RequestBuilder(_AsyncClientAdapter('https://localhost', cred), 'test-database', 'test-scope') diff --git a/acouchbase_analytics/tests/test_server_t.py b/acouchbase_analytics/tests/test_server_t.py index bb5da8d..e7ac690 100644 --- a/acouchbase_analytics/tests/test_server_t.py +++ b/acouchbase_analytics/tests/test_server_t.py @@ -20,24 +20,17 @@ import pytest -from acouchbase_analytics.errors import (AnalyticsError, - InvalidCredentialError, - QueryError, - TimeoutError) +from acouchbase_analytics.errors import AnalyticsError, InvalidCredentialError, QueryError, TimeoutError from acouchbase_analytics.options import QueryOptions from acouchbase_analytics.result import AsyncQueryResult from tests import AsyncYieldFixture -from tests.test_server import (ErrorType, - NonRetriableSpecificationType, - ResultType, - RetriableGroupType) +from tests.test_server import ErrorType, NonRetriableSpecificationType, ResultType, RetriableGroupType if TYPE_CHECKING: from tests.environments.base_environment import AsyncTestEnvironment class TestServerTestSuite: - TEST_MANIFEST = [ 'test_auth_error_unauthorized', 'test_auth_error_insufficient_permissions', @@ -47,7 +40,7 @@ class TestServerTestSuite: 'test_error_retriable_http503', 'test_error_timeout', 'test_results_object_values', - 'test_results_raw_values' + 'test_results_raw_values', ] async def test_auth_error_unauthorized(self, test_env: AsyncTestEnvironment) -> None: @@ -70,23 +63,32 @@ async def test_auth_error_insufficient_permissions(self, test_env: AsyncTestEnvi test_env.assert_error_context_num_attempts(1, ex.value._context) test_env.assert_error_context_contains_last_dispatch(ex.value._context) - @pytest.mark.parametrize('retry_group_type', - [RetriableGroupType.Zero, - RetriableGroupType.First, - RetriableGroupType.Middle, - RetriableGroupType.Last]) - @pytest.mark.parametrize('non_retriable_spec', - [NonRetriableSpecificationType.AllEmpty, - NonRetriableSpecificationType.AllFalse, - NonRetriableSpecificationType.Random]) - async def test_error_non_retriable_response(self, - test_env: AsyncTestEnvironment, - retry_group_type: RetriableGroupType, - non_retriable_spec: NonRetriableSpecificationType) -> None: + @pytest.mark.parametrize( + 'retry_group_type', + [RetriableGroupType.Zero, RetriableGroupType.First, RetriableGroupType.Middle, RetriableGroupType.Last], + ) + @pytest.mark.parametrize( + 'non_retriable_spec', + [ + NonRetriableSpecificationType.AllEmpty, + NonRetriableSpecificationType.AllFalse, + NonRetriableSpecificationType.Random, + ], + ) + async def test_error_non_retriable_response( + self, + test_env: AsyncTestEnvironment, + retry_group_type: RetriableGroupType, + non_retriable_spec: NonRetriableSpecificationType, + ) -> None: test_env.set_url_path('/test_error') - test_env.update_request_json({'error_type': ErrorType.Retriable.value, - 'retry_group_type': retry_group_type.value, - 'non_retriable_spec': non_retriable_spec.value}) + test_env.update_request_json( + { + 'error_type': ErrorType.Retriable.value, + 'retry_group_type': retry_group_type.value, + 'non_retriable_spec': non_retriable_spec.value, + } + ) statement = 'SELECT "Hello, data!" AS greeting' with pytest.raises(QueryError) as ex: await test_env.cluster_or_scope.execute_query(statement) @@ -95,20 +97,24 @@ async def test_error_non_retriable_response(self, async def test_error_retriable_response_timeout(self, test_env: AsyncTestEnvironment) -> None: test_env.set_url_path('/test_error') - test_env.update_request_json({'error_type': ErrorType.Retriable.value, - 'retry_group_type': RetriableGroupType.All.value}) + test_env.update_request_json( + {'error_type': ErrorType.Retriable.value, 'retry_group_type': RetriableGroupType.All.value} + ) statement = 'SELECT "Hello, data!" AS greeting' with pytest.raises(TimeoutError) as ex: # just-in-case, increase the max_retries to ensure we hit the timeout - await test_env.cluster_or_scope.execute_query(statement, QueryOptions(max_retries=10, timeout=timedelta(seconds=1.5))) + await test_env.cluster_or_scope.execute_query( + statement, QueryOptions(max_retries=10, timeout=timedelta(seconds=1.5)) + ) - test_env.assert_error_context_num_attempts(4 , ex.value._context, exact=False) + test_env.assert_error_context_num_attempts(4, ex.value._context, exact=False) test_env.assert_error_context_contains_last_dispatch(ex.value._context) async def test_error_retriable_response_retries_exceeded(self, test_env: AsyncTestEnvironment) -> None: test_env.set_url_path('/test_error') - test_env.update_request_json({'error_type': ErrorType.Retriable.value, - 'retry_group_type': RetriableGroupType.All.value}) + test_env.update_request_json( + {'error_type': ErrorType.Retriable.value, 'retry_group_type': RetriableGroupType.All.value} + ) statement = 'SELECT "Hello, data!" AS greeting' allowed_retries = 5 q_opts = QueryOptions(max_retries=allowed_retries, timeout=timedelta(seconds=10)) @@ -116,14 +122,13 @@ async def test_error_retriable_response_retries_exceeded(self, test_env: AsyncTe await test_env.cluster_or_scope.execute_query(statement, q_opts) print(ex.value) - test_env.assert_error_context_num_attempts(allowed_retries+1 , ex.value._context) + test_env.assert_error_context_num_attempts(allowed_retries + 1, ex.value._context) test_env.assert_error_context_contains_last_dispatch(ex.value._context) @pytest.mark.parametrize('analytics_error', [False, True]) async def test_error_retriable_http503(self, test_env: AsyncTestEnvironment, analytics_error: bool) -> None: test_env.set_url_path('/test_error') - test_env.update_request_json({'error_type': ErrorType.Http503.value, - 'analytics_error': analytics_error}) + test_env.update_request_json({'error_type': ErrorType.Http503.value, 'analytics_error': analytics_error}) statement = 'SELECT "Hello, data!" AS greeting' allowed_retries = 5 q_opts = QueryOptions(max_retries=allowed_retries, timeout=timedelta(seconds=10)) @@ -135,7 +140,7 @@ async def test_error_retriable_http503(self, test_env: AsyncTestEnvironment, ana with pytest.raises(AnalyticsError) as ex: await test_env.cluster_or_scope.execute_query(statement, q_opts) - test_env.assert_error_context_num_attempts(allowed_retries+1 , ex.value._context) + test_env.assert_error_context_num_attempts(allowed_retries + 1, ex.value._context) test_env.assert_error_context_contains_last_dispatch(ex.value._context) @pytest.mark.parametrize('server_side', [False, True]) @@ -157,69 +162,67 @@ async def test_error_timeout(self, test_env: AsyncTestEnvironment, server_side: test_env.assert_error_context_missing_last_dispatch(ex.value._context) @pytest.mark.parametrize('stream', [False, True]) - async def test_results_object_values(self, - test_env: AsyncTestEnvironment, - stream: bool) -> None: + async def test_results_object_values(self, test_env: AsyncTestEnvironment, stream: bool) -> None: expected_rows = 50 test_env.set_url_path('/test_results') - test_env.update_request_json({'result_type': ResultType.Object.value, - 'row_count': expected_rows, - 'stream': stream}) + test_env.update_request_json( + {'result_type': ResultType.Object.value, 'row_count': expected_rows, 'stream': stream} + ) statement = 'SELECT "Hello, data!" AS greeting' result = await test_env.cluster_or_scope.execute_query(statement) assert isinstance(result, AsyncQueryResult) await test_env.assert_rows(result, expected_rows) @pytest.mark.parametrize('stream', [False, True]) - async def test_results_raw_values(self, - test_env: AsyncTestEnvironment, - stream: bool) -> None: + async def test_results_raw_values(self, test_env: AsyncTestEnvironment, stream: bool) -> None: expected_rows = 50 test_env.set_url_path('/test_results') - test_env.update_request_json({'result_type': ResultType.Raw.value, - 'row_count': expected_rows, - 'stream': stream}) + test_env.update_request_json( + {'result_type': ResultType.Raw.value, 'row_count': expected_rows, 'stream': stream} + ) statement = 'SELECT "Hello, data!" AS greeting' result = await test_env.cluster_or_scope.execute_query(statement) assert isinstance(result, AsyncQueryResult) await test_env.assert_rows(result, expected_rows) -class ClusterTestServerTests(TestServerTestSuite): +class ClusterTestServerTests(TestServerTestSuite): @pytest.fixture(scope='class', autouse=True) def validate_test_manifest(self) -> None: def valid_test_method(meth: str) -> bool: attr = getattr(ClusterTestServerTests, meth) return callable(attr) and not meth.startswith('__') and meth.startswith('test') + method_list = [meth for meth in dir(ClusterTestServerTests) if valid_test_method(meth)] test_list = set(TestServerTestSuite.TEST_MANIFEST).symmetric_difference(method_list) if test_list: pytest.fail(f'Test manifest invalid. Missing/extra tests: {test_list}.') @pytest.fixture(scope='class', name='test_env') - async def couchbase_test_environment(self, - async_test_env_with_server: AsyncTestEnvironment - ) -> AsyncYieldFixture[AsyncTestEnvironment]: + async def couchbase_test_environment( + self, async_test_env_with_server: AsyncTestEnvironment + ) -> AsyncYieldFixture[AsyncTestEnvironment]: test_env = await async_test_env_with_server.enable_test_server() yield test_env test_env.disable_test_server() -class ScopeTestServerTests(TestServerTestSuite): +class ScopeTestServerTests(TestServerTestSuite): @pytest.fixture(scope='class', autouse=True) def validate_test_manifest(self) -> None: def valid_test_method(meth: str) -> bool: attr = getattr(ScopeTestServerTests, meth) return callable(attr) and not meth.startswith('__') and meth.startswith('test') + method_list = [meth for meth in dir(ScopeTestServerTests) if valid_test_method(meth)] test_list = set(TestServerTestSuite.TEST_MANIFEST).symmetric_difference(method_list) if test_list: pytest.fail(f'Test manifest invalid. Missing/extra tests: {test_list}.') @pytest.fixture(scope='class', name='test_env') - async def couchbase_test_environment(self, - async_test_env_with_server: AsyncTestEnvironment - ) -> AsyncYieldFixture[AsyncTestEnvironment]: + async def couchbase_test_environment( + self, async_test_env_with_server: AsyncTestEnvironment + ) -> AsyncYieldFixture[AsyncTestEnvironment]: test_env = await async_test_env_with_server.enable_test_server() test_env.enable_scope() yield test_env diff --git a/couchbase_analytics/_version.py b/couchbase_analytics/_version.py index a34413d..d5700e9 100644 --- a/couchbase_analytics/_version.py +++ b/couchbase_analytics/_version.py @@ -1,5 +1,5 @@ # This file automatically generated by # /Users/jaredcasey/GIT/couchbase/clients/python/analytics-python-client/./couchbase_analytics_version.py # at -# 2025-07-09 12:11:48.524648 -__version__ = '0.0.1' +# 2025-07-09 14:23:23.011656 +__version__ = '1.0.0.dev1' diff --git a/couchbase_analytics/cluster.py b/couchbase_analytics/cluster.py index 100f08e..8c728b9 100644 --- a/couchbase_analytics/cluster.py +++ b/couchbase_analytics/cluster.py @@ -16,9 +16,7 @@ from __future__ import annotations from concurrent.futures import Future -from typing import (TYPE_CHECKING, - Optional, - Union) +from typing import TYPE_CHECKING, Optional, Union from couchbase_analytics.database import Database from couchbase_analytics.result import BlockingQueryResult @@ -51,12 +49,11 @@ class Cluster: """ # noqa: E501 - def __init__(self, - http_endpoint: str, - credential: Credential, - options: Optional[ClusterOptions] = None, - **kwargs: object) -> None: + def __init__( + self, http_endpoint: str, credential: Credential, options: Optional[ClusterOptions] = None, **kwargs: object + ) -> None: from couchbase_analytics.protocol.cluster import Cluster as _Cluster + self._impl = _Cluster(http_endpoint, credential, options, **kwargs) def database(self, name: str) -> Database: @@ -74,10 +71,9 @@ def database(self, name: str) -> Database: """ return Database(self._impl, name) - def execute_query(self, - statement: str, - *args: object, - **kwargs: object) -> Union[Future[BlockingQueryResult], BlockingQueryResult]: + def execute_query( + self, statement: str, *args: object, **kwargs: object + ) -> Union[Future[BlockingQueryResult], BlockingQueryResult]: """Executes a query against an Analytics cluster. .. note:: @@ -154,11 +150,9 @@ def shutdown(self) -> None: return self._impl.shutdown() @classmethod - def create_instance(cls, - http_endpoint: str, - credential: Credential, - options: Optional[ClusterOptions] = None, - **kwargs: object) -> Cluster: + def create_instance( + cls, http_endpoint: str, credential: Credential, options: Optional[ClusterOptions] = None, **kwargs: object + ) -> Cluster: """Create a Cluster instance Args: diff --git a/couchbase_analytics/cluster.pyi b/couchbase_analytics/cluster.pyi index 6d61e43..9dcbfba 100644 --- a/couchbase_analytics/cluster.pyi +++ b/couchbase_analytics/cluster.pyi @@ -25,178 +25,109 @@ else: from couchbase_analytics import JSONType from couchbase_analytics.credential import Credential from couchbase_analytics.database import Database -from couchbase_analytics.options import (ClusterOptions, - ClusterOptionsKwargs, - QueryOptions, - QueryOptionsKwargs) +from couchbase_analytics.options import ClusterOptions, ClusterOptionsKwargs, QueryOptions, QueryOptionsKwargs from couchbase_analytics.result import BlockingQueryResult class Cluster: @overload def __init__(self, http_endpoint: str, credential: Credential) -> None: ... - @overload - def __init__(self, - http_endpoint: str, - credential: Credential, - options: ClusterOptions) -> None: ... - + def __init__(self, http_endpoint: str, credential: Credential, options: ClusterOptions) -> None: ... @overload - def __init__(self, - http_endpoint: str, - credential: Credential, - **kwargs: Unpack[ClusterOptionsKwargs]) -> None: ... - + def __init__(self, http_endpoint: str, credential: Credential, **kwargs: Unpack[ClusterOptionsKwargs]) -> None: ... @overload - def __init__(self, - http_endpoint: str, - credential: Credential, - options: ClusterOptions, - **kwargs: Unpack[ClusterOptionsKwargs]) -> None: ... - + def __init__( + self, + http_endpoint: str, + credential: Credential, + options: ClusterOptions, + **kwargs: Unpack[ClusterOptionsKwargs], + ) -> None: ... def database(self, name: str) -> Database: ... - @overload def execute_query(self, statement: str) -> BlockingQueryResult: ... - @overload - def execute_query(self, - statement: str, - options: QueryOptions) -> BlockingQueryResult: ... - + def execute_query(self, statement: str, options: QueryOptions) -> BlockingQueryResult: ... @overload - def execute_query(self, - statement: str, - **kwargs: Unpack[QueryOptionsKwargs]) -> BlockingQueryResult: ... - + def execute_query(self, statement: str, **kwargs: Unpack[QueryOptionsKwargs]) -> BlockingQueryResult: ... @overload - def execute_query(self, - statement: str, - options: QueryOptions, - **kwargs: Unpack[QueryOptionsKwargs] - ) -> BlockingQueryResult: ... - + def execute_query( + self, statement: str, options: QueryOptions, **kwargs: Unpack[QueryOptionsKwargs] + ) -> BlockingQueryResult: ... @overload - def execute_query(self, - statement: str, - options: QueryOptions, - *args: JSONType, - **kwargs: Unpack[QueryOptionsKwargs]) -> BlockingQueryResult: ... - + def execute_query( + self, statement: str, options: QueryOptions, *args: JSONType, **kwargs: Unpack[QueryOptionsKwargs] + ) -> BlockingQueryResult: ... @overload - def execute_query(self, - statement: str, - options: QueryOptions, - *args: JSONType, - **kwargs: str) -> BlockingQueryResult: ... - + def execute_query( + self, statement: str, options: QueryOptions, *args: JSONType, **kwargs: str + ) -> BlockingQueryResult: ... @overload - def execute_query(self, - statement: str, - *args: JSONType, - **kwargs: str) -> BlockingQueryResult: ... - + def execute_query(self, statement: str, *args: JSONType, **kwargs: str) -> BlockingQueryResult: ... @overload - def execute_query(self, - statement: str, - enable_cancel: bool) -> Future[BlockingQueryResult]: ... - + def execute_query(self, statement: str, enable_cancel: bool) -> Future[BlockingQueryResult]: ... @overload - def execute_query(self, - statement: str, - enable_cancel: bool, - *args: JSONType) -> Future[BlockingQueryResult]: ... - + def execute_query(self, statement: str, enable_cancel: bool, *args: JSONType) -> Future[BlockingQueryResult]: ... @overload - def execute_query(self, - statement: str, - options: QueryOptions, - enable_cancel: bool) -> Future[BlockingQueryResult]: ... - + def execute_query( + self, statement: str, options: QueryOptions, enable_cancel: bool + ) -> Future[BlockingQueryResult]: ... @overload - def execute_query(self, - statement: str, - enable_cancel: bool, - **kwargs: Unpack[QueryOptionsKwargs]) -> Future[BlockingQueryResult]: ... - + def execute_query( + self, statement: str, enable_cancel: bool, **kwargs: Unpack[QueryOptionsKwargs] + ) -> Future[BlockingQueryResult]: ... @overload - def execute_query(self, - statement: str, - options: QueryOptions, - enable_cancel: bool, - **kwargs: Unpack[QueryOptionsKwargs]) -> Future[BlockingQueryResult]: ... - + def execute_query( + self, statement: str, options: QueryOptions, enable_cancel: bool, **kwargs: Unpack[QueryOptionsKwargs] + ) -> Future[BlockingQueryResult]: ... @overload - def execute_query(self, - statement: str, - options: QueryOptions, - enable_cancel: bool, - *args: JSONType, - **kwargs: Unpack[QueryOptionsKwargs]) -> Future[BlockingQueryResult]: ... - + def execute_query( + self, + statement: str, + options: QueryOptions, + enable_cancel: bool, + *args: JSONType, + **kwargs: Unpack[QueryOptionsKwargs], + ) -> Future[BlockingQueryResult]: ... @overload - def execute_query(self, - statement: str, - options: QueryOptions, - *args: JSONType, - enable_cancel: bool, - **kwargs: Unpack[QueryOptionsKwargs]) -> Future[BlockingQueryResult]: ... - - + def execute_query( + self, + statement: str, + options: QueryOptions, + *args: JSONType, + enable_cancel: bool, + **kwargs: Unpack[QueryOptionsKwargs], + ) -> Future[BlockingQueryResult]: ... @overload - def execute_query(self, - statement: str, - options: QueryOptions, - enable_cancel: bool, - *args: JSONType, - **kwargs: str) -> Future[BlockingQueryResult]: ... - + def execute_query( + self, statement: str, options: QueryOptions, enable_cancel: bool, *args: JSONType, **kwargs: str + ) -> Future[BlockingQueryResult]: ... @overload - def execute_query(self, - statement: str, - options: QueryOptions, - *args: JSONType, - enable_cancel: bool, - **kwargs: str) -> Future[BlockingQueryResult]: ... - + def execute_query( + self, statement: str, options: QueryOptions, *args: JSONType, enable_cancel: bool, **kwargs: str + ) -> Future[BlockingQueryResult]: ... @overload - def execute_query(self, - statement: str, - enable_cancel: bool, - *args: JSONType, - **kwargs: str) -> Future[BlockingQueryResult]: ... - + def execute_query( + self, statement: str, enable_cancel: bool, *args: JSONType, **kwargs: str + ) -> Future[BlockingQueryResult]: ... @overload - def execute_query(self, - statement: str, - *args: JSONType, - enable_cancel: bool, - **kwargs: str) -> Future[BlockingQueryResult]: ... - + def execute_query( + self, statement: str, *args: JSONType, enable_cancel: bool, **kwargs: str + ) -> Future[BlockingQueryResult]: ... def shutdown(self) -> None: ... - @overload @classmethod def create_instance(cls, http_endpoint: str, credential: Credential) -> Cluster: ... - @overload @classmethod - def create_instance(cls, - http_endpoint: str, - credential: Credential, - options: ClusterOptions) -> Cluster: ... - + def create_instance(cls, http_endpoint: str, credential: Credential, options: ClusterOptions) -> Cluster: ... @overload @classmethod - def create_instance(cls, - http_endpoint: str, - credential: Credential, - **kwargs: Unpack[ClusterOptionsKwargs]) -> Cluster: ... - + def create_instance( + cls, http_endpoint: str, credential: Credential, **kwargs: Unpack[ClusterOptionsKwargs] + ) -> Cluster: ... @overload @classmethod - def create_instance(cls, - http_endpoint: str, - credential: Credential, - options: ClusterOptions, - **kwargs: Unpack[ClusterOptionsKwargs]) -> Cluster: ... + def create_instance( + cls, http_endpoint: str, credential: Credential, options: ClusterOptions, **kwargs: Unpack[ClusterOptionsKwargs] + ) -> Cluster: ... diff --git a/couchbase_analytics/common/__init__.py b/couchbase_analytics/common/__init__.py index 04ab2ad..962f59f 100644 --- a/couchbase_analytics/common/__init__.py +++ b/couchbase_analytics/common/__init__.py @@ -13,9 +13,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import (Any, - Dict, - List, - Union) +from typing import Any, Dict, List, Union JSONType = Union[str, int, float, bool, None, Dict[str, Any], List[Any]] diff --git a/couchbase_analytics/common/_core/_certificates.py b/couchbase_analytics/common/_core/_certificates.py index c4dd02c..e0e4d2a 100644 --- a/couchbase_analytics/common/_core/_certificates.py +++ b/couchbase_analytics/common/_core/_certificates.py @@ -62,6 +62,7 @@ def get_nonprod_certificates() -> List[str]: List[str]: List of nonprod Capella certificates. """ import warnings + warnings.warn('Only use non-prod certificate in DEVELOPMENT environments.', ResourceWarning, stacklevel=2) nonprod_cert_dir = Path(Path(__file__).resolve().parent, '_nonprod_certificates') nonprod_certs: List[str] = [] diff --git a/couchbase_analytics/common/_core/duration_str_utils.py b/couchbase_analytics/common/_core/duration_str_utils.py index 9cd8055..5b582d0 100644 --- a/couchbase_analytics/common/_core/duration_str_utils.py +++ b/couchbase_analytics/common/_core/duration_str_utils.py @@ -23,6 +23,7 @@ DURATION_PATTERN = re.compile(r'^([-+]?)((\d*(\.\d*)?){1}(?:ns|us|µs|μs|ms|s|m|h){1})+$') DURATION_PAIRS_PATTERN = re.compile(r'(\d*(?:\.\d*)?)(ns|us|ms|s|m|h)') + def check_valid_duration_str(duration_str: str) -> None: """ Validates if the given string is a valid duration string. @@ -48,7 +49,8 @@ def check_valid_duration_str(duration_str: str) -> None: if not match: raise ValueError('Duration string has invalid format') -def parse_duration_str(duration_str: str, in_millis: Optional[bool]=False) -> float: + +def parse_duration_str(duration_str: str, in_millis: Optional[bool] = False) -> float: check_valid_duration_str(duration_str) # Special case: "0" duration @@ -63,9 +65,9 @@ def parse_duration_str(duration_str: str, in_millis: Optional[bool]=False) -> fl 'ns': 1e-9, # nanoseconds 'us': 1e-6, # microseconds 'ms': 1e-3, # milliseconds - 's': 1.0, # seconds - 'm': 60.0, # minutes - 'h': 3600.0 # hours + 's': 1.0, # seconds + 'm': 60.0, # minutes + 'h': 3600.0, # hours } segments = DURATION_PAIRS_PATTERN.findall(duration_str) @@ -75,11 +77,13 @@ def parse_duration_str(duration_str: str, in_millis: Optional[bool]=False) -> fl value = float(num_str) total_seconds += value * unit_multipliers[unit_str] except OverflowError as e: - raise ValueError((f'Invalid duration. Overflow error while parsing number "{num_str}{unit_str}". ' - f'Error details: {e}')) from None + raise ValueError( + (f'Invalid duration. Overflow error while parsing number "{num_str}{unit_str}". Error details: {e}') + ) from None except ValueError as e: - raise ValueError((f'Invalid duration. Parsing error while parsing number "{num_str}{unit_str}". ' - f'Error details: {e}')) from None + raise ValueError( + (f'Invalid duration. Parsing error while parsing number "{num_str}{unit_str}". Error details: {e}') + ) from None except KeyError: raise ValueError(f'Invalid duration. Unknown unit "{unit_str}"') from None diff --git a/couchbase_analytics/common/_core/error_context.py b/couchbase_analytics/common/_core/error_context.py index b24bd09..4722ffc 100644 --- a/couchbase_analytics/common/_core/error_context.py +++ b/couchbase_analytics/common/_core/error_context.py @@ -16,10 +16,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import (Any, - Dict, - List, - Optional) +from typing import Any, Dict, List, Optional from httpx import Response as HttpCoreResponse diff --git a/couchbase_analytics/common/_core/json_parsing.py b/couchbase_analytics/common/_core/json_parsing.py index 0c17179..43a1c4c 100644 --- a/couchbase_analytics/common/_core/json_parsing.py +++ b/couchbase_analytics/common/_core/json_parsing.py @@ -23,6 +23,7 @@ # passing in a chunk_size is only applying an abstraction over the httpcore stream DEFAULT_HTTP_STREAM_BUFFER_SIZE = 2**16 + @dataclass class JsonStreamConfig: http_stream_buffer_size: int = DEFAULT_HTTP_STREAM_BUFFER_SIZE @@ -36,14 +37,17 @@ class ParsedResultType(IntEnum): """ **INTERNAL** """ + ROW = 0 ERROR = 1 END = 2 UNKNOWN = 3 + class ParsedResult(NamedTuple): """ **INTERNAL** """ + value: Optional[bytes] result_type: ParsedResultType diff --git a/couchbase_analytics/common/_core/json_token_parser_base.py b/couchbase_analytics/common/_core/json_token_parser_base.py index 97a17db..029c164 100644 --- a/couchbase_analytics/common/_core/json_token_parser_base.py +++ b/couchbase_analytics/common/_core/json_token_parser_base.py @@ -17,9 +17,7 @@ from collections import deque from enum import Enum -from typing import (Deque, - NamedTuple, - Optional) +from typing import Deque, NamedTuple, Optional class ParsingState(Enum): @@ -56,6 +54,7 @@ class TokenState(Enum): def __str__(self) -> str: return self.value + class TokenType(Enum): START_MAP = 'start_map' END_MAP = 'end_map' @@ -83,17 +82,21 @@ def from_str(cls, value: str) -> TokenType: def __str__(self) -> str: return self.value + class Token(NamedTuple): type: TokenType value: str - state: Optional[TokenState]=None + state: Optional[TokenState] = None -VALUE_TOKENS = [TokenType.STRING, - TokenType.BOOLEAN, - TokenType.NULL, - TokenType.INTEGER, - TokenType.DOUBLE, - TokenType.NUMBER] + +VALUE_TOKENS = [ + TokenType.STRING, + TokenType.BOOLEAN, + TokenType.NULL, + TokenType.INTEGER, + TokenType.DOUBLE, + TokenType.NUMBER, +] EVENT_TOKENS = { TokenType.START_ARRAY: Token(TokenType.START_ARRAY, '['), @@ -106,9 +109,12 @@ class Token(NamedTuple): START_EVENTS = [TokenType.START_ARRAY, TokenType.START_MAP] -START_EVENT_TRANSITION_STATES = [ParsingState.START_RESULTS_PROCESSING, - ParsingState.START_ERRORS_PROCESSING, - ParsingState.PROCESSING_RESULTS] +START_EVENT_TRANSITION_STATES = [ + ParsingState.START_RESULTS_PROCESSING, + ParsingState.START_ERRORS_PROCESSING, + ParsingState.PROCESSING_RESULTS, +] + class JsonTokenParserBase: def __init__(self, emit_results_enabled: bool) -> None: @@ -156,7 +162,7 @@ def _handle_map_key_token(self, value: str) -> None: self._previous_state = ParsingState.PROCESSING self._push(TokenType.MAP_KEY, f'"{value}"') - def _handle_pop_transition(self, token_state: Optional[TokenState]=None) -> bool: + def _handle_pop_transition(self, token_state: Optional[TokenState] = None) -> bool: if token_state is not None: if token_state == TokenState.RESULTS_START: self._previous_state = self._state @@ -224,7 +230,7 @@ def _handle_value_token(self, token_type: TokenType, value: str) -> Optional[str self._push(TokenType.VALUE, val) return None - def _push(self, token_type: TokenType, value: str, transition: Optional[bool]=False) -> None: + def _push(self, token_type: TokenType, value: str, transition: Optional[bool] = False) -> None: token_state = None if transition is True: token_state = self._handle_push_transition() @@ -239,9 +245,11 @@ def _pop(self) -> Token: def _should_push_pair(self, token: Token) -> bool: # when a results object is complete, the state will have transactioned back to PROCESSING # if we are not emitting rows or errors, we want to keep the results/errors object on the stack - if (self._previous_state == ParsingState.PROCESSING_RESULTS + if ( + self._previous_state == ParsingState.PROCESSING_RESULTS and self._state == ParsingState.PROCESSING - and self._emit_results_enabled is False): + and self._emit_results_enabled is False + ): return True # the initial results object token will have a state of RESULTS_START diff --git a/couchbase_analytics/common/_core/query.py b/couchbase_analytics/common/_core/query.py index 7849580..2e798bd 100644 --- a/couchbase_analytics/common/_core/query.py +++ b/couchbase_analytics/common/_core/query.py @@ -16,17 +16,14 @@ from __future__ import annotations import json -from typing import (Any, - List, - Optional, - TypedDict) +from typing import Any, List, Optional, TypedDict from couchbase_analytics.common._core.duration_str_utils import parse_duration_str class QueryMetricsCore(TypedDict, total=False): """ - **INTERNAL** + **INTERNAL** """ elapsed_time: float @@ -42,7 +39,7 @@ class QueryMetricsCore(TypedDict, total=False): class QueryWarningCore(TypedDict, total=False): """ - **INTERNAL** + **INTERNAL** """ code: int @@ -51,7 +48,7 @@ class QueryWarningCore(TypedDict, total=False): class QueryMetadataCore(TypedDict, total=False): """ - **INTERNAL** + **INTERNAL** """ request_id: str @@ -61,8 +58,7 @@ class QueryMetadataCore(TypedDict, total=False): status: Optional[str] -def build_query_metadata(json_data: Optional[Any]=None, - raw_metadata: Optional[bytes]=None) -> QueryMetadataCore: +def build_query_metadata(json_data: Optional[Any] = None, raw_metadata: Optional[bytes] = None) -> QueryMetadataCore: """ Builds the query metadata from the raw bytes. @@ -83,11 +79,13 @@ def build_query_metadata(json_data: Optional[Any]=None, warnings: List[QueryWarningCore] = [] for warning in json_data.get('warnings', []): - warnings.append({'code':warning.get('code', 0), 'message': warning.get('msg', '')}) + warnings.append({'code': warning.get('code', 0), 'message': warning.get('msg', '')}) - metadata: QueryMetadataCore = {'request_id':json_data.get('requestID', ''), - 'client_context_id':json_data.get('clientContextID', ''), - 'warnings':warnings} + metadata: QueryMetadataCore = { + 'request_id': json_data.get('requestID', ''), + 'client_context_id': json_data.get('clientContextID', ''), + 'warnings': warnings, + } # TODO: include status in metadata?? Seems to only be populated in error scenario if 'status' in json_data: @@ -106,7 +104,7 @@ def build_query_metadata(json_data: Optional[Any]=None, 'result_size': json_data['metrics'].get('resultSize', 0), 'processed_objects': json_data['metrics'].get('processedObjects', 0), 'buffer_cache_hit_ratio': json_data['metrics'].get('bufferCacheHitRatio', ''), - 'buffer_cache_page_read_count': json_data['metrics'].get('bufferCachePageReadCount', 0) + 'buffer_cache_page_read_count': json_data['metrics'].get('bufferCachePageReadCount', 0), } metadata['metrics'] = metrics diff --git a/couchbase_analytics/common/_core/result.py b/couchbase_analytics/common/_core/result.py index 8cbd521..018b8c5 100644 --- a/couchbase_analytics/common/_core/result.py +++ b/couchbase_analytics/common/_core/result.py @@ -17,11 +17,7 @@ import sys from abc import ABC, abstractmethod -from typing import (Any, - Coroutine, - List, - Optional, - Union) +from typing import Any, Coroutine, List, Optional, Union if sys.version_info < (3, 9): from typing import AsyncIterator as PyAsyncIterator diff --git a/couchbase_analytics/common/_core/utils.py b/couchbase_analytics/common/_core/utils.py index fa53189..8c785c4 100644 --- a/couchbase_analytics/common/_core/utils.py +++ b/couchbase_analytics/common/_core/utils.py @@ -18,13 +18,7 @@ from datetime import timedelta from enum import Enum from os import path -from typing import (Any, - Dict, - Generic, - List, - Optional, - TypeVar, - Union) +from typing import Any, Dict, Generic, List, Optional, TypeVar, Union from couchbase_analytics.common.deserializer import Deserializer @@ -38,7 +32,7 @@ def is_null_or_empty(value: Optional[str]) -> bool: def timedelta_as_seconds(duration: timedelta) -> int: if duration and not isinstance(duration, timedelta): - raise ValueError(f"Expected timedelta instead of {duration}") + raise ValueError(f'Expected timedelta instead of {duration}') if duration.total_seconds() < 0: raise ValueError('Timeout must be non-negative.') return int(duration.total_seconds() if duration else 0) @@ -46,7 +40,7 @@ def timedelta_as_seconds(duration: timedelta) -> int: def to_microseconds(value: Union[timedelta, float, int]) -> int: if value and not isinstance(value, (timedelta, float, int)): - raise ValueError(f"Excepted value to be of type Union[timedelta, float, int] instead of {value}") + raise ValueError(f'Excepted value to be of type Union[timedelta, float, int] instead of {value}') if not value: total_us = 0 elif isinstance(value, timedelta): @@ -60,6 +54,7 @@ def to_microseconds(value: Union[timedelta, float, int]) -> int: return total_us + def to_seconds(value: Union[timedelta, float, int]) -> float: if value and not isinstance(value, (timedelta, float, int)): raise ValueError(f'Excepted value to be of type Union[timedelta, float, int] instead of {type(value)}') @@ -79,31 +74,35 @@ def to_seconds(value: Union[timedelta, float, int]) -> float: def validate_raw_dict(value: Dict[str, Any]) -> Dict[str, Any]: if not isinstance(value, dict): - raise ValueError("Raw option must be of type Dict[str, Any].") + raise ValueError('Raw option must be of type Dict[str, Any].') if not all((isinstance(k, str) for k in value.keys())): - raise ValueError("All keys in raw dict must be a str.") + raise ValueError('All keys in raw dict must be a str.') return value def validate_path(value: str) -> str: if not isinstance(value, str): - raise ValueError("Path option must be str.") + raise ValueError('Path option must be str.') if not path.exists(value): - raise FileNotFoundError("Provided path does not exist.") + raise FileNotFoundError('Provided path does not exist.') return value class ValidateBaseClass(Generic[T]): - """ **INTERNAL** """ + """**INTERNAL**""" def __call__(self, value: Any) -> T: expected_base_class = self.__orig_class__.__args__[0] # type: ignore[attr-defined] # this will pass w/ duck-typing which is okay if not issubclass(value.__class__, expected_base_class): - raise ValueError((f"Expected value to be subclass of {expected_base_class} " - "(or implement necessary functionality for the " - f"{expected_base_class} base class).")) + raise ValueError( + ( + f'Expected value to be subclass of {expected_base_class} ' + '(or implement necessary functionality for the ' + f'{expected_base_class} base class).' + ) + ) return value # type: ignore[no-any-return] @@ -118,7 +117,7 @@ def __call__(self, value: Any) -> str: raise ValueError(f"Invalid str representation of {expected_type}. Received '{value}'.") if not isinstance(value, expected_type): - raise ValueError(f"Expected value to be of type {expected_type} instead of {type(value)}") + raise ValueError(f'Expected value to be of type {expected_type} instead of {type(value)}') return value.value # type: ignore[no-any-return] @@ -127,7 +126,7 @@ class ValidateType(Generic[T]): def __call__(self, value: Any) -> T: expected_type = self.__orig_class__.__args__[0] # type: ignore[attr-defined] if not isinstance(value, expected_type): - raise ValueError(f"Expected value to be of type {expected_type} instead of {type(value)}") + raise ValueError(f'Expected value to be of type {expected_type} instead of {type(value)}') return value # type: ignore[no-any-return] @@ -135,11 +134,12 @@ class ValidateList(Generic[T]): def __call__(self, value: Any) -> List[T]: expected_type = self.__orig_class__.__args__[0] # type: ignore[attr-defined] if not isinstance(value, list): - raise ValueError("Expected value to be a list.") + raise ValueError('Expected value to be a list.') if not all((isinstance(v, expected_type) for v in value)): item_types = [type(x) for x in value] - raise ValueError(("Expected all items in list to be of type " - f"{expected_type}. Provided item types {item_types}.")) + raise ValueError( + (f'Expected all items in list to be of type {expected_type}. Provided item types {item_types}.') + ) # we are returning List[T] return value diff --git a/couchbase_analytics/common/backoff_calculator.py b/couchbase_analytics/common/backoff_calculator.py index f2c1c81..9417b76 100644 --- a/couchbase_analytics/common/backoff_calculator.py +++ b/couchbase_analytics/common/backoff_calculator.py @@ -23,15 +23,15 @@ class BackoffCalculator(ABC): def calculate_backoff(self, retry_count: int) -> float: raise NotImplementedError + class DefaultBackoffCalculator(BackoffCalculator): MIN = 100 MAX = 60 * 1000 EXPONENT_BASE = 1.5 - def __init__(self, - min: Optional[int]=None, - max: Optional[int]=None, - exponent_base: Optional[int]=None) -> None: + def __init__( + self, min: Optional[int] = None, max: Optional[int] = None, exponent_base: Optional[int] = None + ) -> None: self._min = min or self.MIN self._max = max or self.MAX self._exp = exponent_base or self.EXPONENT_BASE diff --git a/couchbase_analytics/common/credential.py b/couchbase_analytics/common/credential.py index c6afb99..7abbdaa 100644 --- a/couchbase_analytics/common/credential.py +++ b/couchbase_analytics/common/credential.py @@ -15,9 +15,7 @@ from __future__ import annotations -from typing import (Callable, - Dict, - Tuple) +from typing import Callable, Dict, Tuple class Credential: @@ -51,10 +49,7 @@ def asdict(self) -> Dict[str, str]: """ **INTERNAL** """ - return { - 'username': self._username, - 'password': self._password - } + return {'username': self._username, 'password': self._password} def astuple(self) -> Tuple[bytes, bytes]: """ diff --git a/couchbase_analytics/common/deserializer.py b/couchbase_analytics/common/deserializer.py index cab9b72..01aee2c 100644 --- a/couchbase_analytics/common/deserializer.py +++ b/couchbase_analytics/common/deserializer.py @@ -31,8 +31,7 @@ def deserialize(self, value: bytes) -> Any: @classmethod def __subclasshook__(cls, subclass: type) -> bool: - return (hasattr(subclass, 'deserialize') and - callable(subclass.deserialize)) + return hasattr(subclass, 'deserialize') and callable(subclass.deserialize) class DefaultJsonDeserializer(Deserializer): diff --git a/couchbase_analytics/common/enums.py b/couchbase_analytics/common/enums.py index c720155..5e1d12e 100644 --- a/couchbase_analytics/common/enums.py +++ b/couchbase_analytics/common/enums.py @@ -28,11 +28,15 @@ class QueryScanConsistency(Enum): # This is unfortunate, but Enum is 'special' and this is one of the least invasive manners to document the members -QueryScanConsistency.NOT_BOUNDED.__doc__ = ('Indicates that no specific consistency is required, ' - 'this is the fastest options, but results may not include ' - 'the most recent operations which have been performed.') -QueryScanConsistency.REQUEST_PLUS.__doc__ = ('Indicates that the results to the query should include ' - 'all operations that have occurred up until the query was started. ' - 'This incurs a performance penalty of waiting for the index to catch ' - 'up to the most recent operations, but provides the highest level ' - 'of consistency.') +QueryScanConsistency.NOT_BOUNDED.__doc__ = ( + 'Indicates that no specific consistency is required, ' + 'this is the fastest options, but results may not include ' + 'the most recent operations which have been performed.' +) +QueryScanConsistency.REQUEST_PLUS.__doc__ = ( + 'Indicates that the results to the query should include ' + 'all operations that have occurred up until the query was started. ' + 'This incurs a performance penalty of waiting for the index to catch ' + 'up to the most recent operations, but provides the highest level ' + 'of consistency.' +) diff --git a/couchbase_analytics/common/errors.py b/couchbase_analytics/common/errors.py index 6dba21f..a6b94f1 100644 --- a/couchbase_analytics/common/errors.py +++ b/couchbase_analytics/common/errors.py @@ -15,9 +15,7 @@ from __future__ import annotations -from typing import (Dict, - Optional, - Union) +from typing import Dict, Optional, Union """ @@ -31,10 +29,12 @@ class AnalyticsError(Exception): Generic base error. Analytics specific errors inherit from this base error. """ - def __init__(self, - cause: Optional[Union[BaseException, Exception]] = None, - message: Optional[str] = None, - context: Optional[str] = None) -> None: + def __init__( + self, + cause: Optional[Union[BaseException, Exception]] = None, + message: Optional[str] = None, + context: Optional[str] = None, + ) -> None: self._cause = cause self._message = message self._context = context @@ -65,10 +65,12 @@ class InvalidCredentialError(AnalyticsError): Indicates that an error occurred authenticating the user to the cluster. """ - def __init__(self, - cause: Optional[Union[BaseException, Exception]] = None, - context: Optional[str] = None, - message: Optional[str] = None) -> None: + def __init__( + self, + cause: Optional[Union[BaseException, Exception]] = None, + context: Optional[str] = None, + message: Optional[str] = None, + ) -> None: super().__init__(cause=cause, context=context, message=message) def __repr__(self) -> str: @@ -86,11 +88,7 @@ class QueryError(AnalyticsError): Indicates that an query request received an error from the Analytics server. """ - def __init__(self, - code: int, - server_message: str, - context: str, - message: Optional[str] = None) -> None: + def __init__(self, code: int, server_message: str, context: str, message: Optional[str] = None) -> None: super().__init__(message=message, context=context) self._code = code self._server_message = server_message @@ -115,9 +113,9 @@ def __repr__(self) -> str: details: Dict[str, str] = { 'code': str(self._code), 'server_message': self._server_message, - 'context': self._context or '' + 'context': self._context or '', } - return f"{type(self).__name__}({details})" + return f'{type(self).__name__}({details})' def __str__(self) -> str: return self.__repr__() @@ -128,10 +126,12 @@ class TimeoutError(AnalyticsError): Indicates that a request was unable to complete prior to reaching the deadline specified for the reqest. """ - def __init__(self, - cause: Optional[Union[BaseException, Exception]] = None, - context: Optional[str] = None, - message: Optional[str] = None) -> None: + def __init__( + self, + cause: Optional[Union[BaseException, Exception]] = None, + context: Optional[str] = None, + message: Optional[str] = None, + ) -> None: super().__init__(cause=cause, context=context, message=message) def __repr__(self) -> str: @@ -150,7 +150,7 @@ class FeatureUnavailableError(Exception): """ def __repr__(self) -> str: - return f"{type(self).__name__}({super().__repr__()})" + return f'{type(self).__name__}({super().__repr__()})' def __str__(self) -> str: return self.__repr__() @@ -162,10 +162,12 @@ class InternalSDKError(Exception): (this doesn't mean *you* didn't do anything wrong, it does mean you should not be seeing this message) """ - def __init__(self, - cause: Optional[Union[BaseException, Exception]] = None, - context: Optional[str] = None, - message: Optional[str] = None) -> None: + def __init__( + self, + cause: Optional[Union[BaseException, Exception]] = None, + context: Optional[str] = None, + message: Optional[str] = None, + ) -> None: self._cause = cause self._message = message self._context = context diff --git a/couchbase_analytics/common/options.py b/couchbase_analytics/common/options.py index 7de1e00..387b2b3 100644 --- a/couchbase_analytics/common/options.py +++ b/couchbase_analytics/common/options.py @@ -24,13 +24,15 @@ else: from typing import TypeAlias -from couchbase_analytics.common.options_base import ClusterOptionsBase +from couchbase_analytics.common.options_base import ( + ClusterOptionsBase, + QueryOptionsBase, + SecurityOptionsBase, + TimeoutOptionsBase, +) from couchbase_analytics.common.options_base import ClusterOptionsKwargs as ClusterOptionsKwargs # noqa: F401 -from couchbase_analytics.common.options_base import QueryOptionsBase from couchbase_analytics.common.options_base import QueryOptionsKwargs as QueryOptionsKwargs # noqa: F401 -from couchbase_analytics.common.options_base import SecurityOptionsBase from couchbase_analytics.common.options_base import SecurityOptionsKwargs as SecurityOptionsKwargs # noqa: F401 -from couchbase_analytics.common.options_base import TimeoutOptionsBase from couchbase_analytics.common.options_base import TimeoutOptionsKwargs as TimeoutOptionsKwargs # noqa: F401 """ diff --git a/couchbase_analytics/common/options_base.py b/couchbase_analytics/common/options_base.py index 7430b25..2714956 100644 --- a/couchbase_analytics/common/options_base.py +++ b/couchbase_analytics/common/options_base.py @@ -18,14 +18,7 @@ import sys from datetime import timedelta -from typing import (Any, - Dict, - Iterable, - List, - Literal, - Optional, - TypedDict, - Union) +from typing import Any, Dict, Iterable, List, Literal, Optional, TypedDict, Union if sys.version_info < (3, 10): from typing_extensions import TypeAlias, Unpack @@ -64,7 +57,7 @@ class ClusterOptionsKwargs(TypedDict, total=False): class ClusterOptionsBase(Dict[str, Any]): """ - **INTERNAL** + **INTERNAL** """ VALID_OPTION_KEYS: List[ClusterOptionsValidKeys] = [ @@ -98,7 +91,7 @@ class SecurityOptionsKwargs(TypedDict, total=False): class SecurityOptionsBase(Dict[str, object]): """ - **INTERNAL** + **INTERNAL** """ VALID_OPTION_KEYS: List[SecurityOptionsValidKeys] = [ @@ -127,7 +120,7 @@ class TimeoutOptionsKwargs(TypedDict, total=False): class TimeoutOptionsBase(Dict[str, object]): """ - **INTERNAL** + **INTERNAL** """ VALID_OPTION_KEYS: List[TimeoutOptionsValidKeys] = [ @@ -172,7 +165,6 @@ class QueryOptionsKwargs(TypedDict, total=False): class QueryOptionsBase(Dict[str, object]): - VALID_OPTION_KEYS: List[QueryOptionsValidKeys] = [ 'client_context_id', 'deserializer', diff --git a/couchbase_analytics/common/query.py b/couchbase_analytics/common/query.py index c0ef918..c25930a 100644 --- a/couchbase_analytics/common/query.py +++ b/couchbase_analytics/common/query.py @@ -18,9 +18,7 @@ from datetime import timedelta from typing import List, Optional -from couchbase_analytics.common._core.query import (QueryMetadataCore, - QueryMetricsCore, - QueryWarningCore) +from couchbase_analytics.common._core.query import QueryMetadataCore, QueryMetricsCore, QueryWarningCore class QueryWarning: @@ -42,7 +40,7 @@ def message(self) -> str: return self._raw['message'] def __repr__(self) -> str: - return "QueryWarning:{}".format(self._raw) + return 'QueryWarning:{}'.format(self._raw) class QueryMetrics: @@ -92,7 +90,7 @@ def processed_objects(self) -> int: return self._raw.get('processed_objects') or 0 def __repr__(self) -> str: - return "QueryMetrics:{}".format(self._raw) + return 'QueryMetrics:{}'.format(self._raw) class QueryMetadata: @@ -124,4 +122,4 @@ def metrics(self) -> QueryMetrics: return QueryMetrics(self._raw['metrics']) def __repr__(self) -> str: - return "QueryMetadata:{}".format(self._raw) + return 'QueryMetadata:{}'.format(self._raw) diff --git a/couchbase_analytics/common/request.py b/couchbase_analytics/common/request.py index 6c2d73b..ce1171a 100644 --- a/couchbase_analytics/common/request.py +++ b/couchbase_analytics/common/request.py @@ -39,7 +39,7 @@ def __repr__(self) -> str: 'scheme': self.scheme, 'host': self.host, 'port': str(self.port), - 'path': self.path if self.path else '' + 'path': self.path if self.path else '', } return f'{type(self).__name__}({details})' diff --git a/couchbase_analytics/common/result.py b/couchbase_analytics/common/result.py index df8216b..c35e139 100644 --- a/couchbase_analytics/common/result.py +++ b/couchbase_analytics/common/result.py @@ -15,10 +15,7 @@ from __future__ import annotations -from typing import (TYPE_CHECKING, - Any, - List, - Optional) +from typing import TYPE_CHECKING, Any, List, Optional from couchbase_analytics.common._core.result import QueryResult as QueryResult from couchbase_analytics.common.query import QueryMetadata @@ -79,7 +76,7 @@ def __iter__(self) -> BlockingIterator: return iter(BlockingIterator(self._http_response)) def __repr__(self) -> str: - return "BlockingQueryResult()" + return 'BlockingQueryResult()' class AsyncQueryResult(QueryResult): @@ -146,4 +143,4 @@ def __aiter__(self) -> AsyncIterator: return AsyncIterator(self._http_response).__aiter__() def __repr__(self) -> str: - return "AsyncQueryResult()" + return 'AsyncQueryResult()' diff --git a/couchbase_analytics/common/streaming.py b/couchbase_analytics/common/streaming.py index 880dc1f..c3b02e9 100644 --- a/couchbase_analytics/common/streaming.py +++ b/couchbase_analytics/common/streaming.py @@ -18,10 +18,7 @@ from collections.abc import AsyncIterator as PyAsyncIterator from collections.abc import Iterator from enum import IntEnum -from typing import (TYPE_CHECKING, - Any, - List, - NamedTuple) +from typing import TYPE_CHECKING, Any, List, NamedTuple from couchbase_analytics.common.errors import AnalyticsError, InternalSDKError @@ -34,6 +31,7 @@ class StreamingState(IntEnum): """ **INTERNAL """ + NotStarted = 0 ResetAndNotStarted = 1 Started = 2 @@ -64,19 +62,19 @@ def is_okay(state: StreamingState) -> bool: """ **INTERNAL """ - return state not in [StreamingState.Cancelled, - StreamingState.Error, - StreamingState.Timeout] + return state not in [StreamingState.Cancelled, StreamingState.Error, StreamingState.Timeout] @staticmethod def is_timeout_or_cancelled(state: StreamingState) -> bool: """ **INTERNAL """ - return state in [StreamingState.Cancelled, - StreamingState.Timeout, - StreamingState.AsyncCancelledPriorToTimeout, - StreamingState.SyncCancelledPriorToTimeout] + return state in [ + StreamingState.Cancelled, + StreamingState.Timeout, + StreamingState.AsyncCancelledPriorToTimeout, + StreamingState.SyncCancelledPriorToTimeout, + ] class BlockingIterator(Iterator[Any]): @@ -115,6 +113,7 @@ def __next__(self) -> Any: except Exception as ex: raise InternalSDKError(cause=ex, message='Error attempting to obtain next row.') from None + class AsyncIterator(PyAsyncIterator[Any]): """ **INTERNAL @@ -148,17 +147,21 @@ async def __anext__(self) -> Any: except Exception as ex: raise InternalSDKError(cause=ex, message='Error attempting to obtain next row.') from None + class HttpResponseType(IntEnum): """ **INTERNAL** """ + ROW = 0 ERROR = 1 END = 2 + class ParsedResult(NamedTuple): """ **INTERNAL** """ + result: str result_type: HttpResponseType diff --git a/couchbase_analytics/database.py b/couchbase_analytics/database.py index 71086e4..36fc2a4 100644 --- a/couchbase_analytics/database.py +++ b/couchbase_analytics/database.py @@ -36,12 +36,13 @@ class Database: def __init__(self, cluster: Cluster, database_name: str) -> None: from couchbase_analytics.protocol.database import Database as _Database + self._impl = _Database(cluster, database_name) @property def name(self) -> str: """ - str: The name of this :class:`~couchbase_analytics.database.Database` instance. + str: The name of this :class:`~couchbase_analytics.database.Database` instance. """ return self._impl.name diff --git a/couchbase_analytics/database.pyi b/couchbase_analytics/database.pyi index 3acd867..cfda42f 100644 --- a/couchbase_analytics/database.pyi +++ b/couchbase_analytics/database.pyi @@ -18,8 +18,6 @@ from couchbase_analytics.scope import Scope class Database: def __init__(self, cluster: Cluster, database_name: str) -> None: ... - @property def name(self) -> str: ... - def scope(self, scope_name: str) -> Scope: ... diff --git a/couchbase_analytics/protocol/_core/client_adapter.py b/couchbase_analytics/protocol/_core/client_adapter.py index 78009ea..15f8a35 100644 --- a/couchbase_analytics/protocol/_core/client_adapter.py +++ b/couchbase_analytics/protocol/_core/client_adapter.py @@ -19,10 +19,7 @@ from typing import TYPE_CHECKING, Optional from uuid import uuid4 -from httpx import (URL, - BasicAuth, - Client, - Response) +from httpx import URL, BasicAuth, Client, Response from couchbase_analytics.common.credential import Credential from couchbase_analytics.common.deserializer import Deserializer @@ -37,88 +34,81 @@ class _ClientAdapter: """ - **INTERNAL** + **INTERNAL** """ _ANALYTICS_PATH = '/api/v1/request' - def __init__(self, - http_endpoint: str, - credential: Credential, - options: Optional[object] = None, - **kwargs: object) -> None: + def __init__( + self, http_endpoint: str, credential: Credential, options: Optional[object] = None, **kwargs: object + ) -> None: self._client_id = str(uuid4()) self._opts_builder = OptionsBuilder() # TODO: We should limit the allowed transports to the ones we support # Question is how do we want to limit the transports? Should users even need to override? # self._http_transport_cls = kwargs.pop('http_transport_cls', AnalyticsHTTPTransport) self._http_transport_cls = None - self._conn_details = _ConnectionDetails.create(self._opts_builder, - http_endpoint, - credential, - options, - **kwargs) + self._conn_details = _ConnectionDetails.create(self._opts_builder, http_endpoint, credential, options, **kwargs) @property def analytics_path(self) -> str: """ - **INTERNAL** + **INTERNAL** """ return self._ANALYTICS_PATH @property def client(self) -> Client: """ - **INTERNAL** + **INTERNAL** """ return self._client @property def client_id(self) -> str: """ - **INTERNAL** + **INTERNAL** """ return self._client_id @property def connection_details(self) -> _ConnectionDetails: """ - **INTERNAL** + **INTERNAL** """ return self._conn_details @property def default_deserializer(self) -> Deserializer: """ - **INTERNAL** + **INTERNAL** """ return self._conn_details.default_deserializer @property def has_client(self) -> bool: """ - **INTERNAL** + **INTERNAL** """ return hasattr(self, '_client') @property def options_builder(self) -> OptionsBuilder: """ - **INTERNAL** + **INTERNAL** """ return self._opts_builder - def close_client(self) -> None: """ - **INTERNAL** + **INTERNAL** """ if hasattr(self, '_client'): self._client.close() def create_client(self) -> None: """ - **INTERNAL** + **INTERNAL** """ if not hasattr(self, '_client'): auth = BasicAuth(*self._conn_details.credential) @@ -128,31 +118,22 @@ def create_client(self) -> None: transport = None if self._http_transport_cls is not None: transport = self._http_transport_cls(verify=self._conn_details.ssl_context) - self._client = Client(verify=self._conn_details.ssl_context, - auth=auth, - transport=transport) + self._client = Client(verify=self._conn_details.ssl_context, auth=auth, transport=transport) else: transport = None if self._http_transport_cls is not None: transport = self._http_transport_cls() self._client = Client(auth=auth, transport=transport) - def send_request(self, request: QueryRequest) -> Response: """ - **INTERNAL** + **INTERNAL** """ if not hasattr(self, '_client'): raise RuntimeError('Client not created yet') - url = URL(scheme=request.url.scheme, - host=request.url.ip, - port=request.url.port, - path=request.url.path) - req = self._client.build_request(request.method, - url, - json=request.body, - extensions=request.extensions) + url = URL(scheme=request.url.scheme, host=request.url.ip, port=request.url.port, path=request.url.path) + req = self._client.build_request(request.method, url, json=request.body, extensions=request.extensions) try: return self._client.send(req, stream=True) except socket.gaierror as err: @@ -161,7 +142,7 @@ def send_request(self, request: QueryRequest) -> Response: def reset_client(self) -> None: """ - **INTERNAL** + **INTERNAL** """ if hasattr(self, '_client'): del self._client diff --git a/couchbase_analytics/protocol/_core/http_transport.py b/couchbase_analytics/protocol/_core/http_transport.py index 89e8566..d9d347e 100644 --- a/couchbase_analytics/protocol/_core/http_transport.py +++ b/couchbase_analytics/protocol/_core/http_transport.py @@ -1,42 +1,32 @@ - import ssl import time from types import TracebackType -from typing import (Iterable, - Optional, - TypeVar, - Union) - -from httpcore import (ConnectionInterface, - ConnectionPool, - HTTP2Connection, - HTTP11Connection, - HTTPConnection, - Origin, - Request) +from typing import Iterable, Optional, TypeVar, Union + +from httpcore import ( + ConnectionInterface, + ConnectionPool, + HTTP2Connection, + HTTP11Connection, + HTTPConnection, + Origin, + Request, +) from httpcore import Response as CoreResponse from httpcore._exceptions import ConnectionNotAvailable, UnsupportedProtocol from httpcore._sync.connection_pool import PoolByteStream, PoolRequest -from httpx import (URL, - BaseTransport, - HTTPTransport, - Limits, - Proxy, - Response, - SyncByteStream, - create_ssl_context) -from httpx._transports.default import (SOCKET_OPTION, - ResponseStream, - map_httpcore_exceptions) +from httpx import URL, BaseTransport, HTTPTransport, Limits, Proxy, Response, SyncByteStream, create_ssl_context +from httpx._transports.default import SOCKET_OPTION, ResponseStream, map_httpcore_exceptions from httpx._types import CertTypes, ProxyTypes # httpx._transports.default.py -T = TypeVar("T", bound="HTTPTransport") +T = TypeVar('T', bound='HTTPTransport') DEFAULT_LIMITS = Limits(max_connections=100, max_keepalive_connections=20) # ProxyTypes = Union["URL", str, "Proxy"] # CertTypes = Union[str, Tuple[str, str], Tuple[str, str, str]] + class AnalyticsHTTPConnection(HTTPConnection): def __init__(self, *args, **kwargs) -> None: # type: ignore super().__init__(*args, **kwargs) @@ -46,24 +36,19 @@ def __init__(self, *args, **kwargs) -> None: # type: ignore # 2025-06-05: https://github.com/encode/httpcore/blob/98209758cc14e1a5f966fe1dfdc1064b94055d8c/httpcore/_sync/connection.py#L69 def handle_request(self, request: Request) -> CoreResponse: if not self.can_handle_request(request.url.origin): - raise RuntimeError( - f"Attempted to send request to {request.url.origin} on connection to {self._origin}" - ) + raise RuntimeError(f'Attempted to send request to {request.url.origin} on connection to {self._origin}') # PYCBAC Addition: track the query deadline - timeouts = request.extensions.get("timeout", {}) - timeout = timeouts.get("read", None) + timeouts = request.extensions.get('timeout', {}) + timeout = timeouts.get('read', None) deadline = time.monotonic() + timeout try: with self._request_lock: if self._connection is None: stream = self._connect(request) - ssl_object = stream.get_extra_info("ssl_object") - http2_negotiated = ( - ssl_object is not None - and ssl_object.selected_alpn_protocol() == "h2" - ) + ssl_object = stream.get_extra_info('ssl_object') + http2_negotiated = ssl_object is not None and ssl_object.selected_alpn_protocol() == 'h2' if http2_negotiated or (self._http2 and not self._http1): self._connection = HTTP2Connection( origin=self._origin, @@ -81,11 +66,12 @@ def handle_request(self, request: Request) -> CoreResponse: raise exc # PYCBAC Addition: We _always_ set the request timeouts, so no need to validate keys - query_timeout = round(deadline - time.monotonic(), 6) # round to microseconds - request.extensions["timeout"]["read"] = query_timeout + query_timeout = round(deadline - time.monotonic(), 6) # round to microseconds + request.extensions['timeout']['read'] = query_timeout return self._connection.handle_request(request) + class AnalyticsConnectionPool(ConnectionPool): def __init__(self, *args, **kwargs) -> None: # type: ignore super().__init__(*args, **kwargs) @@ -101,17 +87,13 @@ def handle_request(self, request: Request) -> CoreResponse: This is the core implementation that is called into by `.request()` or `.stream()`. """ scheme = request.url.scheme.decode() - if scheme == "": - raise UnsupportedProtocol( - "Request URL is missing an 'http://' or 'https://' protocol." - ) - if scheme not in ("http", "https", "ws", "wss"): - raise UnsupportedProtocol( - f"Request URL has an unsupported protocol '{scheme}://'." - ) - - timeouts = request.extensions.get("timeout", {}) - timeout = timeouts.get("pool", None) + if scheme == '': + raise UnsupportedProtocol("Request URL is missing an 'http://' or 'https://' protocol.") + if scheme not in ('http', 'https', 'ws', 'wss'): + raise UnsupportedProtocol(f"Request URL has an unsupported protocol '{scheme}://'.") + + timeouts = request.extensions.get('timeout', {}) + timeout = timeouts.get('pool', None) with self._optional_thread_lock: # Add the incoming request to our request queue. @@ -131,14 +113,12 @@ def handle_request(self, request: Request) -> CoreResponse: # Wait until this request has an assigned connection. connection = pool_request.wait_for_connection(timeout=timeout) # PYCBAC Addition: We _always_ set the request timeouts, so no need to validate keys - connect_timeout = round(deadline - time.monotonic(), 6) # round to microseconds - pool_request.request.extensions["timeout"]["connect"] = connect_timeout + connect_timeout = round(deadline - time.monotonic(), 6) # round to microseconds + pool_request.request.extensions['timeout']['connect'] = connect_timeout try: # Send the request on the assigned connection. - response = connection.handle_request( - pool_request.request - ) + response = connection.handle_request(pool_request.request) except ConnectionNotAvailable: # In some cases a connection may initially be available to # handle a request, but then become unavailable. @@ -146,7 +126,7 @@ def handle_request(self, request: Request) -> CoreResponse: # In this case we clear the connection and try again. pool_request.clear_connection() # PYCBAC Addition: We update the timeout for the next attempt - timeout = round(deadline - time.monotonic(), 6) # round to microseconds + timeout = round(deadline - time.monotonic(), 6) # round to microseconds else: break # pragma: nocover @@ -166,9 +146,7 @@ def handle_request(self, request: Request) -> CoreResponse: return CoreResponse( status=response.status, headers=response.headers, - content=PoolByteStream( - stream=response.stream, pool_request=pool_request, pool=self - ), + content=PoolByteStream(stream=response.stream, pool_request=pool_request, pool=self), extensions=response.extensions, ) @@ -188,6 +166,7 @@ def create_connection(self, origin: Origin) -> ConnectionInterface: socket_options=self._socket_options, ) + class AnalyticsHTTPTransport(BaseTransport): def __init__( self, @@ -203,7 +182,6 @@ def __init__( retries: int = 0, socket_options: Optional[Iterable[SOCKET_OPTION]] = None, ) -> None: - proxy = Proxy(url=proxy) if isinstance(proxy, (str, URL)) else proxy ssl_context = create_ssl_context(verify=verify, cert=cert, trust_env=trust_env) diff --git a/couchbase_analytics/protocol/_core/json_stream.py b/couchbase_analytics/protocol/_core/json_stream.py index eb79d06..2808725 100644 --- a/couchbase_analytics/protocol/_core/json_stream.py +++ b/couchbase_analytics/protocol/_core/json_stream.py @@ -19,28 +19,26 @@ from queue import Empty as QueueEmpty from queue import Full as QueueFull from queue import Queue -from typing import (TYPE_CHECKING, - Iterator, - Optional) +from typing import TYPE_CHECKING, Iterator, Optional import ijson -from couchbase_analytics.common._core.json_parsing import (JsonStreamConfig, - ParsedResult, - ParsedResultType) +from couchbase_analytics.common._core.json_parsing import JsonStreamConfig, ParsedResult, ParsedResultType from couchbase_analytics.protocol._core.json_token_parser import JsonTokenParser if TYPE_CHECKING: from couchbase_analytics.protocol._core.request_context import RequestContext + class JsonStream: DEFAULT_HTTP_STREAM_BUFFER_SIZE = 2**16 - def __init__(self, - http_stream_iter: Iterator[bytes], - *, - stream_config: Optional[JsonStreamConfig]=None, - ) -> None: + def __init__( + self, + http_stream_iter: Iterator[bytes], + *, + stream_config: Optional[JsonStreamConfig] = None, + ) -> None: # HTTP stream handling if stream_config is None: stream_config = JsonStreamConfig() @@ -75,7 +73,7 @@ def token_stream_exhausted(self) -> bool: """ return self._token_stream_exhausted - def _continue_processing(self, request_context: Optional[RequestContext]=None) -> bool: + def _continue_processing(self, request_context: Optional[RequestContext] = None) -> bool: """ **INTERNAL** """ @@ -101,7 +99,6 @@ def _put(self, result: ParsedResult) -> None: # TODO: log error as this is unexpected pass - def _handle_json_result(self, row: bytes) -> None: """ **INTERNAL** @@ -117,7 +114,7 @@ def _handle_notification(self, result_type: ParsedResultType) -> None: self._notify_on_results_or_error.set_result(result_type) - def _process_token_stream(self, request_context: Optional[RequestContext]=None) -> None: + def _process_token_stream(self, request_context: Optional[RequestContext] = None) -> None: """ **INTERNAL** """ @@ -142,7 +139,7 @@ def _process_token_stream(self, request_context: Optional[RequestContext]=None) self._put(ParsedResult(self._json_token_parser.get_result(), result_type)) self._handle_notification(result_type) - def read(self, size: Optional[int]=-1) -> bytes: + def read(self, size: Optional[int] = -1) -> bytes: """ **INTERNAL** """ @@ -175,14 +172,19 @@ def get_result(self, timeout: float) -> Optional[ParsedResult]: # TODO: log a message here as indication the stream is slow return None - def start_parsing(self, - request_context: Optional[RequestContext]=None, - notify_on_results_or_error: Optional[Future[ParsedResultType]]=None) -> None: + def start_parsing( + self, + request_context: Optional[RequestContext] = None, + notify_on_results_or_error: Optional[Future[ParsedResultType]] = None, + ) -> None: if self._json_stream_parser is not None: # TODO: logging; I don't think this is an error... return self._notify_on_results_or_error = notify_on_results_or_error self._process_token_stream(request_context=request_context) - def continue_parsing(self, request_context: Optional[RequestContext]=None,) -> None: + def continue_parsing( + self, + request_context: Optional[RequestContext] = None, + ) -> None: self._process_token_stream(request_context=request_context) diff --git a/couchbase_analytics/protocol/_core/json_token_parser.py b/couchbase_analytics/protocol/_core/json_token_parser.py index 1c38e3d..61a9855 100644 --- a/couchbase_analytics/protocol/_core/json_token_parser.py +++ b/couchbase_analytics/protocol/_core/json_token_parser.py @@ -15,29 +15,29 @@ from __future__ import annotations -from typing import (Callable, - List, - Optional) +from typing import Callable, List, Optional -from couchbase_analytics.common._core.json_token_parser_base import (POP_EVENTS, - START_EVENTS, - VALUE_TOKENS, - JsonTokenParserBase, - ParsingState, - TokenType) +from couchbase_analytics.common._core.json_token_parser_base import ( + POP_EVENTS, + START_EVENTS, + VALUE_TOKENS, + JsonTokenParserBase, + ParsingState, + TokenType, +) class JsonTokenParser(JsonTokenParserBase): - def __init__(self, - result_handler: Optional[Callable[[bytes], None]]=None) -> None: + def __init__(self, result_handler: Optional[Callable[[bytes], None]] = None) -> None: self._result_handler = result_handler super().__init__(emit_results_enabled=result_handler is not None) - def _handle_obj_emit(self, obj: str) -> bool: - if (self._emit_results_enabled + if ( + self._emit_results_enabled and self._result_handler is not None - and ParsingState.okay_to_emit(self._state, self._previous_state)): + and ParsingState.okay_to_emit(self._state, self._previous_state) + ): self._result_handler(bytes(obj, 'utf-8')) return True return False @@ -55,7 +55,7 @@ def _handle_pop_event(self, token_type: TokenType) -> None: else: obj = f'{{{",".join(reversed(obj_pairs))}}}' if should_emit and self._handle_obj_emit(obj): - break # this means we emiited the result/error, so stop processing the stack + break # this means we emiited the result/error, so stop processing the stack if len(self._stack) > 0 and self._stack[-1].type == TokenType.MAP_KEY: map_key = self._pop() diff --git a/couchbase_analytics/protocol/_core/net_utils.py b/couchbase_analytics/protocol/_core/net_utils.py index 6042b73..7d7a5d3 100644 --- a/couchbase_analytics/protocol/_core/net_utils.py +++ b/couchbase_analytics/protocol/_core/net_utils.py @@ -16,9 +16,7 @@ from __future__ import annotations import socket -from ipaddress import (IPv4Address, - IPv6Address, - ip_address) +from ipaddress import IPv4Address, IPv6Address, ip_address from random import choice from typing import Optional, Union diff --git a/couchbase_analytics/protocol/_core/request.py b/couchbase_analytics/protocol/_core/request.py index 133e3b6..ee3cfd5 100644 --- a/couchbase_analytics/protocol/_core/request.py +++ b/couchbase_analytics/protocol/_core/request.py @@ -17,15 +17,7 @@ from copy import deepcopy from dataclasses import dataclass -from typing import (TYPE_CHECKING, - Any, - Callable, - Coroutine, - Dict, - Optional, - TypedDict, - Union, - cast) +from typing import TYPE_CHECKING, Any, Callable, Coroutine, Dict, Optional, TypedDict, Union, cast from uuid import uuid4 from couchbase_analytics.common.deserializer import Deserializer @@ -38,17 +30,20 @@ from acouchbase_analytics.protocol._core.client_adapter import _AsyncClientAdapter as AsyncClientAdapter from couchbase_analytics.protocol._core.client_adapter import _ClientAdapter as BlockingClientAdapter + class RequestTimeoutExtensions(TypedDict, total=False): pool: Optional[float] # Timeout for acquiring a connection from the pool connect: Optional[float] # Timeout for establishing a socket connection read: Optional[float] # Timeout for reading data from the socket connection write: Optional[float] # Timeout for writing data to the socket connection + class RequestExtensions(TypedDict, total=False): timeout: RequestTimeoutExtensions sni_hostname: Optional[str] trace: Optional[Callable[[str, str], Union[None, Coroutine[Any, Any, None]]]] + @dataclass class QueryRequest: url: RequestURL @@ -61,8 +56,9 @@ class QueryRequest: options: Optional[QueryOptionsTransformedKwargs] = None enable_cancel: Optional[bool] = None - def add_trace_to_extensions(self, handler: Callable[[str, str], - Union[None, Coroutine[Any, Any, None]]]) -> QueryRequest: + def add_trace_to_extensions( + self, handler: Callable[[str, str], Union[None, Coroutine[Any, Any, None]]] + ) -> QueryRequest: """ **INTERNAL** """ @@ -97,12 +93,12 @@ def update_url(self, ip: str, path: str) -> QueryRequest: class _RequestBuilder: - - def __init__(self, - client: Union[AsyncClientAdapter, BlockingClientAdapter], - database_name: Optional[str]=None, - scope_name: Optional[str]=None - ) -> None: + def __init__( + self, + client: Union[AsyncClientAdapter, BlockingClientAdapter], + database_name: Optional[str] = None, + scope_name: Optional[str] = None, + ) -> None: self._conn_details = client.connection_details self._opts_builder = client.options_builder self._database_name = database_name @@ -111,22 +107,19 @@ def __init__(self, connect_timeout = self._conn_details.get_connect_timeout() self._default_query_timeout = self._conn_details.get_query_timeout() self._extensions: RequestExtensions = { - 'timeout': { - 'pool': connect_timeout, - 'connect': connect_timeout, - 'read': self._default_query_timeout - } + 'timeout': {'pool': connect_timeout, 'connect': connect_timeout, 'read': self._default_query_timeout} } # TODO: warning if we have a secure connection, but the sni_hostname is not set? if self._conn_details.is_secure() and self._conn_details.sni_hostname is not None: self._extensions['sni_hostname'] = self._conn_details.sni_hostname - - def build_base_query_request(self, # noqa: C901 - statement: str, - *args: object, - is_async: Optional[bool] = False, - **kwargs: object) -> QueryRequest: # noqa: C901 + def build_base_query_request( # noqa: C901 + self, + statement: str, + *args: object, + is_async: Optional[bool] = False, + **kwargs: object, + ) -> QueryRequest: # noqa: C901 enable_cancel: Optional[bool] = None cancel_kwarg_token = kwargs.pop('enable_cancel', None) if isinstance(cancel_kwarg_token, bool): @@ -151,10 +144,7 @@ def build_base_query_request(self, # noqa: C901 for key in named_param_keys: named_params[key] = kwargs.pop(key) - q_opts = self._opts_builder.build_options(QueryOptions, - QueryOptionsTransformedKwargs, - kwargs, - opts) + q_opts = self._opts_builder.build_options(QueryOptions, QueryOptionsTransformedKwargs, kwargs, opts) # positional params and named params passed in outside of QueryOptions serve as overrides if parsed_args_list and len(parsed_args_list) > 0: q_opts['positional_parameters'] = parsed_args_list @@ -166,7 +156,7 @@ def build_base_query_request(self, # noqa: C901 body: Dict[str, Union[str, object]] = { 'statement': statement, - 'client_context_id': q_opts.get('client_context_id', None) or str(uuid4()) + 'client_context_id': q_opts.get('client_context_id', None) or str(uuid4()), } if self._database_name is not None and self._scope_name is not None: @@ -175,8 +165,7 @@ def build_base_query_request(self, # noqa: C901 # handle timeouts timeout = q_opts.get('timeout', None) or self._default_query_timeout extensions = deepcopy(self._extensions) - if (timeout is not None - and timeout != self._default_query_timeout): + if timeout is not None and timeout != self._default_query_timeout: extensions['timeout']['read'] = timeout # in the async world we have our own cancel scope that handles the connect timeout if is_async: @@ -206,10 +195,12 @@ def build_base_query_request(self, # noqa: C901 else: body['scan_consistency'] = opt_val - return QueryRequest(self._conn_details.url, - deserializer, - body, - extensions=extensions, - max_retries=max_retries, - options=q_opts, - enable_cancel=enable_cancel) + return QueryRequest( + self._conn_details.url, + deserializer, + body, + extensions=extensions, + max_retries=max_retries, + options=q_opts, + enable_cancel=enable_cancel, + ) diff --git a/couchbase_analytics/protocol/_core/request_context.py b/couchbase_analytics/protocol/_core/request_context.py index 5e3c7cb..ee4d594 100644 --- a/couchbase_analytics/protocol/_core/request_context.py +++ b/couchbase_analytics/protocol/_core/request_context.py @@ -3,25 +3,14 @@ import json import math import time -from concurrent.futures import (CancelledError, - Future, - ThreadPoolExecutor) +from concurrent.futures import CancelledError, Future, ThreadPoolExecutor from threading import Event, Lock -from typing import (TYPE_CHECKING, - Any, - Callable, - Dict, - Iterator, - List, - Optional, - Union) +from typing import TYPE_CHECKING, Any, Callable, Dict, Iterator, List, Optional, Union from uuid import uuid4 from httpx import Response as HttpCoreResponse -from couchbase_analytics.common._core import (JsonStreamConfig, - ParsedResult, - ParsedResultType) +from couchbase_analytics.common._core import JsonStreamConfig, ParsedResult, ParsedResultType from couchbase_analytics.common._core.error_context import ErrorContext from couchbase_analytics.common.backoff_calculator import DefaultBackoffCalculator from couchbase_analytics.common.errors import AnalyticsError, TimeoutError @@ -36,11 +25,12 @@ from couchbase_analytics.protocol._core.client_adapter import _ClientAdapter from couchbase_analytics.protocol._core.request import QueryRequest + # TODO: might not be needed; need to validate httpx iterator behavior class ThreadSafeBytesIterator: def __init__(self, iterator: Iterator[bytes]): if not hasattr(iterator, '__next__'): - raise TypeError("Provided object is not an iterator (missing __next__ method).") + raise TypeError('Provided object is not an iterator (missing __next__ method).') self._iterator = iterator self._lock = Lock() @@ -48,7 +38,7 @@ def __iter__(self) -> ThreadSafeBytesIterator: return self def __next__(self) -> bytes: - with self._lock: # Acquire the lock before accessing the iterator + with self._lock: # Acquire the lock before accessing the iterator try: item = next(self._iterator) return item @@ -56,10 +46,11 @@ def __next__(self) -> bytes: # Always re-raise StopIteration to signal the end of iteration raise + class BackgroundRequest: - def __init__(self, bg_future: Future[BlockingQueryResult], - user_future: Future[BlockingQueryResult], - cancel_event: Event) -> None: + def __init__( + self, bg_future: Future[BlockingQueryResult], user_future: Future[BlockingQueryResult], cancel_event: Event + ) -> None: self._background_work_ft = bg_future self._user_ft = user_future self._cancel_event = cancel_event @@ -98,21 +89,19 @@ def _user_done(self, ft: Future[BlockingQueryResult]) -> None: return - class RequestContext: - - def __init__(self, - client_adapter: _ClientAdapter, - request: QueryRequest, - tp_executor: ThreadPoolExecutor, - stream_config: Optional[JsonStreamConfig]=None) -> None: + def __init__( + self, + client_adapter: _ClientAdapter, + request: QueryRequest, + tp_executor: ThreadPoolExecutor, + stream_config: Optional[JsonStreamConfig] = None, + ) -> None: self._id = str(uuid4()) self._client_adapter = client_adapter self._request = request self._backoff_calc = DefaultBackoffCalculator() - self._error_ctx = ErrorContext(num_attempts=0, - method=request.method, - statement=request.get_request_statement()) + self._error_ctx = ErrorContext(num_attempts=0, method=request.method, statement=request.get_request_statement()) self._request_state = StreamingState.NotStarted self._stream_config = stream_config or JsonStreamConfig() self._json_stream: JsonStream @@ -174,9 +163,9 @@ def _check_cancelled_or_timed_out(self) -> None: if self._request_state in [StreamingState.Timeout, StreamingState.Cancelled, StreamingState.Error]: return - if (self._cancel_event.is_set() - or (self._background_request is not None - and self._background_request.user_cancelled)): + if self._cancel_event.is_set() or ( + self._background_request is not None and self._background_request.user_cancelled + ): self._request_state = StreamingState.Cancelled current_time = time.monotonic() @@ -194,16 +183,17 @@ def _create_stage_notification_future(self) -> None: raise RuntimeError('Stage notification future already created for this context.') self._stage_notification_ft = Future[ParsedResultType]() - def _process_error(self, - json_data: Union[str, List[Dict[str, Any]]], - handle_context_shutdown: Optional[bool]=False) -> None: + def _process_error( + self, json_data: Union[str, List[Dict[str, Any]]], handle_context_shutdown: Optional[bool] = False + ) -> None: self._request_state = StreamingState.Error request_error: Union[AnalyticsError, WrappedError] if isinstance(json_data, str): request_error = ErrorMapper.build_error_from_http_status_code(json_data, self._error_ctx) elif not isinstance(json_data, list): - request_error = AnalyticsError(message='Cannot parse error response; expected JSON array', - context=str(self._error_ctx)) + request_error = AnalyticsError( + message='Cannot parse error response; expected JSON array', context=str(self._error_ctx) + ) else: request_error = ErrorMapper.build_error_from_json(json_data, self._error_ctx) if handle_context_shutdown is True: @@ -216,11 +206,13 @@ def _reset_stream(self) -> None: self._request_state = StreamingState.ResetAndNotStarted self._stage_notification_ft = None - def _start_next_stage(self, - fn: Callable[..., Any], - *args: object, - create_notification: Optional[bool]=False, - reset_previous_stage: Optional[bool]=False) -> None: + def _start_next_stage( + self, + fn: Callable[..., Any], + *args: object, + create_notification: Optional[bool] = False, + reset_previous_stage: Optional[bool] = False, + ) -> None: if reset_previous_stage is True: if self._stage_completed_ft is not None: self._stage_completed_ft = None @@ -312,40 +304,46 @@ def okay_to_delay_and_retry(self, delay: float) -> bool: self._reset_stream() return True - def process_response(self, - close_handler: Callable[[], None], - raw_response: Optional[ParsedResult]=None, - handle_context_shutdown: Optional[bool]=False) -> Any: + def process_response( + self, + close_handler: Callable[[], None], + raw_response: Optional[ParsedResult] = None, + handle_context_shutdown: Optional[bool] = False, + ) -> Any: if raw_response is None: raw_response = self._json_stream.get_result(self._stream_config.queue_timeout) if raw_response is None: close_handler() - raise AnalyticsError(message='Received unexpected empty result from JsonStream.', - context=str(self._error_ctx)) + raise AnalyticsError( + message='Received unexpected empty result from JsonStream.', context=str(self._error_ctx) + ) if raw_response.value is None: close_handler() - raise AnalyticsError(message='Received unexpected empty response value from JsonStream.', - context=str(self._error_ctx)) + raise AnalyticsError( + message='Received unexpected empty response value from JsonStream.', context=str(self._error_ctx) + ) # we have all the data, close the core response/stream close_handler() try: json_response = json.loads(raw_response.value) except json.JSONDecodeError: - self._process_error(str(raw_response.value), - handle_context_shutdown=handle_context_shutdown) + self._process_error(str(raw_response.value), handle_context_shutdown=handle_context_shutdown) else: if 'errors' in json_response: self._process_error(json_response['errors'], handle_context_shutdown=handle_context_shutdown) return json_response - def send_request(self, enable_trace_handling: Optional[bool]=False) -> HttpCoreResponse: + def send_request(self, enable_trace_handling: Optional[bool] = False) -> HttpCoreResponse: self._error_ctx.update_num_attempts() ip = get_request_ip(self._request.url.host, self._request.url.port) if enable_trace_handling is True: - (self._request.update_url(ip, self._client_adapter.analytics_path) - .add_trace_to_extensions(self._trace_handler)) + ( + self._request.update_url(ip, self._client_adapter.analytics_path).add_trace_to_extensions( + self._trace_handler + ) + ) else: self._request.update_url(ip, self._client_adapter.analytics_path) self._error_ctx.update_request_context(self._request) @@ -354,10 +352,11 @@ def send_request(self, enable_trace_handling: Optional[bool]=False) -> HttpCoreR # print(f'Response received: {response.status_code} for request {self._id}, body={self._request.body}.') return response - def send_request_in_background(self, - fn: Callable[..., BlockingQueryResult], - *args: object,) -> Future[BlockingQueryResult]: - + def send_request_in_background( + self, + fn: Callable[..., BlockingQueryResult], + *args: object, + ) -> Future[BlockingQueryResult]: if self._background_request is not None: raise RuntimeError('Background reqeust already created for this context.') # TODO: custom ThreadPoolExecutor, to get a "plain" future @@ -369,16 +368,18 @@ def send_request_in_background(self, def set_state_to_streaming(self) -> None: self._request_state = StreamingState.StreamingResults - def shutdown(self, exc_val: Optional[BaseException]=None) -> None: + def shutdown(self, exc_val: Optional[BaseException] = None) -> None: if self.is_shutdown: return if isinstance(exc_val, CancelledError): self._request_state = StreamingState.Cancelled elif exc_val is not None: self._check_cancelled_or_timed_out() - if self._request_state not in [StreamingState.Timeout, - StreamingState.Cancelled, - StreamingState.SyncCancelledPriorToTimeout]: + if self._request_state not in [ + StreamingState.Timeout, + StreamingState.Cancelled, + StreamingState.SyncCancelledPriorToTimeout, + ]: self._request_state = StreamingState.Error if StreamingState.is_okay(self._request_state): @@ -397,7 +398,7 @@ def start_stream(self, core_response: HttpCoreResponse) -> None: def wait_for_stage_notification(self) -> None: if self._stage_notification_ft is None: raise RuntimeError('Stage notification future not created for this context.') - deadline = round(self._request_deadline - time.monotonic(), 6) # round to microseconds + deadline = round(self._request_deadline - time.monotonic(), 6) # round to microseconds if deadline <= 0: raise TimeoutError(message='Request timed out waiting for stage notification', context=str(self._error_ctx)) result_type = self._stage_notification_ft.result(timeout=deadline) diff --git a/couchbase_analytics/protocol/_core/retries.py b/couchbase_analytics/protocol/_core/retries.py index 30ba5bc..ee36873 100644 --- a/couchbase_analytics/protocol/_core/retries.py +++ b/couchbase_analytics/protocol/_core/retries.py @@ -18,16 +18,11 @@ from concurrent.futures import CancelledError from functools import wraps from time import sleep -from typing import (TYPE_CHECKING, - Callable, - Optional, - Union) +from typing import TYPE_CHECKING, Callable, Optional, Union from httpx import ConnectError, ConnectTimeout -from couchbase_analytics.common.errors import (AnalyticsError, - InternalSDKError, - TimeoutError) +from couchbase_analytics.common.errors import AnalyticsError, InternalSDKError, TimeoutError from couchbase_analytics.common.streaming import StreamingState from couchbase_analytics.protocol.errors import WrappedError @@ -38,7 +33,7 @@ class RetryHandler: """ - **INTERNAL** + **INTERNAL** """ @staticmethod @@ -70,12 +65,11 @@ def handle_retry(ex: WrappedError, ctx: RequestContext) -> Optional[Union[BaseEx ex.maybe_set_cause_context(ctx.error_context) err = ex.unwrap() else: - err = AnalyticsError(cause=ex.unwrap(), - message='Retry limit exceeded.', - context=str(ctx.error_context)) + err = AnalyticsError( + cause=ex.unwrap(), message='Retry limit exceeded.', context=str(ctx.error_context) + ) else: - err = TimeoutError(message='Request timed out during retry delay.', - context=str(ctx.error_context)) + err = TimeoutError(message='Request timed out during retry delay.', context=str(ctx.error_context)) if err: return err @@ -113,13 +107,14 @@ def wrapped_fn(self: HttpStreamingResponse) -> None: # noqa: C901 except BaseException as ex: self._request_context.shutdown(ex) if self._request_context.timed_out: - raise TimeoutError(message='Request timeout.', - context=str(self._request_context.error_context)) from None + raise TimeoutError( + message='Request timeout.', context=str(self._request_context.error_context) + ) from None if self._request_context.cancelled: raise CancelledError('Request was cancelled.') from None - raise InternalSDKError(cause=ex, - message=str(ex), - context=str(self._request_context.error_context)) from None + raise InternalSDKError( + cause=ex, message=str(ex), context=str(self._request_context.error_context) + ) from None finally: if not StreamingState.is_okay(self._request_context.request_state): self.close() diff --git a/couchbase_analytics/protocol/cluster.py b/couchbase_analytics/protocol/cluster.py index 0eef0ef..88c6c6c 100644 --- a/couchbase_analytics/protocol/cluster.py +++ b/couchbase_analytics/protocol/cluster.py @@ -17,9 +17,7 @@ import atexit from concurrent.futures import Future, ThreadPoolExecutor -from typing import (TYPE_CHECKING, - Optional, - Union) +from typing import TYPE_CHECKING, Optional, Union from uuid import uuid4 from couchbase_analytics.common.result import BlockingQueryResult @@ -34,13 +32,9 @@ class Cluster: - - def __init__(self, - http_endpoint: str, - credential: Credential, - options: Optional[ClusterOptions] = None, - **kwargs: object) -> None: - + def __init__( + self, http_endpoint: str, credential: Credential, options: Optional[ClusterOptions] = None, **kwargs: object + ) -> None: self._client_adapter = _ClientAdapter(http_endpoint, credential, options, **kwargs) self._request_builder = _RequestBuilder(self._client_adapter) self._cluster_id = str(uuid4()) @@ -57,34 +51,34 @@ def __init__(self, @property def client_adapter(self) -> _ClientAdapter: """ - **INTERNAL** + **INTERNAL** """ return self._client_adapter @property def cluster_id(self) -> str: """ - **INTERNAL** + **INTERNAL** """ return self._cluster_id @property def has_client(self) -> bool: """ - bool: Indicator on if the cluster HTTP client has been created or not. + bool: Indicator on if the cluster HTTP client has been created or not. """ return self._client_adapter.has_client @property def threadpool_executor(self) -> ThreadPoolExecutor: """ - **INTERNAL** + **INTERNAL** """ return self._tp_executor def _shutdown(self) -> None: """ - **INTERNAL** + **INTERNAL** """ self._client_adapter.close_client() self._client_adapter.reset_client() @@ -93,7 +87,7 @@ def _shutdown(self) -> None: def _create_client(self) -> None: """ - **INTERNAL** + **INTERNAL** """ self._client_adapter.create_client() @@ -117,19 +111,16 @@ def shutdown(self) -> None: # TODO: log warning and/or exception? print('Cluster does not have a connection. Ignoring') - def execute_query(self, - statement: str, - *args: object, - **kwargs: object) -> Union[BlockingQueryResult, Future[BlockingQueryResult]]: + def execute_query( + self, statement: str, *args: object, **kwargs: object + ) -> Union[BlockingQueryResult, Future[BlockingQueryResult]]: base_req = self._request_builder.build_base_query_request(statement, *args, **kwargs) lazy_execute = base_req.options.pop('lazy_execute', None) stream_config = base_req.options.pop('stream_config', None) - request_context = RequestContext(self.client_adapter, - base_req, - self.threadpool_executor, - stream_config=stream_config) - resp = HttpStreamingResponse(request_context, - lazy_execute=lazy_execute) + request_context = RequestContext( + self.client_adapter, base_req, self.threadpool_executor, stream_config=stream_config + ) + resp = HttpStreamingResponse(request_context, lazy_execute=lazy_execute) def _execute_query(http_response: HttpStreamingResponse) -> BlockingQueryResult: http_response.send_request() @@ -137,8 +128,12 @@ def _execute_query(http_response: HttpStreamingResponse) -> BlockingQueryResult: if request_context.cancel_enabled is True: if lazy_execute is True: - raise RuntimeError(('Cannot cancel, via cancel token, a query that is executed lazily.' - ' Queries executed lazily can be cancelled only after iteration begins.')) + raise RuntimeError( + ( + 'Cannot cancel, via cancel token, a query that is executed lazily.' + ' Queries executed lazily can be cancelled only after iteration begins.' + ) + ) return request_context.send_request_in_background(_execute_query, resp) else: @@ -147,9 +142,7 @@ def _execute_query(http_response: HttpStreamingResponse) -> BlockingQueryResult: return BlockingQueryResult(resp) @classmethod - def create_instance(cls, - http_endpoint: str, - credential: Credential, - options: Optional[ClusterOptions], - **kwargs: object) -> Cluster: + def create_instance( + cls, http_endpoint: str, credential: Credential, options: Optional[ClusterOptions], **kwargs: object + ) -> Cluster: return cls(http_endpoint, credential, options, **kwargs) diff --git a/couchbase_analytics/protocol/cluster.pyi b/couchbase_analytics/protocol/cluster.pyi index 0be0060..d552809 100644 --- a/couchbase_analytics/protocol/cluster.pyi +++ b/couchbase_analytics/protocol/cluster.pyi @@ -25,185 +25,114 @@ else: from couchbase_analytics import JSONType from couchbase_analytics.common.credential import Credential from couchbase_analytics.common.result import BlockingQueryResult -from couchbase_analytics.options import (ClusterOptions, - ClusterOptionsKwargs, - QueryOptions, - QueryOptionsKwargs) +from couchbase_analytics.options import ClusterOptions, ClusterOptionsKwargs, QueryOptions, QueryOptionsKwargs from couchbase_analytics.protocol._core.client_adapter import _ClientAdapter class Cluster: @overload def __init__(self, http_endpoint: str, credential: Credential) -> None: ... - @overload - def __init__(self, - http_endpoint: str, - credential: Credential, - options: ClusterOptions) -> None: ... - + def __init__(self, http_endpoint: str, credential: Credential, options: ClusterOptions) -> None: ... @overload - def __init__(self, - http_endpoint: str, - credential: Credential, - **kwargs: Unpack[ClusterOptionsKwargs]) -> None: ... - + def __init__(self, http_endpoint: str, credential: Credential, **kwargs: Unpack[ClusterOptionsKwargs]) -> None: ... @overload - def __init__(self, - http_endpoint: str, - credential: Credential, - options: ClusterOptions, - **kwargs: Unpack[ClusterOptionsKwargs]) -> None: ... - + def __init__( + self, + http_endpoint: str, + credential: Credential, + options: ClusterOptions, + **kwargs: Unpack[ClusterOptionsKwargs], + ) -> None: ... @property def client_adapter(self) -> _ClientAdapter: ... - @property def connected(self) -> bool: ... - @property def threadpool_executor(self) -> ThreadPoolExecutor: ... - @overload def execute_query(self, statement: str) -> BlockingQueryResult: ... - @overload - def execute_query(self, - statement: str, - options: QueryOptions) -> BlockingQueryResult: ... - + def execute_query(self, statement: str, options: QueryOptions) -> BlockingQueryResult: ... @overload - def execute_query(self, - statement: str, - **kwargs: Unpack[QueryOptionsKwargs]) -> BlockingQueryResult: ... - + def execute_query(self, statement: str, **kwargs: Unpack[QueryOptionsKwargs]) -> BlockingQueryResult: ... @overload - def execute_query(self, - statement: str, - options: QueryOptions, - **kwargs: Unpack[QueryOptionsKwargs] - ) -> BlockingQueryResult: ... - + def execute_query( + self, statement: str, options: QueryOptions, **kwargs: Unpack[QueryOptionsKwargs] + ) -> BlockingQueryResult: ... @overload - def execute_query(self, - statement: str, - options: QueryOptions, - *args: JSONType, - **kwargs: Unpack[QueryOptionsKwargs]) -> BlockingQueryResult: ... - + def execute_query( + self, statement: str, options: QueryOptions, *args: JSONType, **kwargs: Unpack[QueryOptionsKwargs] + ) -> BlockingQueryResult: ... @overload - def execute_query(self, - statement: str, - options: QueryOptions, - *args: JSONType, - **kwargs: str) -> BlockingQueryResult: ... - + def execute_query( + self, statement: str, options: QueryOptions, *args: JSONType, **kwargs: str + ) -> BlockingQueryResult: ... @overload - def execute_query(self, - statement: str, - *args: JSONType, - **kwargs: str) -> BlockingQueryResult: ... - + def execute_query(self, statement: str, *args: JSONType, **kwargs: str) -> BlockingQueryResult: ... @overload - def execute_query(self, - statement: str, - enable_cancel: bool) -> Future[BlockingQueryResult]: ... - + def execute_query(self, statement: str, enable_cancel: bool) -> Future[BlockingQueryResult]: ... @overload - def execute_query(self, - statement: str, - enable_cancel: bool, - *args: JSONType) -> Future[BlockingQueryResult]: ... - + def execute_query(self, statement: str, enable_cancel: bool, *args: JSONType) -> Future[BlockingQueryResult]: ... @overload - def execute_query(self, - statement: str, - options: QueryOptions, - enable_cancel: bool) -> Future[BlockingQueryResult]: ... - + def execute_query( + self, statement: str, options: QueryOptions, enable_cancel: bool + ) -> Future[BlockingQueryResult]: ... @overload - def execute_query(self, - statement: str, - enable_cancel: bool, - **kwargs: Unpack[QueryOptionsKwargs]) -> Future[BlockingQueryResult]: ... - + def execute_query( + self, statement: str, enable_cancel: bool, **kwargs: Unpack[QueryOptionsKwargs] + ) -> Future[BlockingQueryResult]: ... @overload - def execute_query(self, - statement: str, - options: QueryOptions, - enable_cancel: bool, - **kwargs: Unpack[QueryOptionsKwargs]) -> Future[BlockingQueryResult]: ... - + def execute_query( + self, statement: str, options: QueryOptions, enable_cancel: bool, **kwargs: Unpack[QueryOptionsKwargs] + ) -> Future[BlockingQueryResult]: ... @overload - def execute_query(self, - statement: str, - options: QueryOptions, - enable_cancel: bool, - *args: JSONType, - **kwargs: Unpack[QueryOptionsKwargs]) -> Future[BlockingQueryResult]: ... - + def execute_query( + self, + statement: str, + options: QueryOptions, + enable_cancel: bool, + *args: JSONType, + **kwargs: Unpack[QueryOptionsKwargs], + ) -> Future[BlockingQueryResult]: ... @overload - def execute_query(self, - statement: str, - options: QueryOptions, - *args: JSONType, - enable_cancel: bool, - **kwargs: Unpack[QueryOptionsKwargs]) -> Future[BlockingQueryResult]: ... - - + def execute_query( + self, + statement: str, + options: QueryOptions, + *args: JSONType, + enable_cancel: bool, + **kwargs: Unpack[QueryOptionsKwargs], + ) -> Future[BlockingQueryResult]: ... @overload - def execute_query(self, - statement: str, - options: QueryOptions, - enable_cancel: bool, - *args: JSONType, - **kwargs: str) -> Future[BlockingQueryResult]: ... - + def execute_query( + self, statement: str, options: QueryOptions, enable_cancel: bool, *args: JSONType, **kwargs: str + ) -> Future[BlockingQueryResult]: ... @overload - def execute_query(self, - statement: str, - options: QueryOptions, - *args: JSONType, - enable_cancel: bool, - **kwargs: str) -> Future[BlockingQueryResult]: ... - + def execute_query( + self, statement: str, options: QueryOptions, *args: JSONType, enable_cancel: bool, **kwargs: str + ) -> Future[BlockingQueryResult]: ... @overload - def execute_query(self, - statement: str, - enable_cancel: bool, - *args: JSONType, - **kwargs: str) -> Future[BlockingQueryResult]: ... - + def execute_query( + self, statement: str, enable_cancel: bool, *args: JSONType, **kwargs: str + ) -> Future[BlockingQueryResult]: ... @overload - def execute_query(self, - statement: str, - *args: JSONType, - enable_cancel: bool, - **kwargs: str) -> Future[BlockingQueryResult]: ... - + def execute_query( + self, statement: str, *args: JSONType, enable_cancel: bool, **kwargs: str + ) -> Future[BlockingQueryResult]: ... def shutdown(self) -> None: ... - @overload @classmethod def create_instance(cls, http_endpoint: str, credential: Credential) -> Cluster: ... - @overload @classmethod - def create_instance(cls, - http_endpoint: str, - credential: Credential, - options: ClusterOptions) -> Cluster: ... - + def create_instance(cls, http_endpoint: str, credential: Credential, options: ClusterOptions) -> Cluster: ... @overload @classmethod - def create_instance(cls, - http_endpoint: str, - credential: Credential, - **kwargs: Unpack[ClusterOptionsKwargs]) -> Cluster: ... - + def create_instance( + cls, http_endpoint: str, credential: Credential, **kwargs: Unpack[ClusterOptionsKwargs] + ) -> Cluster: ... @overload @classmethod - def create_instance(cls, - http_endpoint: str, - credential: Credential, - options: ClusterOptions, - **kwargs: Unpack[ClusterOptionsKwargs]) -> Cluster: ... + def create_instance( + cls, http_endpoint: str, credential: Credential, options: ClusterOptions, **kwargs: Unpack[ClusterOptionsKwargs] + ) -> Cluster: ... diff --git a/couchbase_analytics/protocol/connection.py b/couchbase_analytics/protocol/connection.py index f950688..6279478 100644 --- a/couchbase_analytics/protocol/connection.py +++ b/couchbase_analytics/protocol/connection.py @@ -17,13 +17,7 @@ import ssl from dataclasses import dataclass -from typing import (TYPE_CHECKING, - Dict, - List, - Optional, - Tuple, - TypedDict, - cast) +from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, TypedDict, cast from urllib.parse import parse_qs, urlparse from couchbase_analytics.common._core._certificates import _Certificates @@ -31,14 +25,14 @@ from couchbase_analytics.common._core.utils import is_null_or_empty from couchbase_analytics.common.credential import Credential from couchbase_analytics.common.deserializer import DefaultJsonDeserializer, Deserializer -from couchbase_analytics.common.options import (ClusterOptions, - SecurityOptions, - TimeoutOptions) +from couchbase_analytics.common.options import ClusterOptions, SecurityOptions, TimeoutOptions from couchbase_analytics.common.request import RequestURL -from couchbase_analytics.protocol.options import (ClusterOptionsTransformedKwargs, - QueryStrVal, - SecurityOptionsTransformedKwargs, - TimeoutOptionsTransformedKwargs) +from couchbase_analytics.protocol.options import ( + ClusterOptionsTransformedKwargs, + QueryStrVal, + SecurityOptionsTransformedKwargs, + TimeoutOptionsTransformedKwargs, +) if TYPE_CHECKING: from couchbase_analytics.protocol.options import OptionsBuilder @@ -60,8 +54,9 @@ class DefaultTimeouts(TypedDict): DEFAULT_MAX_RETRIES: int = 7 + def parse_http_endpoint(http_endpoint: str) -> Tuple[RequestURL, Dict[str, List[str]]]: - """ **INTERNAL** + """**INTERNAL** Parse the provided HTTP endpoint @@ -96,14 +91,12 @@ def parse_http_endpoint(http_endpoint: str) -> Tuple[RequestURL, Dict[str, List[ if not is_null_or_empty(parsed_endpoint.path): raise ValueError('The SDK does not currently support HTTP endpoint paths.') - url = RequestURL(scheme=parsed_endpoint.scheme, - host=host, - port=port) + url = RequestURL(scheme=parsed_endpoint.scheme, host=host, port=port) return url, parse_qs(parsed_endpoint.query) -def parse_query_string_value(value: List[str], enforce_str: Optional[bool]=False) -> QueryStrVal: +def parse_query_string_value(value: List[str], enforce_str: Optional[bool] = False) -> QueryStrVal: """Parse a query string value The provided value is a list of at least one element. Returns either a list of strings or a single element @@ -158,6 +151,7 @@ class _ConnectionDetails: """ **INTERNAL** """ + url: RequestURL cluster_options: ClusterOptionsTransformedKwargs credential: Tuple[bytes, bytes] @@ -194,10 +188,16 @@ def validate_security_options(self) -> None: # separate between value options and boolean option (trust_only_capella) solo_security_opts = ['trust_only_pem_file', 'trust_only_pem_str', 'trust_only_certificates'] trust_capella = security_opts.get('trust_only_capella', None) - security_opt_count = sum((1 if security_opts.get(opt, None) is not None else 0 for opt in solo_security_opts)) # noqa: E501 + security_opt_count = sum( + (1 if security_opts.get(opt, None) is not None else 0 for opt in solo_security_opts) + ) # noqa: E501 if security_opt_count > 1 or (security_opt_count == 1 and trust_capella is True): - raise ValueError(('Can only set one of the following options: ' - f'[{", ".join(["trust_only_capella"] + solo_security_opts)}]')) + raise ValueError( + ( + 'Can only set one of the following options: ' + f'[{", ".join(["trust_only_capella"] + solo_security_opts)}]' + ) + ) if not self.is_secure(): return @@ -231,29 +231,29 @@ def validate_security_options(self) -> None: self.ssl_context.check_hostname = True self.ssl_context.verify_mode = ssl.CERT_REQUIRED - @classmethod - def create(cls, - opts_builder: OptionsBuilder, - http_endpoint: str, - credential: Credential, - options: Optional[object] = None, - **kwargs: object) -> _ConnectionDetails: + def create( + cls, + opts_builder: OptionsBuilder, + http_endpoint: str, + credential: Credential, + options: Optional[object] = None, + **kwargs: object, + ) -> _ConnectionDetails: url, query_str_opts = parse_http_endpoint(http_endpoint) - cluster_opts = opts_builder.build_cluster_options(ClusterOptions, - ClusterOptionsTransformedKwargs, - kwargs, - options, - query_str_opts=parse_query_str_options(query_str_opts)) + cluster_opts = opts_builder.build_cluster_options( + ClusterOptions, + ClusterOptionsTransformedKwargs, + kwargs, + options, + query_str_opts=parse_query_str_options(query_str_opts), + ) default_deserializer = cluster_opts.pop('deserializer', None) if default_deserializer is None: default_deserializer = DefaultJsonDeserializer() - conn_dtls = cls(url, - cluster_opts, - credential.astuple(), - default_deserializer) + conn_dtls = cls(url, cluster_opts, credential.astuple(), default_deserializer) conn_dtls.validate_security_options() return conn_dtls diff --git a/couchbase_analytics/protocol/database.py b/couchbase_analytics/protocol/database.py index cf0ac91..c1e9b34 100644 --- a/couchbase_analytics/protocol/database.py +++ b/couchbase_analytics/protocol/database.py @@ -33,21 +33,21 @@ def __init__(self, cluster: Cluster, database_name: str) -> None: @property def client_adapter(self) -> _ClientAdapter: """ - **INTERNAL** + **INTERNAL** """ return self._cluster.client_adapter @property def name(self) -> str: """ - str: The name of this :class:`~couchbase_analytics.protocol.database.Database` instance. + str: The name of this :class:`~couchbase_analytics.protocol.database.Database` instance. """ return self._database_name @property def threadpool_executor(self) -> ThreadPoolExecutor: """ - **INTERNAL** + **INTERNAL** """ return self._cluster.threadpool_executor diff --git a/couchbase_analytics/protocol/database.pyi b/couchbase_analytics/protocol/database.pyi index 6e8052a..f5d21ef 100644 --- a/couchbase_analytics/protocol/database.pyi +++ b/couchbase_analytics/protocol/database.pyi @@ -21,14 +21,10 @@ from couchbase_analytics.protocol.scope import Scope class Database: def __init__(self, cluster: Cluster, database_name: str) -> None: ... - @property def client_adapter(self) -> _ClientAdapter: ... - @property def name(self) -> str: ... - @property def threadpool_executor(self) -> ThreadPoolExecutor: ... - def scope(self, scope_name: str) -> Scope: ... diff --git a/couchbase_analytics/protocol/errors.py b/couchbase_analytics/protocol/errors.py index 901325f..b25417b 100644 --- a/couchbase_analytics/protocol/errors.py +++ b/couchbase_analytics/protocol/errors.py @@ -18,13 +18,7 @@ import socket import sys from functools import wraps -from typing import (Any, - Callable, - Dict, - List, - NamedTuple, - Optional, - Union) +from typing import Any, Callable, Dict, List, NamedTuple, Optional, Union if sys.version_info < (3, 10): from typing_extensions import TypeAlias @@ -32,22 +26,22 @@ from typing import TypeAlias from couchbase_analytics.common._core.error_context import ErrorContext -from couchbase_analytics.common.errors import (AnalyticsError, - InternalSDKError, - InvalidCredentialError, - QueryError, - TimeoutError) - -AnalyticsClientError: TypeAlias = Union[AnalyticsError, - InternalSDKError, - QueryError, - RuntimeError, - ValueError] +from couchbase_analytics.common.errors import ( + AnalyticsError, + InternalSDKError, + InvalidCredentialError, + QueryError, + TimeoutError, +) + +AnalyticsClientError: TypeAlias = Union[AnalyticsError, InternalSDKError, QueryError, RuntimeError, ValueError] + class ServerQueryError(NamedTuple): """ **INTERNAL** """ + code: int message: str retriable: bool = False @@ -79,10 +73,9 @@ def from_json(cls, json_data: Dict[str, Any]) -> ServerQueryError: retriable = bool(json_data.get('retriable', False)) return cls(code=code, message=message, retriable=retriable) + class WrappedError(Exception): - def __init__(self, - cause: Union[BaseException, Exception], - retriable: bool = False) -> None: + def __init__(self, cause: Union[BaseException, Exception], retriable: bool = False) -> None: super().__init__() self._cause = cause self._retriable = retriable @@ -118,15 +111,15 @@ def __repr__(self) -> str: def __str__(self) -> str: return self.__repr__() + # Python does not specify which socket errors are retriable or not, although there is a EAI_AGAIN error # that is commented to be temporary. The current version of the RFC has connect failures as retriable. # https://github.com/python/cpython/blob/0f866cbfefd797b4dae25962457c5579bb90dde5/Modules/addrinfo.h#L58-L71 -class ErrorMapper: +class ErrorMapper: @staticmethod def build_error_from_http_status_code(message: str, context: ErrorContext) -> WrappedError: - if context.status_code == 503: return WrappedError(AnalyticsError(context=str(context), message=message), retriable=True) @@ -178,7 +171,7 @@ def wrapped_fn(host: str, port: int) -> str: return fn(host, port) except socket.gaierror as ex: print(f'getaddrinfo failed for {host}:{port} with error: {ex}') - msg='Connection error occurred while sending request.' + msg = 'Connection error occurred while sending request.' raise WrappedError(AnalyticsError(cause=ex, message=msg), retriable=True) from None return wrapped_fn diff --git a/couchbase_analytics/protocol/options.py b/couchbase_analytics/protocol/options.py index 955edf9..e6830e8 100644 --- a/couchbase_analytics/protocol/options.py +++ b/couchbase_analytics/protocol/options.py @@ -16,38 +16,35 @@ from __future__ import annotations from copy import copy -from typing import (Any, - Callable, - Dict, - List, - Literal, - Optional, - Tuple, - TypedDict, - TypeVar, - Union) +from typing import Any, Callable, Dict, List, Literal, Optional, Tuple, TypedDict, TypeVar, Union from couchbase_analytics.common._core import JsonStreamConfig -from couchbase_analytics.common._core.utils import (VALIDATE_BOOL, - VALIDATE_DESERIALIZER, - VALIDATE_INT, - VALIDATE_STR, - VALIDATE_STR_LIST, - EnumToStr, - to_seconds, - validate_path, - validate_raw_dict) +from couchbase_analytics.common._core.utils import ( + VALIDATE_BOOL, + VALIDATE_DESERIALIZER, + VALIDATE_INT, + VALIDATE_STR, + VALIDATE_STR_LIST, + EnumToStr, + to_seconds, + validate_path, + validate_raw_dict, +) from couchbase_analytics.common.deserializer import Deserializer from couchbase_analytics.common.enums import QueryScanConsistency -from couchbase_analytics.common.options import (ClusterOptions, - OptionsClass, - QueryOptions, - SecurityOptions, - TimeoutOptions) -from couchbase_analytics.common.options_base import (ClusterOptionsValidKeys, - QueryOptionsValidKeys, - SecurityOptionsValidKeys, - TimeoutOptionsValidKeys) +from couchbase_analytics.common.options import ( + ClusterOptions, + OptionsClass, + QueryOptions, + SecurityOptions, + TimeoutOptions, +) +from couchbase_analytics.common.options_base import ( + ClusterOptionsValidKeys, + QueryOptionsValidKeys, + SecurityOptionsValidKeys, + TimeoutOptionsValidKeys, +) QUERY_CONSISTENCY_TO_STR = EnumToStr[QueryScanConsistency]() @@ -81,8 +78,9 @@ class SecurityOptionsTransforms(TypedDict): trust_only_pem_file: Dict[Literal['trust_only_pem_file'], Callable[[Any], str]] trust_only_pem_str: Dict[Literal['trust_only_pem_str'], Callable[[Any], str]] trust_only_certificates: Dict[Literal['trust_only_certificates'], Callable[[Any], List[str]]] - disable_server_certificate_verification: Dict[Literal['disable_server_certificate_verification'], - Callable[[Any], bool]] + disable_server_certificate_verification: Dict[ + Literal['disable_server_certificate_verification'], Callable[[Any], bool] + ] SECURITY_OPTIONS_TRANSFORMS: SecurityOptionsTransforms = { @@ -138,14 +136,14 @@ class QueryOptionsTransforms(TypedDict): 'deserializer': {'deserializer': VALIDATE_DESERIALIZER}, 'lazy_execute': {'lazy_execute': VALIDATE_BOOL}, 'max_retries': {'max_retries': VALIDATE_INT}, - 'named_parameters': {'named_parameters': lambda x: x}, - 'positional_parameters': {'positional_parameters': lambda x: x}, + 'named_parameters': {'named_parameters': lambda x: x}, + 'positional_parameters': {'positional_parameters': lambda x: x}, 'query_context': {'query_context': VALIDATE_STR}, 'raw': {'raw': validate_raw_dict}, 'readonly': {'readonly': VALIDATE_BOOL}, 'scan_consistency': {'scan_consistency': QUERY_CONSISTENCY_TO_STR}, - 'stream_config': {'stream_config': lambda x: x}, - 'timeout': {'timeout': to_seconds} + 'stream_config': {'stream_config': lambda x: x}, + 'timeout': {'timeout': to_seconds}, } @@ -165,33 +163,37 @@ class QueryOptionsTransformedKwargs(TypedDict, total=False): timeout: Optional[float] -TransformedOptionKwargs = TypeVar('TransformedOptionKwargs', - QueryOptionsTransformedKwargs, - ClusterOptionsTransformedKwargs, - SecurityOptionsTransformedKwargs, - TimeoutOptionsTransformedKwargs) +TransformedOptionKwargs = TypeVar( + 'TransformedOptionKwargs', + QueryOptionsTransformedKwargs, + ClusterOptionsTransformedKwargs, + SecurityOptionsTransformedKwargs, + TimeoutOptionsTransformedKwargs, +) -TransformedClusterOptionKwargs = TypeVar('TransformedClusterOptionKwargs', - ClusterOptionsTransformedKwargs, - SecurityOptionsTransformedKwargs, - TimeoutOptionsTransformedKwargs) +TransformedClusterOptionKwargs = TypeVar( + 'TransformedClusterOptionKwargs', + ClusterOptionsTransformedKwargs, + SecurityOptionsTransformedKwargs, + TimeoutOptionsTransformedKwargs, +) -TransformDetailsPair = Union[Tuple[List[QueryOptionsValidKeys], QueryOptionsTransforms], - Tuple[List[ClusterOptionsValidKeys], ClusterOptionsTransforms], - Tuple[List[SecurityOptionsValidKeys], SecurityOptionsTransforms], - Tuple[List[TimeoutOptionsValidKeys], TimeoutOptionsTransforms], - ] +TransformDetailsPair = Union[ + Tuple[List[QueryOptionsValidKeys], QueryOptionsTransforms], + Tuple[List[ClusterOptionsValidKeys], ClusterOptionsTransforms], + Tuple[List[SecurityOptionsValidKeys], SecurityOptionsTransforms], + Tuple[List[TimeoutOptionsValidKeys], TimeoutOptionsTransforms], +] class OptionsBuilder: """ - **INTERNAL** + **INTERNAL** """ - def _get_options_copy(self, - options_class: type[OptionsClass], - orig_kwargs: Dict[str, object], - options: Optional[object] = None) -> Dict[str, object]: + def _get_options_copy( + self, options_class: type[OptionsClass], orig_kwargs: Dict[str, object], options: Optional[object] = None + ) -> Dict[str, object]: orig_kwargs = copy(orig_kwargs) if orig_kwargs else {} # set our options base dict() temp_options: Dict[str, object] = {} @@ -205,7 +207,6 @@ def _get_options_copy(self, return temp_options def _get_transform_details(self, option_type: str) -> TransformDetailsPair: # noqa: C901 - if option_type == 'ClusterOptions': return ClusterOptions.VALID_OPTION_KEYS, CLUSTER_OPTIONS_TRANSFORMS elif option_type == 'SecurityOptions': @@ -217,13 +218,14 @@ def _get_transform_details(self, option_type: str) -> TransformDetailsPair: # n else: raise ValueError('Invalid OptionType.') - def build_cluster_options(self, # noqa: C901 - option_type: type[OptionsClass], - output_type: type[TransformedClusterOptionKwargs], - orig_kwargs: Dict[str, object], - options: Optional[object] = None, - query_str_opts: Optional[Dict[str, QueryStrVal]] = None - ) -> TransformedClusterOptionKwargs: + def build_cluster_options( # noqa: C901 + self, + option_type: type[OptionsClass], + output_type: type[TransformedClusterOptionKwargs], + orig_kwargs: Dict[str, object], + options: Optional[object] = None, + query_str_opts: Optional[Dict[str, QueryStrVal]] = None, + ) -> TransformedClusterOptionKwargs: temp_options = self._get_options_copy(option_type, orig_kwargs, options) # we flatten all the nested options (timeout_options & security_options) @@ -249,25 +251,21 @@ def build_cluster_options(self, # noqa: C901 for k, v in query_str_opts.items(): temp_options[k] = v - keys_to_ignore: List[str] = [*ClusterOptions.VALID_OPTION_KEYS, - *TimeoutOptions.VALID_OPTION_KEYS] + keys_to_ignore: List[str] = [*ClusterOptions.VALID_OPTION_KEYS, *TimeoutOptions.VALID_OPTION_KEYS] # not going to be able to make mypy happy w/ keys_to_ignore :/ - transformed_security_opts = self.build_options(SecurityOptions, - SecurityOptionsTransformedKwargs, - temp_options, - keys_to_ignore=keys_to_ignore) + transformed_security_opts = self.build_options( + SecurityOptions, SecurityOptionsTransformedKwargs, temp_options, keys_to_ignore=keys_to_ignore + ) if transformed_security_opts: temp_options['security_options'] = transformed_security_opts - keys_to_ignore = [*ClusterOptions.VALID_OPTION_KEYS, - *SecurityOptions.VALID_OPTION_KEYS] + keys_to_ignore = [*ClusterOptions.VALID_OPTION_KEYS, *SecurityOptions.VALID_OPTION_KEYS] # not going to be able to make mypy happy w/ keys_to_ignore :/ - transformed_timeout_opts = self.build_options(TimeoutOptions, - TimeoutOptionsTransformedKwargs, - temp_options, - keys_to_ignore=keys_to_ignore) + transformed_timeout_opts = self.build_options( + TimeoutOptions, TimeoutOptionsTransformedKwargs, temp_options, keys_to_ignore=keys_to_ignore + ) if transformed_timeout_opts: temp_options['timeout_options'] = transformed_timeout_opts @@ -276,14 +274,14 @@ def build_cluster_options(self, # noqa: C901 return transformed_opts - def build_options(self, - option_type: type[OptionsClass], - output_type: type[TransformedOptionKwargs], - orig_kwargs: Dict[str, object], - options: Optional[object] = None, - keys_to_ignore: Optional[List[str]] = None - ) -> TransformedOptionKwargs: - + def build_options( + self, + option_type: type[OptionsClass], + output_type: type[TransformedOptionKwargs], + orig_kwargs: Dict[str, object], + options: Optional[object] = None, + keys_to_ignore: Optional[List[str]] = None, + ) -> TransformedOptionKwargs: temp_options = self._get_options_copy(option_type, orig_kwargs, options) transformed_opts: TransformedOptionKwargs = {} # Option 1 satisfies mypy, but we want temp_options to be the limiting factor for the loop. diff --git a/couchbase_analytics/protocol/scope.py b/couchbase_analytics/protocol/scope.py index 650bb6c..6037268 100644 --- a/couchbase_analytics/protocol/scope.py +++ b/couchbase_analytics/protocol/scope.py @@ -29,7 +29,6 @@ class Scope: - def __init__(self, database: Database, scope_name: str) -> None: self._database = database self._scope_name = scope_name @@ -38,37 +37,34 @@ def __init__(self, database: Database, scope_name: str) -> None: @property def client_adapter(self) -> _ClientAdapter: """ - **INTERNAL** + **INTERNAL** """ return self._database.client_adapter @property def name(self) -> str: """ - str: The name of this :class:`~couchbase_analytics.protocol.scope.Scope` instance. + str: The name of this :class:`~couchbase_analytics.protocol.scope.Scope` instance. """ return self._scope_name @property def threadpool_executor(self) -> ThreadPoolExecutor: """ - **INTERNAL** + **INTERNAL** """ return self._database.threadpool_executor - def execute_query(self, - statement: str, - *args: object, - **kwargs: object) -> Union[BlockingQueryResult, Future[BlockingQueryResult]]: + def execute_query( + self, statement: str, *args: object, **kwargs: object + ) -> Union[BlockingQueryResult, Future[BlockingQueryResult]]: base_req = self._request_builder.build_base_query_request(statement, *args, **kwargs) lazy_execute = base_req.options.pop('lazy_execute', None) stream_config = base_req.options.pop('stream_config', None) - request_context = RequestContext(self.client_adapter, - base_req, - self.threadpool_executor, - stream_config=stream_config) - resp = HttpStreamingResponse(request_context, - lazy_execute=lazy_execute) + request_context = RequestContext( + self.client_adapter, base_req, self.threadpool_executor, stream_config=stream_config + ) + resp = HttpStreamingResponse(request_context, lazy_execute=lazy_execute) def _execute_query(http_response: HttpStreamingResponse) -> BlockingQueryResult: http_response.send_request() @@ -76,8 +72,12 @@ def _execute_query(http_response: HttpStreamingResponse) -> BlockingQueryResult: if request_context.cancel_enabled is True: if lazy_execute is True: - raise RuntimeError(('Cannot cancel, via cancel token, a query that is executed lazily.' - ' Queries executed lazily can be cancelled only after iteration begins.')) + raise RuntimeError( + ( + 'Cannot cancel, via cancel token, a query that is executed lazily.' + ' Queries executed lazily can be cancelled only after iteration begins.' + ) + ) return request_context.send_request_in_background(_execute_query, resp) else: if lazy_execute is not True: diff --git a/couchbase_analytics/protocol/scope.pyi b/couchbase_analytics/protocol/scope.pyi index aec42e9..4e4914f 100644 --- a/couchbase_analytics/protocol/scope.pyi +++ b/couchbase_analytics/protocol/scope.pyi @@ -30,129 +30,79 @@ from couchbase_analytics.protocol.database import Database as Database class Scope: def __init__(self, database: Database, scope_name: str) -> None: ... - @property def client_adapter(self) -> _ClientAdapter: ... - @property def name(self) -> str: ... - @property def threadpool_executor(self) -> ThreadPoolExecutor: ... - @overload def execute_query(self, statement: str) -> BlockingQueryResult: ... - @overload - def execute_query(self, - statement: str, - options: QueryOptions) -> BlockingQueryResult: ... - + def execute_query(self, statement: str, options: QueryOptions) -> BlockingQueryResult: ... @overload - def execute_query(self, - statement: str, - **kwargs: Unpack[QueryOptionsKwargs]) -> BlockingQueryResult: ... - + def execute_query(self, statement: str, **kwargs: Unpack[QueryOptionsKwargs]) -> BlockingQueryResult: ... @overload - def execute_query(self, - statement: str, - options: QueryOptions, - **kwargs: Unpack[QueryOptionsKwargs] - ) -> BlockingQueryResult: ... - + def execute_query( + self, statement: str, options: QueryOptions, **kwargs: Unpack[QueryOptionsKwargs] + ) -> BlockingQueryResult: ... @overload - def execute_query(self, - statement: str, - options: QueryOptions, - *args: JSONType, - **kwargs: Unpack[QueryOptionsKwargs]) -> BlockingQueryResult: ... - + def execute_query( + self, statement: str, options: QueryOptions, *args: JSONType, **kwargs: Unpack[QueryOptionsKwargs] + ) -> BlockingQueryResult: ... @overload - def execute_query(self, - statement: str, - options: QueryOptions, - *args: JSONType, - **kwargs: str) -> BlockingQueryResult: ... - + def execute_query( + self, statement: str, options: QueryOptions, *args: JSONType, **kwargs: str + ) -> BlockingQueryResult: ... @overload - def execute_query(self, - statement: str, - *args: JSONType, - **kwargs: str) -> BlockingQueryResult: ... - + def execute_query(self, statement: str, *args: JSONType, **kwargs: str) -> BlockingQueryResult: ... @overload - def execute_query(self, - statement: str, - enable_cancel: bool) -> Future[BlockingQueryResult]: ... - + def execute_query(self, statement: str, enable_cancel: bool) -> Future[BlockingQueryResult]: ... @overload - def execute_query(self, - statement: str, - enable_cancel: bool, - *args: JSONType) -> Future[BlockingQueryResult]: ... - + def execute_query(self, statement: str, enable_cancel: bool, *args: JSONType) -> Future[BlockingQueryResult]: ... @overload - def execute_query(self, - statement: str, - options: QueryOptions, - enable_cancel: bool) -> Future[BlockingQueryResult]: ... - + def execute_query( + self, statement: str, options: QueryOptions, enable_cancel: bool + ) -> Future[BlockingQueryResult]: ... @overload - def execute_query(self, - statement: str, - enable_cancel: bool, - **kwargs: Unpack[QueryOptionsKwargs]) -> Future[BlockingQueryResult]: ... - + def execute_query( + self, statement: str, enable_cancel: bool, **kwargs: Unpack[QueryOptionsKwargs] + ) -> Future[BlockingQueryResult]: ... @overload - def execute_query(self, - statement: str, - options: QueryOptions, - enable_cancel: bool, - **kwargs: Unpack[QueryOptionsKwargs]) -> Future[BlockingQueryResult]: ... - + def execute_query( + self, statement: str, options: QueryOptions, enable_cancel: bool, **kwargs: Unpack[QueryOptionsKwargs] + ) -> Future[BlockingQueryResult]: ... @overload - def execute_query(self, - statement: str, - options: QueryOptions, - enable_cancel: bool, - *args: JSONType, - **kwargs: Unpack[QueryOptionsKwargs]) -> Future[BlockingQueryResult]: ... - + def execute_query( + self, + statement: str, + options: QueryOptions, + enable_cancel: bool, + *args: JSONType, + **kwargs: Unpack[QueryOptionsKwargs], + ) -> Future[BlockingQueryResult]: ... @overload - def execute_query(self, - statement: str, - options: QueryOptions, - *args: JSONType, - enable_cancel: bool, - **kwargs: Unpack[QueryOptionsKwargs]) -> Future[BlockingQueryResult]: ... - - + def execute_query( + self, + statement: str, + options: QueryOptions, + *args: JSONType, + enable_cancel: bool, + **kwargs: Unpack[QueryOptionsKwargs], + ) -> Future[BlockingQueryResult]: ... @overload - def execute_query(self, - statement: str, - options: QueryOptions, - enable_cancel: bool, - *args: JSONType, - **kwargs: str) -> Future[BlockingQueryResult]: ... - + def execute_query( + self, statement: str, options: QueryOptions, enable_cancel: bool, *args: JSONType, **kwargs: str + ) -> Future[BlockingQueryResult]: ... @overload - def execute_query(self, - statement: str, - options: QueryOptions, - *args: JSONType, - enable_cancel: bool, - **kwargs: str) -> Future[BlockingQueryResult]: ... - + def execute_query( + self, statement: str, options: QueryOptions, *args: JSONType, enable_cancel: bool, **kwargs: str + ) -> Future[BlockingQueryResult]: ... @overload - def execute_query(self, - statement: str, - enable_cancel: bool, - *args: JSONType, - **kwargs: str) -> Future[BlockingQueryResult]: ... - + def execute_query( + self, statement: str, enable_cancel: bool, *args: JSONType, **kwargs: str + ) -> Future[BlockingQueryResult]: ... @overload - def execute_query(self, - statement: str, - *args: JSONType, - enable_cancel: bool, - **kwargs: str) -> Future[BlockingQueryResult]: ... + def execute_query( + self, statement: str, *args: JSONType, enable_cancel: bool, **kwargs: str + ) -> Future[BlockingQueryResult]: ... diff --git a/couchbase_analytics/protocol/streaming.py b/couchbase_analytics/protocol/streaming.py index 09b4541..950962d 100644 --- a/couchbase_analytics/protocol/streaming.py +++ b/couchbase_analytics/protocol/streaming.py @@ -22,18 +22,14 @@ from couchbase_analytics.common._core import ParsedResult, ParsedResultType from couchbase_analytics.common._core.query import build_query_metadata -from couchbase_analytics.common.errors import (AnalyticsError, - InternalSDKError, - TimeoutError) +from couchbase_analytics.common.errors import AnalyticsError, InternalSDKError, TimeoutError from couchbase_analytics.common.query import QueryMetadata from couchbase_analytics.protocol._core.request_context import RequestContext from couchbase_analytics.protocol._core.retries import RetryHandler class HttpStreamingResponse: - def __init__(self, - request_context: RequestContext, - lazy_execute: Optional[bool] = None) -> None: + def __init__(self, request_context: RequestContext, lazy_execute: Optional[bool] = None) -> None: self._request_context = request_context if lazy_execute is not None: self._lazy_execute = lazy_execute @@ -55,27 +51,29 @@ def _handle_iteration_abort(self) -> None: self._request_context.shutdown() raise StopIteration elif self._request_context.timed_out: - err = TimeoutError(message='Unable to complete iteration. Request timed out.', - context=str(self._request_context.error_context)) + err = TimeoutError( + message='Unable to complete iteration. Request timed out.', + context=str(self._request_context.error_context), + ) self._request_context.shutdown(err) raise err else: self._request_context.shutdown() raise StopIteration - def _process_response(self, - raw_response: Optional[ParsedResult]=None, - handle_context_shutdown: Optional[bool]=False) -> None: - json_response = self._request_context.process_response(self.close, - raw_response=raw_response, - handle_context_shutdown=handle_context_shutdown) + def _process_response( + self, raw_response: Optional[ParsedResult] = None, handle_context_shutdown: Optional[bool] = False + ) -> None: + json_response = self._request_context.process_response( + self.close, raw_response=raw_response, handle_context_shutdown=handle_context_shutdown + ) self.set_metadata(json_data=json_response) def close(self) -> None: """ **INTERNAL** """ - if hasattr(self,'_core_response'): + if hasattr(self, '_core_response'): self._core_response.close() del self._core_response @@ -87,15 +85,12 @@ def cancel(self) -> None: self._request_context.cancel_request() self._request_context.shutdown() - def get_metadata(self) -> QueryMetadata: if self._metadata is None: raise RuntimeError('Query metadata is only available after all rows have been iterated.') return self._metadata - def set_metadata(self, - json_data: Optional[Any]=None, - raw_metadata: Optional[bytes]=None) -> None: + def set_metadata(self, json_data: Optional[Any] = None, raw_metadata: Optional[bytes] = None) -> None: try: self._metadata = QueryMetadata(build_query_metadata(json_data=json_data, raw_metadata=raw_metadata)) self._request_context.shutdown() @@ -103,20 +98,20 @@ def set_metadata(self, self._request_context.shutdown(err) raise err except Exception as ex: - internal_err = InternalSDKError(cause=ex, - message=str(ex), - context=str(self._request_context.error_context)) + internal_err = InternalSDKError(cause=ex, message=str(ex), context=str(self._request_context.error_context)) self._request_context.shutdown(internal_err) finally: self.close() def get_next_row(self) -> Any: """ - **INTERNAL** + **INTERNAL** """ - if not (hasattr(self, '_core_response') - and self._core_response is not None - and self._request_context.okay_to_iterate): + if not ( + hasattr(self, '_core_response') + and self._core_response is not None + and self._request_context.okay_to_iterate + ): self._handle_iteration_abort() self._request_context.maybe_continue_to_process_stream() @@ -131,8 +126,10 @@ def get_next_row(self) -> Any: continue if raw_response.result_type == ParsedResultType.ROW: if raw_response.value is None: - err = AnalyticsError(message='Unexpected empty row response while streaming.', - context=str(self._request_context.error_context)) + err = AnalyticsError( + message='Unexpected empty row response while streaming.', + context=str(self._request_context.error_context), + ) self._request_context.shutdown(err) self.close() raise err diff --git a/couchbase_analytics/scope.py b/couchbase_analytics/scope.py index a2a8970..02ba341 100644 --- a/couchbase_analytics/scope.py +++ b/couchbase_analytics/scope.py @@ -37,19 +37,19 @@ class Scope: def __init__(self, database: Database, scope_name: str) -> None: from couchbase_analytics.protocol.scope import Scope as _Scope + self._impl = _Scope(database, scope_name) @property def name(self) -> str: """ - str: The name of this :class:`~couchbase_analytics.scope.Scope` instance. + str: The name of this :class:`~couchbase_analytics.scope.Scope` instance. """ return self._impl.name - def execute_query(self, - statement: str, - *args: object, - **kwargs: object) -> Union[Future[BlockingQueryResult], BlockingQueryResult]: + def execute_query( + self, statement: str, *args: object, **kwargs: object + ) -> Union[Future[BlockingQueryResult], BlockingQueryResult]: """Executes a query against an Analytics scope. .. note:: diff --git a/couchbase_analytics/scope.pyi b/couchbase_analytics/scope.pyi index 913b6d8..3486c4e 100644 --- a/couchbase_analytics/scope.pyi +++ b/couchbase_analytics/scope.pyi @@ -29,123 +29,75 @@ from couchbase_analytics.result import BlockingQueryResult class Scope: def __init__(self, database: Database, scope_name: str) -> None: ... - @property def name(self) -> str: ... - @overload def execute_query(self, statement: str) -> BlockingQueryResult: ... - @overload - def execute_query(self, - statement: str, - options: QueryOptions) -> BlockingQueryResult: ... - + def execute_query(self, statement: str, options: QueryOptions) -> BlockingQueryResult: ... @overload - def execute_query(self, - statement: str, - **kwargs: Unpack[QueryOptionsKwargs]) -> BlockingQueryResult: ... - + def execute_query(self, statement: str, **kwargs: Unpack[QueryOptionsKwargs]) -> BlockingQueryResult: ... @overload - def execute_query(self, - statement: str, - options: QueryOptions, - **kwargs: Unpack[QueryOptionsKwargs] - ) -> BlockingQueryResult: ... - + def execute_query( + self, statement: str, options: QueryOptions, **kwargs: Unpack[QueryOptionsKwargs] + ) -> BlockingQueryResult: ... @overload - def execute_query(self, - statement: str, - options: QueryOptions, - *args: JSONType, - **kwargs: Unpack[QueryOptionsKwargs]) -> BlockingQueryResult: ... - + def execute_query( + self, statement: str, options: QueryOptions, *args: JSONType, **kwargs: Unpack[QueryOptionsKwargs] + ) -> BlockingQueryResult: ... @overload - def execute_query(self, - statement: str, - options: QueryOptions, - *args: JSONType, - **kwargs: str) -> BlockingQueryResult: ... - + def execute_query( + self, statement: str, options: QueryOptions, *args: JSONType, **kwargs: str + ) -> BlockingQueryResult: ... @overload - def execute_query(self, - statement: str, - *args: JSONType, - **kwargs: str) -> BlockingQueryResult: ... - + def execute_query(self, statement: str, *args: JSONType, **kwargs: str) -> BlockingQueryResult: ... @overload - def execute_query(self, - statement: str, - enable_cancel: bool) -> Future[BlockingQueryResult]: ... - + def execute_query(self, statement: str, enable_cancel: bool) -> Future[BlockingQueryResult]: ... @overload - def execute_query(self, - statement: str, - enable_cancel: bool, - *args: JSONType) -> Future[BlockingQueryResult]: ... - + def execute_query(self, statement: str, enable_cancel: bool, *args: JSONType) -> Future[BlockingQueryResult]: ... @overload - def execute_query(self, - statement: str, - options: QueryOptions, - enable_cancel: bool) -> Future[BlockingQueryResult]: ... - + def execute_query( + self, statement: str, options: QueryOptions, enable_cancel: bool + ) -> Future[BlockingQueryResult]: ... @overload - def execute_query(self, - statement: str, - enable_cancel: bool, - **kwargs: Unpack[QueryOptionsKwargs]) -> Future[BlockingQueryResult]: ... - + def execute_query( + self, statement: str, enable_cancel: bool, **kwargs: Unpack[QueryOptionsKwargs] + ) -> Future[BlockingQueryResult]: ... @overload - def execute_query(self, - statement: str, - options: QueryOptions, - enable_cancel: bool, - **kwargs: Unpack[QueryOptionsKwargs]) -> Future[BlockingQueryResult]: ... - + def execute_query( + self, statement: str, options: QueryOptions, enable_cancel: bool, **kwargs: Unpack[QueryOptionsKwargs] + ) -> Future[BlockingQueryResult]: ... @overload - def execute_query(self, - statement: str, - options: QueryOptions, - enable_cancel: bool, - *args: JSONType, - **kwargs: Unpack[QueryOptionsKwargs]) -> Future[BlockingQueryResult]: ... - + def execute_query( + self, + statement: str, + options: QueryOptions, + enable_cancel: bool, + *args: JSONType, + **kwargs: Unpack[QueryOptionsKwargs], + ) -> Future[BlockingQueryResult]: ... @overload - def execute_query(self, - statement: str, - options: QueryOptions, - *args: JSONType, - enable_cancel: bool, - **kwargs: Unpack[QueryOptionsKwargs]) -> Future[BlockingQueryResult]: ... - - + def execute_query( + self, + statement: str, + options: QueryOptions, + *args: JSONType, + enable_cancel: bool, + **kwargs: Unpack[QueryOptionsKwargs], + ) -> Future[BlockingQueryResult]: ... @overload - def execute_query(self, - statement: str, - options: QueryOptions, - enable_cancel: bool, - *args: JSONType, - **kwargs: str) -> Future[BlockingQueryResult]: ... - + def execute_query( + self, statement: str, options: QueryOptions, enable_cancel: bool, *args: JSONType, **kwargs: str + ) -> Future[BlockingQueryResult]: ... @overload - def execute_query(self, - statement: str, - options: QueryOptions, - *args: JSONType, - enable_cancel: bool, - **kwargs: str) -> Future[BlockingQueryResult]: ... - + def execute_query( + self, statement: str, options: QueryOptions, *args: JSONType, enable_cancel: bool, **kwargs: str + ) -> Future[BlockingQueryResult]: ... @overload - def execute_query(self, - statement: str, - enable_cancel: bool, - *args: JSONType, - **kwargs: str) -> Future[BlockingQueryResult]: ... - + def execute_query( + self, statement: str, enable_cancel: bool, *args: JSONType, **kwargs: str + ) -> Future[BlockingQueryResult]: ... @overload - def execute_query(self, - statement: str, - *args: JSONType, - enable_cancel: bool, - **kwargs: str) -> Future[BlockingQueryResult]: ... + def execute_query( + self, statement: str, *args: JSONType, enable_cancel: bool, **kwargs: str + ) -> Future[BlockingQueryResult]: ... diff --git a/couchbase_analytics/tests/backoff_calc_t.py b/couchbase_analytics/tests/backoff_calc_t.py index b46f709..5698b0f 100644 --- a/couchbase_analytics/tests/backoff_calc_t.py +++ b/couchbase_analytics/tests/backoff_calc_t.py @@ -23,24 +23,28 @@ MAX = 60 * 1000 EXPONENT_BASE = 2 + class BackoffCalcTestSuite: TEST_MANIFEST = [ 'test_backoff_calcs', ] - @pytest.mark.parametrize('retry_count, max_expected', - [(1, MIN * EXPONENT_BASE ** 0), - (2, MIN * EXPONENT_BASE ** 1), - (3, MIN * EXPONENT_BASE ** 2), - (4, MIN * EXPONENT_BASE ** 3), - (5, MIN * EXPONENT_BASE ** 4), - (6, MIN * EXPONENT_BASE ** 5), - (7, MIN * EXPONENT_BASE ** 6), - (8, MIN * EXPONENT_BASE ** 7), - (9, MIN * EXPONENT_BASE ** 8), - (10, MIN * EXPONENT_BASE ** 9), - (1000, MAX), - ]) + @pytest.mark.parametrize( + 'retry_count, max_expected', + [ + (1, MIN * EXPONENT_BASE**0), + (2, MIN * EXPONENT_BASE**1), + (3, MIN * EXPONENT_BASE**2), + (4, MIN * EXPONENT_BASE**3), + (5, MIN * EXPONENT_BASE**4), + (6, MIN * EXPONENT_BASE**5), + (7, MIN * EXPONENT_BASE**6), + (8, MIN * EXPONENT_BASE**7), + (9, MIN * EXPONENT_BASE**8), + (10, MIN * EXPONENT_BASE**9), + (1000, MAX), + ], + ) def test_backoff_calcs(self, retry_count: int, max_expected: float) -> None: calc = DefaultBackoffCalculator() for _ in range(10): @@ -49,12 +53,12 @@ def test_backoff_calcs(self, retry_count: int, max_expected: float) -> None: class BackoffCalcTests(BackoffCalcTestSuite): - @pytest.fixture(scope='class', autouse=True) def validate_test_manifest(self) -> None: def valid_test_method(meth: str) -> bool: attr = getattr(BackoffCalcTests, meth) return callable(attr) and not meth.startswith('__') and meth.startswith('test') + method_list = [meth for meth in dir(BackoffCalcTests) if valid_test_method(meth)] test_list = set(BackoffCalcTestSuite.TEST_MANIFEST).symmetric_difference(method_list) if test_list: diff --git a/couchbase_analytics/tests/connection_t.py b/couchbase_analytics/tests/connection_t.py index 23b1875..9653063 100644 --- a/couchbase_analytics/tests/connection_t.py +++ b/couchbase_analytics/tests/connection_t.py @@ -42,17 +42,19 @@ class ConnectionTestSuite: 'test_valid_connection_strings', ] - @pytest.mark.parametrize('connstr_opt', - ['invalid_op=10', - 'connect_timeout=2500ms', - 'dispatch_timeout=2500ms', - 'query_timeout=2500ms', - 'socket_connect_timeout=2500ms', - 'trust_only_pem_file=/path/to/file', - 'disable_server_certificate_verification=True' - ]) - def test_connstr_options_fail(self, - connstr_opt: str) -> None: + @pytest.mark.parametrize( + 'connstr_opt', + [ + 'invalid_op=10', + 'connect_timeout=2500ms', + 'dispatch_timeout=2500ms', + 'query_timeout=2500ms', + 'socket_connect_timeout=2500ms', + 'trust_only_pem_file=/path/to/file', + 'disable_server_certificate_verification=True', + ], + ) + def test_connstr_options_fail(self, connstr_opt: str) -> None: cred = Credential.from_username_and_password('Administrator', 'password') connstr = f'https://localhost?{connstr_opt}' with pytest.raises(ValueError): @@ -67,30 +69,30 @@ def test_connstr_options_max_retries(self) -> None: req = req_builder.build_base_query_request('SELECT 1=1') assert req.max_retries == max_retries - @pytest.mark.parametrize('duration, expected_seconds', - [('1h', '3600'), - ('+1h', '3600'), - ('+1h', '3600'), - ('1h10m', '4200'), - ('1.h10m', '4200'), - ('.1h10m', '960'), - ('0001h00010m', '4200'), - ('2m3s4ms', '123.004'), - (('100ns', '1e-7')), - (('100us', '1e-4')), - (('100μs', '1e-4')), - (('1000000ns', '.001')), - (('1000us', '.001')), - (('1000μs', '.001')), - ('4ms3s2m', '123.004'), - ('4ms3s2m5s', '128.004'), - ('2m3.125s', '123.125'), - ]) - def test_connstr_options_timeout(self, - duration: str, - expected_seconds: str) -> None: - opt_keys = ['timeout.connect_timeout', - 'timeout.query_timeout'] + @pytest.mark.parametrize( + 'duration, expected_seconds', + [ + ('1h', '3600'), + ('+1h', '3600'), + ('+1h', '3600'), + ('1h10m', '4200'), + ('1.h10m', '4200'), + ('.1h10m', '960'), + ('0001h00010m', '4200'), + ('2m3s4ms', '123.004'), + (('100ns', '1e-7')), + (('100us', '1e-4')), + (('100μs', '1e-4')), + (('1000000ns', '.001')), + (('1000us', '.001')), + (('1000μs', '.001')), + ('4ms3s2m', '123.004'), + ('4ms3s2m5s', '128.004'), + ('2m3.125s', '123.125'), + ], + ) + def test_connstr_options_timeout(self, duration: str, expected_seconds: str) -> None: + opt_keys = ['timeout.connect_timeout', 'timeout.query_timeout'] opts = dict.fromkeys(opt_keys, duration) cred = Credential.from_username_and_password('Administrator', 'password') connstr = f'https://localhost?{to_query_str(opts)}' @@ -114,12 +116,10 @@ def test_connstr_options_timeout(self, assert read_timeout is not None assert abs(read_timeout - expected) < 1e-9 - @pytest.mark.parametrize('invalid_opt_name', - ['connect_timeout', - 'dispatch_timeout', - 'query_timeout', - 'resolve_timeout', - 'socket_connect_timeout']) + @pytest.mark.parametrize( + 'invalid_opt_name', + ['connect_timeout', 'dispatch_timeout', 'query_timeout', 'resolve_timeout', 'socket_connect_timeout'], + ) def test_connstr_options_timeout_fail(self, invalid_opt_name: str) -> None: opts = {invalid_opt_name: '2500s'} cred = Credential.from_username_and_password('Administrator', 'password') @@ -127,30 +127,31 @@ def test_connstr_options_timeout_fail(self, invalid_opt_name: str) -> None: with pytest.raises(ValueError): _ClientAdapter(connstr, cred) - @pytest.mark.parametrize('bad_duration', - ['123', - '00', - ' 1h', - '1h ', - '1h 2m' - '+-3h', - '-+3h', - '-', - '-.', - '.', - '.h', - '2.3.4h', - '3x', - '3', - '3h4x', - '1H', - '1h-2m', - '-1h', - '-1m', - '-1s' - ]) - def test_connstr_options_timeout_invalid_duration(self, - bad_duration: str) -> None: + @pytest.mark.parametrize( + 'bad_duration', + [ + '123', + '00', + ' 1h', + '1h ', + '1h 2m+-3h', + '-+3h', + '-', + '-.', + '.', + '.h', + '2.3.4h', + '3x', + '3', + '3h4x', + '1H', + '1h-2m', + '-1h', + '-1m', + '-1s', + ], + ) + def test_connstr_options_timeout_invalid_duration(self, bad_duration: str) -> None: opt_keys = ['timeout.connect_timeout', 'timeout.query_timeout'] for key in opt_keys: opts = {key: bad_duration} @@ -159,28 +160,36 @@ def test_connstr_options_timeout_invalid_duration(self, with pytest.raises(ValueError): _ClientAdapter(connstr, cred) - @pytest.mark.parametrize('connstr_opts, expected_opts', - [({'security.trust_only_pem_file': TEST_CERT_PATH}, - {'trust_only_pem_file': TEST_CERT_PATH, - 'trust_only_capella': False}), - ({'security.disable_server_certificate_verification': 'true'}, - {'disable_server_certificate_verification': True}), - ]) - def test_connstr_options_security(self, - connstr_opts: Dict[str, object], - expected_opts: Dict[str, object]) -> None: + @pytest.mark.parametrize( + 'connstr_opts, expected_opts', + [ + ( + {'security.trust_only_pem_file': TEST_CERT_PATH}, + {'trust_only_pem_file': TEST_CERT_PATH, 'trust_only_capella': False}, + ), + ( + {'security.disable_server_certificate_verification': 'true'}, + {'disable_server_certificate_verification': True}, + ), + ], + ) + def test_connstr_options_security(self, connstr_opts: Dict[str, object], expected_opts: Dict[str, object]) -> None: cred = Credential.from_username_and_password('Administrator', 'password') connstr = f'https://localhost?{to_query_str(connstr_opts)}' client = _ClientAdapter(connstr, cred) sec_opts = client.connection_details.cluster_options.get('security_options', {}) assert sec_opts == expected_opts - @pytest.mark.parametrize('invalid_opt_name', - ['trust_only_capella', - 'trust_only_pem_file', - 'trust_only_pem_str', - 'trust_only_certificates', - 'disable_server_certificate_verification']) + @pytest.mark.parametrize( + 'invalid_opt_name', + [ + 'trust_only_capella', + 'trust_only_pem_file', + 'trust_only_pem_str', + 'trust_only_certificates', + 'disable_server_certificate_verification', + ], + ) def test_connstr_options_security_fail(self, invalid_opt_name: str) -> None: opts = {invalid_opt_name: 'True'} cred = Credential.from_username_and_password('Administrator', 'password') @@ -188,33 +197,42 @@ def test_connstr_options_security_fail(self, invalid_opt_name: str) -> None: with pytest.raises(ValueError): _ClientAdapter(connstr, cred) - @pytest.mark.parametrize('connstr', ['10.0.0.1:8091', - 'http://10.0.0.1:11222,10.0.0.2,10.0.0.3:11207', - 'http://10.0.0.1;10.0.0.2:11210;10.0.0.3', - 'http://[::ffff:192.168.0.1]:11207,[::ffff:192.168.0.2]:11207', - 'https://10.0.0.1:11222,10.0.0.2,10.0.0.3:11207', - 'https://10.0.0.1;10.0.0.2:11210;10.0.0.3', - 'https://[::ffff:192.168.0.1]:11207,[::ffff:192.168.0.2]:11207', - 'couchbase://10.0.0.1', - 'couchbases://10.0.0.1']) + @pytest.mark.parametrize( + 'connstr', + [ + '10.0.0.1:8091', + 'http://10.0.0.1:11222,10.0.0.2,10.0.0.3:11207', + 'http://10.0.0.1;10.0.0.2:11210;10.0.0.3', + 'http://[::ffff:192.168.0.1]:11207,[::ffff:192.168.0.2]:11207', + 'https://10.0.0.1:11222,10.0.0.2,10.0.0.3:11207', + 'https://10.0.0.1;10.0.0.2:11210;10.0.0.3', + 'https://[::ffff:192.168.0.1]:11207,[::ffff:192.168.0.2]:11207', + 'couchbase://10.0.0.1', + 'couchbases://10.0.0.1', + ], + ) def test_invalid_connection_strings(self, connstr: str) -> None: cred = Credential.from_username_and_password('Administrator', 'password') with pytest.raises(ValueError): Cluster.create_instance(connstr, cred) - @pytest.mark.parametrize('connstr', ['http://10.0.0.1', - 'http://10.0.0.1:11222', - 'http://[3ffe:2a00:100:7031::1]', - 'http://[::ffff:192.168.0.1]:11207', - 'http://test.local:11210', - 'http://fqdn', - 'https://10.0.0.1', - 'https://10.0.0.1:11222', - 'https://[3ffe:2a00:100:7031::1]', - 'https://[::ffff:192.168.0.1]:11207', - 'https://test.local:11210', - 'https://fqdn' - ]) + @pytest.mark.parametrize( + 'connstr', + [ + 'http://10.0.0.1', + 'http://10.0.0.1:11222', + 'http://[3ffe:2a00:100:7031::1]', + 'http://[::ffff:192.168.0.1]:11207', + 'http://test.local:11210', + 'http://fqdn', + 'https://10.0.0.1', + 'https://10.0.0.1:11222', + 'https://[3ffe:2a00:100:7031::1]', + 'https://[::ffff:192.168.0.1]:11207', + 'https://test.local:11210', + 'https://fqdn', + ], + ) def test_valid_connection_strings(self, connstr: str) -> None: cred = Credential.from_username_and_password('Administrator', 'password') client = _ClientAdapter(connstr, cred) @@ -227,12 +245,12 @@ def test_valid_connection_strings(self, connstr: str) -> None: class ConnectionTests(ConnectionTestSuite): - @pytest.fixture(scope='class', autouse=True) def validate_test_manifest(self) -> None: def valid_test_method(meth: str) -> bool: attr = getattr(ConnectionTests, meth) return callable(attr) and not meth.startswith('__') and meth.startswith('test') + method_list = [meth for meth in dir(ConnectionTests) if valid_test_method(meth)] test_list = set(ConnectionTestSuite.TEST_MANIFEST).symmetric_difference(method_list) if test_list: diff --git a/couchbase_analytics/tests/duration_parsing_t.py b/couchbase_analytics/tests/duration_parsing_t.py index dfff54e..7fce156 100644 --- a/couchbase_analytics/tests/duration_parsing_t.py +++ b/couchbase_analytics/tests/duration_parsing_t.py @@ -21,59 +21,63 @@ class DurationParsingTestSuite: - TEST_MANIFEST = [ 'test_invalid_durations', 'test_valid_durations', ] - @pytest.mark.parametrize('duration', - [ - '', - '10', - '10Gs', - 'abc', - '-', - '+', - '1h-', - '1h 30m', - '1h_30m', - 'h1', - '-.5s', - '1.2.3s', - ]) + @pytest.mark.parametrize( + 'duration', + [ + '', + '10', + '10Gs', + 'abc', + '-', + '+', + '1h-', + '1h 30m', + '1h_30m', + 'h1', + '-.5s', + '1.2.3s', + ], + ) def test_invalid_durations(self, duration: str) -> None: with pytest.raises(ValueError): parse_duration_str(duration) - @pytest.mark.parametrize('duration, expected_millis', - [('0', 0), - ('0s', 0), - ('1h', 3.6e6), - ('+1h', 3.6e6), - ('1h10m', 4.2e6), - ('1.h10m', 4.2e6), - ('1.234h', 1.234 * 3.6e6), - ('1h30m0s', 5.4e6), - ('0.1h10m', 9.6e5), - # TODO: apparently this is invalid in Go, but was okay w/ C++ implementation - ('.1h10m', 9.6e5), - ('0001h00010m', 4.2e6), - ('100ns', 1e-4), - ('100us', 0.1), - ('100μs', 0.1), - ('100µs', 0.1), - ('1000000ns', 1), - ('1000us', 1), - ('1000μs', 1), - ('1000µs', 1), - ('3h15m10s500ms', 11710.5 * 1e3), - ('1h1m1s1ms1us1ns', 3.6e6 + 60e3 + 1e3 + 1 + 0.001 + 0.000001), - ('2m3s4ms', 123004), - ('4ms3s2m', 123004), - ('4ms3s2m5s', 128004), - ('2m3.125s', 123125), - ]) + @pytest.mark.parametrize( + 'duration, expected_millis', + [ + ('0', 0), + ('0s', 0), + ('1h', 3.6e6), + ('+1h', 3.6e6), + ('1h10m', 4.2e6), + ('1.h10m', 4.2e6), + ('1.234h', 1.234 * 3.6e6), + ('1h30m0s', 5.4e6), + ('0.1h10m', 9.6e5), + # TODO: apparently this is invalid in Go, but was okay w/ C++ implementation + ('.1h10m', 9.6e5), + ('0001h00010m', 4.2e6), + ('100ns', 1e-4), + ('100us', 0.1), + ('100μs', 0.1), + ('100µs', 0.1), + ('1000000ns', 1), + ('1000us', 1), + ('1000μs', 1), + ('1000µs', 1), + ('3h15m10s500ms', 11710.5 * 1e3), + ('1h1m1s1ms1us1ns', 3.6e6 + 60e3 + 1e3 + 1 + 0.001 + 0.000001), + ('2m3s4ms', 123004), + ('4ms3s2m', 123004), + ('4ms3s2m5s', 128004), + ('2m3.125s', 123125), + ], + ) def test_valid_durations(self, duration: str, expected_millis: float) -> None: actual = parse_duration_str(duration, in_millis=True) # if we don't allow for a tolerance, we will have issues with float precision @@ -82,13 +86,14 @@ def test_valid_durations(self, duration: str, expected_millis: float) -> None: # 4ms3s2m5s yields 128004.00000000001 != 128004 assert abs(actual - expected_millis) < 1e-9 -class DurationParsingTests(DurationParsingTestSuite): +class DurationParsingTests(DurationParsingTestSuite): @pytest.fixture(scope='class', autouse=True) def validate_test_manifest(self) -> None: def valid_test_method(meth: str) -> bool: attr = getattr(DurationParsingTests, meth) return callable(attr) and not meth.startswith('__') and meth.startswith('test') + method_list = [meth for meth in dir(DurationParsingTests) if valid_test_method(meth)] test_list = set(DurationParsingTestSuite.TEST_MANIFEST).symmetric_difference(method_list) if test_list: diff --git a/couchbase_analytics/tests/json_parsing_t.py b/couchbase_analytics/tests/json_parsing_t.py index 8174eb6..bf761c0 100644 --- a/couchbase_analytics/tests/json_parsing_t.py +++ b/couchbase_analytics/tests/json_parsing_t.py @@ -20,9 +20,7 @@ import pytest -from couchbase_analytics.common._core import (JsonStreamConfig, - ParsedResult, - ParsedResultType) +from couchbase_analytics.common._core import JsonStreamConfig, ParsedResult, ParsedResultType from couchbase_analytics.protocol._core.json_stream import JsonStream from tests.environments.simple_environment import JsonDataType from tests.utils import BytesIterator @@ -32,7 +30,6 @@ class JsonParsingTestSuite: - TEST_MANIFEST = [ 'test_analytics_error', 'test_analytics_error_mid_stream', @@ -40,37 +37,30 @@ class JsonParsingTestSuite: 'test_analytics_many_rows_raw', 'test_analytics_multiple_errors', 'test_analytics_simple_result', - 'test_array', 'test_array_empty', 'test_array_mixed_types', 'test_array_of_objects', - 'test_invalid_empty', 'test_invalid_garbage_between_objects', 'test_invalid_leading_garbage', 'test_invalid_trailing_garbage', 'test_invalid_whitespace_only', - 'test_object', 'test_object_complex_nested_structure', 'test_object_empty', 'test_object_simple_nested', 'test_object_with_empty_key_and_value', 'test_object_with_unicode', - 'test_value_bool', 'test_value_null', ] @pytest.mark.parametrize('buffered_result', [True, False]) - def test_analytics_error(self, - test_env: SimpleEnvironment, - buffered_result: bool) -> None: + def test_analytics_error(self, test_env: SimpleEnvironment, buffered_result: bool) -> None: json_object, bytes_data = test_env.get_json_data(JsonDataType.FAILED_REQUEST) if buffered_result: - parser = JsonStream(BytesIterator(bytes_data), - stream_config=JsonStreamConfig(buffer_entire_result=True)) + parser = JsonStream(BytesIterator(bytes_data), stream_config=JsonStreamConfig(buffer_entire_result=True)) else: parser = JsonStream(BytesIterator(bytes_data)) parser.start_parsing() @@ -132,13 +122,10 @@ def test_analytics_many_rows(self, test_env: SimpleEnvironment) -> None: assert parser.get_result(0.01) is None @pytest.mark.parametrize('buffered_result', [True, False]) - def test_analytics_many_rows_raw(self, - test_env: SimpleEnvironment, - buffered_result: bool) -> None: + def test_analytics_many_rows_raw(self, test_env: SimpleEnvironment, buffered_result: bool) -> None: json_object, bytes_data = test_env.get_json_data(JsonDataType.MULTIPLE_RESULTS_RAW) if buffered_result: - parser = JsonStream(BytesIterator(bytes_data), - stream_config=JsonStreamConfig(buffer_entire_result=True)) + parser = JsonStream(BytesIterator(bytes_data), stream_config=JsonStreamConfig(buffer_entire_result=True)) else: parser = JsonStream(BytesIterator(bytes_data)) @@ -167,13 +154,10 @@ def test_analytics_many_rows_raw(self, assert parser.get_result(0.01) is None @pytest.mark.parametrize('buffered_result', [True, False]) - def test_analytics_multiple_errors(self, - test_env: SimpleEnvironment, - buffered_result: bool) -> None: + def test_analytics_multiple_errors(self, test_env: SimpleEnvironment, buffered_result: bool) -> None: json_object, bytes_data = test_env.get_json_data(JsonDataType.FAILED_REQUEST_MULTI_ERRORS) if buffered_result: - parser = JsonStream(BytesIterator(bytes_data), - stream_config=JsonStreamConfig(buffer_entire_result=True)) + parser = JsonStream(BytesIterator(bytes_data), stream_config=JsonStreamConfig(buffer_entire_result=True)) else: parser = JsonStream(BytesIterator(bytes_data)) parser.start_parsing() @@ -185,13 +169,10 @@ def test_analytics_multiple_errors(self, assert parser.get_result(0.01) is None @pytest.mark.parametrize('buffered_result', [True, False]) - def test_analytics_simple_result(self, - test_env: SimpleEnvironment, - buffered_result: bool) -> None: + def test_analytics_simple_result(self, test_env: SimpleEnvironment, buffered_result: bool) -> None: json_object, bytes_data = test_env.get_json_data(JsonDataType.SIMPLE_REQUEST) if buffered_result: - parser = JsonStream(BytesIterator(bytes_data), - stream_config=JsonStreamConfig(buffer_entire_result=True)) + parser = JsonStream(BytesIterator(bytes_data), stream_config=JsonStreamConfig(buffer_entire_result=True)) else: parser = JsonStream(BytesIterator(bytes_data)) parser.start_parsing() @@ -215,8 +196,9 @@ def test_analytics_simple_result(self, def test_array(self) -> None: data = '[1,2,"three"]' - parser = JsonStream(BytesIterator(bytes(data, 'utf-8')), - stream_config=JsonStreamConfig(buffer_entire_result=True)) + parser = JsonStream( + BytesIterator(bytes(data, 'utf-8')), stream_config=JsonStreamConfig(buffer_entire_result=True) + ) parser.start_parsing() result = parser.get_result(0.01) assert isinstance(result, ParsedResult) @@ -227,8 +209,9 @@ def test_array(self) -> None: def test_array_empty(self) -> None: data = '[]' - parser = JsonStream(BytesIterator(bytes(data, 'utf-8')), - stream_config=JsonStreamConfig(buffer_entire_result=True)) + parser = JsonStream( + BytesIterator(bytes(data, 'utf-8')), stream_config=JsonStreamConfig(buffer_entire_result=True) + ) parser.start_parsing() result = parser.get_result(0.01) assert isinstance(result, ParsedResult) @@ -239,8 +222,9 @@ def test_array_empty(self) -> None: def test_array_mixed_types(self) -> None: data = '[123,"text",true,null,{"key":"value"}]' - parser = JsonStream(BytesIterator(bytes(data, 'utf-8')), - stream_config=JsonStreamConfig(buffer_entire_result=True)) + parser = JsonStream( + BytesIterator(bytes(data, 'utf-8')), stream_config=JsonStreamConfig(buffer_entire_result=True) + ) parser.start_parsing() result = parser.get_result(0.01) assert isinstance(result, ParsedResult) @@ -251,8 +235,9 @@ def test_array_mixed_types(self) -> None: def test_array_of_objects(self) -> None: data = '[{"id":1,"name":"Alice"},{"id":2,"name":"Bob"}]' - parser = JsonStream(BytesIterator(bytes(data, 'utf-8')), - stream_config=JsonStreamConfig(buffer_entire_result=True)) + parser = JsonStream( + BytesIterator(bytes(data, 'utf-8')), stream_config=JsonStreamConfig(buffer_entire_result=True) + ) parser.start_parsing() result = parser.get_result(0.01) assert isinstance(result, ParsedResult) @@ -263,8 +248,9 @@ def test_array_of_objects(self) -> None: def test_invalid_empty(self) -> None: data = '' - parser = JsonStream(BytesIterator(bytes(data, 'utf-8')), - stream_config=JsonStreamConfig(buffer_entire_result=True)) + parser = JsonStream( + BytesIterator(bytes(data, 'utf-8')), stream_config=JsonStreamConfig(buffer_entire_result=True) + ) parser.start_parsing() res = parser.get_result(0.01) assert isinstance(res, ParsedResult) @@ -274,8 +260,9 @@ def test_invalid_empty(self) -> None: def test_invalid_garbage_between_objects(self) -> None: data = '[{"id":1,"name":"Alice"},garbage,{"id":2,"name":"Bob"}]' - parser = JsonStream(BytesIterator(bytes(data, 'utf-8')), - stream_config=JsonStreamConfig(buffer_entire_result=True)) + parser = JsonStream( + BytesIterator(bytes(data, 'utf-8')), stream_config=JsonStreamConfig(buffer_entire_result=True) + ) parser.start_parsing() res = parser.get_result(0.01) assert isinstance(res, ParsedResult) @@ -285,8 +272,9 @@ def test_invalid_garbage_between_objects(self) -> None: def test_invalid_leading_garbage(self) -> None: data = 'garbage{"key":"value"}' - parser = JsonStream(BytesIterator(bytes(data, 'utf-8')), - stream_config=JsonStreamConfig(buffer_entire_result=True)) + parser = JsonStream( + BytesIterator(bytes(data, 'utf-8')), stream_config=JsonStreamConfig(buffer_entire_result=True) + ) parser.start_parsing() res = parser.get_result(0.01) assert isinstance(res, ParsedResult) @@ -296,8 +284,9 @@ def test_invalid_leading_garbage(self) -> None: def test_invalid_trailing_garbage(self) -> None: data = '{"key":"value"}garbage' - parser = JsonStream(BytesIterator(bytes(data, 'utf-8')), - stream_config=JsonStreamConfig(buffer_entire_result=True)) + parser = JsonStream( + BytesIterator(bytes(data, 'utf-8')), stream_config=JsonStreamConfig(buffer_entire_result=True) + ) parser.start_parsing() res = parser.get_result(0.01) assert isinstance(res, ParsedResult) @@ -307,8 +296,9 @@ def test_invalid_trailing_garbage(self) -> None: def test_invalid_whitespace_only(self) -> None: data = ' \n\t ' - parser = JsonStream(BytesIterator(bytes(data, 'utf-8')), - stream_config=JsonStreamConfig(buffer_entire_result=True)) + parser = JsonStream( + BytesIterator(bytes(data, 'utf-8')), stream_config=JsonStreamConfig(buffer_entire_result=True) + ) parser.start_parsing() res = parser.get_result(0.01) assert isinstance(res, ParsedResult) @@ -318,8 +308,9 @@ def test_invalid_whitespace_only(self) -> None: def test_object(self) -> None: data = '{"name":"John","age":30,"city":"New York"}' - parser = JsonStream(BytesIterator(bytes(data, 'utf-8')), - stream_config=JsonStreamConfig(buffer_entire_result=True)) + parser = JsonStream( + BytesIterator(bytes(data, 'utf-8')), stream_config=JsonStreamConfig(buffer_entire_result=True) + ) parser.start_parsing() result = parser.get_result(0.01) assert isinstance(result, ParsedResult) @@ -329,12 +320,14 @@ def test_object(self) -> None: assert parser.get_result(0.01) is None def test_object_complex_nested_structure(self) -> None: - data_list = ['{"users":[{"id":1,"name":"Alice","roles":["admin","editor"]},' - '{"id":2,"name":"Bob","roles":["viewer"]}],', - '"meta":{"count":2,"status":"success"}}'] + data_list = [ + '{"users":[{"id":1,"name":"Alice","roles":["admin","editor"]},{"id":2,"name":"Bob","roles":["viewer"]}],', + '"meta":{"count":2,"status":"success"}}', + ] data = ''.join(data_list) - parser = JsonStream(BytesIterator(bytes(data, 'utf-8')), - stream_config=JsonStreamConfig(buffer_entire_result=True)) + parser = JsonStream( + BytesIterator(bytes(data, 'utf-8')), stream_config=JsonStreamConfig(buffer_entire_result=True) + ) parser.start_parsing() result = parser.get_result(0.01) assert isinstance(result, ParsedResult) @@ -345,8 +338,9 @@ def test_object_complex_nested_structure(self) -> None: def test_object_empty(self) -> None: data = '{}' - parser = JsonStream(BytesIterator(bytes(data, 'utf-8')), - stream_config=JsonStreamConfig(buffer_entire_result=True)) + parser = JsonStream( + BytesIterator(bytes(data, 'utf-8')), stream_config=JsonStreamConfig(buffer_entire_result=True) + ) parser.start_parsing() result = parser.get_result(0.01) assert isinstance(result, ParsedResult) @@ -357,8 +351,9 @@ def test_object_empty(self) -> None: def test_object_simple_nested(self) -> None: data = '{"outer":{"inner":{"key":"value"}}}' - parser = JsonStream(BytesIterator(bytes(data, 'utf-8')), - stream_config=JsonStreamConfig(buffer_entire_result=True)) + parser = JsonStream( + BytesIterator(bytes(data, 'utf-8')), stream_config=JsonStreamConfig(buffer_entire_result=True) + ) parser.start_parsing() result = parser.get_result(0.01) assert isinstance(result, ParsedResult) @@ -369,8 +364,9 @@ def test_object_simple_nested(self) -> None: def test_object_with_empty_key_and_value(self) -> None: data = '{"":""}' - parser = JsonStream(BytesIterator(bytes(data, 'utf-8')), - stream_config=JsonStreamConfig(buffer_entire_result=True)) + parser = JsonStream( + BytesIterator(bytes(data, 'utf-8')), stream_config=JsonStreamConfig(buffer_entire_result=True) + ) parser.start_parsing() result = parser.get_result(0.01) assert isinstance(result, ParsedResult) @@ -381,8 +377,9 @@ def test_object_with_empty_key_and_value(self) -> None: def test_object_with_unicode(self) -> None: data = '{"name":"你好","city":"Denver"}' - parser = JsonStream(BytesIterator(bytes(data, 'utf-8')), - stream_config=JsonStreamConfig(buffer_entire_result=True)) + parser = JsonStream( + BytesIterator(bytes(data, 'utf-8')), stream_config=JsonStreamConfig(buffer_entire_result=True) + ) parser.start_parsing() result = parser.get_result(0.01) assert isinstance(result, ParsedResult) @@ -393,8 +390,9 @@ def test_object_with_unicode(self) -> None: def test_value_bool(self) -> None: data = 'true' - parser = JsonStream(BytesIterator(bytes(data, 'utf-8')), - stream_config=JsonStreamConfig(buffer_entire_result=True)) + parser = JsonStream( + BytesIterator(bytes(data, 'utf-8')), stream_config=JsonStreamConfig(buffer_entire_result=True) + ) parser.start_parsing() result = parser.get_result(0.01) assert isinstance(result, ParsedResult) @@ -405,8 +403,9 @@ def test_value_bool(self) -> None: def test_value_null(self) -> None: data = 'null' - parser = JsonStream(BytesIterator(bytes(data, 'utf-8')), - stream_config=JsonStreamConfig(buffer_entire_result=True)) + parser = JsonStream( + BytesIterator(bytes(data, 'utf-8')), stream_config=JsonStreamConfig(buffer_entire_result=True) + ) parser.start_parsing() result = parser.get_result(0.01) assert isinstance(result, ParsedResult) @@ -415,13 +414,14 @@ def test_value_null(self) -> None: assert result.value.decode('utf-8') == data assert parser.get_result(0.01) is None -class JsonParsingTests(JsonParsingTestSuite): +class JsonParsingTests(JsonParsingTestSuite): @pytest.fixture(scope='class', autouse=True) def validate_test_manifest(self) -> None: def valid_test_method(meth: str) -> bool: attr = getattr(JsonParsingTests, meth) return callable(attr) and not meth.startswith('__') and meth.startswith('test') + method_list = [meth for meth in dir(JsonParsingTests) if valid_test_method(meth)] test_list = set(JsonParsingTestSuite.TEST_MANIFEST).symmetric_difference(method_list) if test_list: diff --git a/couchbase_analytics/tests/options_t.py b/couchbase_analytics/tests/options_t.py index f05e87a..f2fd50d 100644 --- a/couchbase_analytics/tests/options_t.py +++ b/couchbase_analytics/tests/options_t.py @@ -16,25 +16,21 @@ from __future__ import annotations from datetime import timedelta -from typing import (Dict, - Optional, - Type) +from typing import Dict, Optional, Type import pytest from couchbase_analytics.credential import Credential -from couchbase_analytics.deserializer import (DefaultJsonDeserializer, - Deserializer, - PassthroughDeserializer) -from couchbase_analytics.options import (ClusterOptions, - SecurityOptions, - SecurityOptionsKwargs, - TimeoutOptions, - TimeoutOptionsKwargs) +from couchbase_analytics.deserializer import DefaultJsonDeserializer, Deserializer, PassthroughDeserializer +from couchbase_analytics.options import ( + ClusterOptions, + SecurityOptions, + SecurityOptionsKwargs, + TimeoutOptions, + TimeoutOptionsKwargs, +) from couchbase_analytics.protocol._core.client_adapter import _ClientAdapter -from tests.utils import (get_test_cert_list, - get_test_cert_path, - get_test_cert_str) +from tests.utils import get_test_cert_list, get_test_cert_path, get_test_cert_str TEST_CERT_PATH = get_test_cert_path() TEST_CERT_LIST = get_test_cert_list() @@ -42,7 +38,6 @@ class ClusterOptionsTestSuite: - TEST_MANIFEST = [ 'test_options_deserializer', 'test_options_deserializer_kwargs', @@ -80,7 +75,7 @@ def test_options_max_retries(self, max_retries: Optional[int]) -> None: if max_retries is None: assert client.connection_details.get_max_retries() == 7 else: - assert client.connection_details.get_max_retries() == max_retries + assert client.connection_details.get_max_retries() == max_retries @pytest.mark.parametrize('max_retries', [5, 10, None]) def test_options_max_retries_kwargs(self, max_retries: Optional[int]) -> None: @@ -89,147 +84,146 @@ def test_options_max_retries_kwargs(self, max_retries: Optional[int]) -> None: client = _ClientAdapter('https://localhost', cred) assert client.connection_details.get_max_retries() == 7 else: - client = _ClientAdapter('https://localhost', cred, **{'max_retries': max_retries}) - assert client.connection_details.get_max_retries() == max_retries - - @pytest.mark.parametrize('opts, expected_opts', - [({}, None), - ({'trust_only_capella': True}, - {'trust_only_capella': True}), - ({'trust_only_pem_file': TEST_CERT_PATH}, - {'trust_only_pem_file': TEST_CERT_PATH, - 'trust_only_capella': False}), - ({'trust_only_pem_str': TEST_CERT_STR}, - {'trust_only_pem_str': TEST_CERT_STR, - 'trust_only_capella': False}), - ({'trust_only_certificates': TEST_CERT_LIST}, - {'trust_only_certificates': TEST_CERT_LIST, - 'trust_only_capella': False}), - ({'disable_server_certificate_verification': True}, - {'disable_server_certificate_verification': True}), - ]) + client = _ClientAdapter('https://localhost', cred, **{'max_retries': max_retries}) + assert client.connection_details.get_max_retries() == max_retries + + @pytest.mark.parametrize( + 'opts, expected_opts', + [ + ({}, None), + ({'trust_only_capella': True}, {'trust_only_capella': True}), + ( + {'trust_only_pem_file': TEST_CERT_PATH}, + {'trust_only_pem_file': TEST_CERT_PATH, 'trust_only_capella': False}, + ), + ({'trust_only_pem_str': TEST_CERT_STR}, {'trust_only_pem_str': TEST_CERT_STR, 'trust_only_capella': False}), + ( + {'trust_only_certificates': TEST_CERT_LIST}, + {'trust_only_certificates': TEST_CERT_LIST, 'trust_only_capella': False}, + ), + ({'disable_server_certificate_verification': True}, {'disable_server_certificate_verification': True}), + ], + ) def test_security_options(self, opts: SecurityOptionsKwargs, expected_opts: SecurityOptionsKwargs) -> None: cred = Credential.from_username_and_password('Administrator', 'password') - client = _ClientAdapter('https://localhost', - cred, - ClusterOptions(security_options=SecurityOptions(**opts))) + client = _ClientAdapter('https://localhost', cred, ClusterOptions(security_options=SecurityOptions(**opts))) assert expected_opts == client.connection_details.cluster_options.get('security_options') - @pytest.mark.parametrize('opts, expected_opts', - [(SecurityOptions.trust_only_capella(), - {'trust_only_capella': True}), - (SecurityOptions.trust_only_pem_file(TEST_CERT_PATH), - {'trust_only_pem_file': TEST_CERT_PATH, - 'trust_only_capella': False}), - (SecurityOptions.trust_only_pem_str(TEST_CERT_STR), - {'trust_only_pem_str': TEST_CERT_STR, - 'trust_only_capella': False}), - (SecurityOptions.trust_only_certificates(TEST_CERT_LIST), - {'trust_only_certificates': TEST_CERT_LIST, - 'trust_only_capella': False}), - ]) + @pytest.mark.parametrize( + 'opts, expected_opts', + [ + (SecurityOptions.trust_only_capella(), {'trust_only_capella': True}), + ( + SecurityOptions.trust_only_pem_file(TEST_CERT_PATH), + {'trust_only_pem_file': TEST_CERT_PATH, 'trust_only_capella': False}, + ), + ( + SecurityOptions.trust_only_pem_str(TEST_CERT_STR), + {'trust_only_pem_str': TEST_CERT_STR, 'trust_only_capella': False}, + ), + ( + SecurityOptions.trust_only_certificates(TEST_CERT_LIST), + {'trust_only_certificates': TEST_CERT_LIST, 'trust_only_capella': False}, + ), + ], + ) def test_security_options_classmethods(self, opts: SecurityOptions, expected_opts: Dict[str, object]) -> None: cred = Credential.from_username_and_password('Administrator', 'password') - client = _ClientAdapter('https://localhost', - cred, - ClusterOptions(security_options=opts)) + client = _ClientAdapter('https://localhost', cred, ClusterOptions(security_options=opts)) assert expected_opts == client.connection_details.cluster_options.get('security_options') - @pytest.mark.parametrize('opts, expected_opts', - [({}, None), - ({'trust_only_capella': True}, - {'trust_only_capella': True}), - ({'trust_only_pem_file': TEST_CERT_PATH}, - {'trust_only_pem_file': TEST_CERT_PATH, - 'trust_only_capella': False}), - ({'trust_only_pem_str': TEST_CERT_STR}, - {'trust_only_pem_str': TEST_CERT_STR, - 'trust_only_capella': False}), - ({'trust_only_certificates': TEST_CERT_LIST}, - {'trust_only_certificates': TEST_CERT_LIST, - 'trust_only_capella': False}), - ({'disable_server_certificate_verification': True}, - {'disable_server_certificate_verification': True}), - ]) + @pytest.mark.parametrize( + 'opts, expected_opts', + [ + ({}, None), + ({'trust_only_capella': True}, {'trust_only_capella': True}), + ( + {'trust_only_pem_file': TEST_CERT_PATH}, + {'trust_only_pem_file': TEST_CERT_PATH, 'trust_only_capella': False}, + ), + ({'trust_only_pem_str': TEST_CERT_STR}, {'trust_only_pem_str': TEST_CERT_STR, 'trust_only_capella': False}), + ( + {'trust_only_certificates': TEST_CERT_LIST}, + {'trust_only_certificates': TEST_CERT_LIST, 'trust_only_capella': False}, + ), + ({'disable_server_certificate_verification': True}, {'disable_server_certificate_verification': True}), + ], + ) def test_security_options_kwargs(self, opts: Dict[str, object], expected_opts: Dict[str, object]) -> None: cred = Credential.from_username_and_password('Administrator', 'password') client = _ClientAdapter('https://localhost', cred, **opts) assert expected_opts == client.connection_details.cluster_options.get('security_options') - @pytest.mark.parametrize('opts', - [{'trust_only_capella': True, - 'trust_only_pem_file': TEST_CERT_PATH}, - {'trust_only_capella': True, - 'trust_only_pem_str': TEST_CERT_STR}, - {'trust_only_capella': True, - 'trust_only_certificates': TEST_CERT_LIST}, - ]) + @pytest.mark.parametrize( + 'opts', + [ + {'trust_only_capella': True, 'trust_only_pem_file': TEST_CERT_PATH}, + {'trust_only_capella': True, 'trust_only_pem_str': TEST_CERT_STR}, + {'trust_only_capella': True, 'trust_only_certificates': TEST_CERT_LIST}, + ], + ) def test_security_options_invalid(self, opts: SecurityOptionsKwargs) -> None: cred = Credential.from_username_and_password('Administrator', 'password') with pytest.raises(ValueError): - _ClientAdapter('https://localhost', - cred, - ClusterOptions(security_options=SecurityOptions(**opts))) - - @pytest.mark.parametrize('opts', - [{'trust_only_capella': True, - 'trust_only_pem_file': TEST_CERT_PATH}, - {'trust_only_capella': True, - 'trust_only_pem_str': TEST_CERT_STR}, - {'trust_only_capella': True, - 'trust_only_certificates': TEST_CERT_LIST}, - ]) + _ClientAdapter('https://localhost', cred, ClusterOptions(security_options=SecurityOptions(**opts))) + + @pytest.mark.parametrize( + 'opts', + [ + {'trust_only_capella': True, 'trust_only_pem_file': TEST_CERT_PATH}, + {'trust_only_capella': True, 'trust_only_pem_str': TEST_CERT_STR}, + {'trust_only_capella': True, 'trust_only_certificates': TEST_CERT_LIST}, + ], + ) def test_security_options_invalid_kwargs(self, opts: Dict[str, object]) -> None: cred = Credential.from_username_and_password('Administrator', 'password') with pytest.raises(ValueError): _ClientAdapter('https://localhost', cred, **opts) - @pytest.mark.parametrize('opts, expected_opts', - [({}, None), - ({'connect_timeout': timedelta(seconds=30)}, - {'connect_timeout': 30}), - ({'query_timeout': timedelta(seconds=30)}, - {'query_timeout': 30}), - ({'connect_timeout': timedelta(seconds=60), - 'query_timeout': timedelta(seconds=30)}, - {'connect_timeout': 60, - 'query_timeout': 30}), - ]) + @pytest.mark.parametrize( + 'opts, expected_opts', + [ + ({}, None), + ({'connect_timeout': timedelta(seconds=30)}, {'connect_timeout': 30}), + ({'query_timeout': timedelta(seconds=30)}, {'query_timeout': 30}), + ( + {'connect_timeout': timedelta(seconds=60), 'query_timeout': timedelta(seconds=30)}, + {'connect_timeout': 60, 'query_timeout': 30}, + ), + ], + ) def test_timeout_options(self, opts: TimeoutOptionsKwargs, expected_opts: TimeoutOptionsKwargs) -> None: cred = Credential.from_username_and_password('Administrator', 'password') - client = _ClientAdapter('https://localhost', - cred, - ClusterOptions(timeout_options=TimeoutOptions(**opts))) + client = _ClientAdapter('https://localhost', cred, ClusterOptions(timeout_options=TimeoutOptions(**opts))) assert expected_opts == client.connection_details.cluster_options.get('timeout_options') - @pytest.mark.parametrize('opts, expected_opts', - [({'connect_timeout': timedelta(seconds=30)}, - {'connect_timeout': 30}), - ({'query_timeout': timedelta(seconds=30)}, - {'query_timeout': 30}), - ({'connect_timeout': timedelta(seconds=60), - 'query_timeout': timedelta(seconds=30)}, - {'connect_timeout': 60, - 'query_timeout': 30}), - ]) + @pytest.mark.parametrize( + 'opts, expected_opts', + [ + ({'connect_timeout': timedelta(seconds=30)}, {'connect_timeout': 30}), + ({'query_timeout': timedelta(seconds=30)}, {'query_timeout': 30}), + ( + {'connect_timeout': timedelta(seconds=60), 'query_timeout': timedelta(seconds=30)}, + {'connect_timeout': 60, 'query_timeout': 30}, + ), + ], + ) def test_timeout_options_kwargs(self, opts: Dict[str, object], expected_opts: Dict[str, object]) -> None: cred = Credential.from_username_and_password('Administrator', 'password') client = _ClientAdapter('https://localhost', cred, **opts) assert expected_opts == client.connection_details.cluster_options.get('timeout_options') - @pytest.mark.parametrize('opts', - [{'connect_timeout': timedelta(seconds=-1)}, - {'query_timeout': timedelta(seconds=-1)}]) + @pytest.mark.parametrize( + 'opts', [{'connect_timeout': timedelta(seconds=-1)}, {'query_timeout': timedelta(seconds=-1)}] + ) def test_timeout_options_must_be_positive(self, opts: TimeoutOptionsKwargs) -> None: cred = Credential.from_username_and_password('Administrator', 'password') with pytest.raises(ValueError): - _ClientAdapter('https://localhost', - cred, - ClusterOptions(timeout_options=TimeoutOptions(**opts))) + _ClientAdapter('https://localhost', cred, ClusterOptions(timeout_options=TimeoutOptions(**opts))) - @pytest.mark.parametrize('opts', - [{'connect_timeout': timedelta(seconds=-1)}, - {'query_timeout': timedelta(seconds=-1)}]) + @pytest.mark.parametrize( + 'opts', [{'connect_timeout': timedelta(seconds=-1)}, {'query_timeout': timedelta(seconds=-1)}] + ) def test_timeout_options_must_be_positive_kwargs(self, opts: Dict[str, object]) -> None: cred = Credential.from_username_and_password('Administrator', 'password') with pytest.raises(ValueError): @@ -237,12 +231,12 @@ def test_timeout_options_must_be_positive_kwargs(self, opts: Dict[str, object]) class ClusterOptionsTests(ClusterOptionsTestSuite): - @pytest.fixture(scope='class', autouse=True) def validate_test_manifest(self) -> None: def valid_test_method(meth: str) -> bool: attr = getattr(ClusterOptionsTests, meth) return callable(attr) and not meth.startswith('__') and meth.startswith('test') + method_list = [meth for meth in dir(ClusterOptionsTests) if valid_test_method(meth)] test_list = set(ClusterOptionsTestSuite.TEST_MANIFEST).symmetric_difference(method_list) if test_list: diff --git a/couchbase_analytics/tests/query_integration_t.py b/couchbase_analytics/tests/query_integration_t.py index f47ad7f..adb9ffd 100644 --- a/couchbase_analytics/tests/query_integration_t.py +++ b/couchbase_analytics/tests/query_integration_t.py @@ -18,10 +18,7 @@ import json from concurrent.futures import CancelledError, Future from datetime import timedelta -from typing import (TYPE_CHECKING, - Any, - Dict, - Optional) +from typing import TYPE_CHECKING, Any, Dict, Optional import pytest @@ -38,7 +35,6 @@ class QueryTestSuite: - TEST_MANIFEST = [ 'test_cancel_prior_iterating', 'test_cancel_prior_iterating_positional_params', @@ -125,13 +121,12 @@ def test_cancel_prior_iterating(self, test_env: BlockingTestEnvironment, cancel_ test_env.assert_streaming_response_state(res) @pytest.mark.parametrize('cancel_via_future', [False, True]) - def test_cancel_prior_iterating_positional_params(self, - test_env: BlockingTestEnvironment, - query_statement_pos_params_limit2: str, - cancel_via_future: bool) -> None: - ft = test_env.cluster_or_scope.execute_query(query_statement_pos_params_limit2, - 'United States', - enable_cancel=True) + def test_cancel_prior_iterating_positional_params( + self, test_env: BlockingTestEnvironment, query_statement_pos_params_limit2: str, cancel_via_future: bool + ) -> None: + ft = test_env.cluster_or_scope.execute_query( + query_statement_pos_params_limit2, 'United States', enable_cancel=True + ) assert isinstance(ft, Future) res: Optional[BlockingQueryResult] = None rows = [] @@ -160,13 +155,11 @@ def test_cancel_prior_iterating_positional_params(self, test_env.assert_streaming_response_state(res) @pytest.mark.parametrize('cancel_via_future', [False, True]) - def test_cancel_prior_iterating_with_kwargs(self, - test_env: BlockingTestEnvironment, - cancel_via_future: bool) -> None: + def test_cancel_prior_iterating_with_kwargs( + self, test_env: BlockingTestEnvironment, cancel_via_future: bool + ) -> None: statement = 'FROM range(0, 100000) AS r SELECT *' - ft = test_env.cluster_or_scope.execute_query(statement, - timeout=timedelta(seconds=4), - enable_cancel=True) + ft = test_env.cluster_or_scope.execute_query(statement, timeout=timedelta(seconds=4), enable_cancel=True) assert isinstance(ft, Future) res: Optional[BlockingQueryResult] = None rows = [] @@ -195,13 +188,13 @@ def test_cancel_prior_iterating_with_kwargs(self, test_env.assert_streaming_response_state(res) @pytest.mark.parametrize('cancel_via_future', [False, True]) - def test_cancel_prior_iterating_with_options(self, - test_env: BlockingTestEnvironment, - cancel_via_future: bool) -> None: + def test_cancel_prior_iterating_with_options( + self, test_env: BlockingTestEnvironment, cancel_via_future: bool + ) -> None: statement = 'FROM range(0, 100000) AS r SELECT *' - ft = test_env.cluster_or_scope.execute_query(statement, - QueryOptions(timeout=timedelta(seconds=4)), - enable_cancel=True) + ft = test_env.cluster_or_scope.execute_query( + statement, QueryOptions(timeout=timedelta(seconds=4)), enable_cancel=True + ) assert isinstance(ft, Future) res: Optional[BlockingQueryResult] = None rows = [] @@ -229,14 +222,16 @@ def test_cancel_prior_iterating_with_options(self, test_env.assert_streaming_response_state(res) @pytest.mark.parametrize('cancel_via_future', [False, True]) - def test_cancel_prior_iterating_with_opts_and_kwargs(self, - test_env: BlockingTestEnvironment, - cancel_via_future: bool) -> None: + def test_cancel_prior_iterating_with_opts_and_kwargs( + self, test_env: BlockingTestEnvironment, cancel_via_future: bool + ) -> None: statement = 'FROM range(0, 100000) AS r SELECT *' - ft = test_env.cluster_or_scope.execute_query(statement, - QueryOptions(scan_consistency=QueryScanConsistency.NOT_BOUNDED), - timeout=timedelta(seconds=4), - enable_cancel=True) + ft = test_env.cluster_or_scope.execute_query( + statement, + QueryOptions(scan_consistency=QueryScanConsistency.NOT_BOUNDED), + timeout=timedelta(seconds=4), + enable_cancel=True, + ) assert isinstance(ft, Future) res: Optional[BlockingQueryResult] = None rows = [] @@ -264,15 +259,13 @@ def test_cancel_prior_iterating_with_opts_and_kwargs(self, test_env.assert_streaming_response_state(res) @pytest.mark.parametrize('query_type', [SyncQueryType.NORMAL, SyncQueryType.LAZY, SyncQueryType.CANCELLABLE]) - def test_cancel_while_iterating(self, - test_env: BlockingTestEnvironment, - query_statement_limit5: str, - query_type: SyncQueryType) -> None: + def test_cancel_while_iterating( + self, test_env: BlockingTestEnvironment, query_statement_limit5: str, query_type: SyncQueryType + ) -> None: if query_type == SyncQueryType.NORMAL: result = test_env.cluster_or_scope.execute_query(query_statement_limit5) elif query_type == SyncQueryType.LAZY: - result = test_env.cluster_or_scope.execute_query(query_statement_limit5, - QueryOptions(lazy_execute=True)) + result = test_env.cluster_or_scope.execute_query(query_statement_limit5, QueryOptions(lazy_execute=True)) else: res = test_env.cluster_or_scope.execute_query(query_statement_limit5, enable_cancel=True) assert isinstance(res, Future) @@ -303,20 +296,16 @@ def test_cancel_while_iterating(self, def test_query_cannot_set_both_cancel_and_lazy_execution(self, test_env: BlockingTestEnvironment) -> None: statement = 'SELECT 1=1' with pytest.raises(RuntimeError): - test_env.cluster_or_scope.execute_query(statement, - QueryOptions(lazy_execute=True), - enable_cancel=True) + test_env.cluster_or_scope.execute_query(statement, QueryOptions(lazy_execute=True), enable_cancel=True) @pytest.mark.parametrize('query_type', [SyncQueryType.NORMAL, SyncQueryType.LAZY, SyncQueryType.CANCELLABLE]) - def test_query_metadata(self, - test_env: BlockingTestEnvironment, - query_statement_limit5: str, - query_type: SyncQueryType) -> None: + def test_query_metadata( + self, test_env: BlockingTestEnvironment, query_statement_limit5: str, query_type: SyncQueryType + ) -> None: if query_type == SyncQueryType.NORMAL: result = test_env.cluster_or_scope.execute_query(query_statement_limit5) elif query_type == SyncQueryType.LAZY: - result = test_env.cluster_or_scope.execute_query(query_statement_limit5, - QueryOptions(lazy_execute=True)) + result = test_env.cluster_or_scope.execute_query(query_statement_limit5, QueryOptions(lazy_execute=True)) else: res = test_env.cluster_or_scope.execute_query(query_statement_limit5, enable_cancel=True) assert isinstance(res, Future) @@ -340,16 +329,13 @@ def test_query_metadata(self, test_env.assert_streaming_response_state(result) @pytest.mark.parametrize('query_type', [SyncQueryType.NORMAL, SyncQueryType.LAZY, SyncQueryType.CANCELLABLE]) - def test_query_metadata_not_available(self, - test_env: BlockingTestEnvironment, - query_statement_limit5: str, - query_type: SyncQueryType) -> None: - + def test_query_metadata_not_available( + self, test_env: BlockingTestEnvironment, query_statement_limit5: str, query_type: SyncQueryType + ) -> None: if query_type == SyncQueryType.NORMAL: result = test_env.cluster_or_scope.execute_query(query_statement_limit5) elif query_type == SyncQueryType.LAZY: - result = test_env.cluster_or_scope.execute_query(query_statement_limit5, - QueryOptions(lazy_execute=True)) + result = test_env.cluster_or_scope.execute_query(query_statement_limit5, QueryOptions(lazy_execute=True)) else: res = test_env.cluster_or_scope.execute_query(query_statement_limit5, enable_cancel=True) assert isinstance(res, Future) @@ -379,94 +365,96 @@ def test_query_metadata_not_available(self, test_env.assert_streaming_response_state(result) @pytest.mark.parametrize('query_type', [SyncQueryType.NORMAL, SyncQueryType.LAZY, SyncQueryType.CANCELLABLE]) - def test_query_named_parameters(self, - test_env: BlockingTestEnvironment, - query_statement_named_params_limit2: str, - query_type: SyncQueryType) -> None: - + def test_query_named_parameters( + self, test_env: BlockingTestEnvironment, query_statement_named_params_limit2: str, query_type: SyncQueryType + ) -> None: named_parameters: Dict[str, Any] = {'country': 'United States'} if query_type == SyncQueryType.NORMAL: - result = test_env.cluster_or_scope.execute_query(query_statement_named_params_limit2, - QueryOptions(named_parameters=named_parameters)) + result = test_env.cluster_or_scope.execute_query( + query_statement_named_params_limit2, QueryOptions(named_parameters=named_parameters) + ) elif query_type == SyncQueryType.LAZY: - result = test_env.cluster_or_scope.execute_query(query_statement_named_params_limit2, - QueryOptions(named_parameters=named_parameters, - lazy_execute=True)) + result = test_env.cluster_or_scope.execute_query( + query_statement_named_params_limit2, QueryOptions(named_parameters=named_parameters, lazy_execute=True) + ) else: - res = test_env.cluster_or_scope.execute_query(query_statement_named_params_limit2, - QueryOptions(named_parameters=named_parameters), - enable_cancel=True) + res = test_env.cluster_or_scope.execute_query( + query_statement_named_params_limit2, QueryOptions(named_parameters=named_parameters), enable_cancel=True + ) assert isinstance(res, Future) result = res.result() test_env.assert_rows(result, 2) test_env.assert_streaming_response_state(result) @pytest.mark.parametrize('query_type', [SyncQueryType.NORMAL, SyncQueryType.LAZY, SyncQueryType.CANCELLABLE]) - def test_query_named_parameters_no_options(self, - test_env: BlockingTestEnvironment, - query_statement_named_params_limit2: str, - query_type: SyncQueryType) -> None: + def test_query_named_parameters_no_options( + self, test_env: BlockingTestEnvironment, query_statement_named_params_limit2: str, query_type: SyncQueryType + ) -> None: if query_type == SyncQueryType.NORMAL: - result = test_env.cluster_or_scope.execute_query(query_statement_named_params_limit2, - country='United States') + result = test_env.cluster_or_scope.execute_query( + query_statement_named_params_limit2, country='United States' + ) elif query_type == SyncQueryType.LAZY: # this format does not really make sense, if users are using static type checking it will prevent them # but, technically viable so we test it - result = test_env.cluster_or_scope.execute_query(query_statement_named_params_limit2, # type: ignore[call-overload] - lazy_execute=True, - country='United States') + result = test_env.cluster_or_scope.execute_query( + query_statement_named_params_limit2, # type: ignore[call-overload] + lazy_execute=True, + country='United States', + ) else: - res = test_env.cluster_or_scope.execute_query(query_statement_named_params_limit2, - country='United States', - enable_cancel=True) + res = test_env.cluster_or_scope.execute_query( + query_statement_named_params_limit2, country='United States', enable_cancel=True + ) assert isinstance(res, Future) result = res.result() test_env.assert_rows(result, 2) test_env.assert_streaming_response_state(result) @pytest.mark.parametrize('query_type', [SyncQueryType.NORMAL, SyncQueryType.LAZY, SyncQueryType.CANCELLABLE]) - def test_query_named_parameters_override(self, - test_env: BlockingTestEnvironment, - query_statement_named_params_limit2: str, - query_type: SyncQueryType) -> None: - + def test_query_named_parameters_override( + self, test_env: BlockingTestEnvironment, query_statement_named_params_limit2: str, query_type: SyncQueryType + ) -> None: if query_type == SyncQueryType.NORMAL: - result = test_env.cluster_or_scope.execute_query(query_statement_named_params_limit2, - QueryOptions(named_parameters={'country': 'abcdefg'}), - country='United States') + result = test_env.cluster_or_scope.execute_query( + query_statement_named_params_limit2, + QueryOptions(named_parameters={'country': 'abcdefg'}), + country='United States', + ) elif query_type == SyncQueryType.LAZY: - result = test_env.cluster_or_scope.execute_query(query_statement_named_params_limit2, - QueryOptions(named_parameters={'country': 'abcdefg'}, - lazy_execute=True), - country='United States') + result = test_env.cluster_or_scope.execute_query( + query_statement_named_params_limit2, + QueryOptions(named_parameters={'country': 'abcdefg'}, lazy_execute=True), + country='United States', + ) else: - res = test_env.cluster_or_scope.execute_query(query_statement_named_params_limit2, - QueryOptions(named_parameters={'country': 'abcdefg'}), - country='United States', - enable_cancel=True) + res = test_env.cluster_or_scope.execute_query( + query_statement_named_params_limit2, + QueryOptions(named_parameters={'country': 'abcdefg'}), + country='United States', + enable_cancel=True, + ) assert isinstance(res, Future) result = res.result() test_env.assert_rows(result, 2) test_env.assert_streaming_response_state(result) @pytest.mark.parametrize('query_type', [SyncQueryType.NORMAL, SyncQueryType.LAZY, SyncQueryType.CANCELLABLE]) - def test_query_passthrough_deserializer(self, - test_env: BlockingTestEnvironment, - query_type: SyncQueryType) -> None: + def test_query_passthrough_deserializer(self, test_env: BlockingTestEnvironment, query_type: SyncQueryType) -> None: statement = 'FROM range(0, 10) AS num SELECT *' - if query_type == SyncQueryType.NORMAL: - result = test_env.cluster_or_scope.execute_query(statement, - QueryOptions(deserializer=PassthroughDeserializer())) + result = test_env.cluster_or_scope.execute_query( + statement, QueryOptions(deserializer=PassthroughDeserializer()) + ) elif query_type == SyncQueryType.LAZY: - result = test_env.cluster_or_scope.execute_query(statement, - QueryOptions(deserializer=PassthroughDeserializer(), - lazy_execute=True)) + result = test_env.cluster_or_scope.execute_query( + statement, QueryOptions(deserializer=PassthroughDeserializer(), lazy_execute=True) + ) else: - res = test_env.cluster_or_scope.execute_query(statement, - QueryOptions(deserializer=PassthroughDeserializer()), - enable_cancel=True) + res = test_env.cluster_or_scope.execute_query( + statement, QueryOptions(deserializer=PassthroughDeserializer()), enable_cancel=True + ) assert isinstance(res, Future) result = res.result() @@ -476,69 +464,73 @@ def test_query_passthrough_deserializer(self, test_env.assert_streaming_response_state(result) @pytest.mark.parametrize('query_type', [SyncQueryType.NORMAL, SyncQueryType.LAZY, SyncQueryType.CANCELLABLE]) - def test_query_positional_params(self, - test_env: BlockingTestEnvironment, - query_statement_pos_params_limit2: str, - query_type: SyncQueryType) -> None: + def test_query_positional_params( + self, test_env: BlockingTestEnvironment, query_statement_pos_params_limit2: str, query_type: SyncQueryType + ) -> None: if query_type == SyncQueryType.NORMAL: - result = test_env.cluster_or_scope.execute_query(query_statement_pos_params_limit2, - QueryOptions(positional_parameters=['United States'])) + result = test_env.cluster_or_scope.execute_query( + query_statement_pos_params_limit2, QueryOptions(positional_parameters=['United States']) + ) elif query_type == SyncQueryType.LAZY: - result = test_env.cluster_or_scope.execute_query(query_statement_pos_params_limit2, - QueryOptions(positional_parameters=['United States'], - lazy_execute=True)) + result = test_env.cluster_or_scope.execute_query( + query_statement_pos_params_limit2, + QueryOptions(positional_parameters=['United States'], lazy_execute=True), + ) else: - res = test_env.cluster_or_scope.execute_query(query_statement_pos_params_limit2, - QueryOptions(positional_parameters=['United States']), - enable_cancel=True) + res = test_env.cluster_or_scope.execute_query( + query_statement_pos_params_limit2, + QueryOptions(positional_parameters=['United States']), + enable_cancel=True, + ) assert isinstance(res, Future) result = res.result() test_env.assert_rows(result, 2) test_env.assert_streaming_response_state(result) @pytest.mark.parametrize('query_type', [SyncQueryType.NORMAL, SyncQueryType.LAZY, SyncQueryType.CANCELLABLE]) - def test_query_positional_params_no_option(self, - test_env: BlockingTestEnvironment, - query_statement_pos_params_limit2: str, - query_type: SyncQueryType) -> None: - + def test_query_positional_params_no_option( + self, test_env: BlockingTestEnvironment, query_statement_pos_params_limit2: str, query_type: SyncQueryType + ) -> None: if query_type == SyncQueryType.NORMAL: result = test_env.cluster_or_scope.execute_query(query_statement_pos_params_limit2, 'United States') elif query_type == SyncQueryType.LAZY: # this format does not really make sense, if users are using static type checking it will prevent them # but, technically viable so we test it - result = test_env.cluster_or_scope.execute_query(query_statement_pos_params_limit2, # type: ignore[call-overload] - 'United States', - lazy_execute=True) + result = test_env.cluster_or_scope.execute_query( + query_statement_pos_params_limit2, # type: ignore[call-overload] + 'United States', + lazy_execute=True, + ) else: - res = test_env.cluster_or_scope.execute_query(query_statement_pos_params_limit2, - 'United States', - enable_cancel=True) + res = test_env.cluster_or_scope.execute_query( + query_statement_pos_params_limit2, 'United States', enable_cancel=True + ) assert isinstance(res, Future) result = res.result() test_env.assert_rows(result, 2) test_env.assert_streaming_response_state(result) @pytest.mark.parametrize('query_type', [SyncQueryType.NORMAL, SyncQueryType.LAZY, SyncQueryType.CANCELLABLE]) - def test_query_positional_params_override(self, - test_env: BlockingTestEnvironment, - query_statement_pos_params_limit2: str, - query_type: SyncQueryType) -> None: - + def test_query_positional_params_override( + self, test_env: BlockingTestEnvironment, query_statement_pos_params_limit2: str, query_type: SyncQueryType + ) -> None: if query_type == SyncQueryType.NORMAL: - result = test_env.cluster_or_scope.execute_query(query_statement_pos_params_limit2, - QueryOptions(positional_parameters=['abcdefg']), - 'United States') + result = test_env.cluster_or_scope.execute_query( + query_statement_pos_params_limit2, QueryOptions(positional_parameters=['abcdefg']), 'United States' + ) elif query_type == SyncQueryType.LAZY: - result = test_env.cluster_or_scope.execute_query(query_statement_pos_params_limit2, - QueryOptions(positional_parameters=['abcdefg'], - lazy_execute=True), - 'United States') + result = test_env.cluster_or_scope.execute_query( + query_statement_pos_params_limit2, + QueryOptions(positional_parameters=['abcdefg'], lazy_execute=True), + 'United States', + ) else: - res = test_env.cluster_or_scope.execute_query(query_statement_pos_params_limit2, - QueryOptions(positional_parameters=['abcdefg']), - 'United States', - enable_cancel=True) + res = test_env.cluster_or_scope.execute_query( + query_statement_pos_params_limit2, + QueryOptions(positional_parameters=['abcdefg']), + 'United States', + enable_cancel=True, + ) assert isinstance(res, Future) result = res.result() test_env.assert_rows(result, 2) @@ -546,9 +538,9 @@ def test_query_positional_params_override(self, # We test lazy execution in a separate test @pytest.mark.parametrize('query_type', [SyncQueryType.NORMAL, SyncQueryType.CANCELLABLE]) - def test_query_raises_exception_prior_to_iterating(self, - test_env: BlockingTestEnvironment, - query_type: SyncQueryType) -> None: + def test_query_raises_exception_prior_to_iterating( + self, test_env: BlockingTestEnvironment, query_type: SyncQueryType + ) -> None: statement = "I'm not N1QL!" if query_type == SyncQueryType.NORMAL: with pytest.raises(QueryError): @@ -560,10 +552,9 @@ def test_query_raises_exception_prior_to_iterating(self, res.result() @pytest.mark.parametrize('query_type', [SyncQueryType.NORMAL, SyncQueryType.LAZY, SyncQueryType.CANCELLABLE]) - def test_query_raw_options(self, - test_env: BlockingTestEnvironment, - query_statement_pos_params_limit2: str, - query_type: SyncQueryType) -> None: + def test_query_raw_options( + self, test_env: BlockingTestEnvironment, query_statement_pos_params_limit2: str, query_type: SyncQueryType + ) -> None: # via raw, we should be able to pass any option # if using named params, need to match full name param in query # which is different for when we pass in name_parameters via their specific @@ -573,37 +564,35 @@ def test_query_raw_options(self, else: statement = f'SELECT * FROM {test_env.fqdn} WHERE country = $country LIMIT $1;' - if query_type == SyncQueryType.NORMAL: - result = test_env.cluster_or_scope.execute_query(statement, - QueryOptions(raw={'$country': 'United States', - 'args': [2]})) + result = test_env.cluster_or_scope.execute_query( + statement, QueryOptions(raw={'$country': 'United States', 'args': [2]}) + ) elif query_type == SyncQueryType.LAZY: - result = test_env.cluster_or_scope.execute_query(statement, - QueryOptions(raw={'$country': 'United States', - 'args': [2]}, - lazy_execute=True)) + result = test_env.cluster_or_scope.execute_query( + statement, QueryOptions(raw={'$country': 'United States', 'args': [2]}, lazy_execute=True) + ) else: - res = test_env.cluster_or_scope.execute_query(statement, - QueryOptions(raw={'$country': 'United States', - 'args': [2]}), - enable_cancel=True) + res = test_env.cluster_or_scope.execute_query( + statement, QueryOptions(raw={'$country': 'United States', 'args': [2]}), enable_cancel=True + ) assert isinstance(res, Future) result = res.result() test_env.assert_rows(result, 2) if query_type == SyncQueryType.NORMAL: - result = test_env.cluster_or_scope.execute_query(query_statement_pos_params_limit2, - QueryOptions(raw={'args': ['United States']})) + result = test_env.cluster_or_scope.execute_query( + query_statement_pos_params_limit2, QueryOptions(raw={'args': ['United States']}) + ) elif query_type == SyncQueryType.LAZY: - result = test_env.cluster_or_scope.execute_query(query_statement_pos_params_limit2, - QueryOptions(raw={'args': ['United States']}, - lazy_execute=True)) + result = test_env.cluster_or_scope.execute_query( + query_statement_pos_params_limit2, QueryOptions(raw={'args': ['United States']}, lazy_execute=True) + ) else: - res = test_env.cluster_or_scope.execute_query(query_statement_pos_params_limit2, - QueryOptions(raw={'args': ['United States']}), - enable_cancel=True) + res = test_env.cluster_or_scope.execute_query( + query_statement_pos_params_limit2, QueryOptions(raw={'args': ['United States']}), enable_cancel=True + ) assert isinstance(res, Future) result = res.result() test_env.assert_rows(result, 2) @@ -615,19 +604,18 @@ def test_query_timeout(self, test_env: BlockingTestEnvironment, query_type: Sync if query_type == SyncQueryType.NORMAL: with pytest.raises(TimeoutError): - result = test_env.cluster_or_scope.execute_query(statement, - QueryOptions(timeout=timedelta(seconds=2))) + result = test_env.cluster_or_scope.execute_query(statement, QueryOptions(timeout=timedelta(seconds=2))) elif query_type == SyncQueryType.LAZY: - result = test_env.cluster_or_scope.execute_query(statement, - QueryOptions(timeout=timedelta(seconds=2), - lazy_execute=True)) + result = test_env.cluster_or_scope.execute_query( + statement, QueryOptions(timeout=timedelta(seconds=2), lazy_execute=True) + ) with pytest.raises(TimeoutError): for _ in result.rows(): pass else: - res = test_env.cluster_or_scope.execute_query(statement, - QueryOptions(timeout=timedelta(seconds=2)), - enable_cancel=True) + res = test_env.cluster_or_scope.execute_query( + statement, QueryOptions(timeout=timedelta(seconds=2)), enable_cancel=True + ) assert isinstance(res, Future) with pytest.raises(TimeoutError): result = res.result() @@ -636,16 +624,15 @@ def test_query_timeout(self, test_env: BlockingTestEnvironment, query_type: Sync def test_query_timeout_while_streaming(self, test_env: BlockingTestEnvironment, query_type: SyncQueryType) -> None: statement = 'SELECT {"x1": 1, "x2": 2, "x3": 3} FROM range(1, 100000) r;' if query_type == SyncQueryType.NORMAL: - result = test_env.cluster_or_scope.execute_query(statement, - QueryOptions(timeout=timedelta(seconds=2))) + result = test_env.cluster_or_scope.execute_query(statement, QueryOptions(timeout=timedelta(seconds=2))) elif query_type == SyncQueryType.LAZY: - result = test_env.cluster_or_scope.execute_query(statement, - QueryOptions(timeout=timedelta(seconds=2), - lazy_execute=True)) + result = test_env.cluster_or_scope.execute_query( + statement, QueryOptions(timeout=timedelta(seconds=2), lazy_execute=True) + ) else: - res = test_env.cluster_or_scope.execute_query(statement, - QueryOptions(timeout=timedelta(seconds=2)), - enable_cancel=True) + res = test_env.cluster_or_scope.execute_query( + statement, QueryOptions(timeout=timedelta(seconds=2)), enable_cancel=True + ) assert isinstance(res, Future) result = res.result() @@ -654,13 +641,10 @@ def test_query_timeout_while_streaming(self, test_env: BlockingTestEnvironment, pass test_env.assert_streaming_response_state(result) - @pytest.mark.parametrize('query_type', [SyncQueryType.NORMAL, SyncQueryType.LAZY, SyncQueryType.CANCELLABLE]) - def test_simple_query(self, - test_env: BlockingTestEnvironment, - query_statement_limit2: str, - query_type: SyncQueryType) -> None: - + def test_simple_query( + self, test_env: BlockingTestEnvironment, query_statement_limit2: str, query_type: SyncQueryType + ) -> None: if query_type == SyncQueryType.NORMAL: result = test_env.cluster_or_scope.execute_query(query_statement_limit2) elif query_type == SyncQueryType.LAZY: @@ -672,11 +656,8 @@ def test_simple_query(self, test_env.assert_rows(result, 2) test_env.assert_streaming_response_state(result) - def test_query_with_lazy_execution(self, - test_env: BlockingTestEnvironment, - query_statement_limit2: str) -> None: - result = test_env.cluster_or_scope.execute_query(query_statement_limit2, - QueryOptions(lazy_execute=True)) + def test_query_with_lazy_execution(self, test_env: BlockingTestEnvironment, query_statement_limit2: str) -> None: + result = test_env.cluster_or_scope.execute_query(query_statement_limit2, QueryOptions(lazy_execute=True)) expected_state = StreamingState.NotStarted assert result._http_response._request_context.request_state == expected_state expected_state = StreamingState.StreamingResults @@ -699,39 +680,42 @@ def test_query_with_lazy_execution_raises_exception(self, test_env: BlockingTest class ClusterQueryTests(QueryTestSuite): - @pytest.fixture(scope='class', autouse=True) def validate_test_manifest(self) -> None: def valid_test_method(meth: str) -> bool: attr = getattr(ClusterQueryTests, meth) return callable(attr) and not meth.startswith('__') and meth.startswith('test') + method_list = [meth for meth in dir(ClusterQueryTests) if valid_test_method(meth)] test_list = set(QueryTestSuite.TEST_MANIFEST).symmetric_difference(method_list) if test_list: pytest.fail(f'Test manifest invalid. Missing/extra tests: {test_list}.') @pytest.fixture(scope='class', name='test_env') - def couchbase_test_environment(self, - sync_test_env: BlockingTestEnvironment) -> YieldFixture[BlockingTestEnvironment]: + def couchbase_test_environment( + self, sync_test_env: BlockingTestEnvironment + ) -> YieldFixture[BlockingTestEnvironment]: sync_test_env.setup() yield sync_test_env sync_test_env.teardown() -class ScopeQueryTests(QueryTestSuite): +class ScopeQueryTests(QueryTestSuite): @pytest.fixture(scope='class', autouse=True) def validate_test_manifest(self) -> None: def valid_test_method(meth: str) -> bool: attr = getattr(ScopeQueryTests, meth) return callable(attr) and not meth.startswith('__') and meth.startswith('test') + method_list = [meth for meth in dir(ScopeQueryTests) if valid_test_method(meth)] test_list = set(QueryTestSuite.TEST_MANIFEST).symmetric_difference(method_list) if test_list: pytest.fail(f'Test manifest invalid. Missing/extra tests: {test_list}.') @pytest.fixture(scope='class', name='test_env') - def couchbase_test_environment(self, - sync_test_env: BlockingTestEnvironment) -> YieldFixture[BlockingTestEnvironment]: + def couchbase_test_environment( + self, sync_test_env: BlockingTestEnvironment + ) -> YieldFixture[BlockingTestEnvironment]: sync_test_env.setup() test_env = sync_test_env.enable_scope() yield test_env diff --git a/couchbase_analytics/tests/query_options_t.py b/couchbase_analytics/tests/query_options_t.py index 17486ec..dc4f25a 100644 --- a/couchbase_analytics/tests/query_options_t.py +++ b/couchbase_analytics/tests/query_options_t.py @@ -17,11 +17,7 @@ from dataclasses import dataclass from datetime import timedelta -from typing import (Any, - Dict, - List, - Optional, - Union) +from typing import Any, Dict, List, Optional, Union import pytest @@ -65,18 +61,18 @@ class QueryOptionsTestSuite: 'test_options_timeout', 'test_options_timeout_kwargs', 'test_options_timeout_must_be_positive', - 'test_options_timeout_must_be_positive_kwargs' + 'test_options_timeout_must_be_positive_kwargs', ] @pytest.fixture(scope='class') def query_statment(self) -> str: return 'SELECT * FROM default' - def test_options_deserializer(self, - query_statment: str, - request_builder: _RequestBuilder, - query_ctx: QueryContext) -> None: + def test_options_deserializer( + self, query_statment: str, request_builder: _RequestBuilder, query_ctx: QueryContext + ) -> None: from couchbase_analytics.deserializer import DefaultJsonDeserializer + deserializer = DefaultJsonDeserializer() q_opts = QueryOptions(deserializer=deserializer) req = request_builder.build_base_query_request(query_statment, q_opts) @@ -85,11 +81,11 @@ def test_options_deserializer(self, assert req.deserializer == deserializer query_ctx.validate_query_context(req.body) - def test_options_deserializer_kwargs(self, - query_statment: str, - request_builder: _RequestBuilder, - query_ctx: QueryContext) -> None: + def test_options_deserializer_kwargs( + self, query_statment: str, request_builder: _RequestBuilder, query_ctx: QueryContext + ) -> None: from couchbase_analytics.deserializer import DefaultJsonDeserializer + deserializer = DefaultJsonDeserializer() kwargs: QueryOptionsKwargs = {'deserializer': deserializer} req = request_builder.build_base_query_request(query_statment, **kwargs) @@ -99,11 +95,9 @@ def test_options_deserializer_kwargs(self, query_ctx.validate_query_context(req.body) @pytest.mark.parametrize('max_retries', [5, 10, None]) - def test_options_max_retries(self, - query_statment: str, - request_builder: _RequestBuilder, - query_ctx: QueryContext, - max_retries: Optional[int]) -> None: + def test_options_max_retries( + self, query_statment: str, request_builder: _RequestBuilder, query_ctx: QueryContext, max_retries: Optional[int] + ) -> None: if max_retries is not None: q_opts = QueryOptions(max_retries=max_retries) req = request_builder.build_base_query_request(query_statment, q_opts) @@ -115,11 +109,9 @@ def test_options_max_retries(self, query_ctx.validate_query_context(req.body) @pytest.mark.parametrize('max_retries', [5, 10, None]) - def test_options_max_retries_kwargs(self, - query_statment: str, - request_builder: _RequestBuilder, - query_ctx: QueryContext, - max_retries: Optional[int]) -> None: + def test_options_max_retries_kwargs( + self, query_statment: str, request_builder: _RequestBuilder, query_ctx: QueryContext, max_retries: Optional[int] + ) -> None: if max_retries is not None: kwargs: QueryOptionsKwargs = {'max_retries': max_retries} req = request_builder.build_base_query_request(query_statment, **kwargs) @@ -130,10 +122,9 @@ def test_options_max_retries_kwargs(self, assert req.max_retries == (max_retries if max_retries is not None else 7) query_ctx.validate_query_context(req.body) - def test_options_named_parameters(self, - query_statment: str, - request_builder: _RequestBuilder, - query_ctx: QueryContext) -> None: + def test_options_named_parameters( + self, query_statment: str, request_builder: _RequestBuilder, query_ctx: QueryContext + ) -> None: params: Dict[str, JSONType] = {'foo': 'bar', 'baz': 1, 'quz': False} q_opts = QueryOptions(named_parameters=params) req = request_builder.build_base_query_request(query_statment, q_opts) @@ -141,10 +132,9 @@ def test_options_named_parameters(self, assert req.options == exp_opts query_ctx.validate_query_context(req.body) - def test_options_named_parameters_kwargs(self, - query_statment: str, - request_builder: _RequestBuilder, - query_ctx: QueryContext) -> None: + def test_options_named_parameters_kwargs( + self, query_statment: str, request_builder: _RequestBuilder, query_ctx: QueryContext + ) -> None: params: Dict[str, JSONType] = {'foo': 'bar', 'baz': 1, 'quz': False} kwargs: QueryOptionsKwargs = {'named_parameters': params} req = request_builder.build_base_query_request(query_statment, **kwargs) @@ -152,10 +142,9 @@ def test_options_named_parameters_kwargs(self, assert req.options == exp_opts query_ctx.validate_query_context(req.body) - def test_options_positional_parameters(self, - query_statment: str, - request_builder: _RequestBuilder, - query_ctx: QueryContext) -> None: + def test_options_positional_parameters( + self, query_statment: str, request_builder: _RequestBuilder, query_ctx: QueryContext + ) -> None: params: List[JSONType] = ['foo', 'bar', 1, False] q_opts = QueryOptions(positional_parameters=params) req = request_builder.build_base_query_request(query_statment, q_opts) @@ -163,10 +152,9 @@ def test_options_positional_parameters(self, assert req.options == exp_opts query_ctx.validate_query_context(req.body) - def test_options_positional_parameters_kwargs(self, - query_statment: str, - request_builder: _RequestBuilder, - query_ctx: QueryContext) -> None: + def test_options_positional_parameters_kwargs( + self, query_statment: str, request_builder: _RequestBuilder, query_ctx: QueryContext + ) -> None: params: List[JSONType] = ['foo', 'bar', 1, False] kwargs: QueryOptionsKwargs = {'positional_parameters': params} req = request_builder.build_base_query_request(query_statment, **kwargs) @@ -174,10 +162,7 @@ def test_options_positional_parameters_kwargs(self, assert req.options == exp_opts query_ctx.validate_query_context(req.body) - def test_options_raw(self, - query_statment: str, - request_builder: _RequestBuilder, - query_ctx: QueryContext) -> None: + def test_options_raw(self, query_statment: str, request_builder: _RequestBuilder, query_ctx: QueryContext) -> None: pos_params: List[JSONType] = ['foo', 'bar', 1, False] params: Dict[str, Any] = {'readonly': True, 'positional_params': pos_params} q_opts = QueryOptions(raw=params) @@ -186,10 +171,9 @@ def test_options_raw(self, assert req.options == exp_opts query_ctx.validate_query_context(req.body) - def test_options_raw_kwargs(self, - query_statment: str, - request_builder: _RequestBuilder, - query_ctx: QueryContext) -> None: + def test_options_raw_kwargs( + self, query_statment: str, request_builder: _RequestBuilder, query_ctx: QueryContext + ) -> None: pos_params: List[JSONType] = ['foo', 'bar', 1, False] params: Dict[str, Any] = {'readonly': True, 'positional_params': pos_params} kwargs: QueryOptionsKwargs = {'raw': params} @@ -198,104 +182,88 @@ def test_options_raw_kwargs(self, assert req.options == exp_opts query_ctx.validate_query_context(req.body) - def test_options_readonly(self, - query_statment: str, - request_builder: _RequestBuilder, - query_ctx: QueryContext) -> None: + def test_options_readonly( + self, query_statment: str, request_builder: _RequestBuilder, query_ctx: QueryContext + ) -> None: q_opts = QueryOptions(readonly=True) req = request_builder.build_base_query_request(query_statment, q_opts) exp_opts: QueryOptionsTransformedKwargs = {'readonly': True} assert req.options == exp_opts query_ctx.validate_query_context(req.body) - def test_options_readonly_kwargs(self, - query_statment: str, - request_builder: _RequestBuilder, - query_ctx: QueryContext) -> None: + def test_options_readonly_kwargs( + self, query_statment: str, request_builder: _RequestBuilder, query_ctx: QueryContext + ) -> None: kwargs: QueryOptionsKwargs = {'readonly': True} req = request_builder.build_base_query_request(query_statment, **kwargs) exp_opts: QueryOptionsTransformedKwargs = {'readonly': True} assert req.options == exp_opts query_ctx.validate_query_context(req.body) - def test_options_scan_consistency(self, - query_statment: str, - request_builder: _RequestBuilder, - query_ctx: QueryContext) -> None: + def test_options_scan_consistency( + self, query_statment: str, request_builder: _RequestBuilder, query_ctx: QueryContext + ) -> None: from couchbase_analytics.query import QueryScanConsistency + q_opts = QueryOptions(scan_consistency=QueryScanConsistency.REQUEST_PLUS) req = request_builder.build_base_query_request(query_statment, q_opts) - exp_opts: QueryOptionsTransformedKwargs = { - 'scan_consistency': QueryScanConsistency.REQUEST_PLUS.value - } + exp_opts: QueryOptionsTransformedKwargs = {'scan_consistency': QueryScanConsistency.REQUEST_PLUS.value} assert req.options == exp_opts query_ctx.validate_query_context(req.body) - def test_options_scan_consistency_kwargs(self, - query_statment: str, - request_builder: _RequestBuilder, - query_ctx: QueryContext) -> None: + def test_options_scan_consistency_kwargs( + self, query_statment: str, request_builder: _RequestBuilder, query_ctx: QueryContext + ) -> None: from couchbase_analytics.query import QueryScanConsistency + kwargs: QueryOptionsKwargs = {'scan_consistency': QueryScanConsistency.REQUEST_PLUS} req = request_builder.build_base_query_request(query_statment, **kwargs) - exp_opts: QueryOptionsTransformedKwargs = { - 'scan_consistency': QueryScanConsistency.REQUEST_PLUS.value - } + exp_opts: QueryOptionsTransformedKwargs = {'scan_consistency': QueryScanConsistency.REQUEST_PLUS.value} assert req.options == exp_opts query_ctx.validate_query_context(req.body) - def test_options_timeout(self, - query_statment: str, - request_builder: _RequestBuilder, - query_ctx: QueryContext) -> None: + def test_options_timeout( + self, query_statment: str, request_builder: _RequestBuilder, query_ctx: QueryContext + ) -> None: q_opts = QueryOptions(timeout=timedelta(seconds=20)) req = request_builder.build_base_query_request(query_statment, q_opts) - exp_opts: QueryOptionsTransformedKwargs = { - 'timeout': 20.0 - } + exp_opts: QueryOptionsTransformedKwargs = {'timeout': 20.0} assert req.options == exp_opts # NOTE: we add time to the server timeout to ensure a client side timeout assert req.body['timeout'] == '25000.0ms' query_ctx.validate_query_context(req.body) - def test_options_timeout_kwargs(self, - query_statment: str, - request_builder: _RequestBuilder, - query_ctx: QueryContext) -> None: + def test_options_timeout_kwargs( + self, query_statment: str, request_builder: _RequestBuilder, query_ctx: QueryContext + ) -> None: kwargs: QueryOptionsKwargs = {'timeout': timedelta(seconds=20)} req = request_builder.build_base_query_request(query_statment, **kwargs) - exp_opts: QueryOptionsTransformedKwargs = { - 'timeout': 20.0 - } + exp_opts: QueryOptionsTransformedKwargs = {'timeout': 20.0} assert req.options == exp_opts # NOTE: we add time to the server timeout to ensure a client side timeout assert req.body['timeout'] == '25000.0ms' query_ctx.validate_query_context(req.body) - def test_options_timeout_must_be_positive(self, - query_statment: str, - request_builder: _RequestBuilder - ) -> None: + def test_options_timeout_must_be_positive(self, query_statment: str, request_builder: _RequestBuilder) -> None: q_opts = QueryOptions(timeout=timedelta(seconds=-1)) with pytest.raises(ValueError): request_builder.build_base_query_request(query_statment, q_opts) - def test_options_timeout_must_be_positive_kwargs(self, - query_statment: str, - request_builder: _RequestBuilder - ) -> None: + def test_options_timeout_must_be_positive_kwargs( + self, query_statment: str, request_builder: _RequestBuilder + ) -> None: kwargs: QueryOptionsKwargs = {'timeout': timedelta(seconds=-1)} with pytest.raises(ValueError): request_builder.build_base_query_request(query_statment, **kwargs) class ClusterQueryOptionsTests(QueryOptionsTestSuite): - @pytest.fixture(scope='class', autouse=True) def validate_test_manifest(self) -> None: def valid_test_method(meth: str) -> bool: attr = getattr(ClusterQueryOptionsTests, meth) return callable(attr) and not meth.startswith('__') and meth.startswith('test') + method_list = [meth for meth in dir(ClusterQueryOptionsTests) if valid_test_method(meth)] test_list = set(QueryOptionsTestSuite.TEST_MANIFEST).symmetric_difference(method_list) if test_list: @@ -312,12 +280,12 @@ def request_builder(self) -> _RequestBuilder: class ScopeQueryOptionsTests(QueryOptionsTestSuite): - @pytest.fixture(scope='class', autouse=True) def validate_test_manifest(self) -> None: def valid_test_method(meth: str) -> bool: attr = getattr(ScopeQueryOptionsTests, meth) return callable(attr) and not meth.startswith('__') and meth.startswith('test') + method_list = [meth for meth in dir(ScopeQueryOptionsTests) if valid_test_method(meth)] test_list = set(QueryOptionsTestSuite.TEST_MANIFEST).symmetric_difference(method_list) if test_list: @@ -330,6 +298,4 @@ def query_context(self) -> QueryContext: @pytest.fixture(scope='class') def request_builder(self) -> _RequestBuilder: cred = Credential.from_username_and_password('Administrator', 'password') - return _RequestBuilder(_ClientAdapter('https://localhost', cred), - 'test-database', - 'test-scope') + return _RequestBuilder(_ClientAdapter('https://localhost', cred), 'test-database', 'test-scope') diff --git a/couchbase_analytics/tests/test_server_t.py b/couchbase_analytics/tests/test_server_t.py index fc4e5d9..11b3388 100644 --- a/couchbase_analytics/tests/test_server_t.py +++ b/couchbase_analytics/tests/test_server_t.py @@ -21,24 +21,17 @@ import pytest -from couchbase_analytics.errors import (AnalyticsError, - InvalidCredentialError, - QueryError, - TimeoutError) +from couchbase_analytics.errors import AnalyticsError, InvalidCredentialError, QueryError, TimeoutError from couchbase_analytics.options import QueryOptions from couchbase_analytics.result import BlockingQueryResult from tests import SyncQueryType, YieldFixture -from tests.test_server import (ErrorType, - NonRetriableSpecificationType, - ResultType, - RetriableGroupType) +from tests.test_server import ErrorType, NonRetriableSpecificationType, ResultType, RetriableGroupType if TYPE_CHECKING: from tests.environments.base_environment import BlockingTestEnvironment class TestServerTestSuite: - TEST_MANIFEST = [ 'test_auth_error_unauthorized', 'test_auth_error_insufficient_permissions', @@ -48,7 +41,7 @@ class TestServerTestSuite: 'test_error_retriable_http503', 'test_error_timeout', 'test_results_object_values', - 'test_results_raw_values' + 'test_results_raw_values', ] def test_auth_error_unauthorized(self, test_env: BlockingTestEnvironment) -> None: @@ -71,23 +64,32 @@ def test_auth_error_insufficient_permissions(self, test_env: BlockingTestEnviron test_env.assert_error_context_num_attempts(1, ex.value._context) test_env.assert_error_context_contains_last_dispatch(ex.value._context) - @pytest.mark.parametrize('retry_group_type', - [RetriableGroupType.Zero, - RetriableGroupType.First, - RetriableGroupType.Middle, - RetriableGroupType.Last]) - @pytest.mark.parametrize('non_retriable_spec', - [NonRetriableSpecificationType.AllEmpty, - NonRetriableSpecificationType.AllFalse, - NonRetriableSpecificationType.Random]) - def test_error_non_retriable_response(self, - test_env: BlockingTestEnvironment, - retry_group_type: RetriableGroupType, - non_retriable_spec: NonRetriableSpecificationType) -> None: + @pytest.mark.parametrize( + 'retry_group_type', + [RetriableGroupType.Zero, RetriableGroupType.First, RetriableGroupType.Middle, RetriableGroupType.Last], + ) + @pytest.mark.parametrize( + 'non_retriable_spec', + [ + NonRetriableSpecificationType.AllEmpty, + NonRetriableSpecificationType.AllFalse, + NonRetriableSpecificationType.Random, + ], + ) + def test_error_non_retriable_response( + self, + test_env: BlockingTestEnvironment, + retry_group_type: RetriableGroupType, + non_retriable_spec: NonRetriableSpecificationType, + ) -> None: test_env.set_url_path('/test_error') - test_env.update_request_json({'error_type': ErrorType.Retriable.value, - 'retry_group_type': retry_group_type.value, - 'non_retriable_spec': non_retriable_spec.value}) + test_env.update_request_json( + { + 'error_type': ErrorType.Retriable.value, + 'retry_group_type': retry_group_type.value, + 'non_retriable_spec': non_retriable_spec.value, + } + ) statement = 'SELECT "Hello, data!" AS greeting' with pytest.raises(QueryError) as ex: test_env.cluster_or_scope.execute_query(statement) @@ -96,20 +98,24 @@ def test_error_non_retriable_response(self, def test_error_retriable_response_timeout(self, test_env: BlockingTestEnvironment) -> None: test_env.set_url_path('/test_error') - test_env.update_request_json({'error_type': ErrorType.Retriable.value, - 'retry_group_type': RetriableGroupType.All.value}) + test_env.update_request_json( + {'error_type': ErrorType.Retriable.value, 'retry_group_type': RetriableGroupType.All.value} + ) statement = 'SELECT "Hello, data!" AS greeting' with pytest.raises(TimeoutError) as ex: # just-in-case, increase the max_retries to ensure we hit the timeout - test_env.cluster_or_scope.execute_query(statement, QueryOptions(max_retries=10, timeout=timedelta(seconds=1.5))) + test_env.cluster_or_scope.execute_query( + statement, QueryOptions(max_retries=10, timeout=timedelta(seconds=1.5)) + ) - test_env.assert_error_context_num_attempts(4 , ex.value._context, exact=False) + test_env.assert_error_context_num_attempts(4, ex.value._context, exact=False) test_env.assert_error_context_contains_last_dispatch(ex.value._context) def test_error_retriable_response_retries_exceeded(self, test_env: BlockingTestEnvironment) -> None: test_env.set_url_path('/test_error') - test_env.update_request_json({'error_type': ErrorType.Retriable.value, - 'retry_group_type': RetriableGroupType.All.value}) + test_env.update_request_json( + {'error_type': ErrorType.Retriable.value, 'retry_group_type': RetriableGroupType.All.value} + ) statement = 'SELECT "Hello, data!" AS greeting' allowed_retries = 5 q_opts = QueryOptions(max_retries=allowed_retries, timeout=timedelta(seconds=10)) @@ -117,14 +123,13 @@ def test_error_retriable_response_retries_exceeded(self, test_env: BlockingTestE test_env.cluster_or_scope.execute_query(statement, q_opts) print(ex.value) - test_env.assert_error_context_num_attempts(allowed_retries+1 , ex.value._context) + test_env.assert_error_context_num_attempts(allowed_retries + 1, ex.value._context) test_env.assert_error_context_contains_last_dispatch(ex.value._context) @pytest.mark.parametrize('analytics_error', [False, True]) def test_error_retriable_http503(self, test_env: BlockingTestEnvironment, analytics_error: bool) -> None: test_env.set_url_path('/test_error') - test_env.update_request_json({'error_type': ErrorType.Http503.value, - 'analytics_error': analytics_error}) + test_env.update_request_json({'error_type': ErrorType.Http503.value, 'analytics_error': analytics_error}) statement = 'SELECT "Hello, data!" AS greeting' allowed_retries = 5 q_opts = QueryOptions(max_retries=allowed_retries, timeout=timedelta(seconds=10)) @@ -136,10 +141,9 @@ def test_error_retriable_http503(self, test_env: BlockingTestEnvironment, analyt with pytest.raises(AnalyticsError) as ex: test_env.cluster_or_scope.execute_query(statement, q_opts) - test_env.assert_error_context_num_attempts(allowed_retries+1 , ex.value._context) + test_env.assert_error_context_num_attempts(allowed_retries + 1, ex.value._context) test_env.assert_error_context_contains_last_dispatch(ex.value._context) - @pytest.mark.parametrize('server_side', [False, True]) def test_error_timeout(self, test_env: BlockingTestEnvironment, server_side: bool) -> None: test_env.set_url_path('/test_error') @@ -160,21 +164,19 @@ def test_error_timeout(self, test_env: BlockingTestEnvironment, server_side: boo @pytest.mark.parametrize('stream', [False, True]) @pytest.mark.parametrize('query_type', [SyncQueryType.NORMAL, SyncQueryType.LAZY, SyncQueryType.CANCELLABLE]) - def test_results_object_values(self, - test_env: BlockingTestEnvironment, - query_type: SyncQueryType, - stream: bool) -> None: + def test_results_object_values( + self, test_env: BlockingTestEnvironment, query_type: SyncQueryType, stream: bool + ) -> None: expected_rows = 50 test_env.set_url_path('/test_results') - test_env.update_request_json({'result_type': ResultType.Object.value, - 'row_count': expected_rows, - 'stream': stream}) + test_env.update_request_json( + {'result_type': ResultType.Object.value, 'row_count': expected_rows, 'stream': stream} + ) statement = 'SELECT "Hello, data!" AS greeting' if query_type == SyncQueryType.NORMAL: result = test_env.cluster_or_scope.execute_query(statement) elif query_type == SyncQueryType.LAZY: - result = test_env.cluster_or_scope.execute_query(statement, - QueryOptions(lazy_execute=True)) + result = test_env.cluster_or_scope.execute_query(statement, QueryOptions(lazy_execute=True)) else: res = test_env.cluster_or_scope.execute_query(statement, enable_cancel=True) assert isinstance(res, Future) @@ -185,21 +187,19 @@ def test_results_object_values(self, @pytest.mark.parametrize('stream', [False, True]) @pytest.mark.parametrize('query_type', [SyncQueryType.NORMAL, SyncQueryType.LAZY, SyncQueryType.CANCELLABLE]) - def test_results_raw_values(self, - test_env: BlockingTestEnvironment, - query_type: SyncQueryType, - stream: bool) -> None: + def test_results_raw_values( + self, test_env: BlockingTestEnvironment, query_type: SyncQueryType, stream: bool + ) -> None: expected_rows = 50 test_env.set_url_path('/test_results') - test_env.update_request_json({'result_type': ResultType.Raw.value, - 'row_count': expected_rows, - 'stream': stream}) + test_env.update_request_json( + {'result_type': ResultType.Raw.value, 'row_count': expected_rows, 'stream': stream} + ) statement = 'SELECT "Hello, data!" AS greeting' if query_type == SyncQueryType.NORMAL: result = test_env.cluster_or_scope.execute_query(statement) elif query_type == SyncQueryType.LAZY: - result = test_env.cluster_or_scope.execute_query(statement, - QueryOptions(lazy_execute=True)) + result = test_env.cluster_or_scope.execute_query(statement, QueryOptions(lazy_execute=True)) else: res = test_env.cluster_or_scope.execute_query(statement, enable_cancel=True) assert isinstance(res, Future) @@ -210,39 +210,42 @@ def test_results_raw_values(self, class ClusterTestServerTests(TestServerTestSuite): - @pytest.fixture(scope='class', autouse=True) def validate_test_manifest(self) -> None: def valid_test_method(meth: str) -> bool: attr = getattr(ClusterTestServerTests, meth) return callable(attr) and not meth.startswith('__') and meth.startswith('test') + method_list = [meth for meth in dir(ClusterTestServerTests) if valid_test_method(meth)] test_list = set(TestServerTestSuite.TEST_MANIFEST).symmetric_difference(method_list) if test_list: pytest.fail(f'Test manifest invalid. Missing/extra tests: {test_list}.') @pytest.fixture(scope='class', name='test_env') - def couchbase_test_environment(self, sync_test_env_with_server: BlockingTestEnvironment) -> YieldFixture[BlockingTestEnvironment]: + def couchbase_test_environment( + self, sync_test_env_with_server: BlockingTestEnvironment + ) -> YieldFixture[BlockingTestEnvironment]: test_env = sync_test_env_with_server.enable_test_server() yield test_env test_env.disable_test_server() -class ScopeTestServerTests(TestServerTestSuite): +class ScopeTestServerTests(TestServerTestSuite): @pytest.fixture(scope='class', autouse=True) def validate_test_manifest(self) -> None: def valid_test_method(meth: str) -> bool: attr = getattr(ScopeTestServerTests, meth) return callable(attr) and not meth.startswith('__') and meth.startswith('test') + method_list = [meth for meth in dir(ScopeTestServerTests) if valid_test_method(meth)] test_list = set(TestServerTestSuite.TEST_MANIFEST).symmetric_difference(method_list) if test_list: pytest.fail(f'Test manifest invalid. Missing/extra tests: {test_list}.') @pytest.fixture(scope='class', name='test_env') - def couchbase_test_environment(self, - sync_test_env_with_server: BlockingTestEnvironment - ) -> YieldFixture[BlockingTestEnvironment]: + def couchbase_test_environment( + self, sync_test_env_with_server: BlockingTestEnvironment + ) -> YieldFixture[BlockingTestEnvironment]: test_env = sync_test_env_with_server.enable_test_server() test_env.enable_scope() yield test_env diff --git a/couchbase_analytics_version.py b/couchbase_analytics_version.py index 80e71d9..5e1b306 100644 --- a/couchbase_analytics_version.py +++ b/couchbase_analytics_version.py @@ -145,7 +145,9 @@ def get_git_describe() -> str: return stdout.decode('utf-8').rstrip() -def gen_version(do_write: Optional[bool] = True, txt: Optional[str] = None) -> None: +def gen_version( + do_write: Optional[bool] = True, txt: Optional[str] = None, update_pyproject: Optional[bool] = False +) -> None: """ Generate a version based on git tag info. This will write the couchbase_analytics/_version.py file. If not inside a git tree it will @@ -184,12 +186,54 @@ def gen_version(do_write: Optional[bool] = True, txt: Optional[str] = None) -> N with open(VERSION_FILE, 'w') as fp: fp.write('\n'.join(lines)) + if update_pyproject is True: + update_pyproject_version(os.path.join(os.path.dirname(__file__), 'pyproject.toml'), vstr) + + +# uv does not support a dynamic project version (yet), this is a workaround in the interim +def update_pyproject_version(pyproject_path: str, new_version: str) -> bool: + import tomli + import tomli_w # type: ignore[import-not-found] + + if not os.path.exists(pyproject_path): + print(f"Error: pyproject.toml file not found at '{pyproject_path}'") + return False + + try: + with open(pyproject_path, 'rb') as f: + data = tomli.load(f) + + if 'project' in data and isinstance(data['project'], dict): + current_version = data['project'].get('version') + if current_version == new_version: + print(f"Version is already '{new_version}'. No update needed.") + return True + + data['project']['version'] = new_version + print(f"Updated version from '{current_version}' to '{new_version}' in '{pyproject_path}'") + + # Write the modified content back to the file + with open(pyproject_path, 'wb') as f: + tomli_w.dump(data, f) + return True + else: + print(f"Error: '[project]' section not found or is malformed in '{pyproject_path}'.") + return False + + except tomli.TOMLDecodeError as e: + print(f"Error: Failed to parse pyproject.toml at '{pyproject_path}'. Invalid TOML format: {e}") + return False + except Exception as e: + print(f'An unexpected error occurred: {e}') + return False + if __name__ == '__main__': from argparse import ArgumentParser ap = ArgumentParser(description='Parse git version to PEP-440 version') ap.add_argument('-c', '--mode', choices=('show', 'make', 'parse')) + ap.add_argument('--update-pyproject', help='Update pyproject.toml with the version', action='store_true') ap.add_argument('-i', '--input', help='Sample input string (instead of git)') options = ap.parse_args() @@ -197,7 +241,7 @@ def gen_version(do_write: Optional[bool] = True, txt: Optional[str] = None) -> N if cmd == 'show': print(get_version()) elif cmd == 'make': - gen_version(do_write=True, txt=options.input) + gen_version(do_write=True, txt=options.input, update_pyproject=options.update_pyproject) print(get_version()) elif cmd == 'parse': gen_version(do_write=False, txt=options.input) diff --git a/mypy.ini b/mypy.ini index 670a12b..c279f52 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2,6 +2,7 @@ exclude = (?x)( setup\.py$ | docs/ + | tests/utils/ ) [mypy-ijson.*] @@ -12,3 +13,6 @@ ignore_missing_imports = True [mypy-setuptools.*] ignore_missing_imports = True + +[mypy-tests.utils.*] +follow_imports = skip diff --git a/pyproject.toml b/pyproject.toml index c4e8acf..25fd903 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,31 +1,36 @@ [build-system] requires = [ - "setuptools>=65", - "wheel", + "setuptools>=65", + "wheel", ] build-backend = "setuptools.build_meta" [project] name = "couchbase-analytics" -version = "1.0.0-dev.1" +version = "1.0.0.dev1" dependencies = [ - "anyio~=4.9.0", - "httpx~=0.28.1", - "ijson~=3.3.0", - "sniffio~=1.3.1", - "typing-extensions~=4.11; python_version<'3.11'" + "anyio~=4.9.0", + "httpx~=0.28.1", + "ijson~=3.3.0", + "sniffio~=1.3.1", + "typing-extensions~=4.11; python_version<'3.11'", ] requires-python = ">=3.9" authors = [ - {name = "Couchbase, Inc.", email = "PythonPackage@couchbase.com"} + { name = "Couchbase, Inc.", email = "PythonPackage@couchbase.com" }, ] maintainers = [ - {name = "Couchbase, Inc.", email = "PythonPackage@couchbase.com"} + { name = "Couchbase, Inc.", email = "PythonPackage@couchbase.com" }, ] description = "Python Client for Couchbase Analytics" readme = "README.md" -license = {file = "LICENSE"} -keywords = ["couchbase", "nosql", "pycouchbase", "couchbase++", "analytics"] +keywords = [ + "couchbase", + "nosql", + "pycouchbase", + "couchbase++", + "analytics", +] classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", @@ -40,9 +45,12 @@ classifiers = [ "Programming Language :: Python :: Implementation :: CPython", "Topic :: Database", "Topic :: Software Development :: Libraries", - "Topic :: Software Development :: Libraries :: Python Modules" + "Topic :: Software Development :: Libraries :: Python Modules", ] +[project.license] +file = "LICENSE" + [project.urls] Homepage = "https://couchbase.com" Documentation = "https://docs.couchbase.com/python-analytics-sdk/current/hello-world/overview.html" @@ -53,18 +61,20 @@ Repository = "https://github.com/couchbase/analytics-python-client" [dependency-groups] dev = [ - "aiohttp~=3.11.10", - "mypy~=1.16.1", - "pre-commit~=4.2.0", - "pytest~=8.3.5", - "ruff~=0.12.0", + "aiohttp~=3.11.10", + "mypy~=1.16.1", + "pre-commit~=4.2.0", + "pytest~=8.3.5", + "ruff~=0.12.0", + "tomli~=2.2.1", + "tomli-w~=1.2.0", ] sphinx = [ - "Sphinx~=7.4.7", - "sphinx-rtd-theme~=2.0", - "sphinx-copybutton~=0.5", - "enum-tools~=0.12", - "sphinx-toolbox~=3.7" + "Sphinx~=7.4.7", + "sphinx-rtd-theme~=2.0", + "sphinx-copybutton~=0.5", + "enum-tools~=0.12", + "sphinx-toolbox~=3.7", ] [tool.pytest.ini_options] @@ -76,10 +86,10 @@ testpaths = [ "couchbase_analytics/tests", ] python_classes = [ - "*Tests" + "*Tests", ] python_files = [ - "*_t.py" + "*_t.py", ] markers = [ "pycbac_couchbase: marks a test for the couchbase API (deselect with '-m \"not pycbac_couchbase\"')", @@ -90,19 +100,19 @@ markers = [ [tool.ruff] line-length = 120 -extend-exclude = ["tests/test_config.ini", "test*.py", "*_tests.py"] +extend-exclude = [ + "tests/test_config.ini", +] [tool.ruff.lint] -select = ["E", "F", "B", "C"] +select = [ + "E", + "F", + "B", + "C", + "I", +] [tool.ruff.format] quote-style = "single" docstring-code-format = false - -[tool.isort] -multi_line_output = 1 -force_grid_wrap = 3 -use_parentheses = true -ensure_newline_before_comments = true -line_length = 120 -order_by_type = true diff --git a/requirements-dev.in b/requirements-dev.in index 16bf6d7..3361cb1 100644 --- a/requirements-dev.in +++ b/requirements-dev.in @@ -3,3 +3,5 @@ mypy~=1.16.1 pre-commit~=4.2.0 pytest~=8.3.5 ruff~=0.12.0 +tomli~=2.2.1 +tomli-w~=1.2.0 diff --git a/requirements-dev.txt b/requirements-dev.txt index 864bf25..d97c12f 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -60,10 +60,13 @@ pyyaml==6.0.2 # via pre-commit ruff==0.12.2 # via -r requirements-dev.in -tomli==2.2.1 ; python_full_version < '3.11' +tomli==2.2.1 # via + # -r requirements-dev.in # mypy # pytest +tomli-w==1.2.0 + # via -r requirements-dev.in typing-extensions==4.14.0 # via # exceptiongroup diff --git a/tests/__init__.py b/tests/__init__.py index 3c294b1..362877b 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -14,10 +14,7 @@ # limitations under the License. from enum import Enum -from typing import (AsyncGenerator, - Generator, - Optional, - TypeVar) +from typing import AsyncGenerator, Generator, Optional, TypeVar T = TypeVar('T') AsyncYieldFixture = AsyncGenerator[T, None] @@ -29,6 +26,7 @@ class SyncQueryType(Enum): LAZY = 'lazy' CANCELLABLE = 'cancellable' + class AnalyticsTestEnvironmentError(Exception): """Raised when something with the test environment is incorrect.""" @@ -36,7 +34,7 @@ def __init__(self, message: Optional[str] = None) -> None: super().__init__(message) def __repr__(self) -> str: - return f"{type(self).__name__}({super().__repr__()})" + return f'{type(self).__name__}({super().__repr__()})' def __str__(self) -> str: return self.__repr__() diff --git a/tests/analytics_config.py b/tests/analytics_config.py index 3e521ec..170daca 100644 --- a/tests/analytics_config.py +++ b/tests/analytics_config.py @@ -26,7 +26,7 @@ from tests import AnalyticsTestEnvironmentError BASEDIR = pathlib.Path(__file__).parent.parent -CONFIG_FILE = os.path.join(pathlib.Path(__file__).parent, "test_config.ini") +CONFIG_FILE = os.path.join(pathlib.Path(__file__).parent, 'test_config.ini') ENV_TRUE = ['true', '1', 'y', 'yes', 'on'] @@ -87,40 +87,47 @@ def load_config(cls) -> AnalyticsConfig: test_config = ConfigParser() test_config.read(CONFIG_FILE) test_config_analytics = test_config['analytics'] - analytics_config._scheme = os.environ.get('PYCBAC_SCHEME', - test_config_analytics.get('scheme', fallback='https')) - analytics_config._host = os.environ.get('PYCBAC_HOST', - test_config_analytics.get('host', fallback='localhost')) + analytics_config._scheme = os.environ.get( + 'PYCBAC_SCHEME', test_config_analytics.get('scheme', fallback='https') + ) + analytics_config._host = os.environ.get( + 'PYCBAC_HOST', test_config_analytics.get('host', fallback='localhost') + ) port = os.environ.get('PYCBAC_PORT', test_config_analytics.get('port', fallback='8095')) analytics_config._port = int(port) - analytics_config._username = os.environ.get('PYCBAC_USERNAME', - test_config_analytics.get('username', fallback='Administrator')) - analytics_config._password = os.environ.get('PYCBAC_PASSWORD', - test_config_analytics.get('password', fallback='password')) + analytics_config._username = os.environ.get( + 'PYCBAC_USERNAME', test_config_analytics.get('username', fallback='Administrator') + ) + analytics_config._password = os.environ.get( + 'PYCBAC_PASSWORD', test_config_analytics.get('password', fallback='password') + ) use_nonprod = os.environ.get('PYCBAC_NONPROD', test_config_analytics.get('nonprod', fallback='OFF')) if use_nonprod.lower() in ENV_TRUE: analytics_config._nonprod = True else: analytics_config._nonprod = False - analytics_config._database_name = os.environ.get('PYCBAC_DATABASE', - test_config_analytics.get('database_name', - fallback='travel-sample')) - analytics_config._scope_name = os.environ.get('PYCBAC_SCOPE', - test_config_analytics.get('scope_name', fallback='inventory')) - analytics_config._collection_name = os.environ.get('PYCBAC_COLLECTION', - test_config_analytics.get('collection_name', - fallback='airline')) - disable_cert_verification = os.environ.get('PYCBAC_DISABLE_SERVER_CERT_VERIFICATION', - test_config_analytics.get('disable_server_cert_verification', - fallback='ON')) + analytics_config._database_name = os.environ.get( + 'PYCBAC_DATABASE', test_config_analytics.get('database_name', fallback='travel-sample') + ) + analytics_config._scope_name = os.environ.get( + 'PYCBAC_SCOPE', test_config_analytics.get('scope_name', fallback='inventory') + ) + analytics_config._collection_name = os.environ.get( + 'PYCBAC_COLLECTION', test_config_analytics.get('collection_name', fallback='airline') + ) + disable_cert_verification = os.environ.get( + 'PYCBAC_DISABLE_SERVER_CERT_VERIFICATION', + test_config_analytics.get('disable_server_cert_verification', fallback='ON'), + ) if disable_cert_verification.lower() in ENV_TRUE: analytics_config._disable_server_certificate_verification = True fqdn = os.environ.get('PYCBAC_FQDN', test_config_analytics.get('fqdn', fallback=None)) if fqdn is not None: fqdn_tokens = fqdn.split('.') if len(fqdn_tokens) != 3: - raise AnalyticsTestEnvironmentError(('Invalid FQDN provided. Expected database.scope.collection. ' - f'FQDN provide={fqdn}')) + raise AnalyticsTestEnvironmentError( + (f'Invalid FQDN provided. Expected database.scope.collection. FQDN provide={fqdn}') + ) analytics_config._database_name = f'{fqdn_tokens[0]}' analytics_config._scope_name = f'{fqdn_tokens[1]}' @@ -133,7 +140,7 @@ def load_config(cls) -> AnalyticsConfig: analytics_config._collection_name = 'airline' except Exception as ex: - raise AnalyticsTestEnvironmentError(f'Problem trying read/load test configuration:\n{ex}') + raise AnalyticsTestEnvironmentError(f'Problem trying read/load test configuration:\n{ex}') from None return analytics_config diff --git a/tests/environments/base_environment.py b/tests/environments/base_environment.py index 4155561..30daa4d 100644 --- a/tests/environments/base_environment.py +++ b/tests/environments/base_environment.py @@ -19,13 +19,7 @@ import pathlib import sys from os import path -from typing import (TYPE_CHECKING, - Any, - Dict, - List, - Optional, - TypedDict, - Union) +from typing import TYPE_CHECKING, Any, Dict, List, Optional, TypedDict, Union if sys.version_info < (3, 11): from typing_extensions import Unpack @@ -50,9 +44,8 @@ from tests.analytics_config import AnalyticsConfig -TEST_AIRLINE_DATA_PATH = path.join(pathlib.Path(__file__).parent.parent, - 'test_data', - 'airline.json') +TEST_AIRLINE_DATA_PATH = path.join(pathlib.Path(__file__).parent.parent, 'test_data', 'airline.json') + class TestEnvironmentOptionsKwargs(TypedDict, total=False): async_cluster: Optional[AsyncCluster] @@ -63,8 +56,8 @@ class TestEnvironmentOptionsKwargs(TypedDict, total=False): server_handler: Optional[WebServerHandler] backend: Optional[str] -class TestEnvironment: +class TestEnvironment: def __init__(self, config: AnalyticsConfig, **kwargs: Unpack[TestEnvironmentOptionsKwargs]) -> None: self._config = config self._async_cluster = kwargs.pop('async_cluster', None) @@ -93,10 +86,9 @@ def collection_name(self) -> Optional[str]: def use_scope(self) -> bool: return self._use_scope - def assert_error_context_num_attempts(self, - expected_attempts: int, - context: Optional[str], - exact: Optional[bool]=True) -> None: + def assert_error_context_num_attempts( + self, expected_attempts: int, context: Optional[str], exact: Optional[bool] = True + ) -> None: assert isinstance(context, str) ctx_keys = context.replace('{', '').replace('}', '').split(',') assert len(ctx_keys) > 1 @@ -105,9 +97,9 @@ def assert_error_context_num_attempts(self, match_keys = match.split() assert len(match_keys) == 2 if exact is True: - assert int(match_keys[1].replace("'", "").replace('"', '')) == expected_attempts + assert int(match_keys[1].replace("'", '').replace('"', '')) == expected_attempts else: - assert int(match_keys[1].replace("'", "").replace('"', '')) >= expected_attempts + assert int(match_keys[1].replace("'", '').replace('"', '')) >= expected_attempts def assert_error_context_contains_last_dispatch(self, context: Optional[str]) -> None: assert isinstance(context, str) @@ -118,7 +110,7 @@ def assert_error_context_contains_last_dispatch(self, context: Optional[str]) -> match = next((k for k in ctx_keys if 'last_dispatched_from' in k), None) assert match is not None - def assert_error_context_missing_last_dispatch(self, context: Optional[str]=None) -> None: + def assert_error_context_missing_last_dispatch(self, context: Optional[str] = None) -> None: if context is None: return assert isinstance(context, str) @@ -137,6 +129,7 @@ def load_collection_data_from_file(self, file_path: str, limit: Optional[int] = return json_data[:limit] return json_data + class BlockingTestEnvironment(TestEnvironment): def __init__(self, config: AnalyticsConfig, **kwargs: Unpack[TestEnvironmentOptionsKwargs]) -> None: super().__init__(config, **kwargs) @@ -182,10 +175,9 @@ def disable_test_server(self) -> BlockingTestEnvironment: # self._server_handler = None return self - def enable_scope(self, - database_name: Optional[str] = None, - scope_name: Optional[str] = None) -> BlockingTestEnvironment: - + def enable_scope( + self, database_name: Optional[str] = None, scope_name: Optional[str] = None + ) -> BlockingTestEnvironment: if self._cluster is None: raise AnalyticsTestEnvironmentError('No cluster available.') db_name = database_name if database_name is not None else self._database_name @@ -205,9 +197,12 @@ def enable_test_server(self) -> BlockingTestEnvironment: raise AnalyticsTestEnvironmentError('No cluster available, cannot enable test server.') from tests.utils._client_adapter import _TestClientAdapter from tests.utils._test_httpx import TestHTTPTransport + print(f'{self._cluster=}') - new_adapter = _TestClientAdapter(adapter=self._cluster._impl._client_adapter, # type: ignore[call-arg] - http_transport_cls=TestHTTPTransport) + new_adapter = _TestClientAdapter( + adapter=self._cluster._impl._client_adapter, + http_transport_cls=TestHTTPTransport, + ) new_adapter.create_client() self._cluster._impl._client_adapter = new_adapter url = self._cluster._impl.client_adapter.connection_details.url.get_formatted_url() @@ -222,16 +217,18 @@ def setup(self) -> None: setup_statements = [ f'CREATE DATABASE `{self.config.database_name}` IF NOT EXISTS;', f'CREATE SCOPE `{self.config.database_name}`.`{self.config.scope_name}` IF NOT EXISTS;', - ('CREATE COLLECTION ' - f'`{self.config.database_name}`.`{self.config.scope_name}`.`{self.config.collection_name}`' - ' IF NOT EXISTS PRIMARY KEY (pk: UUID) AUTOGENERATED;') + ( + 'CREATE COLLECTION ' + f'`{self.config.database_name}`.`{self.config.scope_name}`.`{self.config.collection_name}`' + ' IF NOT EXISTS PRIMARY KEY (pk: UUID) AUTOGENERATED;' + ), ] for statement in setup_statements: try: self.cluster.execute_query(statement) except Exception as ex: - raise AnalyticsTestEnvironmentError(f'Unable to execute statement={statement}. Error: {ex}') + raise AnalyticsTestEnvironmentError(f'Unable to execute statement={statement}. Error: {ex}') from None json_data = self.load_collection_data_from_file(TEST_AIRLINE_DATA_PATH) docs = [] @@ -241,13 +238,15 @@ def setup(self) -> None: if 'scope' in d: d['scope'] = self.config.scope_name docs.append(json.dumps(d)) - statement = (f'USE `{self.config.database_name}`.`{self.config.scope_name}`; ' - f'UPSERT INTO `{self.config.collection_name}` ({",".join(docs)})') + statement = ( + f'USE `{self.config.database_name}`.`{self.config.scope_name}`; ' + f'UPSERT INTO `{self.config.collection_name}` ({",".join(docs)})' + ) try: self.cluster.execute_query(statement) except Exception as ex: - raise AnalyticsTestEnvironmentError(f'Unable to load collection data. Error: {ex}') + raise AnalyticsTestEnvironmentError(f'Unable to load collection data. Error: {ex}') from None def set_url_path(self, url_path: str) -> None: if self._server_handler is None: @@ -263,16 +262,18 @@ def teardown(self) -> None: teardown_statements = [ f'DROP DATABASE `{self.config.database_name}` IF EXISTS;', f'DROP SCOPE `{self.config.database_name}`.`{self.config.scope_name}` IF EXISTS;', - ('DROP COLLECTION ' - f'`{self.config.database_name}`.`{self.config.scope_name}`.`{self.config.collection_name}`' - ' IF EXISTS;') + ( + 'DROP COLLECTION ' + f'`{self.config.database_name}`.`{self.config.scope_name}`.`{self.config.collection_name}`' + ' IF EXISTS;' + ), ] for statement in teardown_statements: try: self.cluster.execute_query(statement) except Exception as ex: - raise AnalyticsTestEnvironmentError(f'Unable to execute statement={statement}. Error: {ex}') + raise AnalyticsTestEnvironmentError(f'Unable to execute statement={statement}. Error: {ex}') from None def update_request_extensions(self, extensions: Dict[str, object]) -> None: if self._server_handler is None: @@ -289,9 +290,9 @@ def update_request_json(self, json: Dict[str, object]) -> None: self._cluster._impl._client_adapter.update_request_json(json) @classmethod - def get_environment(cls, - config: AnalyticsConfig, - server_handler: Optional[WebServerHandler]=None) -> BlockingTestEnvironment: + def get_environment( + cls, config: AnalyticsConfig, server_handler: Optional[WebServerHandler] = None + ) -> BlockingTestEnvironment: if config is None: raise AnalyticsTestEnvironmentError('No test config provided.') @@ -306,6 +307,7 @@ def get_environment(cls, sec_opts: Optional[SecurityOptions] = None if config.nonprod is True: from couchbase_analytics.common._core._certificates import _Certificates + sec_opts = SecurityOptions.trust_only_certificates(_Certificates.get_nonprod_certificates()) if config.disable_server_certificate_verification is True: @@ -326,7 +328,6 @@ def get_environment(cls, return cls(config, **env_opts) - class AsyncTestEnvironment(TestEnvironment): def __init__(self, config: AnalyticsConfig, **kwargs: Unpack[TestEnvironmentOptionsKwargs]) -> None: self._backend = kwargs.pop('backend', None) @@ -372,10 +373,9 @@ def disable_test_server(self) -> AsyncTestEnvironment: self._server_handler.stop_server() return self - def enable_scope(self, - database_name: Optional[str] = None, - scope_name: Optional[str] = None) -> AsyncTestEnvironment: - + def enable_scope( + self, database_name: Optional[str] = None, scope_name: Optional[str] = None + ) -> AsyncTestEnvironment: if self._async_cluster is None: raise AnalyticsTestEnvironmentError('No cluster available.') db_name = database_name if database_name is not None else self._database_name @@ -398,8 +398,10 @@ async def enable_test_server(self) -> AsyncTestEnvironment: # close the adapter here b/c we need to await await self._async_cluster._impl._client_adapter.close_client() - new_adapter = _TestAsyncClientAdapter(adapter=self._async_cluster._impl._client_adapter, # type: ignore[call-arg] - http_transport_cls=TestAsyncHTTPTransport) + new_adapter = _TestAsyncClientAdapter( + adapter=self._async_cluster._impl._client_adapter, + http_transport_cls=TestAsyncHTTPTransport, + ) await new_adapter.create_client() self._async_cluster._impl._client_adapter = new_adapter url = self._async_cluster._impl.client_adapter.connection_details.url.get_formatted_url() @@ -414,16 +416,18 @@ async def setup(self) -> None: setup_statements = [ f'CREATE DATABASE `{self.config.database_name}` IF NOT EXISTS;', f'CREATE SCOPE `{self.config.database_name}`.`{self.config.scope_name}` IF NOT EXISTS;', - ('CREATE COLLECTION ' - f'`{self.config.database_name}`.`{self.config.scope_name}`.`{self.config.collection_name}`' - ' IF NOT EXISTS PRIMARY KEY (pk: UUID) AUTOGENERATED;') + ( + 'CREATE COLLECTION ' + f'`{self.config.database_name}`.`{self.config.scope_name}`.`{self.config.collection_name}`' + ' IF NOT EXISTS PRIMARY KEY (pk: UUID) AUTOGENERATED;' + ), ] for statement in setup_statements: try: await self.cluster.execute_query(statement) except Exception as ex: - raise AnalyticsTestEnvironmentError(f'Unable to execute statement={statement}. Error: {ex}') + raise AnalyticsTestEnvironmentError(f'Unable to execute statement={statement}. Error: {ex}') from None json_data = self.load_collection_data_from_file(TEST_AIRLINE_DATA_PATH) docs = [] @@ -433,13 +437,15 @@ async def setup(self) -> None: if 'scope' in d: d['scope'] = self.config.scope_name docs.append(json.dumps(d)) - statement = (f'USE `{self.config.database_name}`.`{self.config.scope_name}`; ' - f'UPSERT INTO `{self.config.collection_name}` ({",".join(docs)})') + statement = ( + f'USE `{self.config.database_name}`.`{self.config.scope_name}`; ' + f'UPSERT INTO `{self.config.collection_name}` ({",".join(docs)})' + ) try: await self.cluster.execute_query(statement) except Exception as ex: - raise AnalyticsTestEnvironmentError(f'Unable to load collection data. Error: {ex}') + raise AnalyticsTestEnvironmentError(f'Unable to load collection data. Error: {ex}') from None def set_url_path(self, url_path: str) -> None: if self._server_handler is None: @@ -458,16 +464,18 @@ async def teardown(self) -> None: teardown_statements = [ f'DROP DATABASE `{self.config.database_name}` IF EXISTS;', f'DROP SCOPE `{self.config.database_name}`.`{self.config.scope_name}` IF EXISTS;', - ('DROP COLLECTION ' - f'`{self.config.database_name}`.`{self.config.scope_name}`.`{self.config.collection_name}`' - ' IF EXISTS;') + ( + 'DROP COLLECTION ' + f'`{self.config.database_name}`.`{self.config.scope_name}`.`{self.config.collection_name}`' + ' IF EXISTS;' + ), ] for statement in teardown_statements: try: await self.cluster.execute_query(statement) except Exception as ex: - raise AnalyticsTestEnvironmentError(f'Unable to execute statement={statement}. Error: {ex}') + raise AnalyticsTestEnvironmentError(f'Unable to execute statement={statement}. Error: {ex}') from None def update_request_extensions(self, extensions: Dict[str, object]) -> None: if self._server_handler is None: @@ -484,10 +492,9 @@ def update_request_json(self, json: Dict[str, object]) -> None: self._async_cluster._impl._client_adapter.update_request_json(json) @classmethod - def get_environment(cls, - config: AnalyticsConfig, - server_handler: Optional[WebServerHandler]=None, - backend: Optional[str]=None) -> AsyncTestEnvironment: + def get_environment( + cls, config: AnalyticsConfig, server_handler: Optional[WebServerHandler] = None, backend: Optional[str] = None + ) -> AsyncTestEnvironment: if config is None: raise AnalyticsTestEnvironmentError('No test config provided.') @@ -504,6 +511,7 @@ def get_environment(cls, sec_opts: Optional[SecurityOptions] = None if config.nonprod is True: from couchbase_analytics.common._core._certificates import _Certificates + sec_opts = SecurityOptions.trust_only_certificates(_Certificates.get_nonprod_certificates()) if config.disable_server_certificate_verification is True: @@ -524,26 +532,30 @@ def get_environment(cls, env_opts['collection_name'] = config.collection_name return cls(config, **env_opts) + @pytest.fixture(scope='class', name='sync_test_env') def base_test_environment(analytics_config: AnalyticsConfig) -> BlockingTestEnvironment: - print("Creating sync test environment") + print('Creating sync test environment') return BlockingTestEnvironment.get_environment(analytics_config) + @pytest.fixture(scope='class', name='sync_test_env_with_server') def base_test_environment_with_server(analytics_config: AnalyticsConfig) -> BlockingTestEnvironment: - print("Creating sync test environment w/ test server") + print('Creating sync test environment w/ test server') server_handler = WebServerHandler() return BlockingTestEnvironment.get_environment(analytics_config, server_handler=server_handler) + @pytest.fixture(scope='class', name='async_test_env') def base_async_test_environment(analytics_config: AnalyticsConfig, anyio_backend: str) -> AsyncTestEnvironment: - print("Creating async test environment") + print('Creating async test environment') return AsyncTestEnvironment.get_environment(analytics_config, backend=anyio_backend) + @pytest.fixture(scope='class', name='async_test_env_with_server') -def base_async_test_environment_with_server(analytics_config: AnalyticsConfig, anyio_backend:str) -> AsyncTestEnvironment: - print("Creating async test environment w/ test server") +def base_async_test_environment_with_server( + analytics_config: AnalyticsConfig, anyio_backend: str +) -> AsyncTestEnvironment: + print('Creating async test environment w/ test server') server_handler = WebServerHandler() - return AsyncTestEnvironment.get_environment(analytics_config, - server_handler=server_handler, - backend=anyio_backend) + return AsyncTestEnvironment.get_environment(analytics_config, server_handler=server_handler, backend=anyio_backend) diff --git a/tests/environments/simple_environment.py b/tests/environments/simple_environment.py index 27646eb..6c33d40 100644 --- a/tests/environments/simple_environment.py +++ b/tests/environments/simple_environment.py @@ -1,3 +1,4 @@ +# ruff: noqa: E501 import json from enum import Enum from typing import Any, Tuple @@ -13,8 +14,9 @@ class JsonDataType(Enum): FAILED_REQUEST_MULTI_ERRORS = 'failed_request_multi_errors' FAILED_REQUEST_MID_STREAM = 'failed_request_mid_stream' + JSON_DATA = { - 'simple_request':""" + 'simple_request': """ { "requestID": "98f69cf0-6d00-4a61-b8b6-e3b29fb6061b", "signature": { @@ -37,7 +39,7 @@ class JsonDataType(Enum): "processedObjects": 0 } }""".strip(), - 'multiple_results':""" + 'multiple_results': """ { "requestID": "94c7f89f-92b6-4aba-a90d-be715ca47309", "signature": { @@ -94,7 +96,7 @@ class JsonDataType(Enum): "bufferCacheHitRatio": "100.00%" } }""".strip(), -'multiple_results_raw':""" + 'multiple_results_raw': """ { "requestID": "94c7f89f-92b6-4aba-a90d-be715ca47309", "signature": { @@ -125,7 +127,7 @@ class JsonDataType(Enum): "bufferCacheHitRatio": "100.00%" } }""".strip(), - 'failed_request':""" + 'failed_request': """ { "requestID": "c5f50c58-c044-481f-a26a-357a29f7446e", "errors": [ @@ -148,7 +150,7 @@ class JsonDataType(Enum): "errorCount": 1 } }""".strip(), - 'failed_request_multi_errors':""" + 'failed_request_multi_errors': """ { "requestID": "c5f50c58-c044-481f-a26a-357a29f7446e", "errors": [ @@ -175,7 +177,7 @@ class JsonDataType(Enum): "errorCount": 2 } }""".strip(), - 'failed_request_mid_stream':""" + 'failed_request_mid_stream': """ { "requestID": "c5f50c58-c044-481f-a26a-357a29f7446e", "results": [ @@ -201,9 +203,10 @@ class JsonDataType(Enum): "bufferCachePageReadCount": 0, "errorCount": 2 } -}""".strip() +}""".strip(), } + class BaseSimpleEnvironment: def __init__(self, backend: str) -> None: self._backend = backend @@ -218,18 +221,22 @@ def get_json_data(self, json_type: JsonDataType) -> Tuple[Any, bytes]: data = JSON_DATA[key] return json.loads(data), bytes(data, 'utf-8') + class AsyncSimpleEnvironment(BaseSimpleEnvironment): def __init__(self, backend: str) -> None: super().__init__(backend) + class SimpleEnvironment(BaseSimpleEnvironment): def __init__(self, backend: str) -> None: super().__init__(backend) + @pytest.fixture(scope='class', name='simple_async_test_env') def simple_async_test_environment(anyio_backend: str) -> AsyncSimpleEnvironment: return AsyncSimpleEnvironment(anyio_backend) + @pytest.fixture(scope='class', name='simple_test_env') def simple_test_environment(anyio_backend: str) -> SimpleEnvironment: return SimpleEnvironment(anyio_backend) diff --git a/tests/test_server/__init__.py b/tests/test_server/__init__.py index e779751..b19f568 100644 --- a/tests/test_server/__init__.py +++ b/tests/test_server/__init__.py @@ -29,10 +29,10 @@ class ErrorType(Enum): def from_str(error_type: str) -> ErrorType: match = next((t for t in ErrorType if t.value == error_type), None) if not match: - raise ValueError(f'Invalid error type: {error_type}. ' - f'Valid options are: {[e.value for e in ErrorType]}') + raise ValueError(f'Invalid error type: {error_type}. Valid options are: {[e.value for e in ErrorType]}') return match + class ResultType(Enum): Object = 'object' Raw = 'raw' @@ -41,10 +41,10 @@ class ResultType(Enum): def from_str(result_type: str) -> ResultType: match = next((t for t in ResultType if t.value == result_type), None) if not match: - raise ValueError(f'Invalid result type: {result_type}. ' - f'Valid options are: {[e.value for e in ResultType]}') + raise ValueError(f'Invalid result type: {result_type}. Valid options are: {[e.value for e in ResultType]}') return match + class RetriableGroupType(Enum): All = 'all' Zero = 'zero' @@ -56,10 +56,12 @@ class RetriableGroupType(Enum): def from_str(rgt: str) -> RetriableGroupType: match = next((t for t in RetriableGroupType if t.value == rgt), None) if not match: - raise ValueError(f'Invalid retriable group type: {rgt}. ' - f'Valid options are: {[e.value for e in RetriableGroupType]}') + raise ValueError( + f'Invalid retriable group type: {rgt}. Valid options are: {[e.value for e in RetriableGroupType]}' + ) return match + class NonRetriableSpecificationType(Enum): AllEmpty = 'all_empty' AllFalse = 'all_false' @@ -69,6 +71,8 @@ class NonRetriableSpecificationType(Enum): def from_str(nrst: str) -> NonRetriableSpecificationType: match = next((t for t in NonRetriableSpecificationType if t.value == nrst), None) if not match: - raise ValueError(f'Invalid non-retriable specification type: {nrst}. ' - f'Valid options are: {[e.value for e in NonRetriableSpecificationType]}') + raise ValueError( + f'Invalid non-retriable specification type: {nrst}. ' + f'Valid options are: {[e.value for e in NonRetriableSpecificationType]}' + ) return match diff --git a/tests/test_server/request.py b/tests/test_server/request.py index 78f2661..7ded62a 100644 --- a/tests/test_server/request.py +++ b/tests/test_server/request.py @@ -1,14 +1,9 @@ from __future__ import annotations from dataclasses import dataclass -from typing import (Any, - Dict, - Optional) +from typing import Any, Dict, Optional -from tests.test_server import (ErrorType, - NonRetriableSpecificationType, - ResultType, - RetriableGroupType) +from tests.test_server import ErrorType, NonRetriableSpecificationType, ResultType, RetriableGroupType @dataclass @@ -32,10 +27,13 @@ def from_json(cls, json_data: Dict[str, Any]) -> ServerErrorRequest: nrst = None if non_retry_spec is not None: nrst = NonRetriableSpecificationType.from_str(non_retry_spec) - return cls(error_type=err_type, - retry_group_type=rgt, - non_retriable_spec=nrst, - error_count=json_data.get('error_count', None)) + return cls( + error_type=err_type, + retry_group_type=rgt, + non_retriable_spec=nrst, + error_count=json_data.get('error_count', None), + ) + @dataclass class ServerResultsRequest: @@ -47,7 +45,6 @@ class ServerResultsRequest: @classmethod def from_json(cls, json_data: Dict[str, Any]) -> ServerResultsRequest: - until_raw = json_data.get('until', None) if until_raw is not None and not isinstance(until_raw, (float, int)): raise ValueError(f'Invalid "until" value: {until_raw}. Must be a number.') @@ -67,11 +64,14 @@ def from_json(cls, json_data: Dict[str, Any]) -> ServerResultsRequest: raise ValueError(f'Invalid "chunk_size" value: {chunk_raw}. Must be an integer.') chunk_size = int(chunk_raw) if chunk_raw is not None else None - return cls(result_type=result_type, - row_count=row_count, - chunk_size=chunk_size, - stream=json_data.get('stream', False), - until=until) + return cls( + result_type=result_type, + row_count=row_count, + chunk_size=chunk_size, + stream=json_data.get('stream', False), + until=until, + ) + @dataclass class ServerSlowResultsRequest: @@ -81,6 +81,7 @@ class ServerSlowResultsRequest: stream: Optional[bool] = False until: Optional[float] = None + @dataclass class ServerTimeoutRequest: error_type: ErrorType @@ -94,9 +95,10 @@ def from_json(cls, json_data: Dict[str, Any]) -> ServerTimeoutRequest: raise ValueError('Missing "timeout" in JSON data.') if not isinstance(timeout, (int, float)): raise ValueError(f'Invalid "timeout" value: {timeout}. Must be a number.') - return cls(error_type=ErrorType.Timeout, - timeout=float(timeout), - server_side=json_data.get('server_side', False)) + return cls( + error_type=ErrorType.Timeout, timeout=float(timeout), server_side=json_data.get('server_side', False) + ) + @dataclass class ServerHttp503Request: @@ -105,5 +107,4 @@ class ServerHttp503Request: @classmethod def from_json(cls, json_data: Dict[str, Any]) -> ServerHttp503Request: - return cls(error_type=ErrorType.Http503, - analytics_error=json_data.get('analytics_error', False)) + return cls(error_type=ErrorType.Http503, analytics_error=json_data.get('analytics_error', False)) diff --git a/tests/test_server/response.py b/tests/test_server/response.py index 83a9562..dc60a7c 100644 --- a/tests/test_server/response.py +++ b/tests/test_server/response.py @@ -18,126 +18,118 @@ import json from dataclasses import dataclass, field from random import choice -from typing import (Any, - Callable, - Dict, - Generator, - List, - Optional, - Union) +from typing import Any, Callable, Dict, Generator, List, Optional, Union from uuid import uuid4 -from tests.test_server import (ErrorType, - NonRetriableSpecificationType, - ResultType, - RetriableGroupType) +from tests.test_server import ErrorType, NonRetriableSpecificationType, ResultType, RetriableGroupType US_CITIES = [ - "New York City", - "Los Angeles", - "Chicago", - "Houston", - "Phoenix", - "Philadelphia", - "San Antonio", - "San Diego", - "Dallas", - "San Jose", - "Austin", - "Jacksonville", - "Fort Worth", - "Columbus", - "Charlotte", - "Indianapolis", - "San Francisco", - "Seattle", - "Denver", - "Washington, D.C.", - "Boston", - "El Paso", - "Nashville", - "Detroit", - "Oklahoma City", - "Portland", - "Las Vegas", - "Memphis", - "Louisville", - "Baltimore", - "Milwaukee", - "Albuquerque", - "Tucson", - "Fresno", - "Sacramento", - "Mesa", - "Atlanta", - "Kansas City", - "Colorado Springs", - "Raleigh", - "Omaha", - "Miami", - "Long Beach", - "Virginia Beach", - "Oakland", - "Minneapolis", - "Tampa", - "New Orleans", - "Cleveland", - "Orlando" + 'New York City', + 'Los Angeles', + 'Chicago', + 'Houston', + 'Phoenix', + 'Philadelphia', + 'San Antonio', + 'San Diego', + 'Dallas', + 'San Jose', + 'Austin', + 'Jacksonville', + 'Fort Worth', + 'Columbus', + 'Charlotte', + 'Indianapolis', + 'San Francisco', + 'Seattle', + 'Denver', + 'Washington, D.C.', + 'Boston', + 'El Paso', + 'Nashville', + 'Detroit', + 'Oklahoma City', + 'Portland', + 'Las Vegas', + 'Memphis', + 'Louisville', + 'Baltimore', + 'Milwaukee', + 'Albuquerque', + 'Tucson', + 'Fresno', + 'Sacramento', + 'Mesa', + 'Atlanta', + 'Kansas City', + 'Colorado Springs', + 'Raleigh', + 'Omaha', + 'Miami', + 'Long Beach', + 'Virginia Beach', + 'Oakland', + 'Minneapolis', + 'Tampa', + 'New Orleans', + 'Cleveland', + 'Orlando', ] NAMES = [ - "Alice Smith", - "Bob Johnson", - "Catherine Davis", - "David Miller", - "Emily Wilson", - "Frank Moore", - "Grace Taylor", - "Henry Anderson", - "Ivy Thomas", - "Jack Jackson", - "Karen White", - "Leo Harris", - "Mia Martin", - "Noah Garcia", - "Olivia Rodriguez", - "Peter Martinez", - "Quinn Clark", - "Rachel Lewis", - "Sam Lee", - "Tina Hall", - "Uma Young", - "Victor King", - "Wendy Wright", - "Xavier Lopez", - "Yara Hill", - "Zack Scott", - "Amelia Green", - "Ben Baker", - "Chloe Adams", - "Daniel Nelson", - "Ella Carter", - "Finn Mitchell", - "Georgia Perez", - "Harry Roberts", - "Isla Turner", - "James Phillips", - "Kim Campbell", - "Liam Parker", - "Maya Evans", - "Nathan Edwards", - "Owen Collins", - "Penelope Stewart", - "Ryan Sanchez", - "Sophia Morris", - "Thomas Rogers", - "Victoria Reed", - "William Cook", - "Zara Bell", - "Ethan Murphy", - "Lily Russell" + 'Alice Smith', + 'Bob Johnson', + 'Catherine Davis', + 'David Miller', + 'Emily Wilson', + 'Frank Moore', + 'Grace Taylor', + 'Henry Anderson', + 'Ivy Thomas', + 'Jack Jackson', + 'Karen White', + 'Leo Harris', + 'Mia Martin', + 'Noah Garcia', + 'Olivia Rodriguez', + 'Peter Martinez', + 'Quinn Clark', + 'Rachel Lewis', + 'Sam Lee', + 'Tina Hall', + 'Uma Young', + 'Victor King', + 'Wendy Wright', + 'Xavier Lopez', + 'Yara Hill', + 'Zack Scott', + 'Amelia Green', + 'Ben Baker', + 'Chloe Adams', + 'Daniel Nelson', + 'Ella Carter', + 'Finn Mitchell', + 'Georgia Perez', + 'Harry Roberts', + 'Isla Turner', + 'James Phillips', + 'Kim Campbell', + 'Liam Parker', + 'Maya Evans', + 'Nathan Edwards', + 'Owen Collins', + 'Penelope Stewart', + 'Ryan Sanchez', + 'Sophia Morris', + 'Thomas Rogers', + 'Victoria Reed', + 'William Cook', + 'Zara Bell', + 'Ethan Murphy', + 'Lily Russell', ] + @dataclass class ServerResponseMetrics: elapsed_time: float @@ -162,13 +154,14 @@ def to_json_repr(self) -> Dict[str, Union[str, int]]: 'processedObjects': self.processed_objects, 'bufferCacheHitRatio': f'{self.buffer_cache_hit_ratio}%', 'bufferCachePageReadCount': self.buffer_cache_page_read_count, - 'errorCount': self.error_count + 'errorCount': self.error_count, } @staticmethod def create() -> ServerResponseMetrics: return ServerResponseMetrics(0.0, 0.0, 0.0, 0.0, 0, 0, 0, 0.0, 0, 0) + @dataclass class ServerResponseError: code: int @@ -176,18 +169,15 @@ class ServerResponseError: retriable: Optional[bool] = None def to_json_repr(self) -> Dict[str, Union[str, int, bool]]: - output: Dict[str, Union[str, int, bool]] = { - 'code': self.code, - 'msg': self.msg - } + output: Dict[str, Union[str, int, bool]] = {'code': self.code, 'msg': self.msg} if self.retriable is not None: output['retriable'] = self.retriable return output @staticmethod - def _build_retry_group(retry_specification: NonRetriableSpecificationType, - err_count: int, - retriable_idx: Optional[int] = -1) -> List[ServerResponseError]: + def _build_retry_group( + retry_specification: NonRetriableSpecificationType, err_count: int, retriable_idx: Optional[int] = -1 + ) -> List[ServerResponseError]: errors: List[ServerResponseError] = [] for err_idx in range(err_count): if err_idx == retriable_idx: @@ -197,7 +187,7 @@ def _build_retry_group(retry_specification: NonRetriableSpecificationType, elif retry_specification == NonRetriableSpecificationType.AllFalse: errors.append(ServerResponseError(24040, 'Some unknown error occurred', False)) elif retry_specification == NonRetriableSpecificationType.Random: - if choice([0,1]): + if choice([0, 1]): errors.append(ServerResponseError(24040, 'Some unknown error occurred', False)) else: errors.append(ServerResponseError(24040, 'Some unknown error occurred')) @@ -207,15 +197,15 @@ def _build_retry_group(retry_specification: NonRetriableSpecificationType, return errors @staticmethod - def build_retry_group(group_type: RetriableGroupType, - retry_specification: Optional[NonRetriableSpecificationType] = None, - err_count: Optional[int] = None) -> List[ServerResponseError]: + def build_retry_group( + group_type: RetriableGroupType, + retry_specification: Optional[NonRetriableSpecificationType] = None, + err_count: Optional[int] = None, + ) -> List[ServerResponseError]: if err_count is None: err_count = choice([2, 3, 4, 5]) if group_type == RetriableGroupType.All: - return [ServerResponseError(24045, - 'Some unknown retriable error occurred', - True) for _ in range(err_count)] + return [ServerResponseError(24045, 'Some unknown retriable error occurred', True) for _ in range(err_count)] if retry_specification is None: raise RuntimeError('No non-retriable specification type provided.') @@ -224,19 +214,22 @@ def build_retry_group(group_type: RetriableGroupType, elif group_type == RetriableGroupType.First: return ServerResponseError._build_retry_group(retry_specification, err_count, retriable_idx=0) elif group_type == RetriableGroupType.Middle: - return ServerResponseError._build_retry_group(retry_specification, err_count, retriable_idx=(err_count // 2)) + return ServerResponseError._build_retry_group( + retry_specification, err_count, retriable_idx=(err_count // 2) + ) elif group_type == RetriableGroupType.Last: - return ServerResponseError._build_retry_group(retry_specification, err_count, retriable_idx=err_count-1) + return ServerResponseError._build_retry_group(retry_specification, err_count, retriable_idx=err_count - 1) else: raise RuntimeError('Unrecognized retriable group type.') - @staticmethod - def build_errors(resp: ServerResponse, - error_type: ErrorType, - group_type: Optional[RetriableGroupType]=None, - retry_specification: Optional[NonRetriableSpecificationType]=None, - err_count: Optional[int] = None) -> ServerResponse: + def build_errors( + resp: ServerResponse, + error_type: ErrorType, + group_type: Optional[RetriableGroupType] = None, + retry_specification: Optional[NonRetriableSpecificationType] = None, + err_count: Optional[int] = None, + ) -> ServerResponse: if error_type == ErrorType.Timeout: resp.http_status = 200 resp.status = 'timeout' @@ -251,7 +244,9 @@ def build_errors(resp: ServerResponse, resp.http_status = 403 resp.status = 'fatal' resp.metrics.error_count = 1 - resp.errors = [ServerResponseError(20001, 'Insufficient permissions or the requested object does not exist')] + resp.errors = [ + ServerResponseError(20001, 'Insufficient permissions or the requested object does not exist') + ] elif error_type == ErrorType.Unauthorized: resp.http_status = 401 resp.status = 'fatal' @@ -280,15 +275,13 @@ def to_json_repr(self) -> Union[List[str], List[Dict[str, Any]]]: return self.results @staticmethod - def build_results(resp: ServerResponse, - row_count: int, - result_type: ResultType) -> None: + def build_results(resp: ServerResponse, row_count: int, result_type: ResultType) -> None: if result_type == ResultType.Object: obj_results: List[Dict[str, Any]] = [] for idx in range(row_count): name = choice(NAMES) city = choice(US_CITIES) - obj_results.append({'id': idx+1, 'name': name, 'city': city}) + obj_results.append({'id': idx + 1, 'name': name, 'city': city}) resp.results = ServerResponseResults(obj_results) resp.metrics.result_count = row_count resp.metrics.result_size = row_count * 10 @@ -302,22 +295,27 @@ def build_results(resp: ServerResponse, @staticmethod def get_result_genetaotr(result_type: ResultType) -> Callable[[], Union[Generator[bytes, None, None]]]: if result_type == ResultType.Object: + def obj_generator() -> Generator[bytes, None, None]: idx = 0 while True: name = choice(NAMES) city = choice(US_CITIES) - yield bytes(json.dumps({'id': idx+1, 'name': name, 'city': city}), 'utf-8') + yield bytes(json.dumps({'id': idx + 1, 'name': name, 'city': city}), 'utf-8') idx += 1 + return obj_generator elif result_type == ResultType.Raw: + def raw_generator() -> Generator[bytes, None, None]: while True: yield bytes(choice(NAMES), 'utf-8') + return raw_generator else: raise RuntimeError(f'Unrecognized result type. Got type: {result_type}') + @dataclass class ServerResponse: http_status: int diff --git a/tests/test_server/web_server.py b/tests/test_server/web_server.py index 6017c6a..c748b9d 100644 --- a/tests/test_server/web_server.py +++ b/tests/test_server/web_server.py @@ -13,6 +13,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +# ruff: noqa: E402 + from __future__ import annotations import asyncio @@ -30,28 +32,33 @@ sys.path.append(str(CLIENT_ROOT)) from tests.test_server import ErrorType -from tests.test_server.request import (ServerErrorRequest, - ServerHttp503Request, - ServerResultsRequest, - ServerTimeoutRequest) -from tests.test_server.response import (ServerResponse, - ServerResponseError, - ServerResponseResults) +from tests.test_server.request import ( + ServerErrorRequest, + ServerHttp503Request, + ServerResultsRequest, + ServerTimeoutRequest, +) +from tests.test_server.response import ServerResponse, ServerResponseError, ServerResponseResults from tests.utils import AsyncBytesIterator, AsyncInfiniteBytesIterator -logging.basicConfig(level=logging.INFO, - stream=sys.stderr, - format='%(asctime)s - %(levelname)s - (PID:%(process)d) - %(message)s') +logging.basicConfig( + level=logging.INFO, stream=sys.stderr, format='%(asctime)s - %(levelname)s - (PID:%(process)d) - %(message)s' +) logger = logging.getLogger(__name__) + class AsyncWebServer: - def __init__(self, host: Optional[str]='0.0.0.0', port:Optional[int]=8080) -> None: + def __init__(self, host: Optional[str] = '0.0.0.0', port: Optional[int] = 8080) -> None: self._app = web.Application() self._host = host self._port = port - self._app.add_routes([web.post('/test_error', self.handle_error_request), - web.post('/test_results', self.handle_results_request), - web.post('/test_slow_results', self.handle_slow_results_request)]) + self._app.add_routes( + [ + web.post('/test_error', self.handle_error_request), + web.post('/test_results', self.handle_results_request), + web.post('/test_slow_results', self.handle_slow_results_request), + ] + ) async def _handle_timeout_error_request(self, request: ServerTimeoutRequest) -> web.Response: timeout = request.timeout @@ -66,12 +73,14 @@ async def _handle_timeout_error_request(self, request: ServerTimeoutRequest) -> resp.update_elapsed_time(elapsed) return web.json_response(resp.to_json_repr()) - return web.json_response({ - 'requestID': request_id, - 'status': 'timeout', - 'elapsedTime': f'{elapsed}s', - 'message': f'Request timed out after {timeout} seconds.' - }) + return web.json_response( + { + 'requestID': request_id, + 'status': 'timeout', + 'elapsedTime': f'{elapsed}s', + 'message': f'Request timed out after {timeout} seconds.', + } + ) def _handle_auth_error_request(self, error_type: ErrorType) -> web.Response: start = perf_counter() @@ -96,18 +105,21 @@ def _handle_http503_error_request(self, request: ServerHttp503Request) -> web.Re async def _handle_retry_error_request(self, request: ServerErrorRequest) -> web.Response: start = perf_counter() resp = ServerResponse.create() - ServerResponseError.build_errors(resp, - request.error_type, - group_type=request.retry_group_type, - retry_specification=request.non_retriable_spec, - err_count=request.error_count) + ServerResponseError.build_errors( + resp, + request.error_type, + group_type=request.retry_group_type, + retry_specification=request.non_retriable_spec, + err_count=request.error_count, + ) end = perf_counter() elapsed = end - start resp.update_elapsed_time(elapsed) - res = resp.to_json_repr() return web.json_response(resp.to_json_repr()) - async def _handle_results_request(self, request: ServerResultsRequest, web_request: web.Request) -> Union[web.Response, web.StreamResponse]: + async def _handle_results_request( + self, request: ServerResultsRequest, web_request: web.Request + ) -> Union[web.Response, web.StreamResponse]: resp = ServerResponse.create() start = perf_counter() if request.until is not None: @@ -118,7 +130,9 @@ async def _handle_results_request(self, request: ServerResultsRequest, web_reque chunk_size = request.chunk_size or 100 bytes_generator = ServerResponseResults.get_result_genetaotr(request.result_type) initial_data = bytes(json.dumps({'requestID': resp.request_id, 'status': resp.status}), 'utf-8') - async_inf_iterator = AsyncInfiniteBytesIterator(bytes_generator(), initial_data=initial_data, chunk_size=chunk_size) + async_inf_iterator = AsyncInfiniteBytesIterator( + bytes_generator(), initial_data=initial_data, chunk_size=chunk_size + ) while deadline > now: chunk = await async_inf_iterator.__anext__() await response.write(chunk) @@ -172,20 +186,17 @@ async def handle_error_request(self, request: web.Request) -> web.Response: elif error_req.error_type == ErrorType.Http503: http503_req = ServerHttp503Request.from_json(received_json) return self._handle_http503_error_request(http503_req) - logger.info(f"Received JSON: {received_json}") - return web.json_response({ - 'status': 'success', - 'data': received_json - }) + logger.info(f'Received JSON: {received_json}') + return web.json_response({'status': 'success', 'data': received_json}) except json.JSONDecodeError: received_text = await request.text() - msg = "POST request received, but data is not valid JSON. Showing as plain text." + msg = 'POST request received, but data is not valid JSON. Showing as plain text.' logger.error(msg) logger.error(f'Received text: {received_text}') - return web.Response(status=400, text="Bad Request") + return web.Response(status=400, text='Bad Request') except Exception as e: logger.error(f'An error occurred: {e}', exc_info=True) - return web.Response(status=400, text="Bad Request") + return web.Response(status=400, text='Bad Request') async def handle_results_request(self, request: web.Request) -> Union[web.Response, web.StreamResponse]: try: @@ -194,13 +205,13 @@ async def handle_results_request(self, request: web.Request) -> Union[web.Respon return await self._handle_results_request(result_req, request) except json.JSONDecodeError: received_text = await request.text() - msg = "POST request received, but data is not valid JSON. Showing as plain text." + msg = 'POST request received, but data is not valid JSON. Showing as plain text.' logger.error(msg) logger.error(f'Received text: {received_text}') - return web.Response(status=400, text="Bad Request") + return web.Response(status=400, text='Bad Request') except Exception as e: logger.error(f'An error occurred: {e}', exc_info=True) - return web.Response(status=400, text="Bad Request") + return web.Response(status=400, text='Bad Request') async def handle_slow_results_request(self, request: web.Request) -> web.StreamResponse: try: @@ -208,20 +219,17 @@ async def handle_slow_results_request(self, request: web.Request) -> web.StreamR if 'request_type' not in received_json: raise ValueError('Missing "request_type" in JSON data.') - logger.info(f"Received JSON: {received_json}") - return web.json_response({ - 'status': 'success', - 'data': received_json - }) + logger.info(f'Received JSON: {received_json}') + return web.json_response({'status': 'success', 'data': received_json}) except json.JSONDecodeError: received_text = await request.text() - msg = "POST request received, but data is not valid JSON. Showing as plain text." + msg = 'POST request received, but data is not valid JSON. Showing as plain text.' logger.error(msg) logger.error(f'Received text: {received_text}') - return web.Response(status=400, text="Bad Request") + return web.Response(status=400, text='Bad Request') except Exception as e: logger.error(f'An error occurred: {e}', exc_info=True) - return web.Response(status=400, text="Bad Request") + return web.Response(status=400, text='Bad Request') async def start(self) -> None: runner = web.AppRunner(self._app) @@ -234,6 +242,7 @@ async def stop(self) -> None: await self._app.shutdown() await self._app.cleanup() + async def run_server(host: str, port: int) -> None: server = AsyncWebServer(host=host, port=port) logger.info(f'Attempting to start server on {host}:{port}...') @@ -254,15 +263,12 @@ async def run_server(host: str, port: int) -> None: if __name__ == '__main__': from argparse import ArgumentParser + ap = ArgumentParser(description='Run Async Web Server') - ap.add_argument('--host', - type=str, - default='127.0.0.1', - help='Host address to bind to (e.g., 127.0.0.1 for localhost only)') - ap.add_argument('--port', - type=int, - default=8000, - help='Port number to listen on') + ap.add_argument( + '--host', type=str, default='127.0.0.1', help='Host address to bind to (e.g., 127.0.0.1 for localhost only)' + ) + ap.add_argument('--port', type=int, default=8000, help='Port number to listen on') options = ap.parse_args() try: asyncio.run(run_server(host=options.host, port=options.port)) diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py index bd6633e..73aa224 100644 --- a/tests/utils/__init__.py +++ b/tests/utils/__init__.py @@ -22,30 +22,28 @@ import random from collections.abc import AsyncIterator as PyAsyncIterator from collections.abc import Iterator -from typing import (Any, - Dict, - Generator, - List, - Optional, - Tuple, - Union) +from typing import Any, Dict, Generator, List, Optional, Tuple, Union from urllib.parse import quote import anyio class AsyncInfiniteBytesIterator(PyAsyncIterator[bytes]): - - def __init__(self, - data_generator: Generator[bytes, None, None], - initial_data: Optional[Union[bytes, str]] = None, - chunk_size: Optional[int] = 100, - simulate_delay: Optional[bool] = False, - simulate_delay_range: Optional[Tuple[float, float]] = (0.01, 0.1)) -> None: + def __init__( + self, + data_generator: Generator[bytes, None, None], + initial_data: Optional[Union[bytes, str]] = None, + chunk_size: Optional[int] = 100, + simulate_delay: Optional[bool] = False, + simulate_delay_range: Optional[Tuple[float, float]] = (0.01, 0.1), + ) -> None: self._data_generator = data_generator self._initial_data = bytearray() if initial_data is not None: - self._initial_data = bytearray(initial_data)[:-1] if isinstance(initial_data, bytes) else bytearray(initial_data, 'utf-8')[:-1] + if isinstance(initial_data, bytes): + self._initial_data = bytearray(initial_data)[:-1] + else: + self._initial_data = bytearray(initial_data, 'utf-8')[:-1] self._initial_data += b',"results":[' self._end_data = bytearray() @@ -64,7 +62,10 @@ def get_data_count(self) -> int: def stop_iterating(self, end_data: Optional[Union[bytes, str]] = None) -> None: self._stop_iterating = True if end_data is not None: - self._end_data = bytearray(end_data)[1:-1] if isinstance(end_data, bytes) else bytearray(end_data, 'utf-8')[1:-1] + if isinstance(end_data, bytes): + self._end_data = bytearray(end_data)[1:-1] + else: + self._end_data = bytearray(end_data, 'utf-8')[1:-1] def __aiter__(self) -> AsyncInfiniteBytesIterator: return self @@ -99,19 +100,21 @@ async def __anext__(self) -> bytes: if self._stop >= len(self._data): self._stop = len(self._data) - chunk = bytes(self._data[:self._stop]) - del self._data[:self._stop] + chunk = bytes(self._data[: self._stop]) + del self._data[: self._stop] self._stop += self._chunk_size return chunk -class AsyncBytesIterator(PyAsyncIterator[bytes]): - def __init__(self, - data: Union[bytes, str], - chunk_size: Optional[int] = 100, - simulate_delay: Optional[bool] = False, - simulate_delay_range: Optional[Tuple[float, float]] = (0.01, 0.1)) -> None: +class AsyncBytesIterator(PyAsyncIterator[bytes]): + def __init__( + self, + data: Union[bytes, str], + chunk_size: Optional[int] = 100, + simulate_delay: Optional[bool] = False, + simulate_delay_range: Optional[Tuple[float, float]] = (0.01, 0.1), + ) -> None: self._data = data if isinstance(data, bytes) else bytes(data, 'utf-8') self._chunk_size = chunk_size or 100 self._simulate_delay = simulate_delay or False @@ -138,13 +141,13 @@ async def __anext__(self) -> bytes: if self._stop >= len(self._data): self._stop = len(self._data) - chunk = self._data[self._start:self._stop] + chunk = self._data[self._start : self._stop] self._start = self._stop self._stop += self._chunk_size return chunk -class BytesIterator(Iterator[bytes]): +class BytesIterator(Iterator[bytes]): def __init__(self, data: Union[bytes, str], chunk_size: Optional[int] = 100) -> None: self._data = data if isinstance(data, bytes) else bytes(data, 'utf-8') self._chunk_size = chunk_size or 100 @@ -167,7 +170,7 @@ def __next__(self) -> bytes: if self._stop >= len(self._data): self._stop = len(self._data) - chunk = self._data[self._start:self._stop] + chunk = self._data[self._start : self._stop] self._start = self._stop self._stop += self._chunk_size return chunk @@ -176,15 +179,18 @@ def __next__(self) -> bytes: def get_test_cert_path() -> str: return os.path.join(pathlib.Path(__file__).parent, 'certs', 'dinocluster.pem') + def get_test_cert_list() -> List[str]: cert_file = pathlib.Path(get_test_cert_path()) cert_file1 = pathlib.Path(os.path.join(pathlib.Path(__file__).parent, 'certs', 'dinoca.pem')) return [cert_file.read_text(), cert_file1.read_text()] + def get_test_cert_str() -> str: cert_file = pathlib.Path(get_test_cert_path()) return cert_file.read_text() + def to_query_str(params: Dict[str, Any]) -> str: encoded_params = [] for k, v in params.items(): diff --git a/tests/utils/_async_client_adapter.py b/tests/utils/_async_client_adapter.py index eb38dab..ac909f5 100644 --- a/tests/utils/_async_client_adapter.py +++ b/tests/utils/_async_client_adapter.py @@ -23,6 +23,7 @@ def client_adapter_init_override(self, *args, **kwargs) -> None: # type: ignore if self._http_transport_cls is None: self._http_transport_cls = adapter._http_transport_cls + # async def create_client_override(self: _AsyncClientAdapter) -> None: # if not hasattr(self, '_client'): # auth = BasicAuth(*self._conn_details.credential) @@ -39,6 +40,7 @@ def client_adapter_init_override(self, *args, **kwargs) -> None: # type: ignore # transport = self._http_transport_cls() # self._client = AsyncClient(auth=auth, transport=transport) + async def send_request_override(self: _AsyncClientAdapter, request: QueryRequest) -> Response: if not hasattr(self, '_client'): raise RuntimeError('Client not created yet') @@ -61,14 +63,8 @@ async def send_request_override(self: _AsyncClientAdapter, request: QueryRequest print(f'{request_extensions=}') - url = URL(scheme=request.url.scheme, - host=request.url.host, - port=request.url.port, - path=request.url.path) - req = self._client.build_request(request.method, - url, - json=request_json, - extensions=request_extensions) + url = URL(scheme=request.url.scheme, host=request.url.host, port=request.url.port, path=request.url.path) + req = self._client.build_request(request.method, url, json=request_json, extensions=request_extensions) try: return await self._client.send(req, stream=True) except socket.gaierror as err: @@ -79,21 +75,25 @@ async def send_request_override(self: _AsyncClientAdapter, request: QueryRequest def set_request_path(self: _AsyncClientAdapter, path: str) -> None: self._ANALYTICS_PATH = path + def update_request_json(self: _AsyncClientAdapter, json: Dict[str, object]) -> None: self._request_json = json # type: ignore[attr-defined] + def update_request_extensions(self: _AsyncClientAdapter, extensions: Dict[str, str]) -> None: self._request_extensions = extensions # type: ignore[attr-defined] + class _TestAsyncClientAdapter(_AsyncClientAdapter): pass + _TestAsyncClientAdapter.__init__ = client_adapter_init_override # type: ignore[method-assign] # _TestAsyncClientAdapter.create_client = create_client_override # type: ignore[method-assign] _TestAsyncClientAdapter.send_request = send_request_override # type: ignore[method-assign] -setattr(_TestAsyncClientAdapter, 'set_request_path', set_request_path) -setattr(_TestAsyncClientAdapter, 'update_request_json', update_request_json) -setattr(_TestAsyncClientAdapter, 'update_request_extensions', update_request_extensions) -setattr(_TestAsyncClientAdapter, 'PYCBAC_TESTING', True) +_TestAsyncClientAdapter.set_request_path = set_request_path +_TestAsyncClientAdapter.update_request_json = update_request_json +_TestAsyncClientAdapter.update_request_extensions = update_request_extensions +_TestAsyncClientAdapter.PYCBAC_TESTING = True -__all__ = ["_TestAsyncClientAdapter"] +__all__ = ['_TestAsyncClientAdapter'] diff --git a/tests/utils/_async_utils.py b/tests/utils/_async_utils.py index 6215cbf..b48ae0e 100644 --- a/tests/utils/_async_utils.py +++ b/tests/utils/_async_utils.py @@ -16,17 +16,12 @@ from __future__ import annotations from types import TracebackType -from typing import (Any, - Callable, - List, - Optional, - Type) +from typing import Any, Callable, List, Optional, Type import anyio class TaskGroupResultCollector: - def __init__(self) -> None: self._results: List[Any] = [] @@ -46,12 +41,11 @@ async def __aenter__(self) -> TaskGroupResultCollector: await self._taskgroup.__aenter__() return self - async def __aexit__(self, - exc_type: Optional[Type[BaseException]], - exc_val: Optional[BaseException], - exc_tb: Optional[TracebackType]) -> Any: + async def __aexit__( + self, exc_type: Optional[Type[BaseException]], exc_val: Optional[BaseException], exc_tb: Optional[TracebackType] + ) -> Any: try: - res = await self._taskgroup.__aexit__(exc_type= exc_type, exc_val=exc_val, exc_tb=exc_tb) + res = await self._taskgroup.__aexit__(exc_type=exc_type, exc_val=exc_val, exc_tb=exc_tb) return res finally: del self._taskgroup diff --git a/tests/utils/_client_adapter.py b/tests/utils/_client_adapter.py index 4b6ee83..bbc74b1 100644 --- a/tests/utils/_client_adapter.py +++ b/tests/utils/_client_adapter.py @@ -38,6 +38,7 @@ def client_adapter_init_override(self, *args, **kwargs) -> None: # type: ignore # self._client = Client(auth=BasicAuth(*self._conn_details.credential), # transport=transport) + def send_request_override(self: _ClientAdapter, request: QueryRequest) -> Response: if not hasattr(self, '_client'): raise RuntimeError('Client not created yet') @@ -60,38 +61,37 @@ def send_request_override(self: _ClientAdapter, request: QueryRequest) -> Respon print(f'{request_extensions=}') - url = URL(scheme=request.url.scheme, - host=request.url.host, - port=request.url.port, - path=request.url.path) - req = self._client.build_request(request.method, - url, - json=request_json, - extensions=request_extensions) + url = URL(scheme=request.url.scheme, host=request.url.host, port=request.url.port, path=request.url.path) + req = self._client.build_request(request.method, url, json=request_json, extensions=request_extensions) try: return self._client.send(req, stream=True) except socket.gaierror as err: req_url = self._conn_details.url.get_formatted_url() raise RuntimeError(f'Unable to connect to {req_url}') from err + def set_request_path(self: _ClientAdapter, path: str) -> None: self._ANALYTICS_PATH = path + def update_request_json(self: _ClientAdapter, json: Dict[str, object]) -> None: self._request_json = json # type: ignore[attr-defined] + def update_request_extensions(self: _ClientAdapter, extensions: Dict[str, str]) -> None: self._request_extensions = extensions # type: ignore[attr-defined] + class _TestClientAdapter(_ClientAdapter): pass + _TestClientAdapter.__init__ = client_adapter_init_override # type: ignore[method-assign] # _TestClientAdapter.create_client = create_client_override _TestClientAdapter.send_request = send_request_override # type: ignore[method-assign] -setattr(_TestClientAdapter, 'set_request_path', set_request_path) -setattr(_TestClientAdapter, 'update_request_json', update_request_json) -setattr(_TestClientAdapter, 'update_request_extensions', update_request_extensions) -setattr(_TestClientAdapter, 'PYCBAC_TESTING', True) +_TestClientAdapter.set_request_path = set_request_path +_TestClientAdapter.update_request_json = update_request_json +_TestClientAdapter.update_request_extensions = update_request_extensions +_TestClientAdapter.PYCBAC_TESTING = True -__all__ = ["_TestClientAdapter"] +__all__ = ['_TestClientAdapter'] diff --git a/tests/utils/_run_web_server.py b/tests/utils/_run_web_server.py index def7823..0d550d7 100644 --- a/tests/utils/_run_web_server.py +++ b/tests/utils/_run_web_server.py @@ -26,13 +26,14 @@ print(f'Web server script path: {WEB_SERVER_PATH}') -logging.basicConfig(level=logging.INFO, - stream=sys.stderr, - format='%(asctime)s - %(levelname)s - (PID:%(process)d) - %(message)s') +logging.basicConfig( + level=logging.INFO, stream=sys.stderr, format='%(asctime)s - %(levelname)s - (PID:%(process)d) - %(message)s' +) logger = logging.getLogger(__name__) + class WebServerHandler: - def __init__(self, host: Optional[str]='0.0.0.0', port:Optional[int]=8080) -> None: + def __init__(self, host: Optional[str] = '0.0.0.0', port: Optional[int] = 8080) -> None: self._host = host or '0.0.0.0' self._port = port or 8080 self._server_process: Optional[subprocess.Popen[bytes]] = None @@ -54,19 +55,18 @@ def start_server(self) -> None: raise FileNotFoundError(msg) try: - cmd = [sys.executable, - WEB_SERVER_PATH, - '--host', - self._host, - '--port', - str(self._port)] + cmd = [sys.executable, WEB_SERVER_PATH, '--host', self._host, '--port', str(self._port)] self._server_process = subprocess.Popen(cmd, stdout=sys.stdout, stderr=sys.stderr) time.sleep(1) # Check if the server process unexpectedly exited during startup if self._server_process.poll() is not None: - logger.error((f'Server process (PID: {self._server_process.pid}) exited immediately after launch. ' - f'Exit code: {self._server_process.returncode}.')) + logger.error( + ( + f'Server process (PID: {self._server_process.pid}) exited immediately after launch. ' + f'Exit code: {self._server_process.returncode}.' + ) + ) self._server_process = None else: logger.info('Server should be running at http://%s:%d/', self._host, self._port) diff --git a/tests/utils/_test_async_httpx.py b/tests/utils/_test_async_httpx.py index 5238f07..da7ad7d 100644 --- a/tests/utils/_test_async_httpx.py +++ b/tests/utils/_test_async_httpx.py @@ -1,25 +1,14 @@ import typing -from httpcore import (AsyncConnectionPool, - Origin, - Request, - Response) -from httpcore._async.connection import (RETRIES_BACKOFF_FACTOR, - AsyncHTTPConnection, - exponential_backoff, - logger) +from httpcore import AsyncConnectionPool, Origin, Request, Response +from httpcore._async.connection import RETRIES_BACKOFF_FACTOR, AsyncHTTPConnection, exponential_backoff, logger from httpcore._async.connection_pool import AsyncPoolRequest, PoolByteStream from httpcore._async.interfaces import AsyncConnectionInterface from httpcore._backends.base import AsyncNetworkStream -from httpcore._exceptions import (ConnectError, - ConnectionNotAvailable, - ConnectTimeout, - UnsupportedProtocol) +from httpcore._exceptions import ConnectError, ConnectionNotAvailable, ConnectTimeout, UnsupportedProtocol from httpcore._ssl import default_ssl_context from httpcore._trace import Trace -from httpx import (AsyncHTTPTransport, - Limits, - create_ssl_context) +from httpx import AsyncHTTPTransport, Limits, create_ssl_context class TestAsyncHTTPConnection(AsyncHTTPConnection): @@ -27,12 +16,12 @@ def __init__(self, *args, **kwargs) -> None: # type: ignore super().__init__(*args, **kwargs) async def _connect(self, request: Request) -> AsyncNetworkStream: - timeouts = request.extensions.get("timeout", {}) - sni_hostname = request.extensions.get("sni_hostname", None) - timeout = timeouts.get("connect", None) + timeouts = request.extensions.get('timeout', {}) + sni_hostname = request.extensions.get('sni_hostname', None) + timeout = timeouts.get('connect', None) # TESTING_OVERRIDE - test_connect_timeout = timeouts.get("test_connect_timeout", None) - print(f"PYCBAC OVERRIDE: connect timeout: {timeout}, test_connect_timeout: {test_connect_timeout}") + test_connect_timeout = timeouts.get('test_connect_timeout', None) + print(f'PYCBAC OVERRIDE: connect timeout: {timeout}, test_connect_timeout: {test_connect_timeout}') retries_left = self._retries delays = exponential_backoff(factor=RETRIES_BACKOFF_FACTOR) @@ -41,45 +30,36 @@ async def _connect(self, request: Request) -> AsyncNetworkStream: try: if self._uds is None: kwargs = { - "host": self._origin.host.decode("ascii"), - "port": self._origin.port, - "local_address": self._local_address, - "timeout": timeout, - "socket_options": self._socket_options, + 'host': self._origin.host.decode('ascii'), + 'port': self._origin.port, + 'local_address': self._local_address, + 'timeout': timeout, + 'socket_options': self._socket_options, } - async with Trace("connect_tcp", logger, request, kwargs) as trace: + async with Trace('connect_tcp', logger, request, kwargs) as trace: stream = await self._network_backend.connect_tcp(**kwargs) trace.return_value = stream else: kwargs = { - "path": self._uds, - "timeout": timeout, - "socket_options": self._socket_options, + 'path': self._uds, + 'timeout': timeout, + 'socket_options': self._socket_options, } - async with Trace( - "connect_unix_socket", logger, request, kwargs - ) as trace: - stream = await self._network_backend.connect_unix_socket( - **kwargs - ) + async with Trace('connect_unix_socket', logger, request, kwargs) as trace: + stream = await self._network_backend.connect_unix_socket(**kwargs) trace.return_value = stream - if self._origin.scheme in (b"https", b"wss"): - ssl_context = ( - default_ssl_context() - if self._ssl_context is None - else self._ssl_context - ) - alpn_protocols = ["http/1.1", "h2"] if self._http2 else ["http/1.1"] + if self._origin.scheme in (b'https', b'wss'): + ssl_context = default_ssl_context() if self._ssl_context is None else self._ssl_context + alpn_protocols = ['http/1.1', 'h2'] if self._http2 else ['http/1.1'] ssl_context.set_alpn_protocols(alpn_protocols) kwargs = { - "ssl_context": ssl_context, - "server_hostname": sni_hostname - or self._origin.host.decode("ascii"), - "timeout": timeout, + 'ssl_context': ssl_context, + 'server_hostname': sni_hostname or self._origin.host.decode('ascii'), + 'timeout': timeout, } - async with Trace("start_tls", logger, request, kwargs) as trace: + async with Trace('start_tls', logger, request, kwargs) as trace: stream = await stream.start_tls(**kwargs) trace.return_value = stream return stream @@ -88,16 +68,17 @@ async def _connect(self, request: Request) -> AsyncNetworkStream: raise retries_left -= 1 delay = next(delays) - async with Trace("retry", logger, request, kwargs) as trace: + async with Trace('retry', logger, request, kwargs) as trace: await self._network_backend.sleep(delay) + class TestAsyncConnectionPool(AsyncConnectionPool): def __init__(self, *args, **kwargs) -> None: # type: ignore super().__init__(*args, **kwargs) def create_connection(self, origin: Origin) -> AsyncConnectionInterface: if self._proxy is not None: - if self._proxy.url.scheme in (b"socks5", b"socks5h"): + if self._proxy.url.scheme in (b'socks5', b'socks5h'): from httpcore._async.socks_proxy import AsyncSocks5Connection return AsyncSocks5Connection( @@ -110,7 +91,7 @@ def create_connection(self, origin: Origin) -> AsyncConnectionInterface: http2=self._http2, network_backend=self._network_backend, ) - elif origin.scheme == b"http": + elif origin.scheme == b'http': from httpcore._async.http_proxy import AsyncForwardHTTPConnection return AsyncForwardHTTPConnection( @@ -156,20 +137,16 @@ async def handle_async_request(self, request: Request) -> Response: This is the core implementation that is called into by `.request()` or `.stream()`. """ scheme = request.url.scheme.decode() - if scheme == "": - raise UnsupportedProtocol( - "Request URL is missing an 'http://' or 'https://' protocol." - ) - if scheme not in ("http", "https", "ws", "wss"): - raise UnsupportedProtocol( - f"Request URL has an unsupported protocol '{scheme}://'." - ) + if scheme == '': + raise UnsupportedProtocol("Request URL is missing an 'http://' or 'https://' protocol.") + if scheme not in ('http', 'https', 'ws', 'wss'): + raise UnsupportedProtocol(f"Request URL has an unsupported protocol '{scheme}://'.") - timeouts = request.extensions.get("timeout", {}) - timeout = timeouts.get("pool", None) + timeouts = request.extensions.get('timeout', {}) + timeout = timeouts.get('pool', None) # TESTING_OVERRIDE - test_pool_timeout = timeouts.get("test_pool_timeout", None) - print(f"PYCBAC OVERRIDE: pool timeout: {timeout}, test_pool_timeout: {test_pool_timeout}") + test_pool_timeout = timeouts.get('test_pool_timeout', None) + print(f'PYCBAC OVERRIDE: pool timeout: {timeout}, test_pool_timeout: {test_pool_timeout}') with self._optional_thread_lock: # Add the incoming request to our request queue. @@ -189,9 +166,7 @@ async def handle_async_request(self, request: Request) -> Response: try: # Send the request on the assigned connection. - response = await connection.handle_async_request( - pool_request.request - ) + response = await connection.handle_async_request(pool_request.request) except ConnectionNotAvailable: # In some cases a connection may initially be available to # handle a request, but then become unavailable. @@ -217,12 +192,11 @@ async def handle_async_request(self, request: Request) -> Response: return Response( status=response.status, headers=response.headers, - content=PoolByteStream( - stream=response.stream, pool_request=pool_request, pool=self - ), + content=PoolByteStream(stream=response.stream, pool_request=pool_request, pool=self), extensions=response.extensions, ) + def async_http_transport_init_override(self, *args, **kwargs) -> None: # type: ignore verify = kwargs.get('verify') cert = kwargs.get('cert') @@ -251,9 +225,10 @@ def async_http_transport_init_override(self, *args, **kwargs) -> None: # type: socket_options=socket_options, ) + AsyncHTTPTransport.__init__ = async_http_transport_init_override # type: ignore -setattr(AsyncHTTPTransport, 'PYCBAC_TESTING', True) +AsyncHTTPTransport.PYCBAC_TESTING = True TestAsyncHTTPTransport = AsyncHTTPTransport -__all__ = ["TestAsyncHTTPTransport"] +__all__ = ['TestAsyncHTTPTransport'] diff --git a/tests/utils/_test_httpx.py b/tests/utils/_test_httpx.py index ed3c243..e0a85f2 100644 --- a/tests/utils/_test_httpx.py +++ b/tests/utils/_test_httpx.py @@ -1,27 +1,15 @@ import time import typing -from httpcore import (ConnectionPool, - Origin, - Request, - Response) +from httpcore import ConnectionPool, Origin, Request, Response from httpcore._backends.base import NetworkStream -from httpcore._exceptions import (ConnectError, - ConnectionNotAvailable, - ConnectTimeout, - PoolTimeout, - UnsupportedProtocol) +from httpcore._exceptions import ConnectError, ConnectionNotAvailable, ConnectTimeout, PoolTimeout, UnsupportedProtocol from httpcore._ssl import default_ssl_context -from httpcore._sync.connection import (RETRIES_BACKOFF_FACTOR, - HTTPConnection, - exponential_backoff, - logger) +from httpcore._sync.connection import RETRIES_BACKOFF_FACTOR, HTTPConnection, exponential_backoff, logger from httpcore._sync.connection_pool import PoolByteStream, PoolRequest from httpcore._sync.interfaces import ConnectionInterface from httpcore._trace import Trace -from httpx import (HTTPTransport, - Limits, - create_ssl_context) +from httpx import HTTPTransport, Limits, create_ssl_context class TestHTTPConnection(HTTPConnection): @@ -29,12 +17,12 @@ def __init__(self, *args, **kwargs) -> None: # type: ignore super().__init__(*args, **kwargs) def _connect(self, request: Request) -> NetworkStream: - timeouts = request.extensions.get("timeout", {}) - sni_hostname = request.extensions.get("sni_hostname", None) - timeout = timeouts.get("connect", None) + timeouts = request.extensions.get('timeout', {}) + sni_hostname = request.extensions.get('sni_hostname', None) + timeout = timeouts.get('connect', None) # -- START PYCBAC TESTING -- - test_connect_timeout = timeouts.get("test_connect_timeout", None) - print(f"PYCBAC OVERRIDE: connect timeout: {timeout}, test_connect_timeout: {test_connect_timeout}") + test_connect_timeout = timeouts.get('test_connect_timeout', None) + print(f'PYCBAC OVERRIDE: connect timeout: {timeout}, test_connect_timeout: {test_connect_timeout}') # -- END PYCBAC TESTING -- retries_left = self._retries @@ -47,52 +35,43 @@ def _connect(self, request: Request) -> NetworkStream: try: if self._uds is None: kwargs = { - "host": self._origin.host.decode("ascii"), - "port": self._origin.port, - "local_address": self._local_address, - "timeout": timeout, - "socket_options": self._socket_options, + 'host': self._origin.host.decode('ascii'), + 'port': self._origin.port, + 'local_address': self._local_address, + 'timeout': timeout, + 'socket_options': self._socket_options, } - with Trace("connect_tcp", logger, request, kwargs) as trace: + with Trace('connect_tcp', logger, request, kwargs) as trace: # -- START PYCBAC TESTING -- if test_connect_timeout is not None: time.sleep(test_connect_timeout) current_time = time.monotonic() if current_time > deadline: - raise ConnectTimeout(f"Connection timed out after {timeout} seconds") + raise ConnectTimeout(f'Connection timed out after {timeout} seconds') # -- END PYCBAC TESTING -- stream = self._network_backend.connect_tcp(**kwargs) trace.return_value = stream else: kwargs = { - "path": self._uds, - "timeout": timeout, - "socket_options": self._socket_options, + 'path': self._uds, + 'timeout': timeout, + 'socket_options': self._socket_options, } - with Trace( - "connect_unix_socket", logger, request, kwargs - ) as trace: - stream = self._network_backend.connect_unix_socket( - **kwargs - ) + with Trace('connect_unix_socket', logger, request, kwargs) as trace: + stream = self._network_backend.connect_unix_socket(**kwargs) trace.return_value = stream - if self._origin.scheme in (b"https", b"wss"): - ssl_context = ( - default_ssl_context() - if self._ssl_context is None - else self._ssl_context - ) - alpn_protocols = ["http/1.1", "h2"] if self._http2 else ["http/1.1"] + if self._origin.scheme in (b'https', b'wss'): + ssl_context = default_ssl_context() if self._ssl_context is None else self._ssl_context + alpn_protocols = ['http/1.1', 'h2'] if self._http2 else ['http/1.1'] ssl_context.set_alpn_protocols(alpn_protocols) kwargs = { - "ssl_context": ssl_context, - "server_hostname": sni_hostname - or self._origin.host.decode("ascii"), - "timeout": timeout, + 'ssl_context': ssl_context, + 'server_hostname': sni_hostname or self._origin.host.decode('ascii'), + 'timeout': timeout, } - with Trace("start_tls", logger, request, kwargs) as trace: + with Trace('start_tls', logger, request, kwargs) as trace: stream = stream.start_tls(**kwargs) trace.return_value = stream return stream @@ -101,16 +80,17 @@ def _connect(self, request: Request) -> NetworkStream: raise retries_left -= 1 delay = next(delays) - with Trace("retry", logger, request, kwargs) as trace: + with Trace('retry', logger, request, kwargs) as trace: self._network_backend.sleep(delay) + class TestConnectionPool(ConnectionPool): def __init__(self, *args, **kwargs) -> None: # type: ignore super().__init__(*args, **kwargs) def create_connection(self, origin: Origin) -> ConnectionInterface: if self._proxy is not None: - if self._proxy.url.scheme in (b"socks5", b"socks5h"): + if self._proxy.url.scheme in (b'socks5', b'socks5h'): from httpcore._sync.socks_proxy import Socks5Connection return Socks5Connection( @@ -123,7 +103,7 @@ def create_connection(self, origin: Origin) -> ConnectionInterface: http2=self._http2, network_backend=self._network_backend, ) - elif origin.scheme == b"http": + elif origin.scheme == b'http': from httpcore._sync.http_proxy import ForwardHTTPConnection return ForwardHTTPConnection( @@ -169,20 +149,16 @@ def handle_request(self, request: Request) -> Response: This is the core implementation that is called into by `.request()` or `.stream()`. """ scheme = request.url.scheme.decode() - if scheme == "": - raise UnsupportedProtocol( - "Request URL is missing an 'http://' or 'https://' protocol." - ) - if scheme not in ("http", "https", "ws", "wss"): - raise UnsupportedProtocol( - f"Request URL has an unsupported protocol '{scheme}://'." - ) + if scheme == '': + raise UnsupportedProtocol("Request URL is missing an 'http://' or 'https://' protocol.") + if scheme not in ('http', 'https', 'ws', 'wss'): + raise UnsupportedProtocol(f"Request URL has an unsupported protocol '{scheme}://'.") - timeouts = request.extensions.get("timeout", {}) - timeout = timeouts.get("pool", None) + timeouts = request.extensions.get('timeout', {}) + timeout = timeouts.get('pool', None) # -- START PYCBAC TESTING -- - test_pool_timeout = timeouts.get("test_pool_timeout", None) - print(f"PYCBAC OVERRIDE: pool timeout: {timeout}, test_pool_timeout: {test_pool_timeout}") + test_pool_timeout = timeouts.get('test_pool_timeout', None) + print(f'PYCBAC OVERRIDE: pool timeout: {timeout}, test_pool_timeout: {test_pool_timeout}') # -- END PYCBAC TESTING -- with self._optional_thread_lock: @@ -205,19 +181,17 @@ def handle_request(self, request: Request) -> Response: time.sleep(test_pool_timeout) current_time = time.monotonic() if current_time > deadline: - raise PoolTimeout(f"Connection timed out after {timeout} seconds") + raise PoolTimeout(f'Connection timed out after {timeout} seconds') # -- END PYCBAC TESTING -- # Wait until this request has an assigned connection. connection = pool_request.wait_for_connection(timeout=timeout) # PYCBAC Addition: We _always_ set the request timeouts, so no need to validate keys - connect_timeout = round(deadline - time.monotonic(), 6) # round to microseconds - pool_request.request.extensions["timeout"]["connect"] = connect_timeout + connect_timeout = round(deadline - time.monotonic(), 6) # round to microseconds + pool_request.request.extensions['timeout']['connect'] = connect_timeout try: # Send the request on the assigned connection. - response = connection.handle_request( - pool_request.request - ) + response = connection.handle_request(pool_request.request) except ConnectionNotAvailable: # In some cases a connection may initially be available to # handle a request, but then become unavailable. @@ -225,7 +199,7 @@ def handle_request(self, request: Request) -> Response: # In this case we clear the connection and try again. pool_request.clear_connection() # PYCBAC Addition: We update the timeout for the next attempt - timeout = round(deadline - time.monotonic(), 6) # round to microseconds + timeout = round(deadline - time.monotonic(), 6) # round to microseconds else: break # pragma: nocover @@ -245,12 +219,11 @@ def handle_request(self, request: Request) -> Response: return Response( status=response.status, headers=response.headers, - content=PoolByteStream( - stream=response.stream, pool_request=pool_request, pool=self - ), + content=PoolByteStream(stream=response.stream, pool_request=pool_request, pool=self), extensions=response.extensions, ) + def http_transport_init_override(self, *args, **kwargs) -> None: # type: ignore verify = kwargs.get('verify') cert = kwargs.get('cert') @@ -279,9 +252,10 @@ def http_transport_init_override(self, *args, **kwargs) -> None: # type: ignore socket_options=socket_options, ) + HTTPTransport.__init__ = http_transport_init_override # type: ignore -setattr(HTTPTransport, 'PYCBAC_TESTING', True) +HTTPTransport.PYCBAC_TESTING = True TestHTTPTransport = HTTPTransport -__all__ = ["TestHTTPTransport"] +__all__ = ['TestHTTPTransport'] From 107679d4dab915dc9ab95d95edfd1b8c95a8832a Mon Sep 17 00:00:00 2001 From: Jared Casey Date: Tue, 15 Jul 2025 11:06:45 -0600 Subject: [PATCH 09/18] rename StreamingState to RequestState --- .../protocol/_core/request_context.py | 56 ++++++++--------- .../protocol/_core/retries.py | 4 +- .../tests/query_integration_t.py | 10 ++-- couchbase_analytics/common/request.py | 51 ++++++++++++++++ couchbase_analytics/common/streaming.py | 50 ---------------- .../protocol/_core/request_context.py | 60 +++++++++---------- couchbase_analytics/protocol/_core/retries.py | 4 +- .../tests/query_integration_t.py | 24 ++++---- 8 files changed, 130 insertions(+), 129 deletions(-) diff --git a/acouchbase_analytics/protocol/_core/request_context.py b/acouchbase_analytics/protocol/_core/request_context.py index 3056c53..a5480cd 100644 --- a/acouchbase_analytics/protocol/_core/request_context.py +++ b/acouchbase_analytics/protocol/_core/request_context.py @@ -17,7 +17,7 @@ from couchbase_analytics.common._core.error_context import ErrorContext from couchbase_analytics.common.backoff_calculator import DefaultBackoffCalculator from couchbase_analytics.common.errors import AnalyticsError -from couchbase_analytics.common.streaming import StreamingState +from couchbase_analytics.common.request import RequestState from couchbase_analytics.protocol.connection import DEFAULT_TIMEOUTS from couchbase_analytics.protocol.errors import ErrorMapper @@ -43,7 +43,7 @@ def __init__( self._backend = backend or current_async_library() self._backoff_calc = DefaultBackoffCalculator() self._error_ctx = ErrorContext(num_attempts=0, method=request.method, statement=request.get_request_statement()) - self._request_state = StreamingState.NotStarted + self._request_state = RequestState.NotStarted self._stream_config = stream_config or JsonStreamConfig() self._json_stream: AsyncJsonStream self._stage_completed: Optional[anyio.Event] = None @@ -56,7 +56,7 @@ def __init__( @property def cancelled(self) -> bool: self._check_cancelled_or_timed_out() - return self._request_state in [StreamingState.Cancelled, StreamingState.AsyncCancelledPriorToTimeout] + return self._request_state in [RequestState.Cancelled, RequestState.AsyncCancelledPriorToTimeout] @property def error_context(self) -> ErrorContext: @@ -73,19 +73,19 @@ def is_shutdown(self) -> bool: @property def okay_to_iterate(self) -> bool: self._check_cancelled_or_timed_out() - return StreamingState.okay_to_iterate(self._request_state) + return RequestState.okay_to_iterate(self._request_state) @property def okay_to_stream(self) -> bool: self._check_cancelled_or_timed_out() - return StreamingState.okay_to_stream(self._request_state) + return RequestState.okay_to_stream(self._request_state) @property def request_error(self) -> Optional[Union[BaseException, Exception]]: return self._request_error @property - def request_state(self) -> StreamingState: + def request_state(self) -> RequestState: return self._request_state @property @@ -99,10 +99,10 @@ def results_or_errors_type(self) -> ParsedResultType: @property def timed_out(self) -> bool: self._check_cancelled_or_timed_out() - return self._request_state == StreamingState.Timeout + return self._request_state == RequestState.Timeout def _check_cancelled_or_timed_out(self) -> None: - if self._request_state in [StreamingState.Timeout, StreamingState.Cancelled, StreamingState.Error]: + if self._request_state in [RequestState.Timeout, RequestState.Cancelled, RequestState.Error]: return if hasattr(self, '_request_deadline') is False: @@ -115,10 +115,10 @@ def _check_cancelled_or_timed_out(self) -> None: timed_out = current_time >= self._request_deadline if timed_out: - if self._request_state == StreamingState.Cancelled: - self._request_state = StreamingState.AsyncCancelledPriorToTimeout + if self._request_state == RequestState.Cancelled: + self._request_state = RequestState.AsyncCancelledPriorToTimeout else: - self._request_state = StreamingState.Timeout + self._request_state = RequestState.Timeout async def _execute(self, fn: Callable[..., Awaitable[Any]], *args: object) -> None: await fn(*args) @@ -131,22 +131,22 @@ def _maybe_set_request_error( self._check_cancelled_or_timed_out() if exc_val is None: return - if not StreamingState.is_timeout_or_cancelled(self._request_state): + if not RequestState.is_timeout_or_cancelled(self._request_state): # This handles httpx timeouts if exc_type is not None and issubclass(exc_type, TimeoutException): - self._request_state = StreamingState.Timeout + self._request_state = RequestState.Timeout elif issubclass(type(exc_val), TimeoutException): - self._request_state = StreamingState.Timeout + self._request_state = RequestState.Timeout elif isinstance(exc_val, CancelledError): - self._request_state = StreamingState.Cancelled + self._request_state = RequestState.Cancelled else: - self._request_state = StreamingState.Error + self._request_state = RequestState.Error self._request_error = exc_val async def _process_error( self, json_data: Union[str, List[Dict[str, Any]]], handle_context_shutdown: Optional[bool] = False ) -> None: - self._request_state = StreamingState.Error + self._request_state = RequestState.Error if isinstance(json_data, str): self._request_error = ErrorMapper.build_error_from_http_status_code(json_data, self._error_ctx) elif not isinstance(json_data, list): @@ -163,7 +163,7 @@ async def _process_error( def _reset_stream(self) -> None: if hasattr(self, '_json_stream'): del self._json_stream - self._request_state = StreamingState.ResetAndNotStarted + self._request_state = RequestState.ResetAndNotStarted self._stage_completed = None self._cancel_scope_deadline_updated = False @@ -211,10 +211,10 @@ def calculate_backoff(self) -> float: def cancel_request(self, fn: Optional[Callable[..., Awaitable[Any]]] = None, *args: object) -> None: if fn is not None: self._taskgroup.start_soon(fn, *args) - if self._request_state == StreamingState.Timeout: + if self._request_state == RequestState.Timeout: return self._taskgroup.cancel_scope.cancel() - self._request_state = StreamingState.Cancelled + self._request_state = RequestState.Cancelled def create_response_task(self, fn: Callable[..., Coroutine[Any, Any, Any]], *args: object) -> Task[Any]: if self._backend is None or self._backend.backend_lib != 'asyncio': @@ -246,12 +246,12 @@ async def get_result_from_stream(self) -> ParsedResult: async def initialize(self) -> None: # TODO: Add useful logging messages - if self._request_state == StreamingState.ResetAndNotStarted: + if self._request_state == RequestState.ResetAndNotStarted: self._update_cancel_scope_deadline(self._connect_deadline, is_absolute=True) # print('Skipping initialization as request is a retry') return await self.__aenter__() - self._request_state = StreamingState.Started + self._request_state = RequestState.Started # we set the request timeout once the context is initialized in order to create the deadline # closer to when the upstream logic will begin to use the request context timeouts = self._request.get_request_timeouts() or {} @@ -272,7 +272,7 @@ def maybe_continue_to_process_stream(self) -> None: def okay_to_delay_and_retry(self, delay: float) -> bool: # TODO: Add useful logging messages self._check_cancelled_or_timed_out() - if self._request_state in [StreamingState.Timeout, StreamingState.Cancelled]: + if self._request_state in [RequestState.Timeout, RequestState.Cancelled]: return False current_time = get_time() @@ -280,10 +280,10 @@ def okay_to_delay_and_retry(self, delay: float) -> bool: will_time_out = self._request_deadline < delay_time # print(f'{current_time=}; {delay_time=}; req_deadline={self._request_deadline}; {will_time_out=}') if will_time_out: - self._request_state = StreamingState.Timeout + self._request_state = RequestState.Timeout return False elif self.retry_limit_exceeded: - self._request_state = StreamingState.Error + self._request_state = RequestState.Error return False else: self._reset_stream() @@ -358,8 +358,8 @@ async def shutdown( else: self._maybe_set_request_error(exc_type, exc_val) - if StreamingState.is_okay(self._request_state): - self._request_state = StreamingState.Completed + if RequestState.is_okay(self._request_state): + self._request_state = RequestState.Completed self._shutdown = True def start_stream(self, core_response: HttpCoreResponse) -> None: @@ -374,7 +374,7 @@ async def wait_for_results_or_errors(self) -> None: await self._json_stream.has_results_or_errors.wait() if self._json_stream.results_or_errors_type == ParsedResultType.ROW: # we move to iterating rows - self._request_state = StreamingState.StreamingResults + self._request_state = RequestState.StreamingResults async def __aenter__(self) -> AsyncRequestContext: self._taskgroup = anyio.create_task_group() diff --git a/acouchbase_analytics/protocol/_core/retries.py b/acouchbase_analytics/protocol/_core/retries.py index e1c465c..ea606c4 100644 --- a/acouchbase_analytics/protocol/_core/retries.py +++ b/acouchbase_analytics/protocol/_core/retries.py @@ -23,7 +23,7 @@ from acouchbase_analytics.protocol._core.anyio_utils import sleep from couchbase_analytics.common.errors import AnalyticsError, InternalSDKError, TimeoutError -from couchbase_analytics.common.streaming import StreamingState +from couchbase_analytics.common.request import RequestState from couchbase_analytics.protocol.errors import WrappedError if TYPE_CHECKING: @@ -122,7 +122,7 @@ async def wrapped_fn(self: AsyncHttpStreamingResponse) -> None: # noqa: C901 cause=ex, message=str(ex), context=str(self._request_context.error_context) ) from None finally: - if not StreamingState.is_okay(self._request_context.request_state): + if not RequestState.is_okay(self._request_context.request_state): await self.close() return wrapped_fn diff --git a/acouchbase_analytics/tests/query_integration_t.py b/acouchbase_analytics/tests/query_integration_t.py index 0b98cdf..baddf08 100644 --- a/acouchbase_analytics/tests/query_integration_t.py +++ b/acouchbase_analytics/tests/query_integration_t.py @@ -26,7 +26,7 @@ from acouchbase_analytics.errors import QueryError, TimeoutError from acouchbase_analytics.options import QueryOptions from acouchbase_analytics.result import AsyncQueryResult -from couchbase_analytics.common.streaming import StreamingState +from couchbase_analytics.common.request import RequestState from tests import AsyncYieldFixture if TYPE_CHECKING: @@ -99,7 +99,7 @@ async def test_query_cancel_async_while_iterating( assert isinstance(qtask, Task) res = await qtask assert isinstance(res, AsyncQueryResult) - expected_state = StreamingState.StreamingResults + expected_state = RequestState.StreamingResults assert res._http_response._request_context.request_state == expected_state rows = [] count = 0 @@ -111,7 +111,7 @@ async def test_query_cancel_async_while_iterating( count += 1 assert len(rows) == count - expected_state = StreamingState.Cancelled + expected_state = RequestState.Cancelled assert res._http_response._request_context.request_state == expected_state with pytest.raises(RuntimeError): res.metadata() @@ -124,7 +124,7 @@ async def test_query_cancel_while_iterating( assert isinstance(qtask, Task) res = await qtask assert isinstance(res, AsyncQueryResult) - expected_state = StreamingState.StreamingResults + expected_state = RequestState.StreamingResults assert res._http_response._request_context.request_state == expected_state rows = [] count = 0 @@ -136,7 +136,7 @@ async def test_query_cancel_while_iterating( count += 1 assert len(rows) == count - expected_state = StreamingState.Cancelled + expected_state = RequestState.Cancelled assert res._http_response._request_context.request_state == expected_state with pytest.raises(RuntimeError): res.metadata() diff --git a/couchbase_analytics/common/request.py b/couchbase_analytics/common/request.py index ce1171a..1b9399f 100644 --- a/couchbase_analytics/common/request.py +++ b/couchbase_analytics/common/request.py @@ -16,9 +16,60 @@ from __future__ import annotations from dataclasses import dataclass +from enum import IntEnum from typing import Dict, Optional +class RequestState(IntEnum): + """ + **INTERNAL + """ + + NotStarted = 0 + ResetAndNotStarted = 1 + Started = 2 + Cancelled = 3 + Completed = 4 + StreamingResults = 5 + Error = 6 + Timeout = 7 + AsyncCancelledPriorToTimeout = 8 + SyncCancelledPriorToTimeout = 9 + + @staticmethod + def okay_to_stream(state: RequestState) -> bool: + """ + **INTERNAL + """ + return state in [RequestState.NotStarted, RequestState.ResetAndNotStarted] + + @staticmethod + def okay_to_iterate(state: RequestState) -> bool: + """ + **INTERNAL + """ + return state == RequestState.StreamingResults + + @staticmethod + def is_okay(state: RequestState) -> bool: + """ + **INTERNAL + """ + return state not in [RequestState.Cancelled, RequestState.Error, RequestState.Timeout] + + @staticmethod + def is_timeout_or_cancelled(state: RequestState) -> bool: + """ + **INTERNAL + """ + return state in [ + RequestState.Cancelled, + RequestState.Timeout, + RequestState.AsyncCancelledPriorToTimeout, + RequestState.SyncCancelledPriorToTimeout, + ] + + @dataclass class RequestURL: scheme: str diff --git a/couchbase_analytics/common/streaming.py b/couchbase_analytics/common/streaming.py index c3b02e9..5ccbfc5 100644 --- a/couchbase_analytics/common/streaming.py +++ b/couchbase_analytics/common/streaming.py @@ -27,56 +27,6 @@ from couchbase_analytics.protocol.streaming import HttpStreamingResponse -class StreamingState(IntEnum): - """ - **INTERNAL - """ - - NotStarted = 0 - ResetAndNotStarted = 1 - Started = 2 - Cancelled = 3 - Completed = 4 - StreamingResults = 5 - Error = 6 - Timeout = 7 - AsyncCancelledPriorToTimeout = 8 - SyncCancelledPriorToTimeout = 9 - - @staticmethod - def okay_to_stream(state: StreamingState) -> bool: - """ - **INTERNAL - """ - return state in [StreamingState.NotStarted, StreamingState.ResetAndNotStarted] - - @staticmethod - def okay_to_iterate(state: StreamingState) -> bool: - """ - **INTERNAL - """ - return state == StreamingState.StreamingResults - - @staticmethod - def is_okay(state: StreamingState) -> bool: - """ - **INTERNAL - """ - return state not in [StreamingState.Cancelled, StreamingState.Error, StreamingState.Timeout] - - @staticmethod - def is_timeout_or_cancelled(state: StreamingState) -> bool: - """ - **INTERNAL - """ - return state in [ - StreamingState.Cancelled, - StreamingState.Timeout, - StreamingState.AsyncCancelledPriorToTimeout, - StreamingState.SyncCancelledPriorToTimeout, - ] - - class BlockingIterator(Iterator[Any]): """ **INTERNAL diff --git a/couchbase_analytics/protocol/_core/request_context.py b/couchbase_analytics/protocol/_core/request_context.py index ee4d594..4a71fc9 100644 --- a/couchbase_analytics/protocol/_core/request_context.py +++ b/couchbase_analytics/protocol/_core/request_context.py @@ -14,8 +14,8 @@ from couchbase_analytics.common._core.error_context import ErrorContext from couchbase_analytics.common.backoff_calculator import DefaultBackoffCalculator from couchbase_analytics.common.errors import AnalyticsError, TimeoutError +from couchbase_analytics.common.request import RequestState from couchbase_analytics.common.result import BlockingQueryResult -from couchbase_analytics.common.streaming import StreamingState from couchbase_analytics.protocol._core.json_stream import JsonStream from couchbase_analytics.protocol._core.net_utils import get_request_ip from couchbase_analytics.protocol.connection import DEFAULT_TIMEOUTS @@ -102,7 +102,7 @@ def __init__( self._request = request self._backoff_calc = DefaultBackoffCalculator() self._error_ctx = ErrorContext(num_attempts=0, method=request.method, statement=request.get_request_statement()) - self._request_state = StreamingState.NotStarted + self._request_state = RequestState.NotStarted self._stream_config = stream_config or JsonStreamConfig() self._json_stream: JsonStream self._cancel_event = Event() @@ -120,7 +120,7 @@ def cancel_enabled(self) -> Optional[bool]: @property def cancelled(self) -> bool: self._check_cancelled_or_timed_out() - return self._request_state in [StreamingState.Cancelled, StreamingState.SyncCancelledPriorToTimeout] + return self._request_state in [RequestState.Cancelled, RequestState.SyncCancelledPriorToTimeout] @property def error_context(self) -> ErrorContext: @@ -138,16 +138,16 @@ def is_shutdown(self) -> bool: def okay_to_iterate(self) -> bool: # NOTE: Called prior to upstream logic attempting to iterate over results from HTTP client self._check_cancelled_or_timed_out() - return StreamingState.okay_to_iterate(self._request_state) + return RequestState.okay_to_iterate(self._request_state) @property def okay_to_stream(self) -> bool: # NOTE: Called prior to upstream logic attempting to send request to HTTP client self._check_cancelled_or_timed_out() - return StreamingState.okay_to_stream(self._request_state) + return RequestState.okay_to_stream(self._request_state) @property - def request_state(self) -> StreamingState: + def request_state(self) -> RequestState: return self._request_state @property @@ -157,25 +157,25 @@ def retry_limit_exceeded(self) -> bool: @property def timed_out(self) -> bool: self._check_cancelled_or_timed_out() - return self._request_state == StreamingState.Timeout + return self._request_state == RequestState.Timeout def _check_cancelled_or_timed_out(self) -> None: - if self._request_state in [StreamingState.Timeout, StreamingState.Cancelled, StreamingState.Error]: + if self._request_state in [RequestState.Timeout, RequestState.Cancelled, RequestState.Error]: return if self._cancel_event.is_set() or ( self._background_request is not None and self._background_request.user_cancelled ): - self._request_state = StreamingState.Cancelled + self._request_state = RequestState.Cancelled current_time = time.monotonic() timed_out = current_time >= self._request_deadline # print(f'{current_time=}; req_deadline={self._request_deadline}; {timed_out=}') if timed_out: - if self._request_state == StreamingState.Cancelled: - self._request_state = StreamingState.SyncCancelledPriorToTimeout + if self._request_state == RequestState.Cancelled: + self._request_state = RequestState.SyncCancelledPriorToTimeout else: - self._request_state = StreamingState.Timeout + self._request_state = RequestState.Timeout def _create_stage_notification_future(self) -> None: # TODO: custom ThreadPoolExecutor, to get a "plain" future @@ -186,7 +186,7 @@ def _create_stage_notification_future(self) -> None: def _process_error( self, json_data: Union[str, List[Dict[str, Any]]], handle_context_shutdown: Optional[bool] = False ) -> None: - self._request_state = StreamingState.Error + self._request_state = RequestState.Error request_error: Union[AnalyticsError, WrappedError] if isinstance(json_data, str): request_error = ErrorMapper.build_error_from_http_status_code(json_data, self._error_ctx) @@ -203,7 +203,7 @@ def _process_error( def _reset_stream(self) -> None: if hasattr(self, '_json_stream'): del self._json_stream - self._request_state = StreamingState.ResetAndNotStarted + self._request_state = RequestState.ResetAndNotStarted self._stage_notification_ft = None def _start_next_stage( @@ -241,9 +241,9 @@ def calculate_backoff(self) -> float: return self._backoff_calc.calculate_backoff(self._error_ctx.num_attempts) / 1000 def cancel_request(self) -> None: - if self._request_state == StreamingState.Timeout: + if self._request_state == RequestState.Timeout: return - self._request_state = StreamingState.Cancelled + self._request_state = RequestState.Cancelled def deserialize_result(self, result: bytes) -> Any: return self._request.deserializer.deserialize(result) @@ -263,10 +263,10 @@ def get_result_from_stream(self) -> Optional[ParsedResult]: def initialize(self) -> None: # TODO: Add useful logging messages - if self._request_state == StreamingState.ResetAndNotStarted: + if self._request_state == RequestState.ResetAndNotStarted: # print('Skipping initialization as request is a retry') return - self._request_state = StreamingState.Started + self._request_state = RequestState.Started timeouts = self._request.get_request_timeouts() or {} current_time = time.monotonic() self._request_deadline = current_time + (timeouts.get('read', None) or DEFAULT_TIMEOUTS['query_timeout']) @@ -287,7 +287,7 @@ def maybe_continue_to_process_stream(self) -> None: def okay_to_delay_and_retry(self, delay: float) -> bool: self._check_cancelled_or_timed_out() - if self._request_state in [StreamingState.Timeout, StreamingState.Cancelled]: + if self._request_state in [RequestState.Timeout, RequestState.Cancelled]: return False current_time = time.monotonic() @@ -295,10 +295,10 @@ def okay_to_delay_and_retry(self, delay: float) -> bool: will_time_out = self._request_deadline < delay_time # print(f'{current_time=}; {delay_time=}; req_deadline={self._request_deadline}; {will_time_out=}') if will_time_out: - self._request_state = StreamingState.Timeout + self._request_state = RequestState.Timeout return False elif self.retry_limit_exceeded: - self._request_state = StreamingState.Error + self._request_state = RequestState.Error return False else: self._reset_stream() @@ -366,24 +366,24 @@ def send_request_in_background( return user_ft def set_state_to_streaming(self) -> None: - self._request_state = StreamingState.StreamingResults + self._request_state = RequestState.StreamingResults def shutdown(self, exc_val: Optional[BaseException] = None) -> None: if self.is_shutdown: return if isinstance(exc_val, CancelledError): - self._request_state = StreamingState.Cancelled + self._request_state = RequestState.Cancelled elif exc_val is not None: self._check_cancelled_or_timed_out() if self._request_state not in [ - StreamingState.Timeout, - StreamingState.Cancelled, - StreamingState.SyncCancelledPriorToTimeout, + RequestState.Timeout, + RequestState.Cancelled, + RequestState.SyncCancelledPriorToTimeout, ]: - self._request_state = StreamingState.Error + self._request_state = RequestState.Error - if StreamingState.is_okay(self._request_state): - self._request_state = StreamingState.Completed + if RequestState.is_okay(self._request_state): + self._request_state = RequestState.Completed self._shutdown = True def start_stream(self, core_response: HttpCoreResponse) -> None: @@ -404,4 +404,4 @@ def wait_for_stage_notification(self) -> None: result_type = self._stage_notification_ft.result(timeout=deadline) if result_type == ParsedResultType.ROW: # we move to iterating rows - self._request_state = StreamingState.StreamingResults + self._request_state = RequestState.StreamingResults diff --git a/couchbase_analytics/protocol/_core/retries.py b/couchbase_analytics/protocol/_core/retries.py index ee36873..5412859 100644 --- a/couchbase_analytics/protocol/_core/retries.py +++ b/couchbase_analytics/protocol/_core/retries.py @@ -23,7 +23,7 @@ from httpx import ConnectError, ConnectTimeout from couchbase_analytics.common.errors import AnalyticsError, InternalSDKError, TimeoutError -from couchbase_analytics.common.streaming import StreamingState +from couchbase_analytics.common.request import RequestState from couchbase_analytics.protocol.errors import WrappedError if TYPE_CHECKING: @@ -116,7 +116,7 @@ def wrapped_fn(self: HttpStreamingResponse) -> None: # noqa: C901 cause=ex, message=str(ex), context=str(self._request_context.error_context) ) from None finally: - if not StreamingState.is_okay(self._request_context.request_state): + if not RequestState.is_okay(self._request_context.request_state): self.close() return wrapped_fn diff --git a/couchbase_analytics/tests/query_integration_t.py b/couchbase_analytics/tests/query_integration_t.py index adb9ffd..9b7effb 100644 --- a/couchbase_analytics/tests/query_integration_t.py +++ b/couchbase_analytics/tests/query_integration_t.py @@ -22,7 +22,7 @@ import pytest -from couchbase_analytics.common.streaming import StreamingState +from couchbase_analytics.common.request import RequestState from couchbase_analytics.deserializer import PassthroughDeserializer from couchbase_analytics.errors import QueryError, TimeoutError from couchbase_analytics.options import QueryOptions @@ -110,7 +110,7 @@ def test_cancel_prior_iterating(self, test_env: BlockingTestEnvironment, cancel_ res.cancel() assert isinstance(res, BlockingQueryResult) - assert res._http_response._request_context.request_state == StreamingState.Cancelled + assert res._http_response._request_context.request_state == RequestState.Cancelled for row in res.rows(): rows.append(row) @@ -144,7 +144,7 @@ def test_cancel_prior_iterating_positional_params( res.cancel() assert isinstance(res, BlockingQueryResult) - assert res._http_response._request_context.request_state == StreamingState.Cancelled + assert res._http_response._request_context.request_state == RequestState.Cancelled for row in res.rows(): rows.append(row) @@ -177,7 +177,7 @@ def test_cancel_prior_iterating_with_kwargs( res.cancel() assert isinstance(res, BlockingQueryResult) - assert res._http_response._request_context.request_state == StreamingState.Cancelled + assert res._http_response._request_context.request_state == RequestState.Cancelled for row in res.rows(): rows.append(row) @@ -212,7 +212,7 @@ def test_cancel_prior_iterating_with_options( res.cancel() assert isinstance(res, BlockingQueryResult) - assert res._http_response._request_context.request_state == StreamingState.Cancelled + assert res._http_response._request_context.request_state == RequestState.Cancelled for row in res.rows(): rows.append(row) @@ -249,7 +249,7 @@ def test_cancel_prior_iterating_with_opts_and_kwargs( res.cancel() assert isinstance(res, BlockingQueryResult) - assert res._http_response._request_context.request_state == StreamingState.Cancelled + assert res._http_response._request_context.request_state == RequestState.Cancelled for row in res.rows(): rows.append(row) @@ -273,9 +273,9 @@ def test_cancel_while_iterating( assert isinstance(result, BlockingQueryResult) if query_type != SyncQueryType.LAZY: - expected_state = StreamingState.StreamingResults + expected_state = RequestState.StreamingResults else: - expected_state = StreamingState.NotStarted + expected_state = RequestState.NotStarted assert result._http_response._request_context.request_state == expected_state rows = [] count = 0 @@ -287,7 +287,7 @@ def test_cancel_while_iterating( count += 1 assert len(rows) == count - expected_state = StreamingState.Cancelled + expected_state = RequestState.Cancelled assert result._http_response._request_context.request_state == expected_state with pytest.raises(RuntimeError): result.metadata() @@ -658,9 +658,9 @@ def test_simple_query( def test_query_with_lazy_execution(self, test_env: BlockingTestEnvironment, query_statement_limit2: str) -> None: result = test_env.cluster_or_scope.execute_query(query_statement_limit2, QueryOptions(lazy_execute=True)) - expected_state = StreamingState.NotStarted + expected_state = RequestState.NotStarted assert result._http_response._request_context.request_state == expected_state - expected_state = StreamingState.StreamingResults + expected_state = RequestState.StreamingResults count = 0 for row in result.rows(): assert result._http_response._request_context.request_state == expected_state @@ -672,7 +672,7 @@ def test_query_with_lazy_execution(self, test_env: BlockingTestEnvironment, quer def test_query_with_lazy_execution_raises_exception(self, test_env: BlockingTestEnvironment) -> None: statement = "I'm not N1QL!" result = test_env.cluster_or_scope.execute_query(statement, QueryOptions(lazy_execute=True)) - expected_state = StreamingState.NotStarted + expected_state = RequestState.NotStarted assert result._http_response._request_context.request_state == expected_state with pytest.raises(QueryError): list(result.rows()) From 84e4bd77447464d97e19536851068fb6cf093b85 Mon Sep 17 00:00:00 2001 From: Jared Casey Date: Wed, 16 Jul 2025 15:08:12 -0600 Subject: [PATCH 10/18] Packaging Updates Changes ======= * Update pyproject.toml and setup.py to handle package_data * Update MANIFEST.in to correctly handle package_data * Add uv.lock file to git --- MANIFEST.in | 4 +- couchbase_analytics/_version.py | 4 +- .../capella.pem} | 0 .../{_certificates.py => certificates.py} | 4 +- .../nonprod.pem} | 0 couchbase_analytics/protocol/connection.py | 2 +- pyproject.toml | 15 + setup.py | 15 +- tests/environments/base_environment.py | 4 +- uv.lock | 1919 +++++++++++++++++ 10 files changed, 1944 insertions(+), 23 deletions(-) rename couchbase_analytics/common/_core/{_capella_certificates/_capella.pem => capella_certificates/capella.pem} (100%) rename couchbase_analytics/common/_core/{_certificates.py => certificates.py} (98%) rename couchbase_analytics/common/_core/{_nonprod_certificates/_nonprod.pem => nonprod_certificates/nonprod.pem} (100%) create mode 100644 uv.lock diff --git a/MANIFEST.in b/MANIFEST.in index 3e9e4fb..ea232c5 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,7 +1,7 @@ include *.txt LICENSE CONTRIBUTING.md pyproject.toml couchbase_analytics_version.py include couchbase-sdk-analytics-python-black-duck-manifest.yaml -include couchbase_analytics/common/core/_nonprod_certificates/*.pem -include couchbase_analytics/common/core/_capella_certificates/*.pem +include couchbase_analytics/common/_core/nonprod_certificates/*.pem +include couchbase_analytics/common/_core/capella_certificates/*.pem recursive-include couchbase_analytics *.py exclude couchbase_analytics/tests/*.py recursive-include acouchbase_analytics *.py diff --git a/couchbase_analytics/_version.py b/couchbase_analytics/_version.py index d5700e9..2c590d1 100644 --- a/couchbase_analytics/_version.py +++ b/couchbase_analytics/_version.py @@ -1,5 +1,5 @@ # This file automatically generated by -# /Users/jaredcasey/GIT/couchbase/clients/python/analytics-python-client/./couchbase_analytics_version.py +# /Users/jaredcasey/GIT/couchbase/clients/python/analytics-python-client/couchbase_analytics_version.py # at -# 2025-07-09 14:23:23.011656 +# 2025-07-16 15:02:22.211821 __version__ = '1.0.0.dev1' diff --git a/couchbase_analytics/common/_core/_capella_certificates/_capella.pem b/couchbase_analytics/common/_core/capella_certificates/capella.pem similarity index 100% rename from couchbase_analytics/common/_core/_capella_certificates/_capella.pem rename to couchbase_analytics/common/_core/capella_certificates/capella.pem diff --git a/couchbase_analytics/common/_core/_certificates.py b/couchbase_analytics/common/_core/certificates.py similarity index 98% rename from couchbase_analytics/common/_core/_certificates.py rename to couchbase_analytics/common/_core/certificates.py index e0e4d2a..6210130 100644 --- a/couchbase_analytics/common/_core/_certificates.py +++ b/couchbase_analytics/common/_core/certificates.py @@ -44,7 +44,7 @@ def get_capella_certificates() -> List[str]: Returns: List[str]: List of Capella certificates. """ - nonprod_cert_dir = Path(Path(__file__).resolve().parent, '_capella_certificates') + nonprod_cert_dir = Path(Path(__file__).resolve().parent, 'capella_certificates') nonprod_certs: List[str] = [] for cert in nonprod_cert_dir.iterdir(): if os.path.isdir(cert) or cert.suffix != '.pem': @@ -64,7 +64,7 @@ def get_nonprod_certificates() -> List[str]: import warnings warnings.warn('Only use non-prod certificate in DEVELOPMENT environments.', ResourceWarning, stacklevel=2) - nonprod_cert_dir = Path(Path(__file__).resolve().parent, '_nonprod_certificates') + nonprod_cert_dir = Path(Path(__file__).resolve().parent, 'nonprod_certificates') nonprod_certs: List[str] = [] for cert in nonprod_cert_dir.iterdir(): if os.path.isdir(cert) or cert.suffix != '.pem': diff --git a/couchbase_analytics/common/_core/_nonprod_certificates/_nonprod.pem b/couchbase_analytics/common/_core/nonprod_certificates/nonprod.pem similarity index 100% rename from couchbase_analytics/common/_core/_nonprod_certificates/_nonprod.pem rename to couchbase_analytics/common/_core/nonprod_certificates/nonprod.pem diff --git a/couchbase_analytics/protocol/connection.py b/couchbase_analytics/protocol/connection.py index 6279478..f8b8f2e 100644 --- a/couchbase_analytics/protocol/connection.py +++ b/couchbase_analytics/protocol/connection.py @@ -20,7 +20,7 @@ from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, TypedDict, cast from urllib.parse import parse_qs, urlparse -from couchbase_analytics.common._core._certificates import _Certificates +from couchbase_analytics.common._core.certificates import _Certificates from couchbase_analytics.common._core.duration_str_utils import parse_duration_str from couchbase_analytics.common._core.utils import is_null_or_empty from couchbase_analytics.common.credential import Credential diff --git a/pyproject.toml b/pyproject.toml index 25fd903..b16848b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -77,6 +77,21 @@ sphinx = [ "sphinx-toolbox~=3.7", ] +[tool.setuptools] +include-package-data = true + +[tool.setuptools.packages.find] +include = [ + "acouchbase_analytics", + "couchbase_analytics", + "acouchbase_analytics.*", + "couchbase_analytics.*", +] +exclude = [ + "acouchbase_analytics.tests", + "couchbase_analytics.tests", +] + [tool.pytest.ini_options] minversion = "8.0" log_cli = true diff --git a/setup.py b/setup.py index 5813aed..122ec77 100644 --- a/setup.py +++ b/setup.py @@ -16,7 +16,7 @@ import os import sys -from setuptools import find_packages, setup +from setuptools import setup sys.path.append('.') import couchbase_analytics_version # nopep8 # isort:skip # noqa: E402 @@ -29,24 +29,11 @@ PYCBAC_README = os.path.join(os.path.dirname(__file__), 'README.md') PYCBAC_VERSION = couchbase_analytics_version.get_version() - -package_data = { - 'couchbase_analytics.common.core._nonprod_certificates': ['*.pem'], - 'couchbase_analytics.common.core._capella_certificates': ['*.pem'], -} - print(f'Python Analytics SDK version: {PYCBAC_VERSION}') setup( name='couchbase-analytics', version=PYCBAC_VERSION, - python_requires='>=3.9', - install_requires=['httpx~=0.28.1', 'ijson~=3.3.0', 'typing-extensions~=4.11; python_version<"3.11"'], - packages=find_packages( - include=['acouchbase_analytics', 'couchbase_analytics', 'acouchbase_analytics.*', 'couchbase_analytics.*'], - exclude=['acouchbase_analytics.tests', 'couchbase_analytics.tests'], - ), - package_data=package_data, long_description=open(PYCBAC_README, 'r').read(), long_description_content_type='text/markdown', ) diff --git a/tests/environments/base_environment.py b/tests/environments/base_environment.py index 30daa4d..a18cf73 100644 --- a/tests/environments/base_environment.py +++ b/tests/environments/base_environment.py @@ -306,7 +306,7 @@ def get_environment( cred = Credential.from_username_and_password(username, pw) sec_opts: Optional[SecurityOptions] = None if config.nonprod is True: - from couchbase_analytics.common._core._certificates import _Certificates + from couchbase_analytics.common._core.certificates import _Certificates sec_opts = SecurityOptions.trust_only_certificates(_Certificates.get_nonprod_certificates()) @@ -510,7 +510,7 @@ def get_environment( cred = Credential.from_username_and_password(username, pw) sec_opts: Optional[SecurityOptions] = None if config.nonprod is True: - from couchbase_analytics.common._core._certificates import _Certificates + from couchbase_analytics.common._core.certificates import _Certificates sec_opts = SecurityOptions.trust_only_certificates(_Certificates.get_nonprod_certificates()) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..5a55883 --- /dev/null +++ b/uv.lock @@ -0,0 +1,1919 @@ +version = 1 +revision = 2 +requires-python = ">=3.9" +resolution-markers = [ + "python_full_version >= '3.13'", + "python_full_version >= '3.11' and python_full_version < '3.13'", + "python_full_version < '3.11'", +] + +[[package]] +name = "aiohappyeyeballs" +version = "2.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.11.18" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "async-timeout", marker = "python_full_version < '3.11'" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/e7/fa1a8c00e2c54b05dc8cb5d1439f627f7c267874e3f7bb047146116020f9/aiohttp-3.11.18.tar.gz", hash = "sha256:ae856e1138612b7e412db63b7708735cff4d38d0399f6a5435d3dac2669f558a", size = 7678653, upload-time = "2025-04-21T09:43:09.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/c3/e5f64af7e97a02f547020e6ff861595766bb5ecb37c7492fac9fe3c14f6c/aiohttp-3.11.18-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:96264854fedbea933a9ca4b7e0c745728f01380691687b7365d18d9e977179c4", size = 711703, upload-time = "2025-04-21T09:40:25.487Z" }, + { url = "https://files.pythonhosted.org/packages/5f/2f/53c26e96efa5fd01ebcfe1fefdfb7811f482bb21f4fa103d85eca4dcf888/aiohttp-3.11.18-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9602044ff047043430452bc3a2089743fa85da829e6fc9ee0025351d66c332b6", size = 471348, upload-time = "2025-04-21T09:40:27.569Z" }, + { url = "https://files.pythonhosted.org/packages/80/47/dcc248464c9b101532ee7d254a46f6ed2c1fd3f4f0f794cf1f2358c0d45b/aiohttp-3.11.18-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5691dc38750fcb96a33ceef89642f139aa315c8a193bbd42a0c33476fd4a1609", size = 457611, upload-time = "2025-04-21T09:40:28.978Z" }, + { url = "https://files.pythonhosted.org/packages/4c/ca/67d816ef075e8ac834b5f1f6b18e8db7d170f7aebaf76f1be462ea10cab0/aiohttp-3.11.18-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:554c918ec43f8480b47a5ca758e10e793bd7410b83701676a4782672d670da55", size = 1591976, upload-time = "2025-04-21T09:40:30.804Z" }, + { url = "https://files.pythonhosted.org/packages/46/00/0c120287aa51c744438d99e9aae9f8c55ca5b9911c42706966c91c9d68d6/aiohttp-3.11.18-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8a4076a2b3ba5b004b8cffca6afe18a3b2c5c9ef679b4d1e9859cf76295f8d4f", size = 1632819, upload-time = "2025-04-21T09:40:32.731Z" }, + { url = "https://files.pythonhosted.org/packages/54/a3/3923c9040cd4927dfee1aa017513701e35adcfc35d10729909688ecaa465/aiohttp-3.11.18-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:767a97e6900edd11c762be96d82d13a1d7c4fc4b329f054e88b57cdc21fded94", size = 1666567, upload-time = "2025-04-21T09:40:34.901Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ab/40dacb15c0c58f7f17686ea67bc186e9f207341691bdb777d1d5ff4671d5/aiohttp-3.11.18-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f0ddc9337a0fb0e727785ad4f41163cc314376e82b31846d3835673786420ef1", size = 1594959, upload-time = "2025-04-21T09:40:36.714Z" }, + { url = "https://files.pythonhosted.org/packages/0d/98/d40c2b7c4a5483f9a16ef0adffce279ced3cc44522e84b6ba9e906be5168/aiohttp-3.11.18-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f414f37b244f2a97e79b98d48c5ff0789a0b4b4609b17d64fa81771ad780e415", size = 1538516, upload-time = "2025-04-21T09:40:38.263Z" }, + { url = "https://files.pythonhosted.org/packages/cf/10/e0bf3a03524faac45a710daa034e6f1878b24a1fef9c968ac8eb786ae657/aiohttp-3.11.18-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:fdb239f47328581e2ec7744ab5911f97afb10752332a6dd3d98e14e429e1a9e7", size = 1529037, upload-time = "2025-04-21T09:40:40.349Z" }, + { url = "https://files.pythonhosted.org/packages/ad/d6/5ff5282e00e4eb59c857844984cbc5628f933e2320792e19f93aff518f52/aiohttp-3.11.18-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:f2c50bad73ed629cc326cc0f75aed8ecfb013f88c5af116f33df556ed47143eb", size = 1546813, upload-time = "2025-04-21T09:40:42.106Z" }, + { url = "https://files.pythonhosted.org/packages/de/96/f1014f84101f9b9ad2d8acf3cc501426475f7f0cc62308ae5253e2fac9a7/aiohttp-3.11.18-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0a8d8f20c39d3fa84d1c28cdb97f3111387e48209e224408e75f29c6f8e0861d", size = 1523852, upload-time = "2025-04-21T09:40:44.164Z" }, + { url = "https://files.pythonhosted.org/packages/a5/86/ec772c6838dd6bae3229065af671891496ac1834b252f305cee8152584b2/aiohttp-3.11.18-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:106032eaf9e62fd6bc6578c8b9e6dc4f5ed9a5c1c7fb2231010a1b4304393421", size = 1603766, upload-time = "2025-04-21T09:40:46.203Z" }, + { url = "https://files.pythonhosted.org/packages/84/38/31f85459c9402d409c1499284fc37a96f69afadce3cfac6a1b5ab048cbf1/aiohttp-3.11.18-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:b491e42183e8fcc9901d8dcd8ae644ff785590f1727f76ca86e731c61bfe6643", size = 1620647, upload-time = "2025-04-21T09:40:48.168Z" }, + { url = "https://files.pythonhosted.org/packages/31/2f/54aba0040764dd3d362fb37bd6aae9b3034fcae0b27f51b8a34864e48209/aiohttp-3.11.18-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ad8c745ff9460a16b710e58e06a9dec11ebc0d8f4dd82091cefb579844d69868", size = 1559260, upload-time = "2025-04-21T09:40:50.219Z" }, + { url = "https://files.pythonhosted.org/packages/ca/d2/a05c7dd9e1b6948c1c5d04f1a8bcfd7e131923fa809bb87477d5c76f1517/aiohttp-3.11.18-cp310-cp310-win32.whl", hash = "sha256:8e57da93e24303a883146510a434f0faf2f1e7e659f3041abc4e3fb3f6702a9f", size = 418051, upload-time = "2025-04-21T09:40:52.272Z" }, + { url = "https://files.pythonhosted.org/packages/39/e2/796a6179e8abe267dfc84614a50291560a989d28acacbc5dab3bcd4cbec4/aiohttp-3.11.18-cp310-cp310-win_amd64.whl", hash = "sha256:cc93a4121d87d9f12739fc8fab0a95f78444e571ed63e40bfc78cd5abe700ac9", size = 442908, upload-time = "2025-04-21T09:40:54.345Z" }, + { url = "https://files.pythonhosted.org/packages/2f/10/fd9ee4f9e042818c3c2390054c08ccd34556a3cb209d83285616434cf93e/aiohttp-3.11.18-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:427fdc56ccb6901ff8088544bde47084845ea81591deb16f957897f0f0ba1be9", size = 712088, upload-time = "2025-04-21T09:40:55.776Z" }, + { url = "https://files.pythonhosted.org/packages/22/eb/6a77f055ca56f7aae2cd2a5607a3c9e7b9554f1497a069dcfcb52bfc9540/aiohttp-3.11.18-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2c828b6d23b984255b85b9b04a5b963a74278b7356a7de84fda5e3b76866597b", size = 471450, upload-time = "2025-04-21T09:40:57.301Z" }, + { url = "https://files.pythonhosted.org/packages/78/dc/5f3c0d27c91abf0bb5d103e9c9b0ff059f60cf6031a5f06f456c90731f42/aiohttp-3.11.18-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5c2eaa145bb36b33af1ff2860820ba0589e165be4ab63a49aebfd0981c173b66", size = 457836, upload-time = "2025-04-21T09:40:59.322Z" }, + { url = "https://files.pythonhosted.org/packages/49/7b/55b65af9ef48b9b811c91ff8b5b9de9650c71147f10523e278d297750bc8/aiohttp-3.11.18-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d518ce32179f7e2096bf4e3e8438cf445f05fedd597f252de9f54c728574756", size = 1690978, upload-time = "2025-04-21T09:41:00.795Z" }, + { url = "https://files.pythonhosted.org/packages/a2/5a/3f8938c4f68ae400152b42742653477fc625d6bfe02e764f3521321c8442/aiohttp-3.11.18-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0700055a6e05c2f4711011a44364020d7a10fbbcd02fbf3e30e8f7e7fddc8717", size = 1745307, upload-time = "2025-04-21T09:41:02.89Z" }, + { url = "https://files.pythonhosted.org/packages/b4/42/89b694a293333ef6f771c62da022163bcf44fb03d4824372d88e3dc12530/aiohttp-3.11.18-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8bd1cde83e4684324e6ee19adfc25fd649d04078179890be7b29f76b501de8e4", size = 1780692, upload-time = "2025-04-21T09:41:04.461Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ce/1a75384e01dd1bf546898b6062b1b5f7a59b6692ef802e4dd6db64fed264/aiohttp-3.11.18-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:73b8870fe1c9a201b8c0d12c94fe781b918664766728783241a79e0468427e4f", size = 1676934, upload-time = "2025-04-21T09:41:06.728Z" }, + { url = "https://files.pythonhosted.org/packages/a5/31/442483276e6c368ab5169797d9873b5875213cbcf7e74b95ad1c5003098a/aiohttp-3.11.18-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:25557982dd36b9e32c0a3357f30804e80790ec2c4d20ac6bcc598533e04c6361", size = 1621190, upload-time = "2025-04-21T09:41:08.293Z" }, + { url = "https://files.pythonhosted.org/packages/7b/83/90274bf12c079457966008a58831a99675265b6a34b505243e004b408934/aiohttp-3.11.18-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7e889c9df381a2433802991288a61e5a19ceb4f61bd14f5c9fa165655dcb1fd1", size = 1658947, upload-time = "2025-04-21T09:41:11.054Z" }, + { url = "https://files.pythonhosted.org/packages/91/c1/da9cee47a0350b78fdc93670ebe7ad74103011d7778ab4c382ca4883098d/aiohttp-3.11.18-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:9ea345fda05bae217b6cce2acf3682ce3b13d0d16dd47d0de7080e5e21362421", size = 1654443, upload-time = "2025-04-21T09:41:13.213Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f2/73cbe18dc25d624f79a09448adfc4972f82ed6088759ddcf783cd201956c/aiohttp-3.11.18-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:9f26545b9940c4b46f0a9388fd04ee3ad7064c4017b5a334dd450f616396590e", size = 1644169, upload-time = "2025-04-21T09:41:14.827Z" }, + { url = "https://files.pythonhosted.org/packages/5b/32/970b0a196c4dccb1b0cfa5b4dc3b20f63d76f1c608f41001a84b2fd23c3d/aiohttp-3.11.18-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:3a621d85e85dccabd700294494d7179ed1590b6d07a35709bb9bd608c7f5dd1d", size = 1728532, upload-time = "2025-04-21T09:41:17.168Z" }, + { url = "https://files.pythonhosted.org/packages/0b/50/b1dc810a41918d2ea9574e74125eb053063bc5e14aba2d98966f7d734da0/aiohttp-3.11.18-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9c23fd8d08eb9c2af3faeedc8c56e134acdaf36e2117ee059d7defa655130e5f", size = 1750310, upload-time = "2025-04-21T09:41:19.353Z" }, + { url = "https://files.pythonhosted.org/packages/95/24/39271f5990b35ff32179cc95537e92499d3791ae82af7dcf562be785cd15/aiohttp-3.11.18-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d9e6b0e519067caa4fd7fb72e3e8002d16a68e84e62e7291092a5433763dc0dd", size = 1691580, upload-time = "2025-04-21T09:41:21.868Z" }, + { url = "https://files.pythonhosted.org/packages/6b/78/75d0353feb77f041460564f12fe58e456436bbc00cbbf5d676dbf0038cc2/aiohttp-3.11.18-cp311-cp311-win32.whl", hash = "sha256:122f3e739f6607e5e4c6a2f8562a6f476192a682a52bda8b4c6d4254e1138f4d", size = 417565, upload-time = "2025-04-21T09:41:24.78Z" }, + { url = "https://files.pythonhosted.org/packages/ed/97/b912dcb654634a813f8518de359364dfc45976f822116e725dc80a688eee/aiohttp-3.11.18-cp311-cp311-win_amd64.whl", hash = "sha256:e6f3c0a3a1e73e88af384b2e8a0b9f4fb73245afd47589df2afcab6b638fa0e6", size = 443652, upload-time = "2025-04-21T09:41:26.48Z" }, + { url = "https://files.pythonhosted.org/packages/b5/d2/5bc436f42bf4745c55f33e1e6a2d69e77075d3e768e3d1a34f96ee5298aa/aiohttp-3.11.18-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:63d71eceb9cad35d47d71f78edac41fcd01ff10cacaa64e473d1aec13fa02df2", size = 706671, upload-time = "2025-04-21T09:41:28.021Z" }, + { url = "https://files.pythonhosted.org/packages/fe/d0/2dbabecc4e078c0474abb40536bbde717fb2e39962f41c5fc7a216b18ea7/aiohttp-3.11.18-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d1929da615840969929e8878d7951b31afe0bac883d84418f92e5755d7b49508", size = 466169, upload-time = "2025-04-21T09:41:29.783Z" }, + { url = "https://files.pythonhosted.org/packages/70/84/19edcf0b22933932faa6e0be0d933a27bd173da02dc125b7354dff4d8da4/aiohttp-3.11.18-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d0aebeb2392f19b184e3fdd9e651b0e39cd0f195cdb93328bd124a1d455cd0e", size = 457554, upload-time = "2025-04-21T09:41:31.327Z" }, + { url = "https://files.pythonhosted.org/packages/32/d0/e8d1f034ae5624a0f21e4fb3feff79342ce631f3a4d26bd3e58b31ef033b/aiohttp-3.11.18-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3849ead845e8444f7331c284132ab314b4dac43bfae1e3cf350906d4fff4620f", size = 1690154, upload-time = "2025-04-21T09:41:33.541Z" }, + { url = "https://files.pythonhosted.org/packages/16/de/2f9dbe2ac6f38f8495562077131888e0d2897e3798a0ff3adda766b04a34/aiohttp-3.11.18-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5e8452ad6b2863709f8b3d615955aa0807bc093c34b8e25b3b52097fe421cb7f", size = 1733402, upload-time = "2025-04-21T09:41:35.634Z" }, + { url = "https://files.pythonhosted.org/packages/e0/04/bd2870e1e9aef990d14b6df2a695f17807baf5c85a4c187a492bda569571/aiohttp-3.11.18-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b8d2b42073611c860a37f718b3d61ae8b4c2b124b2e776e2c10619d920350ec", size = 1783958, upload-time = "2025-04-21T09:41:37.456Z" }, + { url = "https://files.pythonhosted.org/packages/23/06/4203ffa2beb5bedb07f0da0f79b7d9039d1c33f522e0d1a2d5b6218e6f2e/aiohttp-3.11.18-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40fbf91f6a0ac317c0a07eb328a1384941872f6761f2e6f7208b63c4cc0a7ff6", size = 1695288, upload-time = "2025-04-21T09:41:39.756Z" }, + { url = "https://files.pythonhosted.org/packages/30/b2/e2285dda065d9f29ab4b23d8bcc81eb881db512afb38a3f5247b191be36c/aiohttp-3.11.18-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ff5625413fec55216da5eaa011cf6b0a2ed67a565914a212a51aa3755b0009", size = 1618871, upload-time = "2025-04-21T09:41:41.972Z" }, + { url = "https://files.pythonhosted.org/packages/57/e0/88f2987885d4b646de2036f7296ebea9268fdbf27476da551c1a7c158bc0/aiohttp-3.11.18-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7f33a92a2fde08e8c6b0c61815521324fc1612f397abf96eed86b8e31618fdb4", size = 1646262, upload-time = "2025-04-21T09:41:44.192Z" }, + { url = "https://files.pythonhosted.org/packages/e0/19/4d2da508b4c587e7472a032290b2981f7caeca82b4354e19ab3df2f51d56/aiohttp-3.11.18-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:11d5391946605f445ddafda5eab11caf310f90cdda1fd99865564e3164f5cff9", size = 1677431, upload-time = "2025-04-21T09:41:46.049Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ae/047473ea50150a41440f3265f53db1738870b5a1e5406ece561ca61a3bf4/aiohttp-3.11.18-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3cc314245deb311364884e44242e00c18b5896e4fe6d5f942e7ad7e4cb640adb", size = 1637430, upload-time = "2025-04-21T09:41:47.973Z" }, + { url = "https://files.pythonhosted.org/packages/11/32/c6d1e3748077ce7ee13745fae33e5cb1dac3e3b8f8787bf738a93c94a7d2/aiohttp-3.11.18-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:0f421843b0f70740772228b9e8093289924359d306530bcd3926f39acbe1adda", size = 1703342, upload-time = "2025-04-21T09:41:50.323Z" }, + { url = "https://files.pythonhosted.org/packages/c5/1d/a3b57bfdbe285f0d45572d6d8f534fd58761da3e9cbc3098372565005606/aiohttp-3.11.18-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e220e7562467dc8d589e31c1acd13438d82c03d7f385c9cd41a3f6d1d15807c1", size = 1740600, upload-time = "2025-04-21T09:41:52.111Z" }, + { url = "https://files.pythonhosted.org/packages/a5/71/f9cd2fed33fa2b7ce4d412fb7876547abb821d5b5520787d159d0748321d/aiohttp-3.11.18-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ab2ef72f8605046115bc9aa8e9d14fd49086d405855f40b79ed9e5c1f9f4faea", size = 1695131, upload-time = "2025-04-21T09:41:53.94Z" }, + { url = "https://files.pythonhosted.org/packages/97/97/d1248cd6d02b9de6aa514793d0dcb20099f0ec47ae71a933290116c070c5/aiohttp-3.11.18-cp312-cp312-win32.whl", hash = "sha256:12a62691eb5aac58d65200c7ae94d73e8a65c331c3a86a2e9670927e94339ee8", size = 412442, upload-time = "2025-04-21T09:41:55.689Z" }, + { url = "https://files.pythonhosted.org/packages/33/9a/e34e65506e06427b111e19218a99abf627638a9703f4b8bcc3e3021277ed/aiohttp-3.11.18-cp312-cp312-win_amd64.whl", hash = "sha256:364329f319c499128fd5cd2d1c31c44f234c58f9b96cc57f743d16ec4f3238c8", size = 439444, upload-time = "2025-04-21T09:41:57.977Z" }, + { url = "https://files.pythonhosted.org/packages/0a/18/be8b5dd6b9cf1b2172301dbed28e8e5e878ee687c21947a6c81d6ceaa15d/aiohttp-3.11.18-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:474215ec618974054cf5dc465497ae9708543cbfc312c65212325d4212525811", size = 699833, upload-time = "2025-04-21T09:42:00.298Z" }, + { url = "https://files.pythonhosted.org/packages/0d/84/ecdc68e293110e6f6f6d7b57786a77555a85f70edd2b180fb1fafaff361a/aiohttp-3.11.18-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6ced70adf03920d4e67c373fd692123e34d3ac81dfa1c27e45904a628567d804", size = 462774, upload-time = "2025-04-21T09:42:02.015Z" }, + { url = "https://files.pythonhosted.org/packages/d7/85/f07718cca55884dad83cc2433746384d267ee970e91f0dcc75c6d5544079/aiohttp-3.11.18-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d9f6c0152f8d71361905aaf9ed979259537981f47ad099c8b3d81e0319814bd", size = 454429, upload-time = "2025-04-21T09:42:03.728Z" }, + { url = "https://files.pythonhosted.org/packages/82/02/7f669c3d4d39810db8842c4e572ce4fe3b3a9b82945fdd64affea4c6947e/aiohttp-3.11.18-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a35197013ed929c0aed5c9096de1fc5a9d336914d73ab3f9df14741668c0616c", size = 1670283, upload-time = "2025-04-21T09:42:06.053Z" }, + { url = "https://files.pythonhosted.org/packages/ec/79/b82a12f67009b377b6c07a26bdd1b81dab7409fc2902d669dbfa79e5ac02/aiohttp-3.11.18-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:540b8a1f3a424f1af63e0af2d2853a759242a1769f9f1ab053996a392bd70118", size = 1717231, upload-time = "2025-04-21T09:42:07.953Z" }, + { url = "https://files.pythonhosted.org/packages/a6/38/d5a1f28c3904a840642b9a12c286ff41fc66dfa28b87e204b1f242dbd5e6/aiohttp-3.11.18-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f9e6710ebebfce2ba21cee6d91e7452d1125100f41b906fb5af3da8c78b764c1", size = 1769621, upload-time = "2025-04-21T09:42:09.855Z" }, + { url = "https://files.pythonhosted.org/packages/53/2d/deb3749ba293e716b5714dda06e257f123c5b8679072346b1eb28b766a0b/aiohttp-3.11.18-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f8af2ef3b4b652ff109f98087242e2ab974b2b2b496304063585e3d78de0b000", size = 1678667, upload-time = "2025-04-21T09:42:11.741Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a8/04b6e11683a54e104b984bd19a9790eb1ae5f50968b601bb202d0406f0ff/aiohttp-3.11.18-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:28c3f975e5ae3dbcbe95b7e3dcd30e51da561a0a0f2cfbcdea30fc1308d72137", size = 1601592, upload-time = "2025-04-21T09:42:14.137Z" }, + { url = "https://files.pythonhosted.org/packages/5e/9d/c33305ae8370b789423623f0e073d09ac775cd9c831ac0f11338b81c16e0/aiohttp-3.11.18-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c28875e316c7b4c3e745172d882d8a5c835b11018e33432d281211af35794a93", size = 1621679, upload-time = "2025-04-21T09:42:16.056Z" }, + { url = "https://files.pythonhosted.org/packages/56/45/8e9a27fff0538173d47ba60362823358f7a5f1653c6c30c613469f94150e/aiohttp-3.11.18-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:13cd38515568ae230e1ef6919e2e33da5d0f46862943fcda74e7e915096815f3", size = 1656878, upload-time = "2025-04-21T09:42:18.368Z" }, + { url = "https://files.pythonhosted.org/packages/84/5b/8c5378f10d7a5a46b10cb9161a3aac3eeae6dba54ec0f627fc4ddc4f2e72/aiohttp-3.11.18-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0e2a92101efb9f4c2942252c69c63ddb26d20f46f540c239ccfa5af865197bb8", size = 1620509, upload-time = "2025-04-21T09:42:20.141Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2f/99dee7bd91c62c5ff0aa3c55f4ae7e1bc99c6affef780d7777c60c5b3735/aiohttp-3.11.18-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:e6d3e32b8753c8d45ac550b11a1090dd66d110d4ef805ffe60fa61495360b3b2", size = 1680263, upload-time = "2025-04-21T09:42:21.993Z" }, + { url = "https://files.pythonhosted.org/packages/03/0a/378745e4ff88acb83e2d5c884a4fe993a6e9f04600a4560ce0e9b19936e3/aiohttp-3.11.18-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ea4cf2488156e0f281f93cc2fd365025efcba3e2d217cbe3df2840f8c73db261", size = 1715014, upload-time = "2025-04-21T09:42:23.87Z" }, + { url = "https://files.pythonhosted.org/packages/f6/0b/b5524b3bb4b01e91bc4323aad0c2fcaebdf2f1b4d2eb22743948ba364958/aiohttp-3.11.18-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d4df95ad522c53f2b9ebc07f12ccd2cb15550941e11a5bbc5ddca2ca56316d7", size = 1666614, upload-time = "2025-04-21T09:42:25.764Z" }, + { url = "https://files.pythonhosted.org/packages/c7/b7/3d7b036d5a4ed5a4c704e0754afe2eef24a824dfab08e6efbffb0f6dd36a/aiohttp-3.11.18-cp313-cp313-win32.whl", hash = "sha256:cdd1bbaf1e61f0d94aced116d6e95fe25942f7a5f42382195fd9501089db5d78", size = 411358, upload-time = "2025-04-21T09:42:27.558Z" }, + { url = "https://files.pythonhosted.org/packages/1e/3c/143831b32cd23b5263a995b2a1794e10aa42f8a895aae5074c20fda36c07/aiohttp-3.11.18-cp313-cp313-win_amd64.whl", hash = "sha256:bdd619c27e44382cf642223f11cfd4d795161362a5a1fc1fa3940397bc89db01", size = 437658, upload-time = "2025-04-21T09:42:29.209Z" }, + { url = "https://files.pythonhosted.org/packages/da/fa/14e97d31f602866abeeb7af07c47fccd2ad92542250531b7b2975633f817/aiohttp-3.11.18-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:469ac32375d9a716da49817cd26f1916ec787fc82b151c1c832f58420e6d3533", size = 712454, upload-time = "2025-04-21T09:42:31.296Z" }, + { url = "https://files.pythonhosted.org/packages/54/18/c651486e8f8dd44bcb79b9c2bbfd2efde42e10ddb8bbac9caa7d6e1363f6/aiohttp-3.11.18-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3cec21dd68924179258ae14af9f5418c1ebdbba60b98c667815891293902e5e0", size = 471772, upload-time = "2025-04-21T09:42:33.049Z" }, + { url = "https://files.pythonhosted.org/packages/0e/79/3b3f5b29e1c7313569cf86bc6a08484de700a8af5b7c98daa2e25cfe3f31/aiohttp-3.11.18-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b426495fb9140e75719b3ae70a5e8dd3a79def0ae3c6c27e012fc59f16544a4a", size = 457978, upload-time = "2025-04-21T09:42:34.823Z" }, + { url = "https://files.pythonhosted.org/packages/e3/40/f894bb78bf5d02663dac6b853965e66f18478db9fa8dbab0111a1ef06d80/aiohttp-3.11.18-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad2f41203e2808616292db5d7170cccf0c9f9c982d02544443c7eb0296e8b0c7", size = 1598194, upload-time = "2025-04-21T09:42:36.741Z" }, + { url = "https://files.pythonhosted.org/packages/e0/f4/206e072bd546786d225c8cd173e35a5a8a0e1c904cbea31ab7d415a40e48/aiohttp-3.11.18-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5bc0ae0a5e9939e423e065a3e5b00b24b8379f1db46046d7ab71753dfc7dd0e1", size = 1636984, upload-time = "2025-04-21T09:42:39.305Z" }, + { url = "https://files.pythonhosted.org/packages/1c/b6/762fb278cc06fb6a6d1ab698ac9ccc852913684e69ed6c9ce58e201deb5e/aiohttp-3.11.18-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fe7cdd3f7d1df43200e1c80f1aed86bb36033bf65e3c7cf46a2b97a253ef8798", size = 1670821, upload-time = "2025-04-21T09:42:41.299Z" }, + { url = "https://files.pythonhosted.org/packages/5d/04/83179727a2ff485da1121d22817830173934b4f5c62cc16fccdd962a30ec/aiohttp-3.11.18-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5199be2a2f01ffdfa8c3a6f5981205242986b9e63eb8ae03fd18f736e4840721", size = 1594289, upload-time = "2025-04-21T09:42:45.603Z" }, + { url = "https://files.pythonhosted.org/packages/0b/3d/ce16c66106086b25b9c8f2e0ec5b4ba6b9a57463ec80ecfe09905bc5d626/aiohttp-3.11.18-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7ccec9e72660b10f8e283e91aa0295975c7bd85c204011d9f5eb69310555cf30", size = 1541054, upload-time = "2025-04-21T09:42:47.922Z" }, + { url = "https://files.pythonhosted.org/packages/22/23/6357f8cc4240ff10fa9720a53dbcb42998dc845a76496ac5a726e51af9a8/aiohttp-3.11.18-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1596ebf17e42e293cbacc7a24c3e0dc0f8f755b40aff0402cb74c1ff6baec1d3", size = 1531172, upload-time = "2025-04-21T09:42:49.839Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6a/64e39ae4c5d7fd308be394661c136a664df5b801d850376638add277e2a1/aiohttp-3.11.18-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:eab7b040a8a873020113ba814b7db7fa935235e4cbaf8f3da17671baa1024863", size = 1547347, upload-time = "2025-04-21T09:42:52.288Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6a/91d0c16776e46cc05c59ffc998f9c8b9559534be45c70f579cd93fd6b231/aiohttp-3.11.18-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:5d61df4a05476ff891cff0030329fee4088d40e4dc9b013fac01bc3c745542c2", size = 1526207, upload-time = "2025-04-21T09:42:54.301Z" }, + { url = "https://files.pythonhosted.org/packages/44/49/05eb21c47530b06a562f812ebf96028ada312b80f3a348a33447fac47e3d/aiohttp-3.11.18-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:46533e6792e1410f9801d09fd40cbbff3f3518d1b501d6c3c5b218f427f6ff08", size = 1605179, upload-time = "2025-04-21T09:42:56.67Z" }, + { url = "https://files.pythonhosted.org/packages/d9/01/16ef0248d7ae21340bcef794197774076f9b1326d5c97372eb07a9df4955/aiohttp-3.11.18-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:c1b90407ced992331dd6d4f1355819ea1c274cc1ee4d5b7046c6761f9ec11829", size = 1625656, upload-time = "2025-04-21T09:42:58.999Z" }, + { url = "https://files.pythonhosted.org/packages/45/71/250147cc232ea93cba34092c80a0dffa889e9ca0020b65c5913721473a12/aiohttp-3.11.18-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a2fd04ae4971b914e54fe459dd7edbbd3f2ba875d69e057d5e3c8e8cac094935", size = 1565783, upload-time = "2025-04-21T09:43:01.184Z" }, + { url = "https://files.pythonhosted.org/packages/d0/22/1a949e69cb9654e67b45831f675d2bfa5627eb61c4c4707a209ba5863ef4/aiohttp-3.11.18-cp39-cp39-win32.whl", hash = "sha256:b2f317d1678002eee6fe85670039fb34a757972284614638f82b903a03feacdc", size = 418350, upload-time = "2025-04-21T09:43:04.357Z" }, + { url = "https://files.pythonhosted.org/packages/4f/ca/3f44aabf63be958ee8ee0cb4c7ad24ea58cc73b0a73919bac9a0b4b92410/aiohttp-3.11.18-cp39-cp39-win_amd64.whl", hash = "sha256:5e7007b8d1d09bce37b54111f593d173691c530b80f27c6493b928dabed9e6ef", size = 443178, upload-time = "2025-04-21T09:43:06.296Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/b5/6d55e80f6d8a08ce22b982eafa278d823b541c925f11ee774b0b9c43473d/aiosignal-1.3.2.tar.gz", hash = "sha256:a8c255c66fafb1e499c9351d0bf32ff2d8a0321595ebac3b93713656d2436f54", size = 19424, upload-time = "2024-12-13T17:10:40.86Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/6a/bc7e17a3e87a2985d3e8f4da4cd0f481060eb78fb08596c42be62c90a4d9/aiosignal-1.3.2-py2.py3-none-any.whl", hash = "sha256:45cde58e409a301715980c2b01d0c28bdde3770d8290b5eb2173759d9acb31a5", size = 7597, upload-time = "2024-12-13T17:10:38.469Z" }, +] + +[[package]] +name = "alabaster" +version = "0.7.16" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/3e/13dd8e5ed9094e734ac430b5d0eb4f2bb001708a8b7856cbf8e084e001ba/alabaster-0.7.16.tar.gz", hash = "sha256:75a8b99c28a5dad50dd7f8ccdd447a121ddb3892da9e53d1ca5cca3106d58d65", size = 23776, upload-time = "2024-01-10T00:56:10.189Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/34/d4e1c02d3bee589efb5dfa17f88ea08bdb3e3eac12bc475462aec52ed223/alabaster-0.7.16-py3-none-any.whl", hash = "sha256:b46733c07dce03ae4e150330b975c75737fa60f0a7c591b6c8bf4928a28e2c92", size = 13511, upload-time = "2024-01-10T00:56:08.388Z" }, +] + +[[package]] +name = "anyio" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "sniffio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/7d/4c1bd541d4dffa1b52bd83fb8527089e097a106fc90b467a7313b105f840/anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028", size = 190949, upload-time = "2025-03-17T00:02:54.77Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916, upload-time = "2025-03-17T00:02:52.713Z" }, +] + +[[package]] +name = "apeye" +version = "1.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "apeye-core" }, + { name = "domdf-python-tools" }, + { name = "platformdirs" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4f/6b/cc65e31843d7bfda8313a9dc0c77a21e8580b782adca53c7cb3e511fe023/apeye-1.4.1.tar.gz", hash = "sha256:14ea542fad689e3bfdbda2189a354a4908e90aee4bf84c15ab75d68453d76a36", size = 99219, upload-time = "2023-08-14T15:32:41.381Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/7b/2d63664777b3e831ac1b1d8df5bbf0b7c8bee48e57115896080890527b1b/apeye-1.4.1-py3-none-any.whl", hash = "sha256:44e58a9104ec189bf42e76b3a7fe91e2b2879d96d48e9a77e5e32ff699c9204e", size = 107989, upload-time = "2023-08-14T15:32:40.064Z" }, +] + +[[package]] +name = "apeye-core" +version = "1.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "domdf-python-tools" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e5/4c/4f108cfd06923bd897bf992a6ecb6fb122646ee7af94d7f9a64abd071d4c/apeye_core-1.1.5.tar.gz", hash = "sha256:5de72ed3d00cc9b20fea55e54b7ab8f5ef8500eb33a5368bc162a5585e238a55", size = 96511, upload-time = "2024-01-30T17:45:48.727Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/9f/fa9971d2a0c6fef64c87ba362a493a4f230eff4ea8dfb9f4c7cbdf71892e/apeye_core-1.1.5-py3-none-any.whl", hash = "sha256:dc27a93f8c9e246b3b238c5ea51edf6115ab2618ef029b9f2d9a190ec8228fbf", size = 99286, upload-time = "2024-01-30T17:45:46.764Z" }, +] + +[[package]] +name = "async-timeout" +version = "5.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" }, +] + +[[package]] +name = "attrs" +version = "25.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/1367933a8532ee6ff8d63537de4f1177af4bff9f3e829baf7331f595bb24/attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b", size = 812032, upload-time = "2025-03-13T11:10:22.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815, upload-time = "2025-03-13T11:10:21.14Z" }, +] + +[[package]] +name = "autodocsumm" +version = "0.2.14" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/03/96/92afe8a7912b327c01f0a8b6408c9556ee13b1aba5b98d587ac7327ff32d/autodocsumm-0.2.14.tar.gz", hash = "sha256:2839a9d4facc3c4eccd306c08695540911042b46eeafcdc3203e6d0bab40bc77", size = 46357, upload-time = "2024-10-23T18:51:47.369Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/bc/3f66af9beb683728e06ca08797e4e9d3e44f432f339718cae3ba856a9cad/autodocsumm-0.2.14-py3-none-any.whl", hash = "sha256:3bad8717fc5190802c60392a7ab04b9f3c97aa9efa8b3780b3d81d615bfe5dc0", size = 14640, upload-time = "2024-10-23T18:51:45.115Z" }, +] + +[[package]] +name = "babel" +version = "2.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/6b/d52e42361e1aa00709585ecc30b3f9684b3ab62530771402248b1b1d6240/babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d", size = 9951852, upload-time = "2025-02-01T15:17:41.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537, upload-time = "2025-02-01T15:17:37.39Z" }, +] + +[[package]] +name = "beautifulsoup4" +version = "4.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d8/e4/0c4c39e18fd76d6a628d4dd8da40543d136ce2d1752bd6eeeab0791f4d6b/beautifulsoup4-4.13.4.tar.gz", hash = "sha256:dbb3c4e1ceae6aefebdaf2423247260cd062430a410e38c66f2baa50a8437195", size = 621067, upload-time = "2025-04-15T17:05:13.836Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/cd/30110dc0ffcf3b131156077b90e9f60ed75711223f306da4db08eff8403b/beautifulsoup4-4.13.4-py3-none-any.whl", hash = "sha256:9bbbb14bfde9d79f38b8cd5f8c7c85f4b8f2523190ebed90e950a8dea4cb1c4b", size = 187285, upload-time = "2025-04-15T17:05:12.221Z" }, +] + +[[package]] +name = "cachecontrol" +version = "0.14.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "msgpack" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/3a/0cbeb04ea57d2493f3ec5a069a117ab467f85e4a10017c6d854ddcbff104/cachecontrol-0.14.3.tar.gz", hash = "sha256:73e7efec4b06b20d9267b441c1f733664f989fb8688391b670ca812d70795d11", size = 28985, upload-time = "2025-04-30T16:45:06.135Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/4c/800b0607b00b3fd20f1087f80ab53d6b4d005515b0f773e4831e37cfa83f/cachecontrol-0.14.3-py3-none-any.whl", hash = "sha256:b35e44a3113f17d2a31c1e6b27b9de6d4405f84ae51baa8c1d3cc5b633010cae", size = 21802, upload-time = "2025-04-30T16:45:03.863Z" }, +] + +[package.optional-dependencies] +filecache = [ + { name = "filelock" }, +] + +[[package]] +name = "certifi" +version = "2025.6.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/73/f7/f14b46d4bcd21092d7d3ccef689615220d8a08fb25e564b65d20738e672e/certifi-2025.6.15.tar.gz", hash = "sha256:d747aa5a8b9bbbb1bb8c22bb13e22bd1f18e9796defa16bab421f7f7a317323b", size = 158753, upload-time = "2025-06-15T02:45:51.329Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/ae/320161bd181fc06471eed047ecce67b693fd7515b16d495d8932db763426/certifi-2025.6.15-py3-none-any.whl", hash = "sha256:2e0c7ce7cb5d8f8634ca55d2ba7e6ec2689a2fd6537d8dec1296a477a4910057", size = 157650, upload-time = "2025-06-15T02:45:49.977Z" }, +] + +[[package]] +name = "cfgv" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114, upload-time = "2023-08-12T20:38:17.776Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249, upload-time = "2023-08-12T20:38:16.269Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/33/89c2ced2b67d1c2a61c19c6751aa8902d46ce3dacb23600a283619f5a12d/charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63", size = 126367, upload-time = "2025-05-02T08:34:42.01Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/28/9901804da60055b406e1a1c5ba7aac1276fb77f1dde635aabfc7fd84b8ab/charset_normalizer-3.4.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c48ed483eb946e6c04ccbe02c6b4d1d48e51944b6db70f697e089c193404941", size = 201818, upload-time = "2025-05-02T08:31:46.725Z" }, + { url = "https://files.pythonhosted.org/packages/d9/9b/892a8c8af9110935e5adcbb06d9c6fe741b6bb02608c6513983048ba1a18/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2d318c11350e10662026ad0eb71bb51c7812fc8590825304ae0bdd4ac283acd", size = 144649, upload-time = "2025-05-02T08:31:48.889Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a5/4179abd063ff6414223575e008593861d62abfc22455b5d1a44995b7c101/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9cbfacf36cb0ec2897ce0ebc5d08ca44213af24265bd56eca54bee7923c48fd6", size = 155045, upload-time = "2025-05-02T08:31:50.757Z" }, + { url = "https://files.pythonhosted.org/packages/3b/95/bc08c7dfeddd26b4be8c8287b9bb055716f31077c8b0ea1cd09553794665/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:18dd2e350387c87dabe711b86f83c9c78af772c748904d372ade190b5c7c9d4d", size = 147356, upload-time = "2025-05-02T08:31:52.634Z" }, + { url = "https://files.pythonhosted.org/packages/a8/2d/7a5b635aa65284bf3eab7653e8b4151ab420ecbae918d3e359d1947b4d61/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8075c35cd58273fee266c58c0c9b670947c19df5fb98e7b66710e04ad4e9ff86", size = 149471, upload-time = "2025-05-02T08:31:56.207Z" }, + { url = "https://files.pythonhosted.org/packages/ae/38/51fc6ac74251fd331a8cfdb7ec57beba8c23fd5493f1050f71c87ef77ed0/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5bf4545e3b962767e5c06fe1738f951f77d27967cb2caa64c28be7c4563e162c", size = 151317, upload-time = "2025-05-02T08:31:57.613Z" }, + { url = "https://files.pythonhosted.org/packages/b7/17/edee1e32215ee6e9e46c3e482645b46575a44a2d72c7dfd49e49f60ce6bf/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7a6ab32f7210554a96cd9e33abe3ddd86732beeafc7a28e9955cdf22ffadbab0", size = 146368, upload-time = "2025-05-02T08:31:59.468Z" }, + { url = "https://files.pythonhosted.org/packages/26/2c/ea3e66f2b5f21fd00b2825c94cafb8c326ea6240cd80a91eb09e4a285830/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b33de11b92e9f75a2b545d6e9b6f37e398d86c3e9e9653c4864eb7e89c5773ef", size = 154491, upload-time = "2025-05-02T08:32:01.219Z" }, + { url = "https://files.pythonhosted.org/packages/52/47/7be7fa972422ad062e909fd62460d45c3ef4c141805b7078dbab15904ff7/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8755483f3c00d6c9a77f490c17e6ab0c8729e39e6390328e42521ef175380ae6", size = 157695, upload-time = "2025-05-02T08:32:03.045Z" }, + { url = "https://files.pythonhosted.org/packages/2f/42/9f02c194da282b2b340f28e5fb60762de1151387a36842a92b533685c61e/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:68a328e5f55ec37c57f19ebb1fdc56a248db2e3e9ad769919a58672958e8f366", size = 154849, upload-time = "2025-05-02T08:32:04.651Z" }, + { url = "https://files.pythonhosted.org/packages/67/44/89cacd6628f31fb0b63201a618049be4be2a7435a31b55b5eb1c3674547a/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:21b2899062867b0e1fde9b724f8aecb1af14f2778d69aacd1a5a1853a597a5db", size = 150091, upload-time = "2025-05-02T08:32:06.719Z" }, + { url = "https://files.pythonhosted.org/packages/1f/79/4b8da9f712bc079c0f16b6d67b099b0b8d808c2292c937f267d816ec5ecc/charset_normalizer-3.4.2-cp310-cp310-win32.whl", hash = "sha256:e8082b26888e2f8b36a042a58307d5b917ef2b1cacab921ad3323ef91901c71a", size = 98445, upload-time = "2025-05-02T08:32:08.66Z" }, + { url = "https://files.pythonhosted.org/packages/7d/d7/96970afb4fb66497a40761cdf7bd4f6fca0fc7bafde3a84f836c1f57a926/charset_normalizer-3.4.2-cp310-cp310-win_amd64.whl", hash = "sha256:f69a27e45c43520f5487f27627059b64aaf160415589230992cec34c5e18a509", size = 105782, upload-time = "2025-05-02T08:32:10.46Z" }, + { url = "https://files.pythonhosted.org/packages/05/85/4c40d00dcc6284a1c1ad5de5e0996b06f39d8232f1031cd23c2f5c07ee86/charset_normalizer-3.4.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:be1e352acbe3c78727a16a455126d9ff83ea2dfdcbc83148d2982305a04714c2", size = 198794, upload-time = "2025-05-02T08:32:11.945Z" }, + { url = "https://files.pythonhosted.org/packages/41/d9/7a6c0b9db952598e97e93cbdfcb91bacd89b9b88c7c983250a77c008703c/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa88ca0b1932e93f2d961bf3addbb2db902198dca337d88c89e1559e066e7645", size = 142846, upload-time = "2025-05-02T08:32:13.946Z" }, + { url = "https://files.pythonhosted.org/packages/66/82/a37989cda2ace7e37f36c1a8ed16c58cf48965a79c2142713244bf945c89/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d524ba3f1581b35c03cb42beebab4a13e6cdad7b36246bd22541fa585a56cccd", size = 153350, upload-time = "2025-05-02T08:32:15.873Z" }, + { url = "https://files.pythonhosted.org/packages/df/68/a576b31b694d07b53807269d05ec3f6f1093e9545e8607121995ba7a8313/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28a1005facc94196e1fb3e82a3d442a9d9110b8434fc1ded7a24a2983c9888d8", size = 145657, upload-time = "2025-05-02T08:32:17.283Z" }, + { url = "https://files.pythonhosted.org/packages/92/9b/ad67f03d74554bed3aefd56fe836e1623a50780f7c998d00ca128924a499/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fdb20a30fe1175ecabed17cbf7812f7b804b8a315a25f24678bcdf120a90077f", size = 147260, upload-time = "2025-05-02T08:32:18.807Z" }, + { url = "https://files.pythonhosted.org/packages/a6/e6/8aebae25e328160b20e31a7e9929b1578bbdc7f42e66f46595a432f8539e/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0f5d9ed7f254402c9e7d35d2f5972c9bbea9040e99cd2861bd77dc68263277c7", size = 149164, upload-time = "2025-05-02T08:32:20.333Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f2/b3c2f07dbcc248805f10e67a0262c93308cfa149a4cd3d1fe01f593e5fd2/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:efd387a49825780ff861998cd959767800d54f8308936b21025326de4b5a42b9", size = 144571, upload-time = "2025-05-02T08:32:21.86Z" }, + { url = "https://files.pythonhosted.org/packages/60/5b/c3f3a94bc345bc211622ea59b4bed9ae63c00920e2e8f11824aa5708e8b7/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f0aa37f3c979cf2546b73e8222bbfa3dc07a641585340179d768068e3455e544", size = 151952, upload-time = "2025-05-02T08:32:23.434Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4d/ff460c8b474122334c2fa394a3f99a04cf11c646da895f81402ae54f5c42/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e70e990b2137b29dc5564715de1e12701815dacc1d056308e2b17e9095372a82", size = 155959, upload-time = "2025-05-02T08:32:24.993Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2b/b964c6a2fda88611a1fe3d4c400d39c66a42d6c169c924818c848f922415/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:0c8c57f84ccfc871a48a47321cfa49ae1df56cd1d965a09abe84066f6853b9c0", size = 153030, upload-time = "2025-05-02T08:32:26.435Z" }, + { url = "https://files.pythonhosted.org/packages/59/2e/d3b9811db26a5ebf444bc0fa4f4be5aa6d76fc6e1c0fd537b16c14e849b6/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6b66f92b17849b85cad91259efc341dce9c1af48e2173bf38a85c6329f1033e5", size = 148015, upload-time = "2025-05-02T08:32:28.376Z" }, + { url = "https://files.pythonhosted.org/packages/90/07/c5fd7c11eafd561bb51220d600a788f1c8d77c5eef37ee49454cc5c35575/charset_normalizer-3.4.2-cp311-cp311-win32.whl", hash = "sha256:daac4765328a919a805fa5e2720f3e94767abd632ae410a9062dff5412bae65a", size = 98106, upload-time = "2025-05-02T08:32:30.281Z" }, + { url = "https://files.pythonhosted.org/packages/a8/05/5e33dbef7e2f773d672b6d79f10ec633d4a71cd96db6673625838a4fd532/charset_normalizer-3.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:e53efc7c7cee4c1e70661e2e112ca46a575f90ed9ae3fef200f2a25e954f4b28", size = 105402, upload-time = "2025-05-02T08:32:32.191Z" }, + { url = "https://files.pythonhosted.org/packages/d7/a4/37f4d6035c89cac7930395a35cc0f1b872e652eaafb76a6075943754f095/charset_normalizer-3.4.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7", size = 199936, upload-time = "2025-05-02T08:32:33.712Z" }, + { url = "https://files.pythonhosted.org/packages/ee/8a/1a5e33b73e0d9287274f899d967907cd0bf9c343e651755d9307e0dbf2b3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3", size = 143790, upload-time = "2025-05-02T08:32:35.768Z" }, + { url = "https://files.pythonhosted.org/packages/66/52/59521f1d8e6ab1482164fa21409c5ef44da3e9f653c13ba71becdd98dec3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a", size = 153924, upload-time = "2025-05-02T08:32:37.284Z" }, + { url = "https://files.pythonhosted.org/packages/86/2d/fb55fdf41964ec782febbf33cb64be480a6b8f16ded2dbe8db27a405c09f/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d41c4d287cfc69060fa91cae9683eacffad989f1a10811995fa309df656ec214", size = 146626, upload-time = "2025-05-02T08:32:38.803Z" }, + { url = "https://files.pythonhosted.org/packages/8c/73/6ede2ec59bce19b3edf4209d70004253ec5f4e319f9a2e3f2f15601ed5f7/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e594135de17ab3866138f496755f302b72157d115086d100c3f19370839dd3a", size = 148567, upload-time = "2025-05-02T08:32:40.251Z" }, + { url = "https://files.pythonhosted.org/packages/09/14/957d03c6dc343c04904530b6bef4e5efae5ec7d7990a7cbb868e4595ee30/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf713fe9a71ef6fd5adf7a79670135081cd4431c2943864757f0fa3a65b1fafd", size = 150957, upload-time = "2025-05-02T08:32:41.705Z" }, + { url = "https://files.pythonhosted.org/packages/0d/c8/8174d0e5c10ccebdcb1b53cc959591c4c722a3ad92461a273e86b9f5a302/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a370b3e078e418187da8c3674eddb9d983ec09445c99a3a263c2011993522981", size = 145408, upload-time = "2025-05-02T08:32:43.709Z" }, + { url = "https://files.pythonhosted.org/packages/58/aa/8904b84bc8084ac19dc52feb4f5952c6df03ffb460a887b42615ee1382e8/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a955b438e62efdf7e0b7b52a64dc5c3396e2634baa62471768a64bc2adb73d5c", size = 153399, upload-time = "2025-05-02T08:32:46.197Z" }, + { url = "https://files.pythonhosted.org/packages/c2/26/89ee1f0e264d201cb65cf054aca6038c03b1a0c6b4ae998070392a3ce605/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7222ffd5e4de8e57e03ce2cef95a4c43c98fcb72ad86909abdfc2c17d227fc1b", size = 156815, upload-time = "2025-05-02T08:32:48.105Z" }, + { url = "https://files.pythonhosted.org/packages/fd/07/68e95b4b345bad3dbbd3a8681737b4338ff2c9df29856a6d6d23ac4c73cb/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:bee093bf902e1d8fc0ac143c88902c3dfc8941f7ea1d6a8dd2bcb786d33db03d", size = 154537, upload-time = "2025-05-02T08:32:49.719Z" }, + { url = "https://files.pythonhosted.org/packages/77/1a/5eefc0ce04affb98af07bc05f3bac9094513c0e23b0562d64af46a06aae4/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dedb8adb91d11846ee08bec4c8236c8549ac721c245678282dcb06b221aab59f", size = 149565, upload-time = "2025-05-02T08:32:51.404Z" }, + { url = "https://files.pythonhosted.org/packages/37/a0/2410e5e6032a174c95e0806b1a6585eb21e12f445ebe239fac441995226a/charset_normalizer-3.4.2-cp312-cp312-win32.whl", hash = "sha256:db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c", size = 98357, upload-time = "2025-05-02T08:32:53.079Z" }, + { url = "https://files.pythonhosted.org/packages/6c/4f/c02d5c493967af3eda9c771ad4d2bbc8df6f99ddbeb37ceea6e8716a32bc/charset_normalizer-3.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e", size = 105776, upload-time = "2025-05-02T08:32:54.573Z" }, + { url = "https://files.pythonhosted.org/packages/ea/12/a93df3366ed32db1d907d7593a94f1fe6293903e3e92967bebd6950ed12c/charset_normalizer-3.4.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0", size = 199622, upload-time = "2025-05-02T08:32:56.363Z" }, + { url = "https://files.pythonhosted.org/packages/04/93/bf204e6f344c39d9937d3c13c8cd5bbfc266472e51fc8c07cb7f64fcd2de/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf", size = 143435, upload-time = "2025-05-02T08:32:58.551Z" }, + { url = "https://files.pythonhosted.org/packages/22/2a/ea8a2095b0bafa6c5b5a55ffdc2f924455233ee7b91c69b7edfcc9e02284/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e", size = 153653, upload-time = "2025-05-02T08:33:00.342Z" }, + { url = "https://files.pythonhosted.org/packages/b6/57/1b090ff183d13cef485dfbe272e2fe57622a76694061353c59da52c9a659/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1", size = 146231, upload-time = "2025-05-02T08:33:02.081Z" }, + { url = "https://files.pythonhosted.org/packages/e2/28/ffc026b26f441fc67bd21ab7f03b313ab3fe46714a14b516f931abe1a2d8/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c", size = 148243, upload-time = "2025-05-02T08:33:04.063Z" }, + { url = "https://files.pythonhosted.org/packages/c0/0f/9abe9bd191629c33e69e47c6ef45ef99773320e9ad8e9cb08b8ab4a8d4cb/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691", size = 150442, upload-time = "2025-05-02T08:33:06.418Z" }, + { url = "https://files.pythonhosted.org/packages/67/7c/a123bbcedca91d5916c056407f89a7f5e8fdfce12ba825d7d6b9954a1a3c/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0", size = 145147, upload-time = "2025-05-02T08:33:08.183Z" }, + { url = "https://files.pythonhosted.org/packages/ec/fe/1ac556fa4899d967b83e9893788e86b6af4d83e4726511eaaad035e36595/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b", size = 153057, upload-time = "2025-05-02T08:33:09.986Z" }, + { url = "https://files.pythonhosted.org/packages/2b/ff/acfc0b0a70b19e3e54febdd5301a98b72fa07635e56f24f60502e954c461/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff", size = 156454, upload-time = "2025-05-02T08:33:11.814Z" }, + { url = "https://files.pythonhosted.org/packages/92/08/95b458ce9c740d0645feb0e96cea1f5ec946ea9c580a94adfe0b617f3573/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b", size = 154174, upload-time = "2025-05-02T08:33:13.707Z" }, + { url = "https://files.pythonhosted.org/packages/78/be/8392efc43487ac051eee6c36d5fbd63032d78f7728cb37aebcc98191f1ff/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148", size = 149166, upload-time = "2025-05-02T08:33:15.458Z" }, + { url = "https://files.pythonhosted.org/packages/44/96/392abd49b094d30b91d9fbda6a69519e95802250b777841cf3bda8fe136c/charset_normalizer-3.4.2-cp313-cp313-win32.whl", hash = "sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7", size = 98064, upload-time = "2025-05-02T08:33:17.06Z" }, + { url = "https://files.pythonhosted.org/packages/e9/b0/0200da600134e001d91851ddc797809e2fe0ea72de90e09bec5a2fbdaccb/charset_normalizer-3.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980", size = 105641, upload-time = "2025-05-02T08:33:18.753Z" }, + { url = "https://files.pythonhosted.org/packages/28/f8/dfb01ff6cc9af38552c69c9027501ff5a5117c4cc18dcd27cb5259fa1888/charset_normalizer-3.4.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:005fa3432484527f9732ebd315da8da8001593e2cf46a3d817669f062c3d9ed4", size = 201671, upload-time = "2025-05-02T08:34:12.696Z" }, + { url = "https://files.pythonhosted.org/packages/32/fb/74e26ee556a9dbfe3bd264289b67be1e6d616329403036f6507bb9f3f29c/charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e92fca20c46e9f5e1bb485887d074918b13543b1c2a1185e69bb8d17ab6236a7", size = 144744, upload-time = "2025-05-02T08:34:14.665Z" }, + { url = "https://files.pythonhosted.org/packages/ad/06/8499ee5aa7addc6f6d72e068691826ff093329fe59891e83b092ae4c851c/charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:50bf98d5e563b83cc29471fa114366e6806bc06bc7a25fd59641e41445327836", size = 154993, upload-time = "2025-05-02T08:34:17.134Z" }, + { url = "https://files.pythonhosted.org/packages/f1/a2/5e4c187680728219254ef107a6949c60ee0e9a916a5dadb148c7ae82459c/charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:721c76e84fe669be19c5791da68232ca2e05ba5185575086e384352e2c309597", size = 147382, upload-time = "2025-05-02T08:34:19.081Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/56aca740dda674f0cc1ba1418c4d84534be51f639b5f98f538b332dc9a95/charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82d8fd25b7f4675d0c47cf95b594d4e7b158aca33b76aa63d07186e13c0e0ab7", size = 149536, upload-time = "2025-05-02T08:34:21.073Z" }, + { url = "https://files.pythonhosted.org/packages/53/13/db2e7779f892386b589173dd689c1b1e304621c5792046edd8a978cbf9e0/charset_normalizer-3.4.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3daeac64d5b371dea99714f08ffc2c208522ec6b06fbc7866a450dd446f5c0f", size = 151349, upload-time = "2025-05-02T08:34:23.193Z" }, + { url = "https://files.pythonhosted.org/packages/69/35/e52ab9a276186f729bce7a0638585d2982f50402046e4b0faa5d2c3ef2da/charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:dccab8d5fa1ef9bfba0590ecf4d46df048d18ffe3eec01eeb73a42e0d9e7a8ba", size = 146365, upload-time = "2025-05-02T08:34:25.187Z" }, + { url = "https://files.pythonhosted.org/packages/a6/d8/af7333f732fc2e7635867d56cb7c349c28c7094910c72267586947561b4b/charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:aaf27faa992bfee0264dc1f03f4c75e9fcdda66a519db6b957a3f826e285cf12", size = 154499, upload-time = "2025-05-02T08:34:27.359Z" }, + { url = "https://files.pythonhosted.org/packages/7a/3d/a5b2e48acef264d71e036ff30bcc49e51bde80219bb628ba3e00cf59baac/charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:eb30abc20df9ab0814b5a2524f23d75dcf83cde762c161917a2b4b7b55b1e518", size = 157735, upload-time = "2025-05-02T08:34:29.798Z" }, + { url = "https://files.pythonhosted.org/packages/85/d8/23e2c112532a29f3eef374375a8684a4f3b8e784f62b01da931186f43494/charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:c72fbbe68c6f32f251bdc08b8611c7b3060612236e960ef848e0a517ddbe76c5", size = 154786, upload-time = "2025-05-02T08:34:31.858Z" }, + { url = "https://files.pythonhosted.org/packages/c7/57/93e0169f08ecc20fe82d12254a200dfaceddc1c12a4077bf454ecc597e33/charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:982bb1e8b4ffda883b3d0a521e23abcd6fd17418f6d2c4118d257a10199c0ce3", size = 150203, upload-time = "2025-05-02T08:34:33.88Z" }, + { url = "https://files.pythonhosted.org/packages/2c/9d/9bf2b005138e7e060d7ebdec7503d0ef3240141587651f4b445bdf7286c2/charset_normalizer-3.4.2-cp39-cp39-win32.whl", hash = "sha256:43e0933a0eff183ee85833f341ec567c0980dae57c464d8a508e1b2ceb336471", size = 98436, upload-time = "2025-05-02T08:34:35.907Z" }, + { url = "https://files.pythonhosted.org/packages/6d/24/5849d46cf4311bbf21b424c443b09b459f5b436b1558c04e45dbb7cc478b/charset_normalizer-3.4.2-cp39-cp39-win_amd64.whl", hash = "sha256:d11b54acf878eef558599658b0ffca78138c8c3655cf4f3a4a673c437e67732e", size = 105772, upload-time = "2025-05-02T08:34:37.935Z" }, + { url = "https://files.pythonhosted.org/packages/20/94/c5790835a017658cbfabd07f3bfb549140c3ac458cfc196323996b10095a/charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0", size = 52626, upload-time = "2025-05-02T08:34:40.053Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "couchbase-analytics" +version = "1.0.0.dev1" +source = { editable = "." } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "ijson" }, + { name = "sniffio" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] + +[package.dev-dependencies] +dev = [ + { name = "aiohttp" }, + { name = "mypy" }, + { name = "pre-commit" }, + { name = "pytest" }, + { name = "ruff" }, + { name = "tomli" }, + { name = "tomli-w" }, +] +sphinx = [ + { name = "enum-tools" }, + { name = "sphinx" }, + { name = "sphinx-copybutton" }, + { name = "sphinx-rtd-theme" }, + { name = "sphinx-toolbox" }, +] + +[package.metadata] +requires-dist = [ + { name = "anyio", specifier = "~=4.9.0" }, + { name = "httpx", specifier = "~=0.28.1" }, + { name = "ijson", specifier = "~=3.3.0" }, + { name = "sniffio", specifier = "~=1.3.1" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'", specifier = "~=4.11" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "aiohttp", specifier = "~=3.11.10" }, + { name = "mypy", specifier = "~=1.16.1" }, + { name = "pre-commit", specifier = "~=4.2.0" }, + { name = "pytest", specifier = "~=8.3.5" }, + { name = "ruff", specifier = "~=0.12.0" }, + { name = "tomli", specifier = "~=2.2.1" }, + { name = "tomli-w", specifier = "~=1.2.0" }, +] +sphinx = [ + { name = "enum-tools", specifier = "~=0.12" }, + { name = "sphinx", specifier = "~=7.4.7" }, + { name = "sphinx-copybutton", specifier = "~=0.5" }, + { name = "sphinx-rtd-theme", specifier = "~=2.0" }, + { name = "sphinx-toolbox", specifier = "~=3.7" }, +] + +[[package]] +name = "cssutils" +version = "2.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/33/9f/329d26121fe165be44b1dfff21aa0dc348f04633931f1d20ed6cf448a236/cssutils-2.11.1.tar.gz", hash = "sha256:0563a76513b6af6eebbe788c3bf3d01c920e46b3f90c8416738c5cfc773ff8e2", size = 711657, upload-time = "2024-06-04T15:51:39.373Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/ec/bb273b7208c606890dc36540fe667d06ce840a6f62f9fae7e658fcdc90fb/cssutils-2.11.1-py3-none-any.whl", hash = "sha256:a67bfdfdff4f3867fab43698ec4897c1a828eca5973f4073321b3bccaf1199b1", size = 385747, upload-time = "2024-06-04T15:51:37.499Z" }, +] + +[[package]] +name = "dict2css" +version = "0.3.0.post1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cssutils" }, + { name = "domdf-python-tools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/24/eb/776eef1f1aa0188c0fc165c3a60b71027539f71f2eedc43ad21b060e9c39/dict2css-0.3.0.post1.tar.gz", hash = "sha256:89c544c21c4ca7472c3fffb9d37d3d926f606329afdb751dc1de67a411b70719", size = 7845, upload-time = "2023-11-22T11:09:20.958Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/47/290daabcf91628f4fc0e17c75a1690b354ba067066cd14407712600e609f/dict2css-0.3.0.post1-py3-none-any.whl", hash = "sha256:f006a6b774c3e31869015122ae82c491fd25e7de4a75607a62aa3e798f837e0d", size = 25647, upload-time = "2023-11-22T11:09:19.221Z" }, +] + +[[package]] +name = "distlib" +version = "0.3.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/dd/1bec4c5ddb504ca60fc29472f3d27e8d4da1257a854e1d96742f15c1d02d/distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403", size = 613923, upload-time = "2024-10-09T18:35:47.551Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/a1/cf2472db20f7ce4a6be1253a81cfdf85ad9c7885ffbed7047fb72c24cf87/distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87", size = 468973, upload-time = "2024-10-09T18:35:44.272Z" }, +] + +[[package]] +name = "docutils" +version = "0.20.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/53/a5da4f2c5739cf66290fac1431ee52aff6851c7c8ffd8264f13affd7bcdd/docutils-0.20.1.tar.gz", hash = "sha256:f08a4e276c3a1583a86dce3e34aba3fe04d02bba2dd51ed16106244e8a923e3b", size = 2058365, upload-time = "2023-05-16T23:39:19.748Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/87/f238c0670b94533ac0353a4e2a1a771a0cc73277b88bff23d3ae35a256c1/docutils-0.20.1-py3-none-any.whl", hash = "sha256:96f387a2c5562db4476f09f13bbab2192e764cac08ebbf3a34a95d9b1e4a59d6", size = 572666, upload-time = "2023-05-16T23:39:15.976Z" }, +] + +[[package]] +name = "domdf-python-tools" +version = "3.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "natsort" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/36/8b/ab2d8a292bba8fe3135cacc8bfd3576710a14b8f2d0a8cde19130d5c9d21/domdf_python_tools-3.10.0.tar.gz", hash = "sha256:2ae308d2f4f1e9145f5f4ba57f840fbfd1c2983ee26e4824347789649d3ae298", size = 100458, upload-time = "2025-02-12T17:34:05.747Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/11/208f72084084d3f6a2ed5ebfdfc846692c3f7ad6dce65e400194924f7eed/domdf_python_tools-3.10.0-py3-none-any.whl", hash = "sha256:5e71c1be71bbcc1f881d690c8984b60e64298ec256903b3147f068bc33090c36", size = 126860, upload-time = "2025-02-12T17:34:04.093Z" }, +] + +[[package]] +name = "enum-tools" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pygments" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/87/50091e20c2765aa495b24521844a7d8f7041d48e4f9b47dd928cd38c8606/enum_tools-0.13.0.tar.gz", hash = "sha256:0d13335e361d300dc0f8fd82c8cf9951417246f9676144f5ee1761eb690228eb", size = 18904, upload-time = "2025-04-17T15:26:59.412Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/45/cf8a8df3ebe78db691ab54525d552085b67658877f0334f4b0c08c43b518/enum_tools-0.13.0-py3-none-any.whl", hash = "sha256:e0112b16767dd08cb94105844b52770eae67ece6f026916a06db4a3d330d2a95", size = 22366, upload-time = "2025-04-17T15:26:58.34Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" }, +] + +[[package]] +name = "filelock" +version = "3.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/10/c23352565a6544bdc5353e0b15fc1c563352101f30e24bf500207a54df9a/filelock-3.18.0.tar.gz", hash = "sha256:adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2", size = 18075, upload-time = "2025-03-14T07:11:40.47Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/36/2a115987e2d8c300a974597416d9de88f2444426de9571f4b59b2cca3acc/filelock-3.18.0-py3-none-any.whl", hash = "sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de", size = 16215, upload-time = "2025-03-14T07:11:39.145Z" }, +] + +[[package]] +name = "frozenlist" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/79/b1/b64018016eeb087db503b038296fd782586432b9c077fc5c7839e9cb6ef6/frozenlist-1.7.0.tar.gz", hash = "sha256:2e310d81923c2437ea8670467121cc3e9b0f76d3043cc1d2331d56c7fb7a3a8f", size = 45078, upload-time = "2025-06-09T23:02:35.538Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/36/0da0a49409f6b47cc2d060dc8c9040b897b5902a8a4e37d9bc1deb11f680/frozenlist-1.7.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cc4df77d638aa2ed703b878dd093725b72a824c3c546c076e8fdf276f78ee84a", size = 81304, upload-time = "2025-06-09T22:59:46.226Z" }, + { url = "https://files.pythonhosted.org/packages/77/f0/77c11d13d39513b298e267b22eb6cb559c103d56f155aa9a49097221f0b6/frozenlist-1.7.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:716a9973a2cc963160394f701964fe25012600f3d311f60c790400b00e568b61", size = 47735, upload-time = "2025-06-09T22:59:48.133Z" }, + { url = "https://files.pythonhosted.org/packages/37/12/9d07fa18971a44150593de56b2f2947c46604819976784bcf6ea0d5db43b/frozenlist-1.7.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a0fd1bad056a3600047fb9462cff4c5322cebc59ebf5d0a3725e0ee78955001d", size = 46775, upload-time = "2025-06-09T22:59:49.564Z" }, + { url = "https://files.pythonhosted.org/packages/70/34/f73539227e06288fcd1f8a76853e755b2b48bca6747e99e283111c18bcd4/frozenlist-1.7.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3789ebc19cb811163e70fe2bd354cea097254ce6e707ae42e56f45e31e96cb8e", size = 224644, upload-time = "2025-06-09T22:59:51.35Z" }, + { url = "https://files.pythonhosted.org/packages/fb/68/c1d9c2f4a6e438e14613bad0f2973567586610cc22dcb1e1241da71de9d3/frozenlist-1.7.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:af369aa35ee34f132fcfad5be45fbfcde0e3a5f6a1ec0712857f286b7d20cca9", size = 222125, upload-time = "2025-06-09T22:59:52.884Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d0/98e8f9a515228d708344d7c6986752be3e3192d1795f748c24bcf154ad99/frozenlist-1.7.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac64b6478722eeb7a3313d494f8342ef3478dff539d17002f849101b212ef97c", size = 233455, upload-time = "2025-06-09T22:59:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/79/df/8a11bcec5600557f40338407d3e5bea80376ed1c01a6c0910fcfdc4b8993/frozenlist-1.7.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f89f65d85774f1797239693cef07ad4c97fdd0639544bad9ac4b869782eb1981", size = 227339, upload-time = "2025-06-09T22:59:56.187Z" }, + { url = "https://files.pythonhosted.org/packages/50/82/41cb97d9c9a5ff94438c63cc343eb7980dac4187eb625a51bdfdb7707314/frozenlist-1.7.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1073557c941395fdfcfac13eb2456cb8aad89f9de27bae29fabca8e563b12615", size = 212969, upload-time = "2025-06-09T22:59:57.604Z" }, + { url = "https://files.pythonhosted.org/packages/13/47/f9179ee5ee4f55629e4f28c660b3fdf2775c8bfde8f9c53f2de2d93f52a9/frozenlist-1.7.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ed8d2fa095aae4bdc7fdd80351009a48d286635edffee66bf865e37a9125c50", size = 222862, upload-time = "2025-06-09T22:59:59.498Z" }, + { url = "https://files.pythonhosted.org/packages/1a/52/df81e41ec6b953902c8b7e3a83bee48b195cb0e5ec2eabae5d8330c78038/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:24c34bea555fe42d9f928ba0a740c553088500377448febecaa82cc3e88aa1fa", size = 222492, upload-time = "2025-06-09T23:00:01.026Z" }, + { url = "https://files.pythonhosted.org/packages/84/17/30d6ea87fa95a9408245a948604b82c1a4b8b3e153cea596421a2aef2754/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:69cac419ac6a6baad202c85aaf467b65ac860ac2e7f2ac1686dc40dbb52f6577", size = 238250, upload-time = "2025-06-09T23:00:03.401Z" }, + { url = "https://files.pythonhosted.org/packages/8f/00/ecbeb51669e3c3df76cf2ddd66ae3e48345ec213a55e3887d216eb4fbab3/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:960d67d0611f4c87da7e2ae2eacf7ea81a5be967861e0c63cf205215afbfac59", size = 218720, upload-time = "2025-06-09T23:00:05.282Z" }, + { url = "https://files.pythonhosted.org/packages/1a/c0/c224ce0e0eb31cc57f67742071bb470ba8246623c1823a7530be0e76164c/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:41be2964bd4b15bf575e5daee5a5ce7ed3115320fb3c2b71fca05582ffa4dc9e", size = 232585, upload-time = "2025-06-09T23:00:07.962Z" }, + { url = "https://files.pythonhosted.org/packages/55/3c/34cb694abf532f31f365106deebdeac9e45c19304d83cf7d51ebbb4ca4d1/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:46d84d49e00c9429238a7ce02dc0be8f6d7cd0cd405abd1bebdc991bf27c15bd", size = 234248, upload-time = "2025-06-09T23:00:09.428Z" }, + { url = "https://files.pythonhosted.org/packages/98/c0/2052d8b6cecda2e70bd81299e3512fa332abb6dcd2969b9c80dfcdddbf75/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:15900082e886edb37480335d9d518cec978afc69ccbc30bd18610b7c1b22a718", size = 221621, upload-time = "2025-06-09T23:00:11.32Z" }, + { url = "https://files.pythonhosted.org/packages/c5/bf/7dcebae315436903b1d98ffb791a09d674c88480c158aa171958a3ac07f0/frozenlist-1.7.0-cp310-cp310-win32.whl", hash = "sha256:400ddd24ab4e55014bba442d917203c73b2846391dd42ca5e38ff52bb18c3c5e", size = 39578, upload-time = "2025-06-09T23:00:13.526Z" }, + { url = "https://files.pythonhosted.org/packages/8f/5f/f69818f017fa9a3d24d1ae39763e29b7f60a59e46d5f91b9c6b21622f4cd/frozenlist-1.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:6eb93efb8101ef39d32d50bce242c84bcbddb4f7e9febfa7b524532a239b4464", size = 43830, upload-time = "2025-06-09T23:00:14.98Z" }, + { url = "https://files.pythonhosted.org/packages/34/7e/803dde33760128acd393a27eb002f2020ddb8d99d30a44bfbaab31c5f08a/frozenlist-1.7.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:aa51e147a66b2d74de1e6e2cf5921890de6b0f4820b257465101d7f37b49fb5a", size = 82251, upload-time = "2025-06-09T23:00:16.279Z" }, + { url = "https://files.pythonhosted.org/packages/75/a9/9c2c5760b6ba45eae11334db454c189d43d34a4c0b489feb2175e5e64277/frozenlist-1.7.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9b35db7ce1cd71d36ba24f80f0c9e7cff73a28d7a74e91fe83e23d27c7828750", size = 48183, upload-time = "2025-06-09T23:00:17.698Z" }, + { url = "https://files.pythonhosted.org/packages/47/be/4038e2d869f8a2da165f35a6befb9158c259819be22eeaf9c9a8f6a87771/frozenlist-1.7.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:34a69a85e34ff37791e94542065c8416c1afbf820b68f720452f636d5fb990cd", size = 47107, upload-time = "2025-06-09T23:00:18.952Z" }, + { url = "https://files.pythonhosted.org/packages/79/26/85314b8a83187c76a37183ceed886381a5f992975786f883472fcb6dc5f2/frozenlist-1.7.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a646531fa8d82c87fe4bb2e596f23173caec9185bfbca5d583b4ccfb95183e2", size = 237333, upload-time = "2025-06-09T23:00:20.275Z" }, + { url = "https://files.pythonhosted.org/packages/1f/fd/e5b64f7d2c92a41639ffb2ad44a6a82f347787abc0c7df5f49057cf11770/frozenlist-1.7.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:79b2ffbba483f4ed36a0f236ccb85fbb16e670c9238313709638167670ba235f", size = 231724, upload-time = "2025-06-09T23:00:21.705Z" }, + { url = "https://files.pythonhosted.org/packages/20/fb/03395c0a43a5976af4bf7534759d214405fbbb4c114683f434dfdd3128ef/frozenlist-1.7.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a26f205c9ca5829cbf82bb2a84b5c36f7184c4316617d7ef1b271a56720d6b30", size = 245842, upload-time = "2025-06-09T23:00:23.148Z" }, + { url = "https://files.pythonhosted.org/packages/d0/15/c01c8e1dffdac5d9803507d824f27aed2ba76b6ed0026fab4d9866e82f1f/frozenlist-1.7.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bcacfad3185a623fa11ea0e0634aac7b691aa925d50a440f39b458e41c561d98", size = 239767, upload-time = "2025-06-09T23:00:25.103Z" }, + { url = "https://files.pythonhosted.org/packages/14/99/3f4c6fe882c1f5514b6848aa0a69b20cb5e5d8e8f51a339d48c0e9305ed0/frozenlist-1.7.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:72c1b0fe8fe451b34f12dce46445ddf14bd2a5bcad7e324987194dc8e3a74c86", size = 224130, upload-time = "2025-06-09T23:00:27.061Z" }, + { url = "https://files.pythonhosted.org/packages/4d/83/220a374bd7b2aeba9d0725130665afe11de347d95c3620b9b82cc2fcab97/frozenlist-1.7.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61d1a5baeaac6c0798ff6edfaeaa00e0e412d49946c53fae8d4b8e8b3566c4ae", size = 235301, upload-time = "2025-06-09T23:00:29.02Z" }, + { url = "https://files.pythonhosted.org/packages/03/3c/3e3390d75334a063181625343e8daab61b77e1b8214802cc4e8a1bb678fc/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7edf5c043c062462f09b6820de9854bf28cc6cc5b6714b383149745e287181a8", size = 234606, upload-time = "2025-06-09T23:00:30.514Z" }, + { url = "https://files.pythonhosted.org/packages/23/1e/58232c19608b7a549d72d9903005e2d82488f12554a32de2d5fb59b9b1ba/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:d50ac7627b3a1bd2dcef6f9da89a772694ec04d9a61b66cf87f7d9446b4a0c31", size = 248372, upload-time = "2025-06-09T23:00:31.966Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a4/e4a567e01702a88a74ce8a324691e62a629bf47d4f8607f24bf1c7216e7f/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ce48b2fece5aeb45265bb7a58259f45027db0abff478e3077e12b05b17fb9da7", size = 229860, upload-time = "2025-06-09T23:00:33.375Z" }, + { url = "https://files.pythonhosted.org/packages/73/a6/63b3374f7d22268b41a9db73d68a8233afa30ed164c46107b33c4d18ecdd/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:fe2365ae915a1fafd982c146754e1de6ab3478def8a59c86e1f7242d794f97d5", size = 245893, upload-time = "2025-06-09T23:00:35.002Z" }, + { url = "https://files.pythonhosted.org/packages/6d/eb/d18b3f6e64799a79673c4ba0b45e4cfbe49c240edfd03a68be20002eaeaa/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:45a6f2fdbd10e074e8814eb98b05292f27bad7d1883afbe009d96abdcf3bc898", size = 246323, upload-time = "2025-06-09T23:00:36.468Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f5/720f3812e3d06cd89a1d5db9ff6450088b8f5c449dae8ffb2971a44da506/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:21884e23cffabb157a9dd7e353779077bf5b8f9a58e9b262c6caad2ef5f80a56", size = 233149, upload-time = "2025-06-09T23:00:37.963Z" }, + { url = "https://files.pythonhosted.org/packages/69/68/03efbf545e217d5db8446acfd4c447c15b7c8cf4dbd4a58403111df9322d/frozenlist-1.7.0-cp311-cp311-win32.whl", hash = "sha256:284d233a8953d7b24f9159b8a3496fc1ddc00f4db99c324bd5fb5f22d8698ea7", size = 39565, upload-time = "2025-06-09T23:00:39.753Z" }, + { url = "https://files.pythonhosted.org/packages/58/17/fe61124c5c333ae87f09bb67186d65038834a47d974fc10a5fadb4cc5ae1/frozenlist-1.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:387cbfdcde2f2353f19c2f66bbb52406d06ed77519ac7ee21be0232147c2592d", size = 44019, upload-time = "2025-06-09T23:00:40.988Z" }, + { url = "https://files.pythonhosted.org/packages/ef/a2/c8131383f1e66adad5f6ecfcce383d584ca94055a34d683bbb24ac5f2f1c/frozenlist-1.7.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3dbf9952c4bb0e90e98aec1bd992b3318685005702656bc6f67c1a32b76787f2", size = 81424, upload-time = "2025-06-09T23:00:42.24Z" }, + { url = "https://files.pythonhosted.org/packages/4c/9d/02754159955088cb52567337d1113f945b9e444c4960771ea90eb73de8db/frozenlist-1.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1f5906d3359300b8a9bb194239491122e6cf1444c2efb88865426f170c262cdb", size = 47952, upload-time = "2025-06-09T23:00:43.481Z" }, + { url = "https://files.pythonhosted.org/packages/01/7a/0046ef1bd6699b40acd2067ed6d6670b4db2f425c56980fa21c982c2a9db/frozenlist-1.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3dabd5a8f84573c8d10d8859a50ea2dec01eea372031929871368c09fa103478", size = 46688, upload-time = "2025-06-09T23:00:44.793Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a2/a910bafe29c86997363fb4c02069df4ff0b5bc39d33c5198b4e9dd42d8f8/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa57daa5917f1738064f302bf2626281a1cb01920c32f711fbc7bc36111058a8", size = 243084, upload-time = "2025-06-09T23:00:46.125Z" }, + { url = "https://files.pythonhosted.org/packages/64/3e/5036af9d5031374c64c387469bfcc3af537fc0f5b1187d83a1cf6fab1639/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c193dda2b6d49f4c4398962810fa7d7c78f032bf45572b3e04dd5249dff27e08", size = 233524, upload-time = "2025-06-09T23:00:47.73Z" }, + { url = "https://files.pythonhosted.org/packages/06/39/6a17b7c107a2887e781a48ecf20ad20f1c39d94b2a548c83615b5b879f28/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfe2b675cf0aaa6d61bf8fbffd3c274b3c9b7b1623beb3809df8a81399a4a9c4", size = 248493, upload-time = "2025-06-09T23:00:49.742Z" }, + { url = "https://files.pythonhosted.org/packages/be/00/711d1337c7327d88c44d91dd0f556a1c47fb99afc060ae0ef66b4d24793d/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8fc5d5cda37f62b262405cf9652cf0856839c4be8ee41be0afe8858f17f4c94b", size = 244116, upload-time = "2025-06-09T23:00:51.352Z" }, + { url = "https://files.pythonhosted.org/packages/24/fe/74e6ec0639c115df13d5850e75722750adabdc7de24e37e05a40527ca539/frozenlist-1.7.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0d5ce521d1dd7d620198829b87ea002956e4319002ef0bc8d3e6d045cb4646e", size = 224557, upload-time = "2025-06-09T23:00:52.855Z" }, + { url = "https://files.pythonhosted.org/packages/8d/db/48421f62a6f77c553575201e89048e97198046b793f4a089c79a6e3268bd/frozenlist-1.7.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:488d0a7d6a0008ca0db273c542098a0fa9e7dfaa7e57f70acef43f32b3f69dca", size = 241820, upload-time = "2025-06-09T23:00:54.43Z" }, + { url = "https://files.pythonhosted.org/packages/1d/fa/cb4a76bea23047c8462976ea7b7a2bf53997a0ca171302deae9d6dd12096/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:15a7eaba63983d22c54d255b854e8108e7e5f3e89f647fc854bd77a237e767df", size = 236542, upload-time = "2025-06-09T23:00:56.409Z" }, + { url = "https://files.pythonhosted.org/packages/5d/32/476a4b5cfaa0ec94d3f808f193301debff2ea42288a099afe60757ef6282/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1eaa7e9c6d15df825bf255649e05bd8a74b04a4d2baa1ae46d9c2d00b2ca2cb5", size = 249350, upload-time = "2025-06-09T23:00:58.468Z" }, + { url = "https://files.pythonhosted.org/packages/8d/ba/9a28042f84a6bf8ea5dbc81cfff8eaef18d78b2a1ad9d51c7bc5b029ad16/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e4389e06714cfa9d47ab87f784a7c5be91d3934cd6e9a7b85beef808297cc025", size = 225093, upload-time = "2025-06-09T23:01:00.015Z" }, + { url = "https://files.pythonhosted.org/packages/bc/29/3a32959e68f9cf000b04e79ba574527c17e8842e38c91d68214a37455786/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:73bd45e1488c40b63fe5a7df892baf9e2a4d4bb6409a2b3b78ac1c6236178e01", size = 245482, upload-time = "2025-06-09T23:01:01.474Z" }, + { url = "https://files.pythonhosted.org/packages/80/e8/edf2f9e00da553f07f5fa165325cfc302dead715cab6ac8336a5f3d0adc2/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99886d98e1643269760e5fe0df31e5ae7050788dd288947f7f007209b8c33f08", size = 249590, upload-time = "2025-06-09T23:01:02.961Z" }, + { url = "https://files.pythonhosted.org/packages/1c/80/9a0eb48b944050f94cc51ee1c413eb14a39543cc4f760ed12657a5a3c45a/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:290a172aae5a4c278c6da8a96222e6337744cd9c77313efe33d5670b9f65fc43", size = 237785, upload-time = "2025-06-09T23:01:05.095Z" }, + { url = "https://files.pythonhosted.org/packages/f3/74/87601e0fb0369b7a2baf404ea921769c53b7ae00dee7dcfe5162c8c6dbf0/frozenlist-1.7.0-cp312-cp312-win32.whl", hash = "sha256:426c7bc70e07cfebc178bc4c2bf2d861d720c4fff172181eeb4a4c41d4ca2ad3", size = 39487, upload-time = "2025-06-09T23:01:06.54Z" }, + { url = "https://files.pythonhosted.org/packages/0b/15/c026e9a9fc17585a9d461f65d8593d281fedf55fbf7eb53f16c6df2392f9/frozenlist-1.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:563b72efe5da92e02eb68c59cb37205457c977aa7a449ed1b37e6939e5c47c6a", size = 43874, upload-time = "2025-06-09T23:01:07.752Z" }, + { url = "https://files.pythonhosted.org/packages/24/90/6b2cebdabdbd50367273c20ff6b57a3dfa89bd0762de02c3a1eb42cb6462/frozenlist-1.7.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee80eeda5e2a4e660651370ebffd1286542b67e268aa1ac8d6dbe973120ef7ee", size = 79791, upload-time = "2025-06-09T23:01:09.368Z" }, + { url = "https://files.pythonhosted.org/packages/83/2e/5b70b6a3325363293fe5fc3ae74cdcbc3e996c2a11dde2fd9f1fb0776d19/frozenlist-1.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d1a81c85417b914139e3a9b995d4a1c84559afc839a93cf2cb7f15e6e5f6ed2d", size = 47165, upload-time = "2025-06-09T23:01:10.653Z" }, + { url = "https://files.pythonhosted.org/packages/f4/25/a0895c99270ca6966110f4ad98e87e5662eab416a17e7fd53c364bf8b954/frozenlist-1.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cbb65198a9132ebc334f237d7b0df163e4de83fb4f2bdfe46c1e654bdb0c5d43", size = 45881, upload-time = "2025-06-09T23:01:12.296Z" }, + { url = "https://files.pythonhosted.org/packages/19/7c/71bb0bbe0832793c601fff68cd0cf6143753d0c667f9aec93d3c323f4b55/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dab46c723eeb2c255a64f9dc05b8dd601fde66d6b19cdb82b2e09cc6ff8d8b5d", size = 232409, upload-time = "2025-06-09T23:01:13.641Z" }, + { url = "https://files.pythonhosted.org/packages/c0/45/ed2798718910fe6eb3ba574082aaceff4528e6323f9a8570be0f7028d8e9/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6aeac207a759d0dedd2e40745575ae32ab30926ff4fa49b1635def65806fddee", size = 225132, upload-time = "2025-06-09T23:01:15.264Z" }, + { url = "https://files.pythonhosted.org/packages/ba/e2/8417ae0f8eacb1d071d4950f32f229aa6bf68ab69aab797b72a07ea68d4f/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bd8c4e58ad14b4fa7802b8be49d47993182fdd4023393899632c88fd8cd994eb", size = 237638, upload-time = "2025-06-09T23:01:16.752Z" }, + { url = "https://files.pythonhosted.org/packages/f8/b7/2ace5450ce85f2af05a871b8c8719b341294775a0a6c5585d5e6170f2ce7/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04fb24d104f425da3540ed83cbfc31388a586a7696142004c577fa61c6298c3f", size = 233539, upload-time = "2025-06-09T23:01:18.202Z" }, + { url = "https://files.pythonhosted.org/packages/46/b9/6989292c5539553dba63f3c83dc4598186ab2888f67c0dc1d917e6887db6/frozenlist-1.7.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a5c505156368e4ea6b53b5ac23c92d7edc864537ff911d2fb24c140bb175e60", size = 215646, upload-time = "2025-06-09T23:01:19.649Z" }, + { url = "https://files.pythonhosted.org/packages/72/31/bc8c5c99c7818293458fe745dab4fd5730ff49697ccc82b554eb69f16a24/frozenlist-1.7.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8bd7eb96a675f18aa5c553eb7ddc24a43c8c18f22e1f9925528128c052cdbe00", size = 232233, upload-time = "2025-06-09T23:01:21.175Z" }, + { url = "https://files.pythonhosted.org/packages/59/52/460db4d7ba0811b9ccb85af996019f5d70831f2f5f255f7cc61f86199795/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:05579bf020096fe05a764f1f84cd104a12f78eaab68842d036772dc6d4870b4b", size = 227996, upload-time = "2025-06-09T23:01:23.098Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c9/f4b39e904c03927b7ecf891804fd3b4df3db29b9e487c6418e37988d6e9d/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:376b6222d114e97eeec13d46c486facd41d4f43bab626b7c3f6a8b4e81a5192c", size = 242280, upload-time = "2025-06-09T23:01:24.808Z" }, + { url = "https://files.pythonhosted.org/packages/b8/33/3f8d6ced42f162d743e3517781566b8481322be321b486d9d262adf70bfb/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0aa7e176ebe115379b5b1c95b4096fb1c17cce0847402e227e712c27bdb5a949", size = 217717, upload-time = "2025-06-09T23:01:26.28Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e8/ad683e75da6ccef50d0ab0c2b2324b32f84fc88ceee778ed79b8e2d2fe2e/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3fbba20e662b9c2130dc771e332a99eff5da078b2b2648153a40669a6d0e36ca", size = 236644, upload-time = "2025-06-09T23:01:27.887Z" }, + { url = "https://files.pythonhosted.org/packages/b2/14/8d19ccdd3799310722195a72ac94ddc677541fb4bef4091d8e7775752360/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f3f4410a0a601d349dd406b5713fec59b4cee7e71678d5b17edda7f4655a940b", size = 238879, upload-time = "2025-06-09T23:01:29.524Z" }, + { url = "https://files.pythonhosted.org/packages/ce/13/c12bf657494c2fd1079a48b2db49fa4196325909249a52d8f09bc9123fd7/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e2cdfaaec6a2f9327bf43c933c0319a7c429058e8537c508964a133dffee412e", size = 232502, upload-time = "2025-06-09T23:01:31.287Z" }, + { url = "https://files.pythonhosted.org/packages/d7/8b/e7f9dfde869825489382bc0d512c15e96d3964180c9499efcec72e85db7e/frozenlist-1.7.0-cp313-cp313-win32.whl", hash = "sha256:5fc4df05a6591c7768459caba1b342d9ec23fa16195e744939ba5914596ae3e1", size = 39169, upload-time = "2025-06-09T23:01:35.503Z" }, + { url = "https://files.pythonhosted.org/packages/35/89/a487a98d94205d85745080a37860ff5744b9820a2c9acbcdd9440bfddf98/frozenlist-1.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:52109052b9791a3e6b5d1b65f4b909703984b770694d3eb64fad124c835d7cba", size = 43219, upload-time = "2025-06-09T23:01:36.784Z" }, + { url = "https://files.pythonhosted.org/packages/56/d5/5c4cf2319a49eddd9dd7145e66c4866bdc6f3dbc67ca3d59685149c11e0d/frozenlist-1.7.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a6f86e4193bb0e235ef6ce3dde5cbabed887e0b11f516ce8a0f4d3b33078ec2d", size = 84345, upload-time = "2025-06-09T23:01:38.295Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7d/ec2c1e1dc16b85bc9d526009961953df9cec8481b6886debb36ec9107799/frozenlist-1.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:82d664628865abeb32d90ae497fb93df398a69bb3434463d172b80fc25b0dd7d", size = 48880, upload-time = "2025-06-09T23:01:39.887Z" }, + { url = "https://files.pythonhosted.org/packages/69/86/f9596807b03de126e11e7d42ac91e3d0b19a6599c714a1989a4e85eeefc4/frozenlist-1.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:912a7e8375a1c9a68325a902f3953191b7b292aa3c3fb0d71a216221deca460b", size = 48498, upload-time = "2025-06-09T23:01:41.318Z" }, + { url = "https://files.pythonhosted.org/packages/5e/cb/df6de220f5036001005f2d726b789b2c0b65f2363b104bbc16f5be8084f8/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9537c2777167488d539bc5de2ad262efc44388230e5118868e172dd4a552b146", size = 292296, upload-time = "2025-06-09T23:01:42.685Z" }, + { url = "https://files.pythonhosted.org/packages/83/1f/de84c642f17c8f851a2905cee2dae401e5e0daca9b5ef121e120e19aa825/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f34560fb1b4c3e30ba35fa9a13894ba39e5acfc5f60f57d8accde65f46cc5e74", size = 273103, upload-time = "2025-06-09T23:01:44.166Z" }, + { url = "https://files.pythonhosted.org/packages/88/3c/c840bfa474ba3fa13c772b93070893c6e9d5c0350885760376cbe3b6c1b3/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:acd03d224b0175f5a850edc104ac19040d35419eddad04e7cf2d5986d98427f1", size = 292869, upload-time = "2025-06-09T23:01:45.681Z" }, + { url = "https://files.pythonhosted.org/packages/a6/1c/3efa6e7d5a39a1d5ef0abeb51c48fb657765794a46cf124e5aca2c7a592c/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2038310bc582f3d6a09b3816ab01737d60bf7b1ec70f5356b09e84fb7408ab1", size = 291467, upload-time = "2025-06-09T23:01:47.234Z" }, + { url = "https://files.pythonhosted.org/packages/4f/00/d5c5e09d4922c395e2f2f6b79b9a20dab4b67daaf78ab92e7729341f61f6/frozenlist-1.7.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b8c05e4c8e5f36e5e088caa1bf78a687528f83c043706640a92cb76cd6999384", size = 266028, upload-time = "2025-06-09T23:01:48.819Z" }, + { url = "https://files.pythonhosted.org/packages/4e/27/72765be905619dfde25a7f33813ac0341eb6b076abede17a2e3fbfade0cb/frozenlist-1.7.0-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:765bb588c86e47d0b68f23c1bee323d4b703218037765dcf3f25c838c6fecceb", size = 284294, upload-time = "2025-06-09T23:01:50.394Z" }, + { url = "https://files.pythonhosted.org/packages/88/67/c94103a23001b17808eb7dd1200c156bb69fb68e63fcf0693dde4cd6228c/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:32dc2e08c67d86d0969714dd484fd60ff08ff81d1a1e40a77dd34a387e6ebc0c", size = 281898, upload-time = "2025-06-09T23:01:52.234Z" }, + { url = "https://files.pythonhosted.org/packages/42/34/a3e2c00c00f9e2a9db5653bca3fec306349e71aff14ae45ecc6d0951dd24/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:c0303e597eb5a5321b4de9c68e9845ac8f290d2ab3f3e2c864437d3c5a30cd65", size = 290465, upload-time = "2025-06-09T23:01:53.788Z" }, + { url = "https://files.pythonhosted.org/packages/bb/73/f89b7fbce8b0b0c095d82b008afd0590f71ccb3dee6eee41791cf8cd25fd/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:a47f2abb4e29b3a8d0b530f7c3598badc6b134562b1a5caee867f7c62fee51e3", size = 266385, upload-time = "2025-06-09T23:01:55.769Z" }, + { url = "https://files.pythonhosted.org/packages/cd/45/e365fdb554159462ca12df54bc59bfa7a9a273ecc21e99e72e597564d1ae/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:3d688126c242a6fabbd92e02633414d40f50bb6002fa4cf995a1d18051525657", size = 288771, upload-time = "2025-06-09T23:01:57.4Z" }, + { url = "https://files.pythonhosted.org/packages/00/11/47b6117002a0e904f004d70ec5194fe9144f117c33c851e3d51c765962d0/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:4e7e9652b3d367c7bd449a727dc79d5043f48b88d0cbfd4f9f1060cf2b414104", size = 288206, upload-time = "2025-06-09T23:01:58.936Z" }, + { url = "https://files.pythonhosted.org/packages/40/37/5f9f3c3fd7f7746082ec67bcdc204db72dad081f4f83a503d33220a92973/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:1a85e345b4c43db8b842cab1feb41be5cc0b10a1830e6295b69d7310f99becaf", size = 282620, upload-time = "2025-06-09T23:02:00.493Z" }, + { url = "https://files.pythonhosted.org/packages/0b/31/8fbc5af2d183bff20f21aa743b4088eac4445d2bb1cdece449ae80e4e2d1/frozenlist-1.7.0-cp313-cp313t-win32.whl", hash = "sha256:3a14027124ddb70dfcee5148979998066897e79f89f64b13328595c4bdf77c81", size = 43059, upload-time = "2025-06-09T23:02:02.072Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ed/41956f52105b8dbc26e457c5705340c67c8cc2b79f394b79bffc09d0e938/frozenlist-1.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3bf8010d71d4507775f658e9823210b7427be36625b387221642725b515dcf3e", size = 47516, upload-time = "2025-06-09T23:02:03.779Z" }, + { url = "https://files.pythonhosted.org/packages/dd/b1/ee59496f51cd244039330015d60f13ce5a54a0f2bd8d79e4a4a375ab7469/frozenlist-1.7.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:cea3dbd15aea1341ea2de490574a4a37ca080b2ae24e4b4f4b51b9057b4c3630", size = 82434, upload-time = "2025-06-09T23:02:05.195Z" }, + { url = "https://files.pythonhosted.org/packages/75/e1/d518391ce36a6279b3fa5bc14327dde80bcb646bb50d059c6ca0756b8d05/frozenlist-1.7.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7d536ee086b23fecc36c2073c371572374ff50ef4db515e4e503925361c24f71", size = 48232, upload-time = "2025-06-09T23:02:07.728Z" }, + { url = "https://files.pythonhosted.org/packages/b7/8d/a0d04f28b6e821a9685c22e67b5fb798a5a7b68752f104bfbc2dccf080c4/frozenlist-1.7.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:dfcebf56f703cb2e346315431699f00db126d158455e513bd14089d992101e44", size = 47186, upload-time = "2025-06-09T23:02:09.243Z" }, + { url = "https://files.pythonhosted.org/packages/93/3a/a5334c0535c8b7c78eeabda1579179e44fe3d644e07118e59a2276dedaf1/frozenlist-1.7.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:974c5336e61d6e7eb1ea5b929cb645e882aadab0095c5a6974a111e6479f8878", size = 226617, upload-time = "2025-06-09T23:02:10.949Z" }, + { url = "https://files.pythonhosted.org/packages/0a/67/8258d971f519dc3f278c55069a775096cda6610a267b53f6248152b72b2f/frozenlist-1.7.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c70db4a0ab5ab20878432c40563573229a7ed9241506181bba12f6b7d0dc41cb", size = 224179, upload-time = "2025-06-09T23:02:12.603Z" }, + { url = "https://files.pythonhosted.org/packages/fc/89/8225905bf889b97c6d935dd3aeb45668461e59d415cb019619383a8a7c3b/frozenlist-1.7.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1137b78384eebaf70560a36b7b229f752fb64d463d38d1304939984d5cb887b6", size = 235783, upload-time = "2025-06-09T23:02:14.678Z" }, + { url = "https://files.pythonhosted.org/packages/54/6e/ef52375aa93d4bc510d061df06205fa6dcfd94cd631dd22956b09128f0d4/frozenlist-1.7.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e793a9f01b3e8b5c0bc646fb59140ce0efcc580d22a3468d70766091beb81b35", size = 229210, upload-time = "2025-06-09T23:02:16.313Z" }, + { url = "https://files.pythonhosted.org/packages/ee/55/62c87d1a6547bfbcd645df10432c129100c5bd0fd92a384de6e3378b07c1/frozenlist-1.7.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:74739ba8e4e38221d2c5c03d90a7e542cb8ad681915f4ca8f68d04f810ee0a87", size = 215994, upload-time = "2025-06-09T23:02:17.9Z" }, + { url = "https://files.pythonhosted.org/packages/45/d2/263fea1f658b8ad648c7d94d18a87bca7e8c67bd6a1bbf5445b1bd5b158c/frozenlist-1.7.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1e63344c4e929b1a01e29bc184bbb5fd82954869033765bfe8d65d09e336a677", size = 225122, upload-time = "2025-06-09T23:02:19.479Z" }, + { url = "https://files.pythonhosted.org/packages/7b/22/7145e35d12fb368d92124f679bea87309495e2e9ddf14c6533990cb69218/frozenlist-1.7.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2ea2a7369eb76de2217a842f22087913cdf75f63cf1307b9024ab82dfb525938", size = 224019, upload-time = "2025-06-09T23:02:20.969Z" }, + { url = "https://files.pythonhosted.org/packages/44/1e/7dae8c54301beb87bcafc6144b9a103bfd2c8f38078c7902984c9a0c4e5b/frozenlist-1.7.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:836b42f472a0e006e02499cef9352ce8097f33df43baaba3e0a28a964c26c7d2", size = 239925, upload-time = "2025-06-09T23:02:22.466Z" }, + { url = "https://files.pythonhosted.org/packages/4b/1e/99c93e54aa382e949a98976a73b9b20c3aae6d9d893f31bbe4991f64e3a8/frozenlist-1.7.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:e22b9a99741294b2571667c07d9f8cceec07cb92aae5ccda39ea1b6052ed4319", size = 220881, upload-time = "2025-06-09T23:02:24.521Z" }, + { url = "https://files.pythonhosted.org/packages/5e/9c/ca5105fa7fb5abdfa8837581be790447ae051da75d32f25c8f81082ffc45/frozenlist-1.7.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:9a19e85cc503d958abe5218953df722748d87172f71b73cf3c9257a91b999890", size = 234046, upload-time = "2025-06-09T23:02:26.206Z" }, + { url = "https://files.pythonhosted.org/packages/8d/4d/e99014756093b4ddbb67fb8f0df11fe7a415760d69ace98e2ac6d5d43402/frozenlist-1.7.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:f22dac33bb3ee8fe3e013aa7b91dc12f60d61d05b7fe32191ffa84c3aafe77bd", size = 235756, upload-time = "2025-06-09T23:02:27.79Z" }, + { url = "https://files.pythonhosted.org/packages/8b/72/a19a40bcdaa28a51add2aaa3a1a294ec357f36f27bd836a012e070c5e8a5/frozenlist-1.7.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9ccec739a99e4ccf664ea0775149f2749b8a6418eb5b8384b4dc0a7d15d304cb", size = 222894, upload-time = "2025-06-09T23:02:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/08/49/0042469993e023a758af81db68c76907cd29e847d772334d4d201cbe9a42/frozenlist-1.7.0-cp39-cp39-win32.whl", hash = "sha256:b3950f11058310008a87757f3eee16a8e1ca97979833239439586857bc25482e", size = 39848, upload-time = "2025-06-09T23:02:31.413Z" }, + { url = "https://files.pythonhosted.org/packages/5a/45/827d86ee475c877f5f766fbc23fb6acb6fada9e52f1c9720e2ba3eae32da/frozenlist-1.7.0-cp39-cp39-win_amd64.whl", hash = "sha256:43a82fce6769c70f2f5a06248b614a7d268080a9d20f7457ef10ecee5af82b63", size = 44102, upload-time = "2025-06-09T23:02:32.808Z" }, + { url = "https://files.pythonhosted.org/packages/ee/45/b82e3c16be2182bff01179db177fe144d58b5dc787a7d4492c6ed8b9317f/frozenlist-1.7.0-py3-none-any.whl", hash = "sha256:9a5af342e34f7e97caf8c995864c7a396418ae2859cc6fdf1b1073020d516a7e", size = 13106, upload-time = "2025-06-09T23:02:34.204Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "html5lib" +version = "1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, + { name = "webencodings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/b6/b55c3f49042f1df3dcd422b7f224f939892ee94f22abcf503a9b7339eaf2/html5lib-1.1.tar.gz", hash = "sha256:b2e5b40261e20f354d198eae92afc10d750afb487ed5e50f9c4eaf07c184146f", size = 272215, upload-time = "2020-06-22T23:32:38.834Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/dd/a834df6482147d48e225a49515aabc28974ad5a4ca3215c18a882565b028/html5lib-1.1-py2.py3-none-any.whl", hash = "sha256:0d78f8fde1c230e99fe37986a60526d7049ed4bf8a9fadbad5f00e22e58e041d", size = 112173, upload-time = "2020-06-22T23:32:36.781Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "identify" +version = "2.6.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/88/d193a27416618628a5eea64e3223acd800b40749a96ffb322a9b55a49ed1/identify-2.6.12.tar.gz", hash = "sha256:d8de45749f1efb108badef65ee8386f0f7bb19a7f26185f74de6367bffbaf0e6", size = 99254, upload-time = "2025-05-23T20:37:53.3Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/cd/18f8da995b658420625f7ef13f037be53ae04ec5ad33f9b718240dcfd48c/identify-2.6.12-py2.py3-none-any.whl", hash = "sha256:ad9672d5a72e0d2ff7c5c8809b62dfa60458626352fb0eb7b55e69bdc45334a2", size = 99145, upload-time = "2025-05-23T20:37:51.495Z" }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, +] + +[[package]] +name = "ijson" +version = "3.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/83/28e9e93a3a61913e334e3a2e78ea9924bb9f9b1ac45898977f9d9dd6133f/ijson-3.3.0.tar.gz", hash = "sha256:7f172e6ba1bee0d4c8f8ebd639577bfe429dee0f3f96775a067b8bae4492d8a0", size = 60079, upload-time = "2024-06-06T08:37:13.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/89/96e3608499b4a500b9bc27aa8242704e675849dd65bdfa8682b00a92477e/ijson-3.3.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7f7a5250599c366369fbf3bc4e176f5daa28eb6bc7d6130d02462ed335361675", size = 85009, upload-time = "2024-06-06T08:34:37.172Z" }, + { url = "https://files.pythonhosted.org/packages/e4/7e/1098503500f5316c5f7912a51c91aca5cbc609c09ce4ecd9c4809983c560/ijson-3.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f87a7e52f79059f9c58f6886c262061065eb6f7554a587be7ed3aa63e6b71b34", size = 57796, upload-time = "2024-06-06T08:34:39.35Z" }, + { url = "https://files.pythonhosted.org/packages/78/f7/27b8c27a285628719ff55b68507581c86b551eb162ce810fe51e3e1a25f2/ijson-3.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b73b493af9e947caed75d329676b1b801d673b17481962823a3e55fe529c8b8b", size = 57218, upload-time = "2024-06-06T08:34:41.651Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c5/1698094cb6a336a223c30e1167cc1b15cdb4bfa75399c1a2eb82fa76cc3c/ijson-3.3.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d5576415f3d76290b160aa093ff968f8bf6de7d681e16e463a0134106b506f49", size = 117153, upload-time = "2024-06-06T08:34:43.463Z" }, + { url = "https://files.pythonhosted.org/packages/4b/21/c206dda0945bd832cc9b0894596b0efc2cb1819a0ac61d8be1429ac09494/ijson-3.3.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4e9ffe358d5fdd6b878a8a364e96e15ca7ca57b92a48f588378cef315a8b019e", size = 110781, upload-time = "2024-06-06T08:34:45.412Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f5/2d733e64577109a9b255d14d031e44a801fa20df9ccc58b54a31e8ecf9e6/ijson-3.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8643c255a25824ddd0895c59f2319c019e13e949dc37162f876c41a283361527", size = 114527, upload-time = "2024-06-06T08:34:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a8/78bfee312aa23417b86189a65f30b0edbceaee96dc6a616cc15f611187d1/ijson-3.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:df3ab5e078cab19f7eaeef1d5f063103e1ebf8c26d059767b26a6a0ad8b250a3", size = 116824, upload-time = "2024-06-06T08:34:48.471Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a4/aff410f7d6aa1a77ee2ab2d6a2d2758422726270cb149c908a9baf33cf58/ijson-3.3.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3dc1fb02c6ed0bae1b4bf96971258bf88aea72051b6e4cebae97cff7090c0607", size = 112647, upload-time = "2024-06-06T08:34:50.339Z" }, + { url = "https://files.pythonhosted.org/packages/77/ee/2b5122dc4713f5a954267147da36e7156240ca21b04ed5295bc0cabf0fbe/ijson-3.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e9afd97339fc5a20f0542c971f90f3ca97e73d3050cdc488d540b63fae45329a", size = 114156, upload-time = "2024-06-06T08:34:51.598Z" }, + { url = "https://files.pythonhosted.org/packages/b3/d7/ad3b266490b60c6939e8a07fd8e4b7e2002aea08eaa9572a016c3e3a9129/ijson-3.3.0-cp310-cp310-win32.whl", hash = "sha256:844c0d1c04c40fd1b60f148dc829d3f69b2de789d0ba239c35136efe9a386529", size = 48931, upload-time = "2024-06-06T08:34:53.995Z" }, + { url = "https://files.pythonhosted.org/packages/0b/68/b9e1c743274c8a23dddb12d2ed13b5f021f6d21669d51ff7fa2e9e6c19df/ijson-3.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:d654d045adafdcc6c100e8e911508a2eedbd2a1b5f93f930ba13ea67d7704ee9", size = 50965, upload-time = "2024-06-06T08:34:55.206Z" }, + { url = "https://files.pythonhosted.org/packages/fd/df/565ba72a6f4b2c833d051af8e2228cfa0b1fef17bb44995c00ad27470c52/ijson-3.3.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:501dce8eaa537e728aa35810656aa00460a2547dcb60937c8139f36ec344d7fc", size = 85041, upload-time = "2024-06-06T08:34:56.479Z" }, + { url = "https://files.pythonhosted.org/packages/f0/42/1361eaa57ece921d0239881bae6a5e102333be5b6e0102a05ec3caadbd5a/ijson-3.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:658ba9cad0374d37b38c9893f4864f284cdcc7d32041f9808fba8c7bcaadf134", size = 57829, upload-time = "2024-06-06T08:34:57.632Z" }, + { url = "https://files.pythonhosted.org/packages/f5/b0/143dbfe12e1d1303ea8d8cd6f40e95cea8f03bcad5b79708614a7856c22e/ijson-3.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2636cb8c0f1023ef16173f4b9a233bcdb1df11c400c603d5f299fac143ca8d70", size = 57217, upload-time = "2024-06-06T08:34:59.397Z" }, + { url = "https://files.pythonhosted.org/packages/0d/80/b3b60c5e5be2839365b03b915718ca462c544fdc71e7a79b7262837995ef/ijson-3.3.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cd174b90db68c3bcca273e9391934a25d76929d727dc75224bf244446b28b03b", size = 121878, upload-time = "2024-06-06T08:35:01.024Z" }, + { url = "https://files.pythonhosted.org/packages/8d/eb/7560fafa4d40412efddf690cb65a9bf2d3429d6035e544103acbf5561dc4/ijson-3.3.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:97a9aea46e2a8371c4cf5386d881de833ed782901ac9f67ebcb63bb3b7d115af", size = 115620, upload-time = "2024-06-06T08:35:02.896Z" }, + { url = "https://files.pythonhosted.org/packages/51/2b/5a34c7841388dce161966e5286931518de832067cd83e6f003d93271e324/ijson-3.3.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c594c0abe69d9d6099f4ece17763d53072f65ba60b372d8ba6de8695ce6ee39e", size = 119200, upload-time = "2024-06-06T08:35:06.291Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b7/1d64fbec0d0a7b0c02e9ad988a89614532028ead8bb52a2456c92e6ee35a/ijson-3.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8e0ff16c224d9bfe4e9e6bd0395826096cda4a3ef51e6c301e1b61007ee2bd24", size = 121107, upload-time = "2024-06-06T08:35:08.261Z" }, + { url = "https://files.pythonhosted.org/packages/d4/b9/01044f09850bc545ffc85b35aaec473d4f4ca2b6667299033d252c1b60dd/ijson-3.3.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0015354011303175eae7e2ef5136414e91de2298e5a2e9580ed100b728c07e51", size = 116658, upload-time = "2024-06-06T08:35:09.99Z" }, + { url = "https://files.pythonhosted.org/packages/fb/0d/53856b61f3d952d299d1695c487e8e28058d01fa2adfba3d6d4b4660c242/ijson-3.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:034642558afa57351a0ffe6de89e63907c4cf6849070cc10a3b2542dccda1afe", size = 118186, upload-time = "2024-06-06T08:35:11.561Z" }, + { url = "https://files.pythonhosted.org/packages/95/2d/5bd86e2307dd594840ee51c4e32de953fee837f028acf0f6afb08914cd06/ijson-3.3.0-cp311-cp311-win32.whl", hash = "sha256:192e4b65495978b0bce0c78e859d14772e841724d3269fc1667dc6d2f53cc0ea", size = 48938, upload-time = "2024-06-06T08:35:13.212Z" }, + { url = "https://files.pythonhosted.org/packages/55/e1/4ba2b65b87f67fb19d698984d92635e46d9ce9dd748ce7d009441a586710/ijson-3.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:72e3488453754bdb45c878e31ce557ea87e1eb0f8b4fc610373da35e8074ce42", size = 50972, upload-time = "2024-06-06T08:35:14.698Z" }, + { url = "https://files.pythonhosted.org/packages/8a/4d/3992f7383e26a950e02dc704bc6c5786a080d5c25fe0fc5543ef477c1883/ijson-3.3.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:988e959f2f3d59ebd9c2962ae71b97c0df58323910d0b368cc190ad07429d1bb", size = 84550, upload-time = "2024-06-06T08:35:16.756Z" }, + { url = "https://files.pythonhosted.org/packages/1b/cc/3d4372e0d0b02a821b982f1fdf10385512dae9b9443c1597719dd37769a9/ijson-3.3.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b2f73f0d0fce5300f23a1383d19b44d103bb113b57a69c36fd95b7c03099b181", size = 57572, upload-time = "2024-06-06T08:35:18.077Z" }, + { url = "https://files.pythonhosted.org/packages/02/de/970d48b1ff9da5d9513c86fdd2acef5cb3415541c8069e0d92a151b84adb/ijson-3.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0ee57a28c6bf523d7cb0513096e4eb4dac16cd935695049de7608ec110c2b751", size = 56902, upload-time = "2024-06-06T08:35:20.065Z" }, + { url = "https://files.pythonhosted.org/packages/5e/a0/4537722c8b3b05e82c23dfe09a3a64dd1e44a013a5ca58b1e77dfe48b2f1/ijson-3.3.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e0155a8f079c688c2ccaea05de1ad69877995c547ba3d3612c1c336edc12a3a5", size = 127400, upload-time = "2024-06-06T08:35:21.81Z" }, + { url = "https://files.pythonhosted.org/packages/b2/96/54956062a99cf49f7a7064b573dcd756da0563ce57910dc34e27a473d9b9/ijson-3.3.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7ab00721304af1ae1afa4313ecfa1bf16b07f55ef91e4a5b93aeaa3e2bd7917c", size = 118786, upload-time = "2024-06-06T08:35:23.496Z" }, + { url = "https://files.pythonhosted.org/packages/07/74/795319531c5b5504508f595e631d592957f24bed7ff51a15bc4c61e7b24c/ijson-3.3.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40ee3821ee90be0f0e95dcf9862d786a7439bd1113e370736bfdf197e9765bfb", size = 126288, upload-time = "2024-06-06T08:35:25.473Z" }, + { url = "https://files.pythonhosted.org/packages/69/6a/e0cec06fbd98851d5d233b59058c1dc2ea767c9bb6feca41aa9164fff769/ijson-3.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:da3b6987a0bc3e6d0f721b42c7a0198ef897ae50579547b0345f7f02486898f5", size = 129569, upload-time = "2024-06-06T08:35:26.871Z" }, + { url = "https://files.pythonhosted.org/packages/2a/4f/82c0d896d8dcb175f99ced7d87705057bcd13523998b48a629b90139a0dc/ijson-3.3.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:63afea5f2d50d931feb20dcc50954e23cef4127606cc0ecf7a27128ed9f9a9e6", size = 121508, upload-time = "2024-06-06T08:35:28.236Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b6/8973474eba4a917885e289d9e138267d3d1f052c2d93b8c968755661a42d/ijson-3.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b5c3e285e0735fd8c5a26d177eca8b52512cdd8687ca86ec77a0c66e9c510182", size = 127896, upload-time = "2024-06-06T08:35:29.61Z" }, + { url = "https://files.pythonhosted.org/packages/94/25/00e66af887adbbe70002e0479c3c2340bdfa17a168e25d4ab5a27b53582d/ijson-3.3.0-cp312-cp312-win32.whl", hash = "sha256:907f3a8674e489abdcb0206723e5560a5cb1fa42470dcc637942d7b10f28b695", size = 49272, upload-time = "2024-06-06T08:35:31.137Z" }, + { url = "https://files.pythonhosted.org/packages/25/a2/e187beee237808b2c417109ae0f4f7ee7c81ecbe9706305d6ac2a509cc45/ijson-3.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:8f890d04ad33262d0c77ead53c85f13abfb82f2c8f078dfbf24b78f59534dfdd", size = 51272, upload-time = "2024-06-06T08:35:32.38Z" }, + { url = "https://files.pythonhosted.org/packages/43/ba/d7a3259db956332f17ba93be2980db020e10c1bd01f610ff7d980b281fbd/ijson-3.3.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:3c556f5553368dff690c11d0a1fb435d4ff1f84382d904ccc2dc53beb27ba62e", size = 85069, upload-time = "2024-06-06T08:36:22.352Z" }, + { url = "https://files.pythonhosted.org/packages/a4/79/97b47b9110fc5ef92d004e615526de6d16af436e7374098004fa79242440/ijson-3.3.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e4396b55a364a03ff7e71a34828c3ed0c506814dd1f50e16ebed3fc447d5188e", size = 57818, upload-time = "2024-06-06T08:36:24.054Z" }, + { url = "https://files.pythonhosted.org/packages/9d/e7/69ddad6389f4d96c095e89c80b765189facfa2cb51f72f3b6fdfe4dcb815/ijson-3.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e6850ae33529d1e43791b30575070670070d5fe007c37f5d06aebc1dd152ab3f", size = 57228, upload-time = "2024-06-06T08:36:25.561Z" }, + { url = "https://files.pythonhosted.org/packages/88/84/ba713c8e4f13b0642d7295cc94924fb21e9f26c1fbf71d47fe16f03904f6/ijson-3.3.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:36aa56d68ea8def26778eb21576ae13f27b4a47263a7a2581ab2ef58b8de4451", size = 116369, upload-time = "2024-06-06T08:36:27.355Z" }, + { url = "https://files.pythonhosted.org/packages/a0/27/ed16f80f7be403f2e4892b1c5eecf18c5bff57cbb23c4b059b9eb0b369cc/ijson-3.3.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7ec759c4a0fc820ad5dc6a58e9c391e7b16edcb618056baedbedbb9ea3b1524", size = 109994, upload-time = "2024-06-06T08:36:29.319Z" }, + { url = "https://files.pythonhosted.org/packages/5d/90/5071a6f491663d3bf1f4f59acfc6d29ea0e0d1aa13a16f06f03fcc4f3497/ijson-3.3.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b51bab2c4e545dde93cb6d6bb34bf63300b7cd06716f195dd92d9255df728331", size = 113745, upload-time = "2024-06-06T08:36:30.75Z" }, + { url = "https://files.pythonhosted.org/packages/de/e3/e39b7a24c156a5d70c39ffb8383231593e549d2e42dda834758f3934fea8/ijson-3.3.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:92355f95a0e4da96d4c404aa3cff2ff033f9180a9515f813255e1526551298c1", size = 115930, upload-time = "2024-06-06T08:36:32.303Z" }, + { url = "https://files.pythonhosted.org/packages/f3/7a/cd669bf1c65b6b99f4d326e425ef89c02abe62abc36c134e021d8193ecfd/ijson-3.3.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:8795e88adff5aa3c248c1edce932db003d37a623b5787669ccf205c422b91e4a", size = 111869, upload-time = "2024-06-06T08:36:34.658Z" }, + { url = "https://files.pythonhosted.org/packages/dd/34/69074a83f3769f527c81952c002ae55e7c43814d1fb71621ada79f2e57b7/ijson-3.3.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:8f83f553f4cde6d3d4eaf58ec11c939c94a0ec545c5b287461cafb184f4b3a14", size = 113322, upload-time = "2024-06-06T08:36:36.603Z" }, + { url = "https://files.pythonhosted.org/packages/e3/d8/2762aac7d749ed443a7c3e25ad071fe143f21ea5f3f33e184e2cf8026c86/ijson-3.3.0-cp39-cp39-win32.whl", hash = "sha256:ead50635fb56577c07eff3e557dac39533e0fe603000684eea2af3ed1ad8f941", size = 48961, upload-time = "2024-06-06T08:36:38.009Z" }, + { url = "https://files.pythonhosted.org/packages/b0/9a/16a68841edea8168a58b200d7b46a7670349ecd35a75bcb96fd84092f603/ijson-3.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:c8a9befb0c0369f0cf5c1b94178d0d78f66d9cebb9265b36be6e4f66236076b8", size = 50985, upload-time = "2024-06-06T08:36:39.369Z" }, + { url = "https://files.pythonhosted.org/packages/c3/28/2e1cf00abe5d97aef074e7835b86a94c9a06be4629a0e2c12600792b51ba/ijson-3.3.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:2af323a8aec8a50fa9effa6d640691a30a9f8c4925bd5364a1ca97f1ac6b9b5c", size = 54308, upload-time = "2024-06-06T08:36:41.127Z" }, + { url = "https://files.pythonhosted.org/packages/04/d2/8c541c28da4f931bac8177e251efe2b6902f7c486d2d4bdd669eed4ff5c0/ijson-3.3.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f64f01795119880023ba3ce43072283a393f0b90f52b66cc0ea1a89aa64a9ccb", size = 66010, upload-time = "2024-06-06T08:36:43.079Z" }, + { url = "https://files.pythonhosted.org/packages/d0/02/8fec0b9037a368811dba7901035e8e0973ebda308f57f30c42101a16a5f7/ijson-3.3.0-pp310-pypy310_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a716e05547a39b788deaf22725490855337fc36613288aa8ae1601dc8c525553", size = 66770, upload-time = "2024-06-06T08:36:44.468Z" }, + { url = "https://files.pythonhosted.org/packages/47/23/90c61f978c83647112460047ea0137bde9c7fe26600ce255bb3e17ea7a21/ijson-3.3.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:473f5d921fadc135d1ad698e2697025045cd8ed7e5e842258295012d8a3bc702", size = 64159, upload-time = "2024-06-06T08:36:45.887Z" }, + { url = "https://files.pythonhosted.org/packages/20/af/aab1a36072590af62d848f03981f1c587ca40a391fc61e418e388d8b0d46/ijson-3.3.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:dd26b396bc3a1e85f4acebeadbf627fa6117b97f4c10b177d5779577c6607744", size = 51095, upload-time = "2024-06-06T08:36:47.414Z" }, + { url = "https://files.pythonhosted.org/packages/ee/38/7e1988ff3b6eb4fc9f3639ac7bbb7ae3a37d574f212635e3bf0106b6d78d/ijson-3.3.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:891f95c036df1bc95309951940f8eea8537f102fa65715cdc5aae20b8523813b", size = 54336, upload-time = "2024-06-06T08:37:04.942Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8d/556e94b4f7e0c68a35597036ad9329b3edadfc6da260c749e2b55b310798/ijson-3.3.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed1336a2a6e5c427f419da0154e775834abcbc8ddd703004108121c6dd9eba9d", size = 66028, upload-time = "2024-06-06T08:37:06.648Z" }, + { url = "https://files.pythonhosted.org/packages/ba/bb/3ef5b0298e8e4524ed9aa338ec224cb159b5f9b8cace05be3a6c5c01bd10/ijson-3.3.0-pp39-pypy39_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f0c819f83e4f7b7f7463b2dc10d626a8be0c85fbc7b3db0edc098c2b16ac968e", size = 66796, upload-time = "2024-06-06T08:37:08.104Z" }, + { url = "https://files.pythonhosted.org/packages/2e/c1/d1507639ad7a9f1673a16a6e0993524a65d85e4f65cde1097039c3dfdaba/ijson-3.3.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33afc25057377a6a43c892de34d229a86f89ea6c4ca3dd3db0dcd17becae0dbb", size = 64215, upload-time = "2024-06-06T08:37:09.81Z" }, + { url = "https://files.pythonhosted.org/packages/1b/36/92ea416ff6383e66d83a576347b7edd9b0aa22cd3bd16c42dbb3608a105b/ijson-3.3.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7914d0cf083471856e9bc2001102a20f08e82311dfc8cf1a91aa422f9414a0d6", size = 51107, upload-time = "2024-06-06T08:37:11.494Z" }, +] + +[[package]] +name = "imagesize" +version = "1.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/84/62473fb57d61e31fef6e36d64a179c8781605429fd927b5dd608c997be31/imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a", size = 1280026, upload-time = "2022-07-01T12:21:05.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/62/85c4c919272577931d407be5ba5d71c20f0b616d31a0befe0ae45bb79abd/imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b", size = 8769, upload-time = "2022-07-01T12:21:02.467Z" }, +] + +[[package]] +name = "importlib-metadata" +version = "8.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/66/650a33bd90f786193e4de4b3ad86ea60b53c89b669a5c7be931fac31cdb0/importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000", size = 56641, upload-time = "2025-04-27T15:29:01.736Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/b0/36bd937216ec521246249be3bf9855081de4c5e06a0c9b4219dbeda50373/importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd", size = 27656, upload-time = "2025-04-27T15:29:00.214Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537, upload-time = "2024-10-18T15:21:54.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/90/d08277ce111dd22f77149fd1a5d4653eeb3b3eaacbdfcbae5afb2600eebd/MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8", size = 14357, upload-time = "2024-10-18T15:20:51.44Z" }, + { url = "https://files.pythonhosted.org/packages/04/e1/6e2194baeae0bca1fae6629dc0cbbb968d4d941469cbab11a3872edff374/MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158", size = 12393, upload-time = "2024-10-18T15:20:52.426Z" }, + { url = "https://files.pythonhosted.org/packages/1d/69/35fa85a8ece0a437493dc61ce0bb6d459dcba482c34197e3efc829aa357f/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579", size = 21732, upload-time = "2024-10-18T15:20:53.578Z" }, + { url = "https://files.pythonhosted.org/packages/22/35/137da042dfb4720b638d2937c38a9c2df83fe32d20e8c8f3185dbfef05f7/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d", size = 20866, upload-time = "2024-10-18T15:20:55.06Z" }, + { url = "https://files.pythonhosted.org/packages/29/28/6d029a903727a1b62edb51863232152fd335d602def598dade38996887f0/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb", size = 20964, upload-time = "2024-10-18T15:20:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/cc/cd/07438f95f83e8bc028279909d9c9bd39e24149b0d60053a97b2bc4f8aa51/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b", size = 21977, upload-time = "2024-10-18T15:20:57.189Z" }, + { url = "https://files.pythonhosted.org/packages/29/01/84b57395b4cc062f9c4c55ce0df7d3108ca32397299d9df00fedd9117d3d/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c", size = 21366, upload-time = "2024-10-18T15:20:58.235Z" }, + { url = "https://files.pythonhosted.org/packages/bd/6e/61ebf08d8940553afff20d1fb1ba7294b6f8d279df9fd0c0db911b4bbcfd/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171", size = 21091, upload-time = "2024-10-18T15:20:59.235Z" }, + { url = "https://files.pythonhosted.org/packages/11/23/ffbf53694e8c94ebd1e7e491de185124277964344733c45481f32ede2499/MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50", size = 15065, upload-time = "2024-10-18T15:21:00.307Z" }, + { url = "https://files.pythonhosted.org/packages/44/06/e7175d06dd6e9172d4a69a72592cb3f7a996a9c396eee29082826449bbc3/MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a", size = 15514, upload-time = "2024-10-18T15:21:01.122Z" }, + { url = "https://files.pythonhosted.org/packages/6b/28/bbf83e3f76936960b850435576dd5e67034e200469571be53f69174a2dfd/MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d", size = 14353, upload-time = "2024-10-18T15:21:02.187Z" }, + { url = "https://files.pythonhosted.org/packages/6c/30/316d194b093cde57d448a4c3209f22e3046c5bb2fb0820b118292b334be7/MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93", size = 12392, upload-time = "2024-10-18T15:21:02.941Z" }, + { url = "https://files.pythonhosted.org/packages/f2/96/9cdafba8445d3a53cae530aaf83c38ec64c4d5427d975c974084af5bc5d2/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832", size = 23984, upload-time = "2024-10-18T15:21:03.953Z" }, + { url = "https://files.pythonhosted.org/packages/f1/a4/aefb044a2cd8d7334c8a47d3fb2c9f328ac48cb349468cc31c20b539305f/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84", size = 23120, upload-time = "2024-10-18T15:21:06.495Z" }, + { url = "https://files.pythonhosted.org/packages/8d/21/5e4851379f88f3fad1de30361db501300d4f07bcad047d3cb0449fc51f8c/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca", size = 23032, upload-time = "2024-10-18T15:21:07.295Z" }, + { url = "https://files.pythonhosted.org/packages/00/7b/e92c64e079b2d0d7ddf69899c98842f3f9a60a1ae72657c89ce2655c999d/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798", size = 24057, upload-time = "2024-10-18T15:21:08.073Z" }, + { url = "https://files.pythonhosted.org/packages/f9/ac/46f960ca323037caa0a10662ef97d0a4728e890334fc156b9f9e52bcc4ca/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e", size = 23359, upload-time = "2024-10-18T15:21:09.318Z" }, + { url = "https://files.pythonhosted.org/packages/69/84/83439e16197337b8b14b6a5b9c2105fff81d42c2a7c5b58ac7b62ee2c3b1/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4", size = 23306, upload-time = "2024-10-18T15:21:10.185Z" }, + { url = "https://files.pythonhosted.org/packages/9a/34/a15aa69f01e2181ed8d2b685c0d2f6655d5cca2c4db0ddea775e631918cd/MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d", size = 15094, upload-time = "2024-10-18T15:21:11.005Z" }, + { url = "https://files.pythonhosted.org/packages/da/b8/3a3bd761922d416f3dc5d00bfbed11f66b1ab89a0c2b6e887240a30b0f6b/MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b", size = 15521, upload-time = "2024-10-18T15:21:12.911Z" }, + { url = "https://files.pythonhosted.org/packages/22/09/d1f21434c97fc42f09d290cbb6350d44eb12f09cc62c9476effdb33a18aa/MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf", size = 14274, upload-time = "2024-10-18T15:21:13.777Z" }, + { url = "https://files.pythonhosted.org/packages/6b/b0/18f76bba336fa5aecf79d45dcd6c806c280ec44538b3c13671d49099fdd0/MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225", size = 12348, upload-time = "2024-10-18T15:21:14.822Z" }, + { url = "https://files.pythonhosted.org/packages/e0/25/dd5c0f6ac1311e9b40f4af06c78efde0f3b5cbf02502f8ef9501294c425b/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028", size = 24149, upload-time = "2024-10-18T15:21:15.642Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f0/89e7aadfb3749d0f52234a0c8c7867877876e0a20b60e2188e9850794c17/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8", size = 23118, upload-time = "2024-10-18T15:21:17.133Z" }, + { url = "https://files.pythonhosted.org/packages/d5/da/f2eeb64c723f5e3777bc081da884b414671982008c47dcc1873d81f625b6/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c", size = 22993, upload-time = "2024-10-18T15:21:18.064Z" }, + { url = "https://files.pythonhosted.org/packages/da/0e/1f32af846df486dce7c227fe0f2398dc7e2e51d4a370508281f3c1c5cddc/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557", size = 24178, upload-time = "2024-10-18T15:21:18.859Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f6/bb3ca0532de8086cbff5f06d137064c8410d10779c4c127e0e47d17c0b71/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22", size = 23319, upload-time = "2024-10-18T15:21:19.671Z" }, + { url = "https://files.pythonhosted.org/packages/a2/82/8be4c96ffee03c5b4a034e60a31294daf481e12c7c43ab8e34a1453ee48b/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48", size = 23352, upload-time = "2024-10-18T15:21:20.971Z" }, + { url = "https://files.pythonhosted.org/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", size = 15097, upload-time = "2024-10-18T15:21:22.646Z" }, + { url = "https://files.pythonhosted.org/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", size = 15601, upload-time = "2024-10-18T15:21:23.499Z" }, + { url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274, upload-time = "2024-10-18T15:21:24.577Z" }, + { url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352, upload-time = "2024-10-18T15:21:25.382Z" }, + { url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122, upload-time = "2024-10-18T15:21:26.199Z" }, + { url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085, upload-time = "2024-10-18T15:21:27.029Z" }, + { url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978, upload-time = "2024-10-18T15:21:27.846Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208, upload-time = "2024-10-18T15:21:28.744Z" }, + { url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357, upload-time = "2024-10-18T15:21:29.545Z" }, + { url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344, upload-time = "2024-10-18T15:21:30.366Z" }, + { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101, upload-time = "2024-10-18T15:21:31.207Z" }, + { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603, upload-time = "2024-10-18T15:21:32.032Z" }, + { url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510, upload-time = "2024-10-18T15:21:33.625Z" }, + { url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486, upload-time = "2024-10-18T15:21:34.611Z" }, + { url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480, upload-time = "2024-10-18T15:21:35.398Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914, upload-time = "2024-10-18T15:21:36.231Z" }, + { url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796, upload-time = "2024-10-18T15:21:37.073Z" }, + { url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473, upload-time = "2024-10-18T15:21:37.932Z" }, + { url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114, upload-time = "2024-10-18T15:21:39.799Z" }, + { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098, upload-time = "2024-10-18T15:21:40.813Z" }, + { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208, upload-time = "2024-10-18T15:21:41.814Z" }, + { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739, upload-time = "2024-10-18T15:21:42.784Z" }, + { url = "https://files.pythonhosted.org/packages/a7/ea/9b1530c3fdeeca613faeb0fb5cbcf2389d816072fab72a71b45749ef6062/MarkupSafe-3.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a", size = 14344, upload-time = "2024-10-18T15:21:43.721Z" }, + { url = "https://files.pythonhosted.org/packages/4b/c2/fbdbfe48848e7112ab05e627e718e854d20192b674952d9042ebd8c9e5de/MarkupSafe-3.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff", size = 12389, upload-time = "2024-10-18T15:21:44.666Z" }, + { url = "https://files.pythonhosted.org/packages/f0/25/7a7c6e4dbd4f867d95d94ca15449e91e52856f6ed1905d58ef1de5e211d0/MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13", size = 21607, upload-time = "2024-10-18T15:21:45.452Z" }, + { url = "https://files.pythonhosted.org/packages/53/8f/f339c98a178f3c1e545622206b40986a4c3307fe39f70ccd3d9df9a9e425/MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144", size = 20728, upload-time = "2024-10-18T15:21:46.295Z" }, + { url = "https://files.pythonhosted.org/packages/1a/03/8496a1a78308456dbd50b23a385c69b41f2e9661c67ea1329849a598a8f9/MarkupSafe-3.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29", size = 20826, upload-time = "2024-10-18T15:21:47.134Z" }, + { url = "https://files.pythonhosted.org/packages/e6/cf/0a490a4bd363048c3022f2f475c8c05582179bb179defcee4766fb3dcc18/MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0", size = 21843, upload-time = "2024-10-18T15:21:48.334Z" }, + { url = "https://files.pythonhosted.org/packages/19/a3/34187a78613920dfd3cdf68ef6ce5e99c4f3417f035694074beb8848cd77/MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0", size = 21219, upload-time = "2024-10-18T15:21:49.587Z" }, + { url = "https://files.pythonhosted.org/packages/17/d8/5811082f85bb88410ad7e452263af048d685669bbbfb7b595e8689152498/MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178", size = 20946, upload-time = "2024-10-18T15:21:50.441Z" }, + { url = "https://files.pythonhosted.org/packages/7c/31/bd635fb5989440d9365c5e3c47556cfea121c7803f5034ac843e8f37c2f2/MarkupSafe-3.0.2-cp39-cp39-win32.whl", hash = "sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f", size = 15063, upload-time = "2024-10-18T15:21:51.385Z" }, + { url = "https://files.pythonhosted.org/packages/b3/73/085399401383ce949f727afec55ec3abd76648d04b9f22e1c0e99cb4bec3/MarkupSafe-3.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a", size = 15506, upload-time = "2024-10-18T15:21:52.974Z" }, +] + +[[package]] +name = "more-itertools" +version = "10.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/a0/834b0cebabbfc7e311f30b46c8188790a37f89fc8d756660346fe5abfd09/more_itertools-10.7.0.tar.gz", hash = "sha256:9fddd5403be01a94b204faadcff459ec3568cf110265d3c54323e1e866ad29d3", size = 127671, upload-time = "2025-04-22T14:17:41.838Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/9f/7ba6f94fc1e9ac3d2b853fdff3035fb2fa5afbed898c4a72b8a020610594/more_itertools-10.7.0-py3-none-any.whl", hash = "sha256:d43980384673cb07d2f7d2d918c616b30c659c089ee23953f601d6609c67510e", size = 65278, upload-time = "2025-04-22T14:17:40.49Z" }, +] + +[[package]] +name = "msgpack" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/45/b1/ea4f68038a18c77c9467400d166d74c4ffa536f34761f7983a104357e614/msgpack-1.1.1.tar.gz", hash = "sha256:77b79ce34a2bdab2594f490c8e80dd62a02d650b91a75159a63ec413b8d104cd", size = 173555, upload-time = "2025-06-13T06:52:51.324Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/52/f30da112c1dc92cf64f57d08a273ac771e7b29dea10b4b30369b2d7e8546/msgpack-1.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:353b6fc0c36fde68b661a12949d7d49f8f51ff5fa019c1e47c87c4ff34b080ed", size = 81799, upload-time = "2025-06-13T06:51:37.228Z" }, + { url = "https://files.pythonhosted.org/packages/e4/35/7bfc0def2f04ab4145f7f108e3563f9b4abae4ab0ed78a61f350518cc4d2/msgpack-1.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:79c408fcf76a958491b4e3b103d1c417044544b68e96d06432a189b43d1215c8", size = 78278, upload-time = "2025-06-13T06:51:38.534Z" }, + { url = "https://files.pythonhosted.org/packages/e8/c5/df5d6c1c39856bc55f800bf82778fd4c11370667f9b9e9d51b2f5da88f20/msgpack-1.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78426096939c2c7482bf31ef15ca219a9e24460289c00dd0b94411040bb73ad2", size = 402805, upload-time = "2025-06-13T06:51:39.538Z" }, + { url = "https://files.pythonhosted.org/packages/20/8e/0bb8c977efecfe6ea7116e2ed73a78a8d32a947f94d272586cf02a9757db/msgpack-1.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8b17ba27727a36cb73aabacaa44b13090feb88a01d012c0f4be70c00f75048b4", size = 408642, upload-time = "2025-06-13T06:51:41.092Z" }, + { url = "https://files.pythonhosted.org/packages/59/a1/731d52c1aeec52006be6d1f8027c49fdc2cfc3ab7cbe7c28335b2910d7b6/msgpack-1.1.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7a17ac1ea6ec3c7687d70201cfda3b1e8061466f28f686c24f627cae4ea8efd0", size = 395143, upload-time = "2025-06-13T06:51:42.575Z" }, + { url = "https://files.pythonhosted.org/packages/2b/92/b42911c52cda2ba67a6418ffa7d08969edf2e760b09015593c8a8a27a97d/msgpack-1.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:88d1e966c9235c1d4e2afac21ca83933ba59537e2e2727a999bf3f515ca2af26", size = 395986, upload-time = "2025-06-13T06:51:43.807Z" }, + { url = "https://files.pythonhosted.org/packages/61/dc/8ae165337e70118d4dab651b8b562dd5066dd1e6dd57b038f32ebc3e2f07/msgpack-1.1.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:f6d58656842e1b2ddbe07f43f56b10a60f2ba5826164910968f5933e5178af75", size = 402682, upload-time = "2025-06-13T06:51:45.534Z" }, + { url = "https://files.pythonhosted.org/packages/58/27/555851cb98dcbd6ce041df1eacb25ac30646575e9cd125681aa2f4b1b6f1/msgpack-1.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:96decdfc4adcbc087f5ea7ebdcfd3dee9a13358cae6e81d54be962efc38f6338", size = 406368, upload-time = "2025-06-13T06:51:46.97Z" }, + { url = "https://files.pythonhosted.org/packages/d4/64/39a26add4ce16f24e99eabb9005e44c663db00e3fce17d4ae1ae9d61df99/msgpack-1.1.1-cp310-cp310-win32.whl", hash = "sha256:6640fd979ca9a212e4bcdf6eb74051ade2c690b862b679bfcb60ae46e6dc4bfd", size = 65004, upload-time = "2025-06-13T06:51:48.582Z" }, + { url = "https://files.pythonhosted.org/packages/7d/18/73dfa3e9d5d7450d39debde5b0d848139f7de23bd637a4506e36c9800fd6/msgpack-1.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:8b65b53204fe1bd037c40c4148d00ef918eb2108d24c9aaa20bc31f9810ce0a8", size = 71548, upload-time = "2025-06-13T06:51:49.558Z" }, + { url = "https://files.pythonhosted.org/packages/7f/83/97f24bf9848af23fe2ba04380388216defc49a8af6da0c28cc636d722502/msgpack-1.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:71ef05c1726884e44f8b1d1773604ab5d4d17729d8491403a705e649116c9558", size = 82728, upload-time = "2025-06-13T06:51:50.68Z" }, + { url = "https://files.pythonhosted.org/packages/aa/7f/2eaa388267a78401f6e182662b08a588ef4f3de6f0eab1ec09736a7aaa2b/msgpack-1.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:36043272c6aede309d29d56851f8841ba907a1a3d04435e43e8a19928e243c1d", size = 79279, upload-time = "2025-06-13T06:51:51.72Z" }, + { url = "https://files.pythonhosted.org/packages/f8/46/31eb60f4452c96161e4dfd26dbca562b4ec68c72e4ad07d9566d7ea35e8a/msgpack-1.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a32747b1b39c3ac27d0670122b57e6e57f28eefb725e0b625618d1b59bf9d1e0", size = 423859, upload-time = "2025-06-13T06:51:52.749Z" }, + { url = "https://files.pythonhosted.org/packages/45/16/a20fa8c32825cc7ae8457fab45670c7a8996d7746ce80ce41cc51e3b2bd7/msgpack-1.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a8b10fdb84a43e50d38057b06901ec9da52baac6983d3f709d8507f3889d43f", size = 429975, upload-time = "2025-06-13T06:51:53.97Z" }, + { url = "https://files.pythonhosted.org/packages/86/ea/6c958e07692367feeb1a1594d35e22b62f7f476f3c568b002a5ea09d443d/msgpack-1.1.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ba0c325c3f485dc54ec298d8b024e134acf07c10d494ffa24373bea729acf704", size = 413528, upload-time = "2025-06-13T06:51:55.507Z" }, + { url = "https://files.pythonhosted.org/packages/75/05/ac84063c5dae79722bda9f68b878dc31fc3059adb8633c79f1e82c2cd946/msgpack-1.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:88daaf7d146e48ec71212ce21109b66e06a98e5e44dca47d853cbfe171d6c8d2", size = 413338, upload-time = "2025-06-13T06:51:57.023Z" }, + { url = "https://files.pythonhosted.org/packages/69/e8/fe86b082c781d3e1c09ca0f4dacd457ede60a13119b6ce939efe2ea77b76/msgpack-1.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:d8b55ea20dc59b181d3f47103f113e6f28a5e1c89fd5b67b9140edb442ab67f2", size = 422658, upload-time = "2025-06-13T06:51:58.419Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2b/bafc9924df52d8f3bb7c00d24e57be477f4d0f967c0a31ef5e2225e035c7/msgpack-1.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4a28e8072ae9779f20427af07f53bbb8b4aa81151054e882aee333b158da8752", size = 427124, upload-time = "2025-06-13T06:51:59.969Z" }, + { url = "https://files.pythonhosted.org/packages/a2/3b/1f717e17e53e0ed0b68fa59e9188f3f610c79d7151f0e52ff3cd8eb6b2dc/msgpack-1.1.1-cp311-cp311-win32.whl", hash = "sha256:7da8831f9a0fdb526621ba09a281fadc58ea12701bc709e7b8cbc362feabc295", size = 65016, upload-time = "2025-06-13T06:52:01.294Z" }, + { url = "https://files.pythonhosted.org/packages/48/45/9d1780768d3b249accecc5a38c725eb1e203d44a191f7b7ff1941f7df60c/msgpack-1.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:5fd1b58e1431008a57247d6e7cc4faa41c3607e8e7d4aaf81f7c29ea013cb458", size = 72267, upload-time = "2025-06-13T06:52:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/389b9c593eda2b8551b2e7126ad3a06af6f9b44274eb3a4f054d48ff7e47/msgpack-1.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ae497b11f4c21558d95de9f64fff7053544f4d1a17731c866143ed6bb4591238", size = 82359, upload-time = "2025-06-13T06:52:03.909Z" }, + { url = "https://files.pythonhosted.org/packages/ab/65/7d1de38c8a22cf8b1551469159d4b6cf49be2126adc2482de50976084d78/msgpack-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:33be9ab121df9b6b461ff91baac6f2731f83d9b27ed948c5b9d1978ae28bf157", size = 79172, upload-time = "2025-06-13T06:52:05.246Z" }, + { url = "https://files.pythonhosted.org/packages/0f/bd/cacf208b64d9577a62c74b677e1ada005caa9b69a05a599889d6fc2ab20a/msgpack-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f64ae8fe7ffba251fecb8408540c34ee9df1c26674c50c4544d72dbf792e5ce", size = 425013, upload-time = "2025-06-13T06:52:06.341Z" }, + { url = "https://files.pythonhosted.org/packages/4d/ec/fd869e2567cc9c01278a736cfd1697941ba0d4b81a43e0aa2e8d71dab208/msgpack-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a494554874691720ba5891c9b0b39474ba43ffb1aaf32a5dac874effb1619e1a", size = 426905, upload-time = "2025-06-13T06:52:07.501Z" }, + { url = "https://files.pythonhosted.org/packages/55/2a/35860f33229075bce803a5593d046d8b489d7ba2fc85701e714fc1aaf898/msgpack-1.1.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cb643284ab0ed26f6957d969fe0dd8bb17beb567beb8998140b5e38a90974f6c", size = 407336, upload-time = "2025-06-13T06:52:09.047Z" }, + { url = "https://files.pythonhosted.org/packages/8c/16/69ed8f3ada150bf92745fb4921bd621fd2cdf5a42e25eb50bcc57a5328f0/msgpack-1.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d275a9e3c81b1093c060c3837e580c37f47c51eca031f7b5fb76f7b8470f5f9b", size = 409485, upload-time = "2025-06-13T06:52:10.382Z" }, + { url = "https://files.pythonhosted.org/packages/c6/b6/0c398039e4c6d0b2e37c61d7e0e9d13439f91f780686deb8ee64ecf1ae71/msgpack-1.1.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4fd6b577e4541676e0cc9ddc1709d25014d3ad9a66caa19962c4f5de30fc09ef", size = 412182, upload-time = "2025-06-13T06:52:11.644Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d0/0cf4a6ecb9bc960d624c93effaeaae75cbf00b3bc4a54f35c8507273cda1/msgpack-1.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb29aaa613c0a1c40d1af111abf025f1732cab333f96f285d6a93b934738a68a", size = 419883, upload-time = "2025-06-13T06:52:12.806Z" }, + { url = "https://files.pythonhosted.org/packages/62/83/9697c211720fa71a2dfb632cad6196a8af3abea56eece220fde4674dc44b/msgpack-1.1.1-cp312-cp312-win32.whl", hash = "sha256:870b9a626280c86cff9c576ec0d9cbcc54a1e5ebda9cd26dab12baf41fee218c", size = 65406, upload-time = "2025-06-13T06:52:14.271Z" }, + { url = "https://files.pythonhosted.org/packages/c0/23/0abb886e80eab08f5e8c485d6f13924028602829f63b8f5fa25a06636628/msgpack-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:5692095123007180dca3e788bb4c399cc26626da51629a31d40207cb262e67f4", size = 72558, upload-time = "2025-06-13T06:52:15.252Z" }, + { url = "https://files.pythonhosted.org/packages/a1/38/561f01cf3577430b59b340b51329803d3a5bf6a45864a55f4ef308ac11e3/msgpack-1.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3765afa6bd4832fc11c3749be4ba4b69a0e8d7b728f78e68120a157a4c5d41f0", size = 81677, upload-time = "2025-06-13T06:52:16.64Z" }, + { url = "https://files.pythonhosted.org/packages/09/48/54a89579ea36b6ae0ee001cba8c61f776451fad3c9306cd80f5b5c55be87/msgpack-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8ddb2bcfd1a8b9e431c8d6f4f7db0773084e107730ecf3472f1dfe9ad583f3d9", size = 78603, upload-time = "2025-06-13T06:52:17.843Z" }, + { url = "https://files.pythonhosted.org/packages/a0/60/daba2699b308e95ae792cdc2ef092a38eb5ee422f9d2fbd4101526d8a210/msgpack-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:196a736f0526a03653d829d7d4c5500a97eea3648aebfd4b6743875f28aa2af8", size = 420504, upload-time = "2025-06-13T06:52:18.982Z" }, + { url = "https://files.pythonhosted.org/packages/20/22/2ebae7ae43cd8f2debc35c631172ddf14e2a87ffcc04cf43ff9df9fff0d3/msgpack-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d592d06e3cc2f537ceeeb23d38799c6ad83255289bb84c2e5792e5a8dea268a", size = 423749, upload-time = "2025-06-13T06:52:20.211Z" }, + { url = "https://files.pythonhosted.org/packages/40/1b/54c08dd5452427e1179a40b4b607e37e2664bca1c790c60c442c8e972e47/msgpack-1.1.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4df2311b0ce24f06ba253fda361f938dfecd7b961576f9be3f3fbd60e87130ac", size = 404458, upload-time = "2025-06-13T06:52:21.429Z" }, + { url = "https://files.pythonhosted.org/packages/2e/60/6bb17e9ffb080616a51f09928fdd5cac1353c9becc6c4a8abd4e57269a16/msgpack-1.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e4141c5a32b5e37905b5940aacbc59739f036930367d7acce7a64e4dec1f5e0b", size = 405976, upload-time = "2025-06-13T06:52:22.995Z" }, + { url = "https://files.pythonhosted.org/packages/ee/97/88983e266572e8707c1f4b99c8fd04f9eb97b43f2db40e3172d87d8642db/msgpack-1.1.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b1ce7f41670c5a69e1389420436f41385b1aa2504c3b0c30620764b15dded2e7", size = 408607, upload-time = "2025-06-13T06:52:24.152Z" }, + { url = "https://files.pythonhosted.org/packages/bc/66/36c78af2efaffcc15a5a61ae0df53a1d025f2680122e2a9eb8442fed3ae4/msgpack-1.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4147151acabb9caed4e474c3344181e91ff7a388b888f1e19ea04f7e73dc7ad5", size = 424172, upload-time = "2025-06-13T06:52:25.704Z" }, + { url = "https://files.pythonhosted.org/packages/8c/87/a75eb622b555708fe0427fab96056d39d4c9892b0c784b3a721088c7ee37/msgpack-1.1.1-cp313-cp313-win32.whl", hash = "sha256:500e85823a27d6d9bba1d057c871b4210c1dd6fb01fbb764e37e4e8847376323", size = 65347, upload-time = "2025-06-13T06:52:26.846Z" }, + { url = "https://files.pythonhosted.org/packages/ca/91/7dc28d5e2a11a5ad804cf2b7f7a5fcb1eb5a4966d66a5d2b41aee6376543/msgpack-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:6d489fba546295983abd142812bda76b57e33d0b9f5d5b71c09a583285506f69", size = 72341, upload-time = "2025-06-13T06:52:27.835Z" }, + { url = "https://files.pythonhosted.org/packages/1f/bd/0792be119d7fe7dc2148689ef65c90507d82d20a204aab3b98c74a1f8684/msgpack-1.1.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f5be6b6bc52fad84d010cb45433720327ce886009d862f46b26d4d154001994b", size = 81882, upload-time = "2025-06-13T06:52:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/75/77/ce06c8e26a816ae8730a8e030d263c5289adcaff9f0476f9b270bdd7c5c2/msgpack-1.1.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3a89cd8c087ea67e64844287ea52888239cbd2940884eafd2dcd25754fb72232", size = 78414, upload-time = "2025-06-13T06:52:40.341Z" }, + { url = "https://files.pythonhosted.org/packages/73/27/190576c497677fb4a0d05d896b24aea6cdccd910f206aaa7b511901befed/msgpack-1.1.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d75f3807a9900a7d575d8d6674a3a47e9f227e8716256f35bc6f03fc597ffbf", size = 400927, upload-time = "2025-06-13T06:52:41.399Z" }, + { url = "https://files.pythonhosted.org/packages/ed/af/6a0aa5a06762e70726ec3c10fb966600d84a7220b52635cb0ab2dc64d32f/msgpack-1.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d182dac0221eb8faef2e6f44701812b467c02674a322c739355c39e94730cdbf", size = 405903, upload-time = "2025-06-13T06:52:42.699Z" }, + { url = "https://files.pythonhosted.org/packages/1e/80/3f3da358cecbbe8eb12360814bd1277d59d2608485934742a074d99894a9/msgpack-1.1.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1b13fe0fb4aac1aa5320cd693b297fe6fdef0e7bea5518cbc2dd5299f873ae90", size = 393192, upload-time = "2025-06-13T06:52:43.986Z" }, + { url = "https://files.pythonhosted.org/packages/98/c6/3a0ec7fdebbb4f3f8f254696cd91d491c29c501dbebd86286c17e8f68cd7/msgpack-1.1.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:435807eeb1bc791ceb3247d13c79868deb22184e1fc4224808750f0d7d1affc1", size = 393851, upload-time = "2025-06-13T06:52:45.177Z" }, + { url = "https://files.pythonhosted.org/packages/39/37/df50d5f8e68514b60fbe70f6e8337ea2b32ae2be030871bcd9d1cf7d4b62/msgpack-1.1.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:4835d17af722609a45e16037bb1d4d78b7bdf19d6c0128116d178956618c4e88", size = 400292, upload-time = "2025-06-13T06:52:46.381Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ec/1e067292e02d2ceb4c8cb5ba222c4f7bb28730eef5676740609dc2627e0f/msgpack-1.1.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a8ef6e342c137888ebbfb233e02b8fbd689bb5b5fcc59b34711ac47ebd504478", size = 401873, upload-time = "2025-06-13T06:52:47.957Z" }, + { url = "https://files.pythonhosted.org/packages/d3/31/e8c9c6b5b58d64c9efa99c8d181fcc25f38ead357b0360379fbc8a4234ad/msgpack-1.1.1-cp39-cp39-win32.whl", hash = "sha256:61abccf9de335d9efd149e2fff97ed5974f2481b3353772e8e2dd3402ba2bd57", size = 65028, upload-time = "2025-06-13T06:52:49.166Z" }, + { url = "https://files.pythonhosted.org/packages/20/d6/cd62cded572e5e25892747a5d27850170bcd03c855e9c69c538e024de6f9/msgpack-1.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:40eae974c873b2992fd36424a5d9407f93e97656d999f43fca9d29f820899084", size = 71700, upload-time = "2025-06-13T06:52:50.244Z" }, +] + +[[package]] +name = "multidict" +version = "6.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/b5/59f27b4ce9951a4bce56b88ba5ff5159486797ab18863f2b4c1c5e8465bd/multidict-6.5.0.tar.gz", hash = "sha256:942bd8002492ba819426a8d7aefde3189c1b87099cdf18aaaefefcf7f3f7b6d2", size = 98512, upload-time = "2025-06-17T14:15:56.556Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/88/f8354ef1cb1121234c3461ff3d11eac5f4fe115f00552d3376306275c9ab/multidict-6.5.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2e118a202904623b1d2606d1c8614e14c9444b59d64454b0c355044058066469", size = 73858, upload-time = "2025-06-17T14:13:21.451Z" }, + { url = "https://files.pythonhosted.org/packages/49/04/634b49c7abe71bd1c61affaeaa0c2a46b6be8d599a07b495259615dbdfe0/multidict-6.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a42995bdcaff4e22cb1280ae7752c3ed3fbb398090c6991a2797a4a0e5ed16a9", size = 43186, upload-time = "2025-06-17T14:13:23.615Z" }, + { url = "https://files.pythonhosted.org/packages/3b/ff/091ff4830ec8f96378578bfffa7f324a9dd16f60274cec861ae65ba10be3/multidict-6.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2261b538145723ca776e55208640fffd7ee78184d223f37c2b40b9edfe0e818a", size = 43031, upload-time = "2025-06-17T14:13:24.725Z" }, + { url = "https://files.pythonhosted.org/packages/10/c1/1b4137845f8b8dbc2332af54e2d7761c6a29c2c33c8d47a0c8c70676bac1/multidict-6.5.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0e5b19f8cd67235fab3e195ca389490415d9fef5a315b1fa6f332925dc924262", size = 233588, upload-time = "2025-06-17T14:13:26.181Z" }, + { url = "https://files.pythonhosted.org/packages/c3/77/cbe9a1f58c6d4f822663788e414637f256a872bc352cedbaf7717b62db58/multidict-6.5.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:177b081e4dec67c3320b16b3aa0babc178bbf758553085669382c7ec711e1ec8", size = 222714, upload-time = "2025-06-17T14:13:27.482Z" }, + { url = "https://files.pythonhosted.org/packages/6c/37/39e1142c2916973818515adc13bbdb68d3d8126935e3855200e059a79bab/multidict-6.5.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4d30a2cc106a7d116b52ee046207614db42380b62e6b1dd2a50eba47c5ca5eb1", size = 242741, upload-time = "2025-06-17T14:13:28.92Z" }, + { url = "https://files.pythonhosted.org/packages/a3/aa/60c3ef0c87ccad3445bf01926a1b8235ee24c3dde483faef1079cc91706d/multidict-6.5.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a72933bc308d7a64de37f0d51795dbeaceebdfb75454f89035cdfc6a74cfd129", size = 235008, upload-time = "2025-06-17T14:13:30.587Z" }, + { url = "https://files.pythonhosted.org/packages/bf/5e/f7e0fd5f5b8a7b9a75b0f5642ca6b6dde90116266920d8cf63b513f3908b/multidict-6.5.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:96d109e663d032280ef8ef62b50924b2e887d5ddf19e301844a6cb7e91a172a6", size = 226627, upload-time = "2025-06-17T14:13:31.831Z" }, + { url = "https://files.pythonhosted.org/packages/b7/74/1bc0a3c6a9105051f68a6991fe235d7358836e81058728c24d5bbdd017cb/multidict-6.5.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b555329c9894332401f03b9a87016f0b707b6fccd4706793ec43b4a639e75869", size = 228232, upload-time = "2025-06-17T14:13:33.402Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/37118291cdc31f4cc680d54047cdea9b520e9a724a643919f71f8c2a2aeb/multidict-6.5.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6994bad9d471ef2156f2b6850b51e20ee409c6b9deebc0e57be096be9faffdce", size = 246616, upload-time = "2025-06-17T14:13:34.964Z" }, + { url = "https://files.pythonhosted.org/packages/ff/89/e2c08d6bdb21a1a55be4285510d058ace5f5acabe6b57900432e863d4c70/multidict-6.5.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:b15f817276c96cde9060569023808eec966bd8da56a97e6aa8116f34ddab6534", size = 235007, upload-time = "2025-06-17T14:13:36.428Z" }, + { url = "https://files.pythonhosted.org/packages/89/1e/e39a98e8e1477ec7a871b3c17265658fbe6d617048059ae7fa5011b224f3/multidict-6.5.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b4bf507c991db535a935b2127cf057a58dbc688c9f309c72080795c63e796f58", size = 244824, upload-time = "2025-06-17T14:13:37.982Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ba/63e11edd45c31e708c5a1904aa7ac4de01e13135a04cfe96bc71eb359b85/multidict-6.5.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:60c3f8f13d443426c55f88cf3172547bbc600a86d57fd565458b9259239a6737", size = 257229, upload-time = "2025-06-17T14:13:39.554Z" }, + { url = "https://files.pythonhosted.org/packages/0f/00/bdcceb6af424936adfc8b92a79d3a95863585f380071393934f10a63f9e3/multidict-6.5.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:a10227168a24420c158747fc201d4279aa9af1671f287371597e2b4f2ff21879", size = 247118, upload-time = "2025-06-17T14:13:40.795Z" }, + { url = "https://files.pythonhosted.org/packages/b6/a0/4aa79e991909cca36ca821a9ba5e8e81e4cd5b887c81f89ded994e0f49df/multidict-6.5.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e3b1425fe54ccfde66b8cfb25d02be34d5dfd2261a71561ffd887ef4088b4b69", size = 243948, upload-time = "2025-06-17T14:13:42.477Z" }, + { url = "https://files.pythonhosted.org/packages/21/8b/e45e19ce43afb31ff6b0fd5d5816b4fcc1fcc2f37e8a82aefae06c40c7a6/multidict-6.5.0-cp310-cp310-win32.whl", hash = "sha256:b4e47ef51237841d1087e1e1548071a6ef22e27ed0400c272174fa585277c4b4", size = 40433, upload-time = "2025-06-17T14:13:43.972Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6e/96e0ba4601343d9344e69503fca072ace19c35f7d4ca3d68401e59acdc8f/multidict-6.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:63b3b24fadc7067282c88fae5b2f366d5b3a7c15c021c2838de8c65a50eeefb4", size = 44423, upload-time = "2025-06-17T14:13:44.991Z" }, + { url = "https://files.pythonhosted.org/packages/eb/4a/9befa919d7a390f13a5511a69282b7437782071160c566de6e0ebf712c9f/multidict-6.5.0-cp310-cp310-win_arm64.whl", hash = "sha256:8b2d61afbafc679b7eaf08e9de4fa5d38bd5dc7a9c0a577c9f9588fb49f02dbb", size = 41481, upload-time = "2025-06-17T14:13:49.389Z" }, + { url = "https://files.pythonhosted.org/packages/75/ba/484f8e96ee58ec4fef42650eb9dbbedb24f9bc155780888398a4725d2270/multidict-6.5.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8b4bf6bb15a05796a07a248084e3e46e032860c899c7a9b981030e61368dba95", size = 73283, upload-time = "2025-06-17T14:13:50.406Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/01d62ea6199d76934c87746695b3ed16aeedfdd564e8d89184577037baac/multidict-6.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:46bb05d50219655c42a4b8fcda9c7ee658a09adbb719c48e65a20284e36328ea", size = 42937, upload-time = "2025-06-17T14:13:51.45Z" }, + { url = "https://files.pythonhosted.org/packages/da/cf/bb462d920f26d9e2e0aff8a78aeb06af1225b826e9a5468870c57591910a/multidict-6.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:54f524d73f4d54e87e03c98f6af601af4777e4668a52b1bd2ae0a4d6fc7b392b", size = 42748, upload-time = "2025-06-17T14:13:52.505Z" }, + { url = "https://files.pythonhosted.org/packages/cd/b1/d5c11ea0fdad68d3ed45f0e2527de6496d2fac8afe6b8ca6d407c20ad00f/multidict-6.5.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:529b03600466480ecc502000d62e54f185a884ed4570dee90d9a273ee80e37b5", size = 236448, upload-time = "2025-06-17T14:13:53.562Z" }, + { url = "https://files.pythonhosted.org/packages/fc/69/c3ceb264994f5b338c812911a8d660084f37779daef298fc30bd817f75c7/multidict-6.5.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:69ad681ad7c93a41ee7005cc83a144b5b34a3838bcf7261e2b5356057b0f78de", size = 228695, upload-time = "2025-06-17T14:13:54.775Z" }, + { url = "https://files.pythonhosted.org/packages/81/3d/c23dcc0d34a35ad29974184db2878021d28fe170ecb9192be6bfee73f1f2/multidict-6.5.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fe9fada8bc0839466b09fa3f6894f003137942984843ec0c3848846329a36ae", size = 247434, upload-time = "2025-06-17T14:13:56.039Z" }, + { url = "https://files.pythonhosted.org/packages/06/b3/06cf7a049129ff52525a859277abb5648e61d7afae7fb7ed02e3806be34e/multidict-6.5.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f94c6ea6405fcf81baef1e459b209a78cda5442e61b5b7a57ede39d99b5204a0", size = 239431, upload-time = "2025-06-17T14:13:57.33Z" }, + { url = "https://files.pythonhosted.org/packages/8a/72/b2fe2fafa23af0c6123aebe23b4cd23fdad01dfe7009bb85624e4636d0dd/multidict-6.5.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:84ca75ad8a39ed75f079a8931435a5b51ee4c45d9b32e1740f99969a5d1cc2ee", size = 231542, upload-time = "2025-06-17T14:13:58.597Z" }, + { url = "https://files.pythonhosted.org/packages/a1/c9/a52ca0a342a02411a31b6af197a6428a5137d805293f10946eeab614ec06/multidict-6.5.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:be4c08f3a2a6cc42b414496017928d95898964fed84b1b2dace0c9ee763061f9", size = 233069, upload-time = "2025-06-17T14:13:59.834Z" }, + { url = "https://files.pythonhosted.org/packages/9b/55/a3328a3929b8e131e2678d5e65f552b0a6874fab62123e31f5a5625650b0/multidict-6.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:046a7540cfbb4d5dc846a1fd9843f3ba980c6523f2e0c5b8622b4a5c94138ae6", size = 250596, upload-time = "2025-06-17T14:14:01.178Z" }, + { url = "https://files.pythonhosted.org/packages/6c/b8/aa3905a38a8287013aeb0a54c73f79ccd8b32d2f1d53e5934643a36502c2/multidict-6.5.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:64306121171d988af77d74be0d8c73ee1a69cf6f96aea7fa6030c88f32a152dd", size = 237858, upload-time = "2025-06-17T14:14:03.232Z" }, + { url = "https://files.pythonhosted.org/packages/d3/eb/f11d5af028014f402e5dd01ece74533964fa4e7bfae4af4824506fa8c398/multidict-6.5.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:b4ac1dd5eb0ecf6f7351d5a9137f30a83f7182209c5d37f61614dfdce5714853", size = 249175, upload-time = "2025-06-17T14:14:04.561Z" }, + { url = "https://files.pythonhosted.org/packages/ac/57/d451905a62e5ef489cb4f92e8190d34ac5329427512afd7f893121da4e96/multidict-6.5.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:bab4a8337235365f4111a7011a1f028826ca683834ebd12de4b85e2844359c36", size = 259532, upload-time = "2025-06-17T14:14:05.798Z" }, + { url = "https://files.pythonhosted.org/packages/d3/90/ff82b5ac5cabe3c79c50cf62a62f3837905aa717e67b6b4b7872804f23c8/multidict-6.5.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:a05b5604c5a75df14a63eeeca598d11b2c3745b9008539b70826ea044063a572", size = 250554, upload-time = "2025-06-17T14:14:07.382Z" }, + { url = "https://files.pythonhosted.org/packages/d5/5a/0cabc50d4bc16e61d8b0a8a74499a1409fa7b4ef32970b7662a423781fc7/multidict-6.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:67c4a640952371c9ca65b6a710598be246ef3be5ca83ed38c16a7660d3980877", size = 248159, upload-time = "2025-06-17T14:14:08.65Z" }, + { url = "https://files.pythonhosted.org/packages/c0/1d/adeabae0771544f140d9f42ab2c46eaf54e793325999c36106078b7f6600/multidict-6.5.0-cp311-cp311-win32.whl", hash = "sha256:fdeae096ca36c12d8aca2640b8407a9d94e961372c68435bef14e31cce726138", size = 40357, upload-time = "2025-06-17T14:14:09.91Z" }, + { url = "https://files.pythonhosted.org/packages/e1/fe/bbd85ae65c96de5c9910c332ee1f4b7be0bf0fb21563895167bcb6502a1f/multidict-6.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:e2977ef8b7ce27723ee8c610d1bd1765da4f3fbe5a64f9bf1fd3b4770e31fbc0", size = 44432, upload-time = "2025-06-17T14:14:11.013Z" }, + { url = "https://files.pythonhosted.org/packages/96/af/f9052d9c4e65195b210da9f7afdea06d3b7592b3221cc0ef1b407f762faa/multidict-6.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:82d0cf0ea49bae43d9e8c3851e21954eff716259ff42da401b668744d1760bcb", size = 41408, upload-time = "2025-06-17T14:14:12.112Z" }, + { url = "https://files.pythonhosted.org/packages/0a/fa/18f4950e00924f7e84c8195f4fc303295e14df23f713d64e778b8fa8b903/multidict-6.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1bb986c8ea9d49947bc325c51eced1ada6d8d9b4c5b15fd3fcdc3c93edef5a74", size = 73474, upload-time = "2025-06-17T14:14:13.528Z" }, + { url = "https://files.pythonhosted.org/packages/6c/66/0392a2a8948bccff57e4793c9dde3e5c088f01e8b7f8867ee58a2f187fc5/multidict-6.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:03c0923da300120830fc467e23805d63bbb4e98b94032bd863bc7797ea5fa653", size = 43741, upload-time = "2025-06-17T14:14:15.188Z" }, + { url = "https://files.pythonhosted.org/packages/98/3e/f48487c91b2a070566cfbab876d7e1ebe7deb0a8002e4e896a97998ae066/multidict-6.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4c78d5ec00fdd35c91680ab5cf58368faad4bd1a8721f87127326270248de9bc", size = 42143, upload-time = "2025-06-17T14:14:16.612Z" }, + { url = "https://files.pythonhosted.org/packages/3f/49/439c6cc1cd00365cf561bdd3579cc3fa1a0d38effb3a59b8d9562839197f/multidict-6.5.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aadc3cb78be90a887f8f6b73945b840da44b4a483d1c9750459ae69687940c97", size = 239303, upload-time = "2025-06-17T14:14:17.707Z" }, + { url = "https://files.pythonhosted.org/packages/c4/24/491786269e90081cb536e4d7429508725bc92ece176d1204a4449de7c41c/multidict-6.5.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5b02e1ca495d71e07e652e4cef91adae3bf7ae4493507a263f56e617de65dafc", size = 236913, upload-time = "2025-06-17T14:14:18.981Z" }, + { url = "https://files.pythonhosted.org/packages/e8/76/bbe2558b820ebeca8a317ab034541790e8160ca4b1e450415383ac69b339/multidict-6.5.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7fe92a62326eef351668eec4e2dfc494927764a0840a1895cff16707fceffcd3", size = 250752, upload-time = "2025-06-17T14:14:20.297Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e3/3977f2c1123f553ceff9f53cd4de04be2c1912333c6fabbcd51531655476/multidict-6.5.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7673ee4f63879ecd526488deb1989041abcb101b2d30a9165e1e90c489f3f7fb", size = 243937, upload-time = "2025-06-17T14:14:21.935Z" }, + { url = "https://files.pythonhosted.org/packages/b6/b8/7a6e9c13c79709cdd2f22ee849f058e6da76892d141a67acc0e6c30d845c/multidict-6.5.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa097ae2a29f573de7e2d86620cbdda5676d27772d4ed2669cfa9961a0d73955", size = 237419, upload-time = "2025-06-17T14:14:23.215Z" }, + { url = "https://files.pythonhosted.org/packages/84/9d/8557f5e88da71bc7e7a8ace1ada4c28197f3bfdc2dd6e51d3b88f2e16e8e/multidict-6.5.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:300da0fa4f8457d9c4bd579695496116563409e676ac79b5e4dca18e49d1c308", size = 237222, upload-time = "2025-06-17T14:14:24.516Z" }, + { url = "https://files.pythonhosted.org/packages/a3/3b/8f023ad60e7969cb6bc0683738d0e1618f5ff5723d6d2d7818dc6df6ad3d/multidict-6.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9a19bd108c35877b57393243d392d024cfbfdefe759fd137abb98f6fc910b64c", size = 247861, upload-time = "2025-06-17T14:14:25.839Z" }, + { url = "https://files.pythonhosted.org/packages/af/1c/9cf5a099ce7e3189906cf5daa72c44ee962dcb4c1983659f3a6f8a7446ab/multidict-6.5.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f32a1777465a35c35ddbbd7fc1293077938a69402fcc59e40b2846d04a120dd", size = 243917, upload-time = "2025-06-17T14:14:27.164Z" }, + { url = "https://files.pythonhosted.org/packages/6c/bb/88ee66ebeef56868044bac58feb1cc25658bff27b20e3cfc464edc181287/multidict-6.5.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:9cc1e10c14ce8112d1e6d8971fe3cdbe13e314f68bea0e727429249d4a6ce164", size = 249214, upload-time = "2025-06-17T14:14:28.795Z" }, + { url = "https://files.pythonhosted.org/packages/3e/ec/a90e88cc4a1309f33088ab1cdd5c0487718f49dfb82c5ffc845bb17c1973/multidict-6.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e95c5e07a06594bdc288117ca90e89156aee8cb2d7c330b920d9c3dd19c05414", size = 258682, upload-time = "2025-06-17T14:14:30.066Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d8/16dd69a6811920a31f4e06114ebe67b1cd922c8b05c9c82b050706d0b6fe/multidict-6.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:40ff26f58323795f5cd2855e2718a1720a1123fb90df4553426f0efd76135462", size = 254254, upload-time = "2025-06-17T14:14:31.323Z" }, + { url = "https://files.pythonhosted.org/packages/ac/a8/90193a5f5ca1bdbf92633d69a25a2ef9bcac7b412b8d48c84d01a2732518/multidict-6.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:76803a29fd71869a8b59c2118c9dcfb3b8f9c8723e2cce6baeb20705459505cf", size = 247741, upload-time = "2025-06-17T14:14:32.717Z" }, + { url = "https://files.pythonhosted.org/packages/cd/43/29c7a747153c05b41d1f67455426af39ed88d6de3f21c232b8f2724bde13/multidict-6.5.0-cp312-cp312-win32.whl", hash = "sha256:df7ecbc65a53a2ce1b3a0c82e6ad1a43dcfe7c6137733f9176a92516b9f5b851", size = 41049, upload-time = "2025-06-17T14:14:33.941Z" }, + { url = "https://files.pythonhosted.org/packages/1e/e8/8f3fc32b7e901f3a2719764d64aeaf6ae77b4ba961f1c3a3cf3867766636/multidict-6.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:0ec1c3fbbb0b655a6540bce408f48b9a7474fd94ed657dcd2e890671fefa7743", size = 44700, upload-time = "2025-06-17T14:14:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/24/e4/e250806adc98d524d41e69c8d4a42bc3513464adb88cb96224df12928617/multidict-6.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:2d24a00d34808b22c1f15902899b9d82d0faeca9f56281641c791d8605eacd35", size = 41703, upload-time = "2025-06-17T14:14:36.168Z" }, + { url = "https://files.pythonhosted.org/packages/1a/c9/092c4e9402b6d16de761cff88cb842a5c8cc50ccecaf9c4481ba53264b9e/multidict-6.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:53d92df1752df67a928fa7f884aa51edae6f1cf00eeb38cbcf318cf841c17456", size = 73486, upload-time = "2025-06-17T14:14:37.238Z" }, + { url = "https://files.pythonhosted.org/packages/08/f9/6f7ddb8213f5fdf4db48d1d640b78e8aef89b63a5de8a2313286db709250/multidict-6.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:680210de2c38eef17ce46b8df8bf2c1ece489261a14a6e43c997d49843a27c99", size = 43745, upload-time = "2025-06-17T14:14:38.32Z" }, + { url = "https://files.pythonhosted.org/packages/f3/a7/b9be0163bfeee3bb08a77a1705e24eb7e651d594ea554107fac8a1ca6a4d/multidict-6.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e279259bcb936732bfa1a8eec82b5d2352b3df69d2fa90d25808cfc403cee90a", size = 42135, upload-time = "2025-06-17T14:14:39.897Z" }, + { url = "https://files.pythonhosted.org/packages/8e/30/93c8203f943a417bda3c573a34d5db0cf733afdfffb0ca78545c7716dbd8/multidict-6.5.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1c185fc1069781e3fc8b622c4331fb3b433979850392daa5efbb97f7f9959bb", size = 238585, upload-time = "2025-06-17T14:14:41.332Z" }, + { url = "https://files.pythonhosted.org/packages/9d/fe/2582b56a1807604774f566eeef183b0d6b148f4b89d1612cd077567b2e1e/multidict-6.5.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6bb5f65ff91daf19ce97f48f63585e51595539a8a523258b34f7cef2ec7e0617", size = 236174, upload-time = "2025-06-17T14:14:42.602Z" }, + { url = "https://files.pythonhosted.org/packages/9b/c4/d8b66d42d385bd4f974cbd1eaa8b265e6b8d297249009f312081d5ded5c7/multidict-6.5.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d8646b4259450c59b9286db280dd57745897897284f6308edbdf437166d93855", size = 250145, upload-time = "2025-06-17T14:14:43.944Z" }, + { url = "https://files.pythonhosted.org/packages/bc/64/62feda5093ee852426aae3df86fab079f8bf1cdbe403e1078c94672ad3ec/multidict-6.5.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d245973d4ecc04eea0a8e5ebec7882cf515480036e1b48e65dffcfbdf86d00be", size = 243470, upload-time = "2025-06-17T14:14:45.343Z" }, + { url = "https://files.pythonhosted.org/packages/67/dc/9f6fa6e854625cf289c0e9f4464b40212a01f76b2f3edfe89b6779b4fb93/multidict-6.5.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a133e7ddc9bc7fb053733d0ff697ce78c7bf39b5aec4ac12857b6116324c8d75", size = 236968, upload-time = "2025-06-17T14:14:46.609Z" }, + { url = "https://files.pythonhosted.org/packages/46/ae/4b81c6e3745faee81a156f3f87402315bdccf04236f75c03e37be19c94ff/multidict-6.5.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:80d696fa38d738fcebfd53eec4d2e3aeb86a67679fd5e53c325756682f152826", size = 236575, upload-time = "2025-06-17T14:14:47.929Z" }, + { url = "https://files.pythonhosted.org/packages/8a/fa/4089d7642ea344226e1bfab60dd588761d4791754f8072e911836a39bedf/multidict-6.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:20d30c9410ac3908abbaa52ee5967a754c62142043cf2ba091e39681bd51d21a", size = 247632, upload-time = "2025-06-17T14:14:49.525Z" }, + { url = "https://files.pythonhosted.org/packages/16/ee/a353dac797de0f28fb7f078cc181c5f2eefe8dd16aa11a7100cbdc234037/multidict-6.5.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:6c65068cc026f217e815fa519d8e959a7188e94ec163ffa029c94ca3ef9d4a73", size = 243520, upload-time = "2025-06-17T14:14:50.83Z" }, + { url = "https://files.pythonhosted.org/packages/50/ec/560deb3d2d95822d6eb1bcb1f1cb728f8f0197ec25be7c936d5d6a5d133c/multidict-6.5.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e355ac668a8c3e49c2ca8daa4c92f0ad5b705d26da3d5af6f7d971e46c096da7", size = 248551, upload-time = "2025-06-17T14:14:52.229Z" }, + { url = "https://files.pythonhosted.org/packages/10/85/ddf277e67c78205f6695f2a7639be459bca9cc353b962fd8085a492a262f/multidict-6.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:08db204213d0375a91a381cae0677ab95dd8c67a465eb370549daf6dbbf8ba10", size = 258362, upload-time = "2025-06-17T14:14:53.934Z" }, + { url = "https://files.pythonhosted.org/packages/02/fc/d64ee1df9b87c5210f2d4c419cab07f28589c81b4e5711eda05a122d0614/multidict-6.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ffa58e3e215af8f6536dc837a990e456129857bb6fd546b3991be470abd9597a", size = 253862, upload-time = "2025-06-17T14:14:55.323Z" }, + { url = "https://files.pythonhosted.org/packages/c9/7c/a2743c00d9e25f4826d3a77cc13d4746398872cf21c843eef96bb9945665/multidict-6.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3e86eb90015c6f21658dbd257bb8e6aa18bdb365b92dd1fba27ec04e58cdc31b", size = 247391, upload-time = "2025-06-17T14:14:57.293Z" }, + { url = "https://files.pythonhosted.org/packages/9b/03/7773518db74c442904dbd349074f1e7f2a854cee4d9529fc59e623d3949e/multidict-6.5.0-cp313-cp313-win32.whl", hash = "sha256:f34a90fbd9959d0f857323bd3c52b3e6011ed48f78d7d7b9e04980b8a41da3af", size = 41115, upload-time = "2025-06-17T14:14:59.33Z" }, + { url = "https://files.pythonhosted.org/packages/eb/9a/6fc51b1dc11a7baa944bc101a92167d8b0f5929d376a8c65168fc0d35917/multidict-6.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:fcb2aa79ac6aef8d5b709bbfc2fdb1d75210ba43038d70fbb595b35af470ce06", size = 44768, upload-time = "2025-06-17T14:15:00.427Z" }, + { url = "https://files.pythonhosted.org/packages/82/2d/0d010be24b663b3c16e3d3307bbba2de5ae8eec496f6027d5c0515b371a8/multidict-6.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:6dcee5e7e92060b4bb9bb6f01efcbb78c13d0e17d9bc6eec71660dd71dc7b0c2", size = 41770, upload-time = "2025-06-17T14:15:01.854Z" }, + { url = "https://files.pythonhosted.org/packages/aa/d1/a71711a5f32f84b7b036e82182e3250b949a0ce70d51a2c6a4079e665449/multidict-6.5.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:cbbc88abea2388fde41dd574159dec2cda005cb61aa84950828610cb5010f21a", size = 80450, upload-time = "2025-06-17T14:15:02.968Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a2/953a9eede63a98fcec2c1a2c1a0d88de120056219931013b871884f51b43/multidict-6.5.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:70b599f70ae6536e5976364d3c3cf36f40334708bd6cebdd1e2438395d5e7676", size = 46971, upload-time = "2025-06-17T14:15:04.149Z" }, + { url = "https://files.pythonhosted.org/packages/44/61/60250212953459edda2c729e1d85130912f23c67bd4f585546fe4bdb1578/multidict-6.5.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:828bab777aa8d29d59700018178061854e3a47727e0611cb9bec579d3882de3b", size = 45548, upload-time = "2025-06-17T14:15:05.666Z" }, + { url = "https://files.pythonhosted.org/packages/11/b6/e78ee82e96c495bc2582b303f68bed176b481c8d81a441fec07404fce2ca/multidict-6.5.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9695fc1462f17b131c111cf0856a22ff154b0480f86f539d24b2778571ff94d", size = 238545, upload-time = "2025-06-17T14:15:06.88Z" }, + { url = "https://files.pythonhosted.org/packages/5a/0f/6132ca06670c8d7b374c3a4fd1ba896fc37fbb66b0de903f61db7d1020ec/multidict-6.5.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b5ac6ebaf5d9814b15f399337ebc6d3a7f4ce9331edd404e76c49a01620b68d", size = 229931, upload-time = "2025-06-17T14:15:08.24Z" }, + { url = "https://files.pythonhosted.org/packages/c0/63/d9957c506e6df6b3e7a194f0eea62955c12875e454b978f18262a65d017b/multidict-6.5.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84a51e3baa77ded07be4766a9e41d977987b97e49884d4c94f6d30ab6acaee14", size = 248181, upload-time = "2025-06-17T14:15:09.907Z" }, + { url = "https://files.pythonhosted.org/packages/43/3f/7d5490579640db5999a948e2c41d4a0efd91a75989bda3e0a03a79c92be2/multidict-6.5.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8de67f79314d24179e9b1869ed15e88d6ba5452a73fc9891ac142e0ee018b5d6", size = 241846, upload-time = "2025-06-17T14:15:11.596Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f7/252b1ce949ece52bba4c0de7aa2e3a3d5964e800bce71fb778c2e6c66f7c/multidict-6.5.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17f78a52c214481d30550ec18208e287dfc4736f0c0148208334b105fd9e0887", size = 232893, upload-time = "2025-06-17T14:15:12.946Z" }, + { url = "https://files.pythonhosted.org/packages/45/7e/0070bfd48c16afc26e056f2acce49e853c0d604a69c7124bc0bbdb1bcc0a/multidict-6.5.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2966d0099cb2e2039f9b0e73e7fd5eb9c85805681aa2a7f867f9d95b35356921", size = 228567, upload-time = "2025-06-17T14:15:14.267Z" }, + { url = "https://files.pythonhosted.org/packages/2a/31/90551c75322113ebf5fd9c5422e8641d6952f6edaf6b6c07fdc49b1bebdd/multidict-6.5.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:86fb42ed5ed1971c642cc52acc82491af97567534a8e381a8d50c02169c4e684", size = 246188, upload-time = "2025-06-17T14:15:15.985Z" }, + { url = "https://files.pythonhosted.org/packages/cc/e2/aa4b02a55e7767ff292871023817fe4db83668d514dab7ccbce25eaf7659/multidict-6.5.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:4e990cbcb6382f9eae4ec720bcac6a1351509e6fc4a5bb70e4984b27973934e6", size = 235178, upload-time = "2025-06-17T14:15:17.395Z" }, + { url = "https://files.pythonhosted.org/packages/7d/5c/f67e726717c4b138b166be1700e2b56e06fbbcb84643d15f9a9d7335ff41/multidict-6.5.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:d99a59d64bb1f7f2117bec837d9e534c5aeb5dcedf4c2b16b9753ed28fdc20a3", size = 243422, upload-time = "2025-06-17T14:15:18.939Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1c/15fa318285e26a50aa3fa979bbcffb90f9b4d5ec58882d0590eda067d0da/multidict-6.5.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:e8ef15cc97c9890212e1caf90f0d63f6560e1e101cf83aeaf63a57556689fb34", size = 254898, upload-time = "2025-06-17T14:15:20.31Z" }, + { url = "https://files.pythonhosted.org/packages/ad/3d/d6c6d1c2e9b61ca80313912d30bb90d4179335405e421ef0a164eac2c0f9/multidict-6.5.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:b8a09aec921b34bd8b9f842f0bcfd76c6a8c033dc5773511e15f2d517e7e1068", size = 247129, upload-time = "2025-06-17T14:15:21.665Z" }, + { url = "https://files.pythonhosted.org/packages/29/15/1568258cf0090bfa78d44be66247cfdb16e27dfd935c8136a1e8632d3057/multidict-6.5.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:ff07b504c23b67f2044533244c230808a1258b3493aaf3ea2a0785f70b7be461", size = 243841, upload-time = "2025-06-17T14:15:23.38Z" }, + { url = "https://files.pythonhosted.org/packages/65/57/64af5dbcfd61427056e840c8e520b502879d480f9632fbe210929fd87393/multidict-6.5.0-cp313-cp313t-win32.whl", hash = "sha256:9232a117341e7e979d210e41c04e18f1dc3a1d251268df6c818f5334301274e1", size = 46761, upload-time = "2025-06-17T14:15:24.733Z" }, + { url = "https://files.pythonhosted.org/packages/26/a8/cac7f7d61e188ff44f28e46cb98f9cc21762e671c96e031f06c84a60556e/multidict-6.5.0-cp313-cp313t-win_amd64.whl", hash = "sha256:44cb5c53fb2d4cbcee70a768d796052b75d89b827643788a75ea68189f0980a1", size = 52112, upload-time = "2025-06-17T14:15:25.906Z" }, + { url = "https://files.pythonhosted.org/packages/51/9f/076533feb1b5488d22936da98b9c217205cfbf9f56f7174e8c5c86d86fe6/multidict-6.5.0-cp313-cp313t-win_arm64.whl", hash = "sha256:51d33fafa82640c0217391d4ce895d32b7e84a832b8aee0dcc1b04d8981ec7f4", size = 44358, upload-time = "2025-06-17T14:15:27.117Z" }, + { url = "https://files.pythonhosted.org/packages/68/0b/b024da30f18241e03a400aebdc3ca1bcbdc0561f9d48019cbe66549aea3e/multidict-6.5.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c0078358470da8dc90c37456f4a9cde9f86200949a048d53682b9cd21e5bbf2b", size = 73804, upload-time = "2025-06-17T14:15:28.305Z" }, + { url = "https://files.pythonhosted.org/packages/a3/8f/5e69092bb8a75b95dd27ed4d21220641ede7e127d8a0228cd5e1d5f2150e/multidict-6.5.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5cc7968b7d1bf8b973c307d38aa3a2f2c783f149bcac855944804252f1df5105", size = 43161, upload-time = "2025-06-17T14:15:29.47Z" }, + { url = "https://files.pythonhosted.org/packages/e1/d9/51968d296800285343055d482b65001bda4fa4950aad5575afe17906f16f/multidict-6.5.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0ad73a60e11aa92f1f2c9330efdeaac4531b719fc568eb8d312fd4112f34cc18", size = 42996, upload-time = "2025-06-17T14:15:30.622Z" }, + { url = "https://files.pythonhosted.org/packages/38/1c/19ce336cf8af2b7c530ea890496603eb9bbf0da4e3a8e0fcc3669ad30c21/multidict-6.5.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3233f21abdcd180b2624eb6988a1e1287210e99bca986d8320afca5005d85844", size = 231051, upload-time = "2025-06-17T14:15:32.296Z" }, + { url = "https://files.pythonhosted.org/packages/73/9b/2cf6eff5b30ff8a67ca231a741053c8cc8269fd860cac2c0e16b376de89d/multidict-6.5.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:bee5c0b79fca78fd2ab644ca4dc831ecf793eb6830b9f542ee5ed2c91bc35a0e", size = 219511, upload-time = "2025-06-17T14:15:33.602Z" }, + { url = "https://files.pythonhosted.org/packages/bf/ac/43c89a11d710ce6e5c824ece7b570fd79839e3d25a6a7d3b2526a77b290c/multidict-6.5.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e053a4d690f4352ce46583080fefade9a903ce0fa9d820db1be80bdb9304fa2f", size = 240287, upload-time = "2025-06-17T14:15:34.915Z" }, + { url = "https://files.pythonhosted.org/packages/16/94/1896d424324618f2e2adbf9acb049aeef8da3f31c109e37ffda63b58d1b5/multidict-6.5.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:42bdee30424c1f4dcda96e07ac60e2a4ede8a89f8ae2f48b5e4ccc060f294c52", size = 232748, upload-time = "2025-06-17T14:15:36.576Z" }, + { url = "https://files.pythonhosted.org/packages/e1/43/2f852c12622bda304a2e0c4419250de3cd0345776ae2e699416cbdc15c9f/multidict-6.5.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58b2ded1a7982cf7b8322b0645713a0086b2b3cf5bb9f7c01edfc1a9f98d20dc", size = 224910, upload-time = "2025-06-17T14:15:37.941Z" }, + { url = "https://files.pythonhosted.org/packages/31/68/9c32a0305a11aec71a85f354d739011221507bce977a3be8d9fa248763e7/multidict-6.5.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3f805b8b951d1fadc5bc18c3c93e509608ac5a883045ee33bc22e28806847c20", size = 225773, upload-time = "2025-06-17T14:15:39.645Z" }, + { url = "https://files.pythonhosted.org/packages/bc/81/488054827b644e615f59211fc26fd64b28a1366143e4985326802f18773b/multidict-6.5.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2540395b63723da748f850568357a39cd8d8d4403ca9439f9fcdad6dd423c780", size = 244097, upload-time = "2025-06-17T14:15:41.164Z" }, + { url = "https://files.pythonhosted.org/packages/9f/71/b9d96548da768dd7284c1f21187129a48906f526d5ed4f71bb050476d91f/multidict-6.5.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:c96aedff25f4e47b6697ba048b2c278f7caa6df82c7c3f02e077bcc8d47b4b76", size = 232831, upload-time = "2025-06-17T14:15:42.897Z" }, + { url = "https://files.pythonhosted.org/packages/fc/45/0c57c9bf9be7808252269f0d3964c1495413bcee36a7a7e836fdb778a578/multidict-6.5.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:e80de5ad995de210fd02a65c2350649b8321d09bd2e44717eaefb0f5814503e8", size = 242201, upload-time = "2025-06-17T14:15:44.286Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d4/2441e56b32f7d25c917557641b35a89e0142a7412bc57182c80330975b8d/multidict-6.5.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:6cb9bcedd9391b313e5ec2fb3aa07c03e050550e7b9e4646c076d5c24ba01532", size = 254479, upload-time = "2025-06-17T14:15:45.718Z" }, + { url = "https://files.pythonhosted.org/packages/0d/93/acbc2fed235c7a7b2b21fe8c6ac1b612f7fee79dbddd9c73d42b1a65599c/multidict-6.5.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:a7d130ed7a112e25ab47309962ecafae07d073316f9d158bc7b3936b52b80121", size = 244179, upload-time = "2025-06-17T14:15:47.174Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b2/07ce91400ee2b296de2d6d55f1d948d88d148182b35a3edcc480ddb0f99a/multidict-6.5.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:95750a9a9741cd1855d1b6cb4c6031ae01c01ad38d280217b64bfae986d39d56", size = 241173, upload-time = "2025-06-17T14:15:48.566Z" }, + { url = "https://files.pythonhosted.org/packages/a0/09/61c0b044065a1d2e1329b0e4f0f2afa992d3bb319129b63dd63c54c2cc15/multidict-6.5.0-cp39-cp39-win32.whl", hash = "sha256:7f78caf409914f108f4212b53a9033abfdc2cbab0647e9ac3a25bb0f21ab43d2", size = 40467, upload-time = "2025-06-17T14:15:50.285Z" }, + { url = "https://files.pythonhosted.org/packages/7f/43/48c2837046222ea6800824d576f110d7622c4048b3dd252ef62c51a0969b/multidict-6.5.0-cp39-cp39-win_amd64.whl", hash = "sha256:220c74009507e847a3a6fc5375875f2a2e05bd9ce28cf607be0e8c94600f4472", size = 44449, upload-time = "2025-06-17T14:15:51.84Z" }, + { url = "https://files.pythonhosted.org/packages/d2/4e/b61b006e75c6e071fac1bd0f32696ad1b052772493c4e9d0121ba604b215/multidict-6.5.0-cp39-cp39-win_arm64.whl", hash = "sha256:d98f4ac9c1ede7e9d04076e2e6d967e15df0079a6381b297270f6bcab661195e", size = 41477, upload-time = "2025-06-17T14:15:53.964Z" }, + { url = "https://files.pythonhosted.org/packages/44/d8/45e8fc9892a7386d074941429e033adb4640e59ff0780d96a8cf46fe788e/multidict-6.5.0-py3-none-any.whl", hash = "sha256:5634b35f225977605385f56153bd95a7133faffc0ffe12ad26e10517537e8dfc", size = 12181, upload-time = "2025-06-17T14:15:55.156Z" }, +] + +[[package]] +name = "mypy" +version = "1.16.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/69/92c7fa98112e4d9eb075a239caa4ef4649ad7d441545ccffbd5e34607cbb/mypy-1.16.1.tar.gz", hash = "sha256:6bd00a0a2094841c5e47e7374bb42b83d64c527a502e3334e1173a0c24437bab", size = 3324747, upload-time = "2025-06-16T16:51:35.145Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/12/2bf23a80fcef5edb75de9a1e295d778e0f46ea89eb8b115818b663eff42b/mypy-1.16.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b4f0fed1022a63c6fec38f28b7fc77fca47fd490445c69d0a66266c59dd0b88a", size = 10958644, upload-time = "2025-06-16T16:51:11.649Z" }, + { url = "https://files.pythonhosted.org/packages/08/50/bfe47b3b278eacf348291742fd5e6613bbc4b3434b72ce9361896417cfe5/mypy-1.16.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:86042bbf9f5a05ea000d3203cf87aa9d0ccf9a01f73f71c58979eb9249f46d72", size = 10087033, upload-time = "2025-06-16T16:35:30.089Z" }, + { url = "https://files.pythonhosted.org/packages/21/de/40307c12fe25675a0776aaa2cdd2879cf30d99eec91b898de00228dc3ab5/mypy-1.16.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ea7469ee5902c95542bea7ee545f7006508c65c8c54b06dc2c92676ce526f3ea", size = 11875645, upload-time = "2025-06-16T16:35:48.49Z" }, + { url = "https://files.pythonhosted.org/packages/a6/d8/85bdb59e4a98b7a31495bd8f1a4445d8ffc86cde4ab1f8c11d247c11aedc/mypy-1.16.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:352025753ef6a83cb9e7f2427319bb7875d1fdda8439d1e23de12ab164179574", size = 12616986, upload-time = "2025-06-16T16:48:39.526Z" }, + { url = "https://files.pythonhosted.org/packages/0e/d0/bb25731158fa8f8ee9e068d3e94fcceb4971fedf1424248496292512afe9/mypy-1.16.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ff9fa5b16e4c1364eb89a4d16bcda9987f05d39604e1e6c35378a2987c1aac2d", size = 12878632, upload-time = "2025-06-16T16:36:08.195Z" }, + { url = "https://files.pythonhosted.org/packages/2d/11/822a9beb7a2b825c0cb06132ca0a5183f8327a5e23ef89717c9474ba0bc6/mypy-1.16.1-cp310-cp310-win_amd64.whl", hash = "sha256:1256688e284632382f8f3b9e2123df7d279f603c561f099758e66dd6ed4e8bd6", size = 9484391, upload-time = "2025-06-16T16:37:56.151Z" }, + { url = "https://files.pythonhosted.org/packages/9a/61/ec1245aa1c325cb7a6c0f8570a2eee3bfc40fa90d19b1267f8e50b5c8645/mypy-1.16.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:472e4e4c100062488ec643f6162dd0d5208e33e2f34544e1fc931372e806c0cc", size = 10890557, upload-time = "2025-06-16T16:37:21.421Z" }, + { url = "https://files.pythonhosted.org/packages/6b/bb/6eccc0ba0aa0c7a87df24e73f0ad34170514abd8162eb0c75fd7128171fb/mypy-1.16.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ea16e2a7d2714277e349e24d19a782a663a34ed60864006e8585db08f8ad1782", size = 10012921, upload-time = "2025-06-16T16:51:28.659Z" }, + { url = "https://files.pythonhosted.org/packages/5f/80/b337a12e2006715f99f529e732c5f6a8c143bb58c92bb142d5ab380963a5/mypy-1.16.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:08e850ea22adc4d8a4014651575567b0318ede51e8e9fe7a68f25391af699507", size = 11802887, upload-time = "2025-06-16T16:50:53.627Z" }, + { url = "https://files.pythonhosted.org/packages/d9/59/f7af072d09793d581a745a25737c7c0a945760036b16aeb620f658a017af/mypy-1.16.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22d76a63a42619bfb90122889b903519149879ddbf2ba4251834727944c8baca", size = 12531658, upload-time = "2025-06-16T16:33:55.002Z" }, + { url = "https://files.pythonhosted.org/packages/82/c4/607672f2d6c0254b94a646cfc45ad589dd71b04aa1f3d642b840f7cce06c/mypy-1.16.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:2c7ce0662b6b9dc8f4ed86eb7a5d505ee3298c04b40ec13b30e572c0e5ae17c4", size = 12732486, upload-time = "2025-06-16T16:37:03.301Z" }, + { url = "https://files.pythonhosted.org/packages/b6/5e/136555ec1d80df877a707cebf9081bd3a9f397dedc1ab9750518d87489ec/mypy-1.16.1-cp311-cp311-win_amd64.whl", hash = "sha256:211287e98e05352a2e1d4e8759c5490925a7c784ddc84207f4714822f8cf99b6", size = 9479482, upload-time = "2025-06-16T16:47:37.48Z" }, + { url = "https://files.pythonhosted.org/packages/b4/d6/39482e5fcc724c15bf6280ff5806548c7185e0c090712a3736ed4d07e8b7/mypy-1.16.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:af4792433f09575d9eeca5c63d7d90ca4aeceda9d8355e136f80f8967639183d", size = 11066493, upload-time = "2025-06-16T16:47:01.683Z" }, + { url = "https://files.pythonhosted.org/packages/e6/e5/26c347890efc6b757f4d5bb83f4a0cf5958b8cf49c938ac99b8b72b420a6/mypy-1.16.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:66df38405fd8466ce3517eda1f6640611a0b8e70895e2a9462d1d4323c5eb4b9", size = 10081687, upload-time = "2025-06-16T16:48:19.367Z" }, + { url = "https://files.pythonhosted.org/packages/44/c7/b5cb264c97b86914487d6a24bd8688c0172e37ec0f43e93b9691cae9468b/mypy-1.16.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:44e7acddb3c48bd2713994d098729494117803616e116032af192871aed80b79", size = 11839723, upload-time = "2025-06-16T16:49:20.912Z" }, + { url = "https://files.pythonhosted.org/packages/15/f8/491997a9b8a554204f834ed4816bda813aefda31cf873bb099deee3c9a99/mypy-1.16.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ab5eca37b50188163fa7c1b73c685ac66c4e9bdee4a85c9adac0e91d8895e15", size = 12722980, upload-time = "2025-06-16T16:37:40.929Z" }, + { url = "https://files.pythonhosted.org/packages/df/f0/2bd41e174b5fd93bc9de9a28e4fb673113633b8a7f3a607fa4a73595e468/mypy-1.16.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dedb6229b2c9086247e21a83c309754b9058b438704ad2f6807f0d8227f6ebdd", size = 12903328, upload-time = "2025-06-16T16:34:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/61/81/5572108a7bec2c46b8aff7e9b524f371fe6ab5efb534d38d6b37b5490da8/mypy-1.16.1-cp312-cp312-win_amd64.whl", hash = "sha256:1f0435cf920e287ff68af3d10a118a73f212deb2ce087619eb4e648116d1fe9b", size = 9562321, upload-time = "2025-06-16T16:48:58.823Z" }, + { url = "https://files.pythonhosted.org/packages/28/e3/96964af4a75a949e67df4b95318fe2b7427ac8189bbc3ef28f92a1c5bc56/mypy-1.16.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ddc91eb318c8751c69ddb200a5937f1232ee8efb4e64e9f4bc475a33719de438", size = 11063480, upload-time = "2025-06-16T16:47:56.205Z" }, + { url = "https://files.pythonhosted.org/packages/f5/4d/cd1a42b8e5be278fab7010fb289d9307a63e07153f0ae1510a3d7b703193/mypy-1.16.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:87ff2c13d58bdc4bbe7dc0dedfe622c0f04e2cb2a492269f3b418df2de05c536", size = 10090538, upload-time = "2025-06-16T16:46:43.92Z" }, + { url = "https://files.pythonhosted.org/packages/c9/4f/c3c6b4b66374b5f68bab07c8cabd63a049ff69796b844bc759a0ca99bb2a/mypy-1.16.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a7cfb0fe29fe5a9841b7c8ee6dffb52382c45acdf68f032145b75620acfbd6f", size = 11836839, upload-time = "2025-06-16T16:36:28.039Z" }, + { url = "https://files.pythonhosted.org/packages/b4/7e/81ca3b074021ad9775e5cb97ebe0089c0f13684b066a750b7dc208438403/mypy-1.16.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:051e1677689c9d9578b9c7f4d206d763f9bbd95723cd1416fad50db49d52f359", size = 12715634, upload-time = "2025-06-16T16:50:34.441Z" }, + { url = "https://files.pythonhosted.org/packages/e9/95/bdd40c8be346fa4c70edb4081d727a54d0a05382d84966869738cfa8a497/mypy-1.16.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d5d2309511cc56c021b4b4e462907c2b12f669b2dbeb68300110ec27723971be", size = 12895584, upload-time = "2025-06-16T16:34:54.857Z" }, + { url = "https://files.pythonhosted.org/packages/5a/fd/d486a0827a1c597b3b48b1bdef47228a6e9ee8102ab8c28f944cb83b65dc/mypy-1.16.1-cp313-cp313-win_amd64.whl", hash = "sha256:4f58ac32771341e38a853c5d0ec0dfe27e18e27da9cdb8bbc882d2249c71a3ee", size = 9573886, upload-time = "2025-06-16T16:36:43.589Z" }, + { url = "https://files.pythonhosted.org/packages/49/5e/ed1e6a7344005df11dfd58b0fdd59ce939a0ba9f7ed37754bf20670b74db/mypy-1.16.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7fc688329af6a287567f45cc1cefb9db662defeb14625213a5b7da6e692e2069", size = 10959511, upload-time = "2025-06-16T16:47:21.945Z" }, + { url = "https://files.pythonhosted.org/packages/30/88/a7cbc2541e91fe04f43d9e4577264b260fecedb9bccb64ffb1a34b7e6c22/mypy-1.16.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5e198ab3f55924c03ead626ff424cad1732d0d391478dfbf7bb97b34602395da", size = 10075555, upload-time = "2025-06-16T16:50:14.084Z" }, + { url = "https://files.pythonhosted.org/packages/93/f7/c62b1e31a32fbd1546cca5e0a2e5f181be5761265ad1f2e94f2a306fa906/mypy-1.16.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09aa4f91ada245f0a45dbc47e548fd94e0dd5a8433e0114917dc3b526912a30c", size = 11874169, upload-time = "2025-06-16T16:49:42.276Z" }, + { url = "https://files.pythonhosted.org/packages/c8/15/db580a28034657fb6cb87af2f8996435a5b19d429ea4dcd6e1c73d418e60/mypy-1.16.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13c7cd5b1cb2909aa318a90fd1b7e31f17c50b242953e7dd58345b2a814f6383", size = 12610060, upload-time = "2025-06-16T16:34:15.215Z" }, + { url = "https://files.pythonhosted.org/packages/ec/78/c17f48f6843048fa92d1489d3095e99324f2a8c420f831a04ccc454e2e51/mypy-1.16.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:58e07fb958bc5d752a280da0e890c538f1515b79a65757bbdc54252ba82e0b40", size = 12875199, upload-time = "2025-06-16T16:35:14.448Z" }, + { url = "https://files.pythonhosted.org/packages/bc/d6/ed42167d0a42680381653fd251d877382351e1bd2c6dd8a818764be3beb1/mypy-1.16.1-cp39-cp39-win_amd64.whl", hash = "sha256:f895078594d918f93337a505f8add9bd654d1a24962b4c6ed9390e12531eb31b", size = 9487033, upload-time = "2025-06-16T16:49:57.907Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d3/53e684e78e07c1a2bf7105715e5edd09ce951fc3f47cf9ed095ec1b7a037/mypy-1.16.1-py3-none-any.whl", hash = "sha256:5fc2ac4027d0ef28d6ba69a0343737a23c4d1b83672bf38d1fe237bdc0643b37", size = 2265923, upload-time = "2025-06-16T16:48:02.366Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "natsort" +version = "8.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e2/a9/a0c57aee75f77794adaf35322f8b6404cbd0f89ad45c87197a937764b7d0/natsort-8.4.0.tar.gz", hash = "sha256:45312c4a0e5507593da193dedd04abb1469253b601ecaf63445ad80f0a1ea581", size = 76575, upload-time = "2023-06-20T04:17:19.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/82/7a9d0550484a62c6da82858ee9419f3dd1ccc9aa1c26a1e43da3ecd20b0d/natsort-8.4.0-py3-none-any.whl", hash = "sha256:4732914fb471f56b5cce04d7bae6f164a592c7712e1c85f9ef585e197299521c", size = 38268, upload-time = "2023-06-20T04:17:17.522Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "pathspec" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.3.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/8b/3c73abc9c759ecd3f1f7ceff6685840859e8070c4d947c93fae71f6a0bf2/platformdirs-4.3.8.tar.gz", hash = "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc", size = 21362, upload-time = "2025-05-07T22:47:42.121Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/39/979e8e21520d4e47a0bbe349e2713c0aac6f3d853d0e5b34d76206c439aa/platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4", size = 18567, upload-time = "2025-05-07T22:47:40.376Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pre-commit" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/08/39/679ca9b26c7bb2999ff122d50faa301e49af82ca9c066ec061cfbc0c6784/pre_commit-4.2.0.tar.gz", hash = "sha256:601283b9757afd87d40c4c4a9b2b5de9637a8ea02eaff7adc2d0fb4e04841146", size = 193424, upload-time = "2025-03-18T21:35:20.987Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/74/a88bf1b1efeae488a0c0b7bdf71429c313722d1fc0f377537fbe554e6180/pre_commit-4.2.0-py2.py3-none-any.whl", hash = "sha256:a009ca7205f1eb497d10b845e52c838a98b6cdd2102a6c8e4540e94ee75c58bd", size = 220707, upload-time = "2025-03-18T21:35:19.343Z" }, +] + +[[package]] +name = "propcache" +version = "0.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/16/43264e4a779dd8588c21a70f0709665ee8f611211bdd2c87d952cfa7c776/propcache-0.3.2.tar.gz", hash = "sha256:20d7d62e4e7ef05f221e0db2856b979540686342e7dd9973b815599c7057e168", size = 44139, upload-time = "2025-06-09T22:56:06.081Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/14/510deed325e262afeb8b360043c5d7c960da7d3ecd6d6f9496c9c56dc7f4/propcache-0.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:22d9962a358aedbb7a2e36187ff273adeaab9743373a272976d2e348d08c7770", size = 73178, upload-time = "2025-06-09T22:53:40.126Z" }, + { url = "https://files.pythonhosted.org/packages/cd/4e/ad52a7925ff01c1325653a730c7ec3175a23f948f08626a534133427dcff/propcache-0.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0d0fda578d1dc3f77b6b5a5dce3b9ad69a8250a891760a548df850a5e8da87f3", size = 43133, upload-time = "2025-06-09T22:53:41.965Z" }, + { url = "https://files.pythonhosted.org/packages/63/7c/e9399ba5da7780871db4eac178e9c2e204c23dd3e7d32df202092a1ed400/propcache-0.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3def3da3ac3ce41562d85db655d18ebac740cb3fa4367f11a52b3da9d03a5cc3", size = 43039, upload-time = "2025-06-09T22:53:43.268Z" }, + { url = "https://files.pythonhosted.org/packages/22/e1/58da211eb8fdc6fc854002387d38f415a6ca5f5c67c1315b204a5d3e9d7a/propcache-0.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9bec58347a5a6cebf239daba9bda37dffec5b8d2ce004d9fe4edef3d2815137e", size = 201903, upload-time = "2025-06-09T22:53:44.872Z" }, + { url = "https://files.pythonhosted.org/packages/c4/0a/550ea0f52aac455cb90111c8bab995208443e46d925e51e2f6ebdf869525/propcache-0.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55ffda449a507e9fbd4aca1a7d9aa6753b07d6166140e5a18d2ac9bc49eac220", size = 213362, upload-time = "2025-06-09T22:53:46.707Z" }, + { url = "https://files.pythonhosted.org/packages/5a/af/9893b7d878deda9bb69fcf54600b247fba7317761b7db11fede6e0f28bd0/propcache-0.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:64a67fb39229a8a8491dd42f864e5e263155e729c2e7ff723d6e25f596b1e8cb", size = 210525, upload-time = "2025-06-09T22:53:48.547Z" }, + { url = "https://files.pythonhosted.org/packages/7c/bb/38fd08b278ca85cde36d848091ad2b45954bc5f15cce494bb300b9285831/propcache-0.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9da1cf97b92b51253d5b68cf5a2b9e0dafca095e36b7f2da335e27dc6172a614", size = 198283, upload-time = "2025-06-09T22:53:50.067Z" }, + { url = "https://files.pythonhosted.org/packages/78/8c/9fe55bd01d362bafb413dfe508c48753111a1e269737fa143ba85693592c/propcache-0.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5f559e127134b07425134b4065be45b166183fdcb433cb6c24c8e4149056ad50", size = 191872, upload-time = "2025-06-09T22:53:51.438Z" }, + { url = "https://files.pythonhosted.org/packages/54/14/4701c33852937a22584e08abb531d654c8bcf7948a8f87ad0a4822394147/propcache-0.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:aff2e4e06435d61f11a428360a932138d0ec288b0a31dd9bd78d200bd4a2b339", size = 199452, upload-time = "2025-06-09T22:53:53.229Z" }, + { url = "https://files.pythonhosted.org/packages/16/44/447f2253d859602095356007657ee535e0093215ea0b3d1d6a41d16e5201/propcache-0.3.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:4927842833830942a5d0a56e6f4839bc484785b8e1ce8d287359794818633ba0", size = 191567, upload-time = "2025-06-09T22:53:54.541Z" }, + { url = "https://files.pythonhosted.org/packages/f2/b3/e4756258749bb2d3b46defcff606a2f47410bab82be5824a67e84015b267/propcache-0.3.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:6107ddd08b02654a30fb8ad7a132021759d750a82578b94cd55ee2772b6ebea2", size = 193015, upload-time = "2025-06-09T22:53:56.44Z" }, + { url = "https://files.pythonhosted.org/packages/1e/df/e6d3c7574233164b6330b9fd697beeac402afd367280e6dc377bb99b43d9/propcache-0.3.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:70bd8b9cd6b519e12859c99f3fc9a93f375ebd22a50296c3a295028bea73b9e7", size = 204660, upload-time = "2025-06-09T22:53:57.839Z" }, + { url = "https://files.pythonhosted.org/packages/b2/53/e4d31dd5170b4a0e2e6b730f2385a96410633b4833dc25fe5dffd1f73294/propcache-0.3.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2183111651d710d3097338dd1893fcf09c9f54e27ff1a8795495a16a469cc90b", size = 206105, upload-time = "2025-06-09T22:53:59.638Z" }, + { url = "https://files.pythonhosted.org/packages/7f/fe/74d54cf9fbe2a20ff786e5f7afcfde446588f0cf15fb2daacfbc267b866c/propcache-0.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:fb075ad271405dcad8e2a7ffc9a750a3bf70e533bd86e89f0603e607b93aa64c", size = 196980, upload-time = "2025-06-09T22:54:01.071Z" }, + { url = "https://files.pythonhosted.org/packages/22/ec/c469c9d59dada8a7679625e0440b544fe72e99311a4679c279562051f6fc/propcache-0.3.2-cp310-cp310-win32.whl", hash = "sha256:404d70768080d3d3bdb41d0771037da19d8340d50b08e104ca0e7f9ce55fce70", size = 37679, upload-time = "2025-06-09T22:54:03.003Z" }, + { url = "https://files.pythonhosted.org/packages/38/35/07a471371ac89d418f8d0b699c75ea6dca2041fbda360823de21f6a9ce0a/propcache-0.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:7435d766f978b4ede777002e6b3b6641dd229cd1da8d3d3106a45770365f9ad9", size = 41459, upload-time = "2025-06-09T22:54:04.134Z" }, + { url = "https://files.pythonhosted.org/packages/80/8d/e8b436717ab9c2cfc23b116d2c297305aa4cd8339172a456d61ebf5669b8/propcache-0.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0b8d2f607bd8f80ddc04088bc2a037fdd17884a6fcadc47a96e334d72f3717be", size = 74207, upload-time = "2025-06-09T22:54:05.399Z" }, + { url = "https://files.pythonhosted.org/packages/d6/29/1e34000e9766d112171764b9fa3226fa0153ab565d0c242c70e9945318a7/propcache-0.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:06766d8f34733416e2e34f46fea488ad5d60726bb9481d3cddf89a6fa2d9603f", size = 43648, upload-time = "2025-06-09T22:54:08.023Z" }, + { url = "https://files.pythonhosted.org/packages/46/92/1ad5af0df781e76988897da39b5f086c2bf0f028b7f9bd1f409bb05b6874/propcache-0.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a2dc1f4a1df4fecf4e6f68013575ff4af84ef6f478fe5344317a65d38a8e6dc9", size = 43496, upload-time = "2025-06-09T22:54:09.228Z" }, + { url = "https://files.pythonhosted.org/packages/b3/ce/e96392460f9fb68461fabab3e095cb00c8ddf901205be4eae5ce246e5b7e/propcache-0.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be29c4f4810c5789cf10ddf6af80b041c724e629fa51e308a7a0fb19ed1ef7bf", size = 217288, upload-time = "2025-06-09T22:54:10.466Z" }, + { url = "https://files.pythonhosted.org/packages/c5/2a/866726ea345299f7ceefc861a5e782b045545ae6940851930a6adaf1fca6/propcache-0.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:59d61f6970ecbd8ff2e9360304d5c8876a6abd4530cb752c06586849ac8a9dc9", size = 227456, upload-time = "2025-06-09T22:54:11.828Z" }, + { url = "https://files.pythonhosted.org/packages/de/03/07d992ccb6d930398689187e1b3c718339a1c06b8b145a8d9650e4726166/propcache-0.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:62180e0b8dbb6b004baec00a7983e4cc52f5ada9cd11f48c3528d8cfa7b96a66", size = 225429, upload-time = "2025-06-09T22:54:13.823Z" }, + { url = "https://files.pythonhosted.org/packages/5d/e6/116ba39448753b1330f48ab8ba927dcd6cf0baea8a0ccbc512dfb49ba670/propcache-0.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c144ca294a204c470f18cf4c9d78887810d04a3e2fbb30eea903575a779159df", size = 213472, upload-time = "2025-06-09T22:54:15.232Z" }, + { url = "https://files.pythonhosted.org/packages/a6/85/f01f5d97e54e428885a5497ccf7f54404cbb4f906688a1690cd51bf597dc/propcache-0.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c5c2a784234c28854878d68978265617aa6dc0780e53d44b4d67f3651a17a9a2", size = 204480, upload-time = "2025-06-09T22:54:17.104Z" }, + { url = "https://files.pythonhosted.org/packages/e3/79/7bf5ab9033b8b8194cc3f7cf1aaa0e9c3256320726f64a3e1f113a812dce/propcache-0.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5745bc7acdafa978ca1642891b82c19238eadc78ba2aaa293c6863b304e552d7", size = 214530, upload-time = "2025-06-09T22:54:18.512Z" }, + { url = "https://files.pythonhosted.org/packages/31/0b/bd3e0c00509b609317df4a18e6b05a450ef2d9a963e1d8bc9c9415d86f30/propcache-0.3.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:c0075bf773d66fa8c9d41f66cc132ecc75e5bb9dd7cce3cfd14adc5ca184cb95", size = 205230, upload-time = "2025-06-09T22:54:19.947Z" }, + { url = "https://files.pythonhosted.org/packages/7a/23/fae0ff9b54b0de4e819bbe559508da132d5683c32d84d0dc2ccce3563ed4/propcache-0.3.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5f57aa0847730daceff0497f417c9de353c575d8da3579162cc74ac294c5369e", size = 206754, upload-time = "2025-06-09T22:54:21.716Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7f/ad6a3c22630aaa5f618b4dc3c3598974a72abb4c18e45a50b3cdd091eb2f/propcache-0.3.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:eef914c014bf72d18efb55619447e0aecd5fb7c2e3fa7441e2e5d6099bddff7e", size = 218430, upload-time = "2025-06-09T22:54:23.17Z" }, + { url = "https://files.pythonhosted.org/packages/5b/2c/ba4f1c0e8a4b4c75910742f0d333759d441f65a1c7f34683b4a74c0ee015/propcache-0.3.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2a4092e8549031e82facf3decdbc0883755d5bbcc62d3aea9d9e185549936dcf", size = 223884, upload-time = "2025-06-09T22:54:25.539Z" }, + { url = "https://files.pythonhosted.org/packages/88/e4/ebe30fc399e98572019eee82ad0caf512401661985cbd3da5e3140ffa1b0/propcache-0.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:85871b050f174bc0bfb437efbdb68aaf860611953ed12418e4361bc9c392749e", size = 211480, upload-time = "2025-06-09T22:54:26.892Z" }, + { url = "https://files.pythonhosted.org/packages/96/0a/7d5260b914e01d1d0906f7f38af101f8d8ed0dc47426219eeaf05e8ea7c2/propcache-0.3.2-cp311-cp311-win32.whl", hash = "sha256:36c8d9b673ec57900c3554264e630d45980fd302458e4ac801802a7fd2ef7897", size = 37757, upload-time = "2025-06-09T22:54:28.241Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2d/89fe4489a884bc0da0c3278c552bd4ffe06a1ace559db5ef02ef24ab446b/propcache-0.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:e53af8cb6a781b02d2ea079b5b853ba9430fcbe18a8e3ce647d5982a3ff69f39", size = 41500, upload-time = "2025-06-09T22:54:29.4Z" }, + { url = "https://files.pythonhosted.org/packages/a8/42/9ca01b0a6f48e81615dca4765a8f1dd2c057e0540f6116a27dc5ee01dfb6/propcache-0.3.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:8de106b6c84506b31c27168582cd3cb3000a6412c16df14a8628e5871ff83c10", size = 73674, upload-time = "2025-06-09T22:54:30.551Z" }, + { url = "https://files.pythonhosted.org/packages/af/6e/21293133beb550f9c901bbece755d582bfaf2176bee4774000bd4dd41884/propcache-0.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:28710b0d3975117239c76600ea351934ac7b5ff56e60953474342608dbbb6154", size = 43570, upload-time = "2025-06-09T22:54:32.296Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c8/0393a0a3a2b8760eb3bde3c147f62b20044f0ddac81e9d6ed7318ec0d852/propcache-0.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce26862344bdf836650ed2487c3d724b00fbfec4233a1013f597b78c1cb73615", size = 43094, upload-time = "2025-06-09T22:54:33.929Z" }, + { url = "https://files.pythonhosted.org/packages/37/2c/489afe311a690399d04a3e03b069225670c1d489eb7b044a566511c1c498/propcache-0.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bca54bd347a253af2cf4544bbec232ab982f4868de0dd684246b67a51bc6b1db", size = 226958, upload-time = "2025-06-09T22:54:35.186Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ca/63b520d2f3d418c968bf596839ae26cf7f87bead026b6192d4da6a08c467/propcache-0.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55780d5e9a2ddc59711d727226bb1ba83a22dd32f64ee15594b9392b1f544eb1", size = 234894, upload-time = "2025-06-09T22:54:36.708Z" }, + { url = "https://files.pythonhosted.org/packages/11/60/1d0ed6fff455a028d678df30cc28dcee7af77fa2b0e6962ce1df95c9a2a9/propcache-0.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:035e631be25d6975ed87ab23153db6a73426a48db688070d925aa27e996fe93c", size = 233672, upload-time = "2025-06-09T22:54:38.062Z" }, + { url = "https://files.pythonhosted.org/packages/37/7c/54fd5301ef38505ab235d98827207176a5c9b2aa61939b10a460ca53e123/propcache-0.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ee6f22b6eaa39297c751d0e80c0d3a454f112f5c6481214fcf4c092074cecd67", size = 224395, upload-time = "2025-06-09T22:54:39.634Z" }, + { url = "https://files.pythonhosted.org/packages/ee/1a/89a40e0846f5de05fdc6779883bf46ba980e6df4d2ff8fb02643de126592/propcache-0.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7ca3aee1aa955438c4dba34fc20a9f390e4c79967257d830f137bd5a8a32ed3b", size = 212510, upload-time = "2025-06-09T22:54:41.565Z" }, + { url = "https://files.pythonhosted.org/packages/5e/33/ca98368586c9566a6b8d5ef66e30484f8da84c0aac3f2d9aec6d31a11bd5/propcache-0.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7a4f30862869fa2b68380d677cc1c5fcf1e0f2b9ea0cf665812895c75d0ca3b8", size = 222949, upload-time = "2025-06-09T22:54:43.038Z" }, + { url = "https://files.pythonhosted.org/packages/ba/11/ace870d0aafe443b33b2f0b7efdb872b7c3abd505bfb4890716ad7865e9d/propcache-0.3.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b77ec3c257d7816d9f3700013639db7491a434644c906a2578a11daf13176251", size = 217258, upload-time = "2025-06-09T22:54:44.376Z" }, + { url = "https://files.pythonhosted.org/packages/5b/d2/86fd6f7adffcfc74b42c10a6b7db721d1d9ca1055c45d39a1a8f2a740a21/propcache-0.3.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cab90ac9d3f14b2d5050928483d3d3b8fb6b4018893fc75710e6aa361ecb2474", size = 213036, upload-time = "2025-06-09T22:54:46.243Z" }, + { url = "https://files.pythonhosted.org/packages/07/94/2d7d1e328f45ff34a0a284cf5a2847013701e24c2a53117e7c280a4316b3/propcache-0.3.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:0b504d29f3c47cf6b9e936c1852246c83d450e8e063d50562115a6be6d3a2535", size = 227684, upload-time = "2025-06-09T22:54:47.63Z" }, + { url = "https://files.pythonhosted.org/packages/b7/05/37ae63a0087677e90b1d14710e532ff104d44bc1efa3b3970fff99b891dc/propcache-0.3.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:ce2ac2675a6aa41ddb2a0c9cbff53780a617ac3d43e620f8fd77ba1c84dcfc06", size = 234562, upload-time = "2025-06-09T22:54:48.982Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7c/3f539fcae630408d0bd8bf3208b9a647ccad10976eda62402a80adf8fc34/propcache-0.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:62b4239611205294cc433845b914131b2a1f03500ff3c1ed093ed216b82621e1", size = 222142, upload-time = "2025-06-09T22:54:50.424Z" }, + { url = "https://files.pythonhosted.org/packages/7c/d2/34b9eac8c35f79f8a962546b3e97e9d4b990c420ee66ac8255d5d9611648/propcache-0.3.2-cp312-cp312-win32.whl", hash = "sha256:df4a81b9b53449ebc90cc4deefb052c1dd934ba85012aa912c7ea7b7e38b60c1", size = 37711, upload-time = "2025-06-09T22:54:52.072Z" }, + { url = "https://files.pythonhosted.org/packages/19/61/d582be5d226cf79071681d1b46b848d6cb03d7b70af7063e33a2787eaa03/propcache-0.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:7046e79b989d7fe457bb755844019e10f693752d169076138abf17f31380800c", size = 41479, upload-time = "2025-06-09T22:54:53.234Z" }, + { url = "https://files.pythonhosted.org/packages/dc/d1/8c747fafa558c603c4ca19d8e20b288aa0c7cda74e9402f50f31eb65267e/propcache-0.3.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ca592ed634a73ca002967458187109265e980422116c0a107cf93d81f95af945", size = 71286, upload-time = "2025-06-09T22:54:54.369Z" }, + { url = "https://files.pythonhosted.org/packages/61/99/d606cb7986b60d89c36de8a85d58764323b3a5ff07770a99d8e993b3fa73/propcache-0.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9ecb0aad4020e275652ba3975740f241bd12a61f1a784df044cf7477a02bc252", size = 42425, upload-time = "2025-06-09T22:54:55.642Z" }, + { url = "https://files.pythonhosted.org/packages/8c/96/ef98f91bbb42b79e9bb82bdd348b255eb9d65f14dbbe3b1594644c4073f7/propcache-0.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7f08f1cc28bd2eade7a8a3d2954ccc673bb02062e3e7da09bc75d843386b342f", size = 41846, upload-time = "2025-06-09T22:54:57.246Z" }, + { url = "https://files.pythonhosted.org/packages/5b/ad/3f0f9a705fb630d175146cd7b1d2bf5555c9beaed54e94132b21aac098a6/propcache-0.3.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1a342c834734edb4be5ecb1e9fb48cb64b1e2320fccbd8c54bf8da8f2a84c33", size = 208871, upload-time = "2025-06-09T22:54:58.975Z" }, + { url = "https://files.pythonhosted.org/packages/3a/38/2085cda93d2c8b6ec3e92af2c89489a36a5886b712a34ab25de9fbca7992/propcache-0.3.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8a544caaae1ac73f1fecfae70ded3e93728831affebd017d53449e3ac052ac1e", size = 215720, upload-time = "2025-06-09T22:55:00.471Z" }, + { url = "https://files.pythonhosted.org/packages/61/c1/d72ea2dc83ac7f2c8e182786ab0fc2c7bd123a1ff9b7975bee671866fe5f/propcache-0.3.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:310d11aa44635298397db47a3ebce7db99a4cc4b9bbdfcf6c98a60c8d5261cf1", size = 215203, upload-time = "2025-06-09T22:55:01.834Z" }, + { url = "https://files.pythonhosted.org/packages/af/81/b324c44ae60c56ef12007105f1460d5c304b0626ab0cc6b07c8f2a9aa0b8/propcache-0.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c1396592321ac83157ac03a2023aa6cc4a3cc3cfdecb71090054c09e5a7cce3", size = 206365, upload-time = "2025-06-09T22:55:03.199Z" }, + { url = "https://files.pythonhosted.org/packages/09/73/88549128bb89e66d2aff242488f62869014ae092db63ccea53c1cc75a81d/propcache-0.3.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8cabf5b5902272565e78197edb682017d21cf3b550ba0460ee473753f28d23c1", size = 196016, upload-time = "2025-06-09T22:55:04.518Z" }, + { url = "https://files.pythonhosted.org/packages/b9/3f/3bdd14e737d145114a5eb83cb172903afba7242f67c5877f9909a20d948d/propcache-0.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0a2f2235ac46a7aa25bdeb03a9e7060f6ecbd213b1f9101c43b3090ffb971ef6", size = 205596, upload-time = "2025-06-09T22:55:05.942Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ca/2f4aa819c357d3107c3763d7ef42c03980f9ed5c48c82e01e25945d437c1/propcache-0.3.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:92b69e12e34869a6970fd2f3da91669899994b47c98f5d430b781c26f1d9f387", size = 200977, upload-time = "2025-06-09T22:55:07.792Z" }, + { url = "https://files.pythonhosted.org/packages/cd/4a/e65276c7477533c59085251ae88505caf6831c0e85ff8b2e31ebcbb949b1/propcache-0.3.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:54e02207c79968ebbdffc169591009f4474dde3b4679e16634d34c9363ff56b4", size = 197220, upload-time = "2025-06-09T22:55:09.173Z" }, + { url = "https://files.pythonhosted.org/packages/7c/54/fc7152e517cf5578278b242396ce4d4b36795423988ef39bb8cd5bf274c8/propcache-0.3.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4adfb44cb588001f68c5466579d3f1157ca07f7504fc91ec87862e2b8e556b88", size = 210642, upload-time = "2025-06-09T22:55:10.62Z" }, + { url = "https://files.pythonhosted.org/packages/b9/80/abeb4a896d2767bf5f1ea7b92eb7be6a5330645bd7fb844049c0e4045d9d/propcache-0.3.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fd3e6019dc1261cd0291ee8919dd91fbab7b169bb76aeef6c716833a3f65d206", size = 212789, upload-time = "2025-06-09T22:55:12.029Z" }, + { url = "https://files.pythonhosted.org/packages/b3/db/ea12a49aa7b2b6d68a5da8293dcf50068d48d088100ac016ad92a6a780e6/propcache-0.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4c181cad81158d71c41a2bce88edce078458e2dd5ffee7eddd6b05da85079f43", size = 205880, upload-time = "2025-06-09T22:55:13.45Z" }, + { url = "https://files.pythonhosted.org/packages/d1/e5/9076a0bbbfb65d1198007059c65639dfd56266cf8e477a9707e4b1999ff4/propcache-0.3.2-cp313-cp313-win32.whl", hash = "sha256:8a08154613f2249519e549de2330cf8e2071c2887309a7b07fb56098f5170a02", size = 37220, upload-time = "2025-06-09T22:55:15.284Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f5/b369e026b09a26cd77aa88d8fffd69141d2ae00a2abaaf5380d2603f4b7f/propcache-0.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:e41671f1594fc4ab0a6dec1351864713cb3a279910ae8b58f884a88a0a632c05", size = 40678, upload-time = "2025-06-09T22:55:16.445Z" }, + { url = "https://files.pythonhosted.org/packages/a4/3a/6ece377b55544941a08d03581c7bc400a3c8cd3c2865900a68d5de79e21f/propcache-0.3.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:9a3cf035bbaf035f109987d9d55dc90e4b0e36e04bbbb95af3055ef17194057b", size = 76560, upload-time = "2025-06-09T22:55:17.598Z" }, + { url = "https://files.pythonhosted.org/packages/0c/da/64a2bb16418740fa634b0e9c3d29edff1db07f56d3546ca2d86ddf0305e1/propcache-0.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:156c03d07dc1323d8dacaa221fbe028c5c70d16709cdd63502778e6c3ccca1b0", size = 44676, upload-time = "2025-06-09T22:55:18.922Z" }, + { url = "https://files.pythonhosted.org/packages/36/7b/f025e06ea51cb72c52fb87e9b395cced02786610b60a3ed51da8af017170/propcache-0.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74413c0ba02ba86f55cf60d18daab219f7e531620c15f1e23d95563f505efe7e", size = 44701, upload-time = "2025-06-09T22:55:20.106Z" }, + { url = "https://files.pythonhosted.org/packages/a4/00/faa1b1b7c3b74fc277f8642f32a4c72ba1d7b2de36d7cdfb676db7f4303e/propcache-0.3.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f066b437bb3fa39c58ff97ab2ca351db465157d68ed0440abecb21715eb24b28", size = 276934, upload-time = "2025-06-09T22:55:21.5Z" }, + { url = "https://files.pythonhosted.org/packages/74/ab/935beb6f1756e0476a4d5938ff44bf0d13a055fed880caf93859b4f1baf4/propcache-0.3.2-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f1304b085c83067914721e7e9d9917d41ad87696bf70f0bc7dee450e9c71ad0a", size = 278316, upload-time = "2025-06-09T22:55:22.918Z" }, + { url = "https://files.pythonhosted.org/packages/f8/9d/994a5c1ce4389610838d1caec74bdf0e98b306c70314d46dbe4fcf21a3e2/propcache-0.3.2-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ab50cef01b372763a13333b4e54021bdcb291fc9a8e2ccb9c2df98be51bcde6c", size = 282619, upload-time = "2025-06-09T22:55:24.651Z" }, + { url = "https://files.pythonhosted.org/packages/2b/00/a10afce3d1ed0287cef2e09506d3be9822513f2c1e96457ee369adb9a6cd/propcache-0.3.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fad3b2a085ec259ad2c2842666b2a0a49dea8463579c606426128925af1ed725", size = 265896, upload-time = "2025-06-09T22:55:26.049Z" }, + { url = "https://files.pythonhosted.org/packages/2e/a8/2aa6716ffa566ca57c749edb909ad27884680887d68517e4be41b02299f3/propcache-0.3.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:261fa020c1c14deafd54c76b014956e2f86991af198c51139faf41c4d5e83892", size = 252111, upload-time = "2025-06-09T22:55:27.381Z" }, + { url = "https://files.pythonhosted.org/packages/36/4f/345ca9183b85ac29c8694b0941f7484bf419c7f0fea2d1e386b4f7893eed/propcache-0.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:46d7f8aa79c927e5f987ee3a80205c987717d3659f035c85cf0c3680526bdb44", size = 268334, upload-time = "2025-06-09T22:55:28.747Z" }, + { url = "https://files.pythonhosted.org/packages/3e/ca/fcd54f78b59e3f97b3b9715501e3147f5340167733d27db423aa321e7148/propcache-0.3.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:6d8f3f0eebf73e3c0ff0e7853f68be638b4043c65a70517bb575eff54edd8dbe", size = 255026, upload-time = "2025-06-09T22:55:30.184Z" }, + { url = "https://files.pythonhosted.org/packages/8b/95/8e6a6bbbd78ac89c30c225210a5c687790e532ba4088afb8c0445b77ef37/propcache-0.3.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:03c89c1b14a5452cf15403e291c0ccd7751d5b9736ecb2c5bab977ad6c5bcd81", size = 250724, upload-time = "2025-06-09T22:55:31.646Z" }, + { url = "https://files.pythonhosted.org/packages/ee/b0/0dd03616142baba28e8b2d14ce5df6631b4673850a3d4f9c0f9dd714a404/propcache-0.3.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:0cc17efde71e12bbaad086d679ce575268d70bc123a5a71ea7ad76f70ba30bba", size = 268868, upload-time = "2025-06-09T22:55:33.209Z" }, + { url = "https://files.pythonhosted.org/packages/c5/98/2c12407a7e4fbacd94ddd32f3b1e3d5231e77c30ef7162b12a60e2dd5ce3/propcache-0.3.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:acdf05d00696bc0447e278bb53cb04ca72354e562cf88ea6f9107df8e7fd9770", size = 271322, upload-time = "2025-06-09T22:55:35.065Z" }, + { url = "https://files.pythonhosted.org/packages/35/91/9cb56efbb428b006bb85db28591e40b7736847b8331d43fe335acf95f6c8/propcache-0.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4445542398bd0b5d32df908031cb1b30d43ac848e20470a878b770ec2dcc6330", size = 265778, upload-time = "2025-06-09T22:55:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4c/b0fe775a2bdd01e176b14b574be679d84fc83958335790f7c9a686c1f468/propcache-0.3.2-cp313-cp313t-win32.whl", hash = "sha256:f86e5d7cd03afb3a1db8e9f9f6eff15794e79e791350ac48a8c924e6f439f394", size = 41175, upload-time = "2025-06-09T22:55:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ff/47f08595e3d9b5e149c150f88d9714574f1a7cbd89fe2817158a952674bf/propcache-0.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:9704bedf6e7cbe3c65eca4379a9b53ee6a83749f047808cbb5044d40d7d72198", size = 44857, upload-time = "2025-06-09T22:55:39.687Z" }, + { url = "https://files.pythonhosted.org/packages/6c/39/8ea9bcfaaff16fd0b0fc901ee522e24c9ec44b4ca0229cfffb8066a06959/propcache-0.3.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a7fad897f14d92086d6b03fdd2eb844777b0c4d7ec5e3bac0fbae2ab0602bbe5", size = 74678, upload-time = "2025-06-09T22:55:41.227Z" }, + { url = "https://files.pythonhosted.org/packages/d3/85/cab84c86966e1d354cf90cdc4ba52f32f99a5bca92a1529d666d957d7686/propcache-0.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1f43837d4ca000243fd7fd6301947d7cb93360d03cd08369969450cc6b2ce3b4", size = 43829, upload-time = "2025-06-09T22:55:42.417Z" }, + { url = "https://files.pythonhosted.org/packages/23/f7/9cb719749152d8b26d63801b3220ce2d3931312b2744d2b3a088b0ee9947/propcache-0.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:261df2e9474a5949c46e962065d88eb9b96ce0f2bd30e9d3136bcde84befd8f2", size = 43729, upload-time = "2025-06-09T22:55:43.651Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a2/0b2b5a210ff311260002a315f6f9531b65a36064dfb804655432b2f7d3e3/propcache-0.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e514326b79e51f0a177daab1052bc164d9d9e54133797a3a58d24c9c87a3fe6d", size = 204483, upload-time = "2025-06-09T22:55:45.327Z" }, + { url = "https://files.pythonhosted.org/packages/3f/e0/7aff5de0c535f783b0c8be5bdb750c305c1961d69fbb136939926e155d98/propcache-0.3.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d4a996adb6904f85894570301939afeee65f072b4fd265ed7e569e8d9058e4ec", size = 217425, upload-time = "2025-06-09T22:55:46.729Z" }, + { url = "https://files.pythonhosted.org/packages/92/1d/65fa889eb3b2a7d6e4ed3c2b568a9cb8817547a1450b572de7bf24872800/propcache-0.3.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:76cace5d6b2a54e55b137669b30f31aa15977eeed390c7cbfb1dafa8dfe9a701", size = 214723, upload-time = "2025-06-09T22:55:48.342Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e2/eecf6989870988dfd731de408a6fa366e853d361a06c2133b5878ce821ad/propcache-0.3.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31248e44b81d59d6addbb182c4720f90b44e1efdc19f58112a3c3a1615fb47ef", size = 200166, upload-time = "2025-06-09T22:55:49.775Z" }, + { url = "https://files.pythonhosted.org/packages/12/06/c32be4950967f18f77489268488c7cdc78cbfc65a8ba8101b15e526b83dc/propcache-0.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:abb7fa19dbf88d3857363e0493b999b8011eea856b846305d8c0512dfdf8fbb1", size = 194004, upload-time = "2025-06-09T22:55:51.335Z" }, + { url = "https://files.pythonhosted.org/packages/46/6c/17b521a6b3b7cbe277a4064ff0aa9129dd8c89f425a5a9b6b4dd51cc3ff4/propcache-0.3.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:d81ac3ae39d38588ad0549e321e6f773a4e7cc68e7751524a22885d5bbadf886", size = 203075, upload-time = "2025-06-09T22:55:52.681Z" }, + { url = "https://files.pythonhosted.org/packages/62/cb/3bdba2b736b3e45bc0e40f4370f745b3e711d439ffbffe3ae416393eece9/propcache-0.3.2-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:cc2782eb0f7a16462285b6f8394bbbd0e1ee5f928034e941ffc444012224171b", size = 195407, upload-time = "2025-06-09T22:55:54.048Z" }, + { url = "https://files.pythonhosted.org/packages/29/bd/760c5c6a60a4a2c55a421bc34a25ba3919d49dee411ddb9d1493bb51d46e/propcache-0.3.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:db429c19a6c7e8a1c320e6a13c99799450f411b02251fb1b75e6217cf4a14fcb", size = 196045, upload-time = "2025-06-09T22:55:55.485Z" }, + { url = "https://files.pythonhosted.org/packages/76/58/ced2757a46f55b8c84358d6ab8de4faf57cba831c51e823654da7144b13a/propcache-0.3.2-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:21d8759141a9e00a681d35a1f160892a36fb6caa715ba0b832f7747da48fb6ea", size = 208432, upload-time = "2025-06-09T22:55:56.884Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ec/d98ea8d5a4d8fe0e372033f5254eddf3254344c0c5dc6c49ab84349e4733/propcache-0.3.2-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:2ca6d378f09adb13837614ad2754fa8afaee330254f404299611bce41a8438cb", size = 210100, upload-time = "2025-06-09T22:55:58.498Z" }, + { url = "https://files.pythonhosted.org/packages/56/84/b6d8a7ecf3f62d7dd09d9d10bbf89fad6837970ef868b35b5ffa0d24d9de/propcache-0.3.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:34a624af06c048946709f4278b4176470073deda88d91342665d95f7c6270fbe", size = 200712, upload-time = "2025-06-09T22:55:59.906Z" }, + { url = "https://files.pythonhosted.org/packages/bf/32/889f4903ddfe4a9dc61da71ee58b763758cf2d608fe1decede06e6467f8d/propcache-0.3.2-cp39-cp39-win32.whl", hash = "sha256:4ba3fef1c30f306b1c274ce0b8baaa2c3cdd91f645c48f06394068f37d3837a1", size = 38187, upload-time = "2025-06-09T22:56:01.212Z" }, + { url = "https://files.pythonhosted.org/packages/67/74/d666795fb9ba1dc139d30de64f3b6fd1ff9c9d3d96ccfdb992cd715ce5d2/propcache-0.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:7a2368eed65fc69a7a7a40b27f22e85e7627b74216f0846b04ba5c116e191ec9", size = 42025, upload-time = "2025-06-09T22:56:02.875Z" }, + { url = "https://files.pythonhosted.org/packages/cc/35/cc0aaecf278bb4575b8555f2b137de5ab821595ddae9da9d3cd1da4072c7/propcache-0.3.2-py3-none-any.whl", hash = "sha256:98f1ec44fb675f5052cccc8e609c46ed23a35a1cfd18545ad4e29002d858a43f", size = 12663, upload-time = "2025-06-09T22:56:04.484Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "8.3.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891, upload-time = "2025-03-02T12:54:54.503Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634, upload-time = "2025-03-02T12:54:52.069Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/95/a3fac87cb7158e231b5a6012e438c647e1a87f09f8e0d123acec8ab8bf71/PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086", size = 184199, upload-time = "2024-08-06T20:31:40.178Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7a/68bd47624dab8fd4afbfd3c48e3b79efe09098ae941de5b58abcbadff5cb/PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf", size = 171758, upload-time = "2024-08-06T20:31:42.173Z" }, + { url = "https://files.pythonhosted.org/packages/49/ee/14c54df452143b9ee9f0f29074d7ca5516a36edb0b4cc40c3f280131656f/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237", size = 718463, upload-time = "2024-08-06T20:31:44.263Z" }, + { url = "https://files.pythonhosted.org/packages/4d/61/de363a97476e766574650d742205be468921a7b532aa2499fcd886b62530/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b", size = 719280, upload-time = "2024-08-06T20:31:50.199Z" }, + { url = "https://files.pythonhosted.org/packages/6b/4e/1523cb902fd98355e2e9ea5e5eb237cbc5f3ad5f3075fa65087aa0ecb669/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed", size = 751239, upload-time = "2024-08-06T20:31:52.292Z" }, + { url = "https://files.pythonhosted.org/packages/b7/33/5504b3a9a4464893c32f118a9cc045190a91637b119a9c881da1cf6b7a72/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180", size = 695802, upload-time = "2024-08-06T20:31:53.836Z" }, + { url = "https://files.pythonhosted.org/packages/5c/20/8347dcabd41ef3a3cdc4f7b7a2aff3d06598c8779faa189cdbf878b626a4/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68", size = 720527, upload-time = "2024-08-06T20:31:55.565Z" }, + { url = "https://files.pythonhosted.org/packages/be/aa/5afe99233fb360d0ff37377145a949ae258aaab831bde4792b32650a4378/PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99", size = 144052, upload-time = "2024-08-06T20:31:56.914Z" }, + { url = "https://files.pythonhosted.org/packages/b5/84/0fa4b06f6d6c958d207620fc60005e241ecedceee58931bb20138e1e5776/PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e", size = 161774, upload-time = "2024-08-06T20:31:58.304Z" }, + { url = "https://files.pythonhosted.org/packages/f8/aa/7af4e81f7acba21a4c6be026da38fd2b872ca46226673c89a758ebdc4fd2/PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774", size = 184612, upload-time = "2024-08-06T20:32:03.408Z" }, + { url = "https://files.pythonhosted.org/packages/8b/62/b9faa998fd185f65c1371643678e4d58254add437edb764a08c5a98fb986/PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee", size = 172040, upload-time = "2024-08-06T20:32:04.926Z" }, + { url = "https://files.pythonhosted.org/packages/ad/0c/c804f5f922a9a6563bab712d8dcc70251e8af811fce4524d57c2c0fd49a4/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c", size = 736829, upload-time = "2024-08-06T20:32:06.459Z" }, + { url = "https://files.pythonhosted.org/packages/51/16/6af8d6a6b210c8e54f1406a6b9481febf9c64a3109c541567e35a49aa2e7/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317", size = 764167, upload-time = "2024-08-06T20:32:08.338Z" }, + { url = "https://files.pythonhosted.org/packages/75/e4/2c27590dfc9992f73aabbeb9241ae20220bd9452df27483b6e56d3975cc5/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85", size = 762952, upload-time = "2024-08-06T20:32:14.124Z" }, + { url = "https://files.pythonhosted.org/packages/9b/97/ecc1abf4a823f5ac61941a9c00fe501b02ac3ab0e373c3857f7d4b83e2b6/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4", size = 735301, upload-time = "2024-08-06T20:32:16.17Z" }, + { url = "https://files.pythonhosted.org/packages/45/73/0f49dacd6e82c9430e46f4a027baa4ca205e8b0a9dce1397f44edc23559d/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e", size = 756638, upload-time = "2024-08-06T20:32:18.555Z" }, + { url = "https://files.pythonhosted.org/packages/22/5f/956f0f9fc65223a58fbc14459bf34b4cc48dec52e00535c79b8db361aabd/PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5", size = 143850, upload-time = "2024-08-06T20:32:19.889Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/8da0bbe2ab9dcdd11f4f4557ccaf95c10b9811b13ecced089d43ce59c3c8/PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44", size = 161980, upload-time = "2024-08-06T20:32:21.273Z" }, + { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873, upload-time = "2024-08-06T20:32:25.131Z" }, + { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302, upload-time = "2024-08-06T20:32:26.511Z" }, + { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154, upload-time = "2024-08-06T20:32:28.363Z" }, + { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223, upload-time = "2024-08-06T20:32:30.058Z" }, + { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542, upload-time = "2024-08-06T20:32:31.881Z" }, + { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164, upload-time = "2024-08-06T20:32:37.083Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611, upload-time = "2024-08-06T20:32:38.898Z" }, + { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591, upload-time = "2024-08-06T20:32:40.241Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338, upload-time = "2024-08-06T20:32:41.93Z" }, + { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309, upload-time = "2024-08-06T20:32:43.4Z" }, + { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679, upload-time = "2024-08-06T20:32:44.801Z" }, + { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428, upload-time = "2024-08-06T20:32:46.432Z" }, + { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361, upload-time = "2024-08-06T20:32:51.188Z" }, + { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523, upload-time = "2024-08-06T20:32:53.019Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660, upload-time = "2024-08-06T20:32:54.708Z" }, + { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload-time = "2024-08-06T20:32:56.985Z" }, + { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload-time = "2024-08-06T20:33:03.001Z" }, + { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" }, + { url = "https://files.pythonhosted.org/packages/65/d8/b7a1db13636d7fb7d4ff431593c510c8b8fca920ade06ca8ef20015493c5/PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d", size = 184777, upload-time = "2024-08-06T20:33:25.896Z" }, + { url = "https://files.pythonhosted.org/packages/0a/02/6ec546cd45143fdf9840b2c6be8d875116a64076218b61d68e12548e5839/PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f", size = 172318, upload-time = "2024-08-06T20:33:27.212Z" }, + { url = "https://files.pythonhosted.org/packages/0e/9a/8cc68be846c972bda34f6c2a93abb644fb2476f4dcc924d52175786932c9/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290", size = 720891, upload-time = "2024-08-06T20:33:28.974Z" }, + { url = "https://files.pythonhosted.org/packages/e9/6c/6e1b7f40181bc4805e2e07f4abc10a88ce4648e7e95ff1abe4ae4014a9b2/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12", size = 722614, upload-time = "2024-08-06T20:33:34.157Z" }, + { url = "https://files.pythonhosted.org/packages/3d/32/e7bd8535d22ea2874cef6a81021ba019474ace0d13a4819c2a4bce79bd6a/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19", size = 737360, upload-time = "2024-08-06T20:33:35.84Z" }, + { url = "https://files.pythonhosted.org/packages/d7/12/7322c1e30b9be969670b672573d45479edef72c9a0deac3bb2868f5d7469/PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e", size = 699006, upload-time = "2024-08-06T20:33:37.501Z" }, + { url = "https://files.pythonhosted.org/packages/82/72/04fcad41ca56491995076630c3ec1e834be241664c0c09a64c9a2589b507/PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725", size = 723577, upload-time = "2024-08-06T20:33:39.389Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5e/46168b1f2757f1fcd442bc3029cd8767d88a98c9c05770d8b420948743bb/PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631", size = 144593, upload-time = "2024-08-06T20:33:46.63Z" }, + { url = "https://files.pythonhosted.org/packages/19/87/5124b1c1f2412bb95c59ec481eaf936cd32f0fe2a7b16b97b81c4c017a6a/PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8", size = 162312, upload-time = "2024-08-06T20:33:49.073Z" }, +] + +[[package]] +name = "requests" +version = "2.32.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e1/0a/929373653770d8a0d7ea76c37de6e41f11eb07559b103b1c02cafb3f7cf8/requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422", size = 135258, upload-time = "2025-06-09T16:43:07.34Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/e4/56027c4a6b4ae70ca9de302488c5ca95ad4a39e190093d6c1a8ace08341b/requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c", size = 64847, upload-time = "2025-06-09T16:43:05.728Z" }, +] + +[[package]] +name = "ruamel-yaml" +version = "0.18.14" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ruamel-yaml-clib", marker = "python_full_version < '3.14' and platform_python_implementation == 'CPython'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/39/87/6da0df742a4684263261c253f00edd5829e6aca970fff69e75028cccc547/ruamel.yaml-0.18.14.tar.gz", hash = "sha256:7227b76aaec364df15936730efbf7d72b30c0b79b1d578bbb8e3dcb2d81f52b7", size = 145511, upload-time = "2025-06-09T08:51:09.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/6d/6fe4805235e193aad4aaf979160dd1f3c487c57d48b810c816e6e842171b/ruamel.yaml-0.18.14-py3-none-any.whl", hash = "sha256:710ff198bb53da66718c7db27eec4fbcc9aa6ca7204e4c1df2f282b6fe5eb6b2", size = 118570, upload-time = "2025-06-09T08:51:06.348Z" }, +] + +[[package]] +name = "ruamel-yaml-clib" +version = "0.2.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/84/80203abff8ea4993a87d823a5f632e4d92831ef75d404c9fc78d0176d2b5/ruamel.yaml.clib-0.2.12.tar.gz", hash = "sha256:6c8fbb13ec503f99a91901ab46e0b07ae7941cd527393187039aec586fdfd36f", size = 225315, upload-time = "2024-10-20T10:10:56.22Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/57/40a958e863e299f0c74ef32a3bde9f2d1ea8d69669368c0c502a0997f57f/ruamel.yaml.clib-0.2.12-cp310-cp310-macosx_13_0_arm64.whl", hash = "sha256:11f891336688faf5156a36293a9c362bdc7c88f03a8a027c2c1d8e0bcde998e5", size = 131301, upload-time = "2024-10-20T10:12:35.876Z" }, + { url = "https://files.pythonhosted.org/packages/98/a8/29a3eb437b12b95f50a6bcc3d7d7214301c6c529d8fdc227247fa84162b5/ruamel.yaml.clib-0.2.12-cp310-cp310-manylinux2014_aarch64.whl", hash = "sha256:a606ef75a60ecf3d924613892cc603b154178ee25abb3055db5062da811fd969", size = 633728, upload-time = "2024-10-20T10:12:37.858Z" }, + { url = "https://files.pythonhosted.org/packages/35/6d/ae05a87a3ad540259c3ad88d71275cbd1c0f2d30ae04c65dcbfb6dcd4b9f/ruamel.yaml.clib-0.2.12-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd5415dded15c3822597455bc02bcd66e81ef8b7a48cb71a33628fc9fdde39df", size = 722230, upload-time = "2024-10-20T10:12:39.457Z" }, + { url = "https://files.pythonhosted.org/packages/7f/b7/20c6f3c0b656fe609675d69bc135c03aac9e3865912444be6339207b6648/ruamel.yaml.clib-0.2.12-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f66efbc1caa63c088dead1c4170d148eabc9b80d95fb75b6c92ac0aad2437d76", size = 686712, upload-time = "2024-10-20T10:12:41.119Z" }, + { url = "https://files.pythonhosted.org/packages/cd/11/d12dbf683471f888d354dac59593873c2b45feb193c5e3e0f2ebf85e68b9/ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:22353049ba4181685023b25b5b51a574bce33e7f51c759371a7422dcae5402a6", size = 663936, upload-time = "2024-10-21T11:26:37.419Z" }, + { url = "https://files.pythonhosted.org/packages/72/14/4c268f5077db5c83f743ee1daeb236269fa8577133a5cfa49f8b382baf13/ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:932205970b9f9991b34f55136be327501903f7c66830e9760a8ffb15b07f05cd", size = 696580, upload-time = "2024-10-21T11:26:39.503Z" }, + { url = "https://files.pythonhosted.org/packages/30/fc/8cd12f189c6405a4c1cf37bd633aa740a9538c8e40497c231072d0fef5cf/ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a52d48f4e7bf9005e8f0a89209bf9a73f7190ddf0489eee5eb51377385f59f2a", size = 663393, upload-time = "2024-12-11T19:58:13.873Z" }, + { url = "https://files.pythonhosted.org/packages/80/29/c0a017b704aaf3cbf704989785cd9c5d5b8ccec2dae6ac0c53833c84e677/ruamel.yaml.clib-0.2.12-cp310-cp310-win32.whl", hash = "sha256:3eac5a91891ceb88138c113f9db04f3cebdae277f5d44eaa3651a4f573e6a5da", size = 100326, upload-time = "2024-10-20T10:12:42.967Z" }, + { url = "https://files.pythonhosted.org/packages/3a/65/fa39d74db4e2d0cd252355732d966a460a41cd01c6353b820a0952432839/ruamel.yaml.clib-0.2.12-cp310-cp310-win_amd64.whl", hash = "sha256:ab007f2f5a87bd08ab1499bdf96f3d5c6ad4dcfa364884cb4549aa0154b13a28", size = 118079, upload-time = "2024-10-20T10:12:44.117Z" }, + { url = "https://files.pythonhosted.org/packages/fb/8f/683c6ad562f558cbc4f7c029abcd9599148c51c54b5ef0f24f2638da9fbb/ruamel.yaml.clib-0.2.12-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:4a6679521a58256a90b0d89e03992c15144c5f3858f40d7c18886023d7943db6", size = 132224, upload-time = "2024-10-20T10:12:45.162Z" }, + { url = "https://files.pythonhosted.org/packages/3c/d2/b79b7d695e2f21da020bd44c782490578f300dd44f0a4c57a92575758a76/ruamel.yaml.clib-0.2.12-cp311-cp311-manylinux2014_aarch64.whl", hash = "sha256:d84318609196d6bd6da0edfa25cedfbabd8dbde5140a0a23af29ad4b8f91fb1e", size = 641480, upload-time = "2024-10-20T10:12:46.758Z" }, + { url = "https://files.pythonhosted.org/packages/68/6e/264c50ce2a31473a9fdbf4fa66ca9b2b17c7455b31ef585462343818bd6c/ruamel.yaml.clib-0.2.12-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb43a269eb827806502c7c8efb7ae7e9e9d0573257a46e8e952f4d4caba4f31e", size = 739068, upload-time = "2024-10-20T10:12:48.605Z" }, + { url = "https://files.pythonhosted.org/packages/86/29/88c2567bc893c84d88b4c48027367c3562ae69121d568e8a3f3a8d363f4d/ruamel.yaml.clib-0.2.12-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:811ea1594b8a0fb466172c384267a4e5e367298af6b228931f273b111f17ef52", size = 703012, upload-time = "2024-10-20T10:12:51.124Z" }, + { url = "https://files.pythonhosted.org/packages/11/46/879763c619b5470820f0cd6ca97d134771e502776bc2b844d2adb6e37753/ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:cf12567a7b565cbf65d438dec6cfbe2917d3c1bdddfce84a9930b7d35ea59642", size = 704352, upload-time = "2024-10-21T11:26:41.438Z" }, + { url = "https://files.pythonhosted.org/packages/02/80/ece7e6034256a4186bbe50dee28cd032d816974941a6abf6a9d65e4228a7/ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:7dd5adc8b930b12c8fc5b99e2d535a09889941aa0d0bd06f4749e9a9397c71d2", size = 737344, upload-time = "2024-10-21T11:26:43.62Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ca/e4106ac7e80efbabdf4bf91d3d32fc424e41418458251712f5672eada9ce/ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1492a6051dab8d912fc2adeef0e8c72216b24d57bd896ea607cb90bb0c4981d3", size = 714498, upload-time = "2024-12-11T19:58:15.592Z" }, + { url = "https://files.pythonhosted.org/packages/67/58/b1f60a1d591b771298ffa0428237afb092c7f29ae23bad93420b1eb10703/ruamel.yaml.clib-0.2.12-cp311-cp311-win32.whl", hash = "sha256:bd0a08f0bab19093c54e18a14a10b4322e1eacc5217056f3c063bd2f59853ce4", size = 100205, upload-time = "2024-10-20T10:12:52.865Z" }, + { url = "https://files.pythonhosted.org/packages/b4/4f/b52f634c9548a9291a70dfce26ca7ebce388235c93588a1068028ea23fcc/ruamel.yaml.clib-0.2.12-cp311-cp311-win_amd64.whl", hash = "sha256:a274fb2cb086c7a3dea4322ec27f4cb5cc4b6298adb583ab0e211a4682f241eb", size = 118185, upload-time = "2024-10-20T10:12:54.652Z" }, + { url = "https://files.pythonhosted.org/packages/48/41/e7a405afbdc26af961678474a55373e1b323605a4f5e2ddd4a80ea80f628/ruamel.yaml.clib-0.2.12-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:20b0f8dc160ba83b6dcc0e256846e1a02d044e13f7ea74a3d1d56ede4e48c632", size = 133433, upload-time = "2024-10-20T10:12:55.657Z" }, + { url = "https://files.pythonhosted.org/packages/ec/b0/b850385604334c2ce90e3ee1013bd911aedf058a934905863a6ea95e9eb4/ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux2014_aarch64.whl", hash = "sha256:943f32bc9dedb3abff9879edc134901df92cfce2c3d5c9348f172f62eb2d771d", size = 647362, upload-time = "2024-10-20T10:12:57.155Z" }, + { url = "https://files.pythonhosted.org/packages/44/d0/3f68a86e006448fb6c005aee66565b9eb89014a70c491d70c08de597f8e4/ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95c3829bb364fdb8e0332c9931ecf57d9be3519241323c5274bd82f709cebc0c", size = 754118, upload-time = "2024-10-20T10:12:58.501Z" }, + { url = "https://files.pythonhosted.org/packages/52/a9/d39f3c5ada0a3bb2870d7db41901125dbe2434fa4f12ca8c5b83a42d7c53/ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:749c16fcc4a2b09f28843cda5a193e0283e47454b63ec4b81eaa2242f50e4ccd", size = 706497, upload-time = "2024-10-20T10:13:00.211Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fa/097e38135dadd9ac25aecf2a54be17ddf6e4c23e43d538492a90ab3d71c6/ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bf165fef1f223beae7333275156ab2022cffe255dcc51c27f066b4370da81e31", size = 698042, upload-time = "2024-10-21T11:26:46.038Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d5/a659ca6f503b9379b930f13bc6b130c9f176469b73b9834296822a83a132/ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:32621c177bbf782ca5a18ba4d7af0f1082a3f6e517ac2a18b3974d4edf349680", size = 745831, upload-time = "2024-10-21T11:26:47.487Z" }, + { url = "https://files.pythonhosted.org/packages/db/5d/36619b61ffa2429eeaefaab4f3374666adf36ad8ac6330d855848d7d36fd/ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b82a7c94a498853aa0b272fd5bc67f29008da798d4f93a2f9f289feb8426a58d", size = 715692, upload-time = "2024-12-11T19:58:17.252Z" }, + { url = "https://files.pythonhosted.org/packages/b1/82/85cb92f15a4231c89b95dfe08b09eb6adca929ef7df7e17ab59902b6f589/ruamel.yaml.clib-0.2.12-cp312-cp312-win32.whl", hash = "sha256:e8c4ebfcfd57177b572e2040777b8abc537cdef58a2120e830124946aa9b42c5", size = 98777, upload-time = "2024-10-20T10:13:01.395Z" }, + { url = "https://files.pythonhosted.org/packages/d7/8f/c3654f6f1ddb75daf3922c3d8fc6005b1ab56671ad56ffb874d908bfa668/ruamel.yaml.clib-0.2.12-cp312-cp312-win_amd64.whl", hash = "sha256:0467c5965282c62203273b838ae77c0d29d7638c8a4e3a1c8bdd3602c10904e4", size = 115523, upload-time = "2024-10-20T10:13:02.768Z" }, + { url = "https://files.pythonhosted.org/packages/29/00/4864119668d71a5fa45678f380b5923ff410701565821925c69780356ffa/ruamel.yaml.clib-0.2.12-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4c8c5d82f50bb53986a5e02d1b3092b03622c02c2eb78e29bec33fd9593bae1a", size = 132011, upload-time = "2024-10-20T10:13:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/7f/5e/212f473a93ae78c669ffa0cb051e3fee1139cb2d385d2ae1653d64281507/ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux2014_aarch64.whl", hash = "sha256:e7e3736715fbf53e9be2a79eb4db68e4ed857017344d697e8b9749444ae57475", size = 642488, upload-time = "2024-10-20T10:13:05.906Z" }, + { url = "https://files.pythonhosted.org/packages/1f/8f/ecfbe2123ade605c49ef769788f79c38ddb1c8fa81e01f4dbf5cf1a44b16/ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b7e75b4965e1d4690e93021adfcecccbca7d61c7bddd8e22406ef2ff20d74ef", size = 745066, upload-time = "2024-10-20T10:13:07.26Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a9/28f60726d29dfc01b8decdb385de4ced2ced9faeb37a847bd5cf26836815/ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:96777d473c05ee3e5e3c3e999f5d23c6f4ec5b0c38c098b3a5229085f74236c6", size = 701785, upload-time = "2024-10-20T10:13:08.504Z" }, + { url = "https://files.pythonhosted.org/packages/84/7e/8e7ec45920daa7f76046578e4f677a3215fe8f18ee30a9cb7627a19d9b4c/ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:3bc2a80e6420ca8b7d3590791e2dfc709c88ab9152c00eeb511c9875ce5778bf", size = 693017, upload-time = "2024-10-21T11:26:48.866Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b3/d650eaade4ca225f02a648321e1ab835b9d361c60d51150bac49063b83fa/ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:e188d2699864c11c36cdfdada94d781fd5d6b0071cd9c427bceb08ad3d7c70e1", size = 741270, upload-time = "2024-10-21T11:26:50.213Z" }, + { url = "https://files.pythonhosted.org/packages/87/b8/01c29b924dcbbed75cc45b30c30d565d763b9c4d540545a0eeecffb8f09c/ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f6f3eac23941b32afccc23081e1f50612bdbe4e982012ef4f5797986828cd01", size = 709059, upload-time = "2024-12-11T19:58:18.846Z" }, + { url = "https://files.pythonhosted.org/packages/30/8c/ed73f047a73638257aa9377ad356bea4d96125b305c34a28766f4445cc0f/ruamel.yaml.clib-0.2.12-cp313-cp313-win32.whl", hash = "sha256:6442cb36270b3afb1b4951f060eccca1ce49f3d087ca1ca4563a6eb479cb3de6", size = 98583, upload-time = "2024-10-20T10:13:09.658Z" }, + { url = "https://files.pythonhosted.org/packages/b0/85/e8e751d8791564dd333d5d9a4eab0a7a115f7e349595417fd50ecae3395c/ruamel.yaml.clib-0.2.12-cp313-cp313-win_amd64.whl", hash = "sha256:e5b8daf27af0b90da7bb903a876477a9e6d7270be6146906b276605997c7e9a3", size = 115190, upload-time = "2024-10-20T10:13:10.66Z" }, + { url = "https://files.pythonhosted.org/packages/e5/46/ccdef7a84ad745c37cb3d9a81790f28fbc9adf9c237dba682017b123294e/ruamel.yaml.clib-0.2.12-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:fc4b630cd3fa2cf7fce38afa91d7cfe844a9f75d7f0f36393fa98815e911d987", size = 131834, upload-time = "2024-10-20T10:13:11.72Z" }, + { url = "https://files.pythonhosted.org/packages/29/09/932360f30ad1b7b79f08757e0a6fb8c5392a52cdcc182779158fe66d25ac/ruamel.yaml.clib-0.2.12-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:bc5f1e1c28e966d61d2519f2a3d451ba989f9ea0f2307de7bc45baa526de9e45", size = 636120, upload-time = "2024-10-20T10:13:12.84Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2a/5b27602e7a4344c1334e26bf4739746206b7a60a8acdba33a61473468b73/ruamel.yaml.clib-0.2.12-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5a0e060aace4c24dcaf71023bbd7d42674e3b230f7e7b97317baf1e953e5b519", size = 724914, upload-time = "2024-10-20T10:13:14.605Z" }, + { url = "https://files.pythonhosted.org/packages/da/1c/23497017c554fc06ff5701b29355522cff850f626337fff35d9ab352cb18/ruamel.yaml.clib-0.2.12-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e2f1c3765db32be59d18ab3953f43ab62a761327aafc1594a2a1fbe038b8b8a7", size = 689072, upload-time = "2024-10-20T10:13:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/68/e6/f3d4ff3223f9ea49c3b7169ec0268e42bd49f87c70c0e3e853895e4a7ae2/ruamel.yaml.clib-0.2.12-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:d85252669dc32f98ebcd5d36768f5d4faeaeaa2d655ac0473be490ecdae3c285", size = 667091, upload-time = "2024-10-21T11:26:52.274Z" }, + { url = "https://files.pythonhosted.org/packages/84/62/ead07043527642491e5011b143f44b81ef80f1025a96069b7210e0f2f0f3/ruamel.yaml.clib-0.2.12-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:e143ada795c341b56de9418c58d028989093ee611aa27ffb9b7f609c00d813ed", size = 699111, upload-time = "2024-10-21T11:26:54.294Z" }, + { url = "https://files.pythonhosted.org/packages/52/b3/fe4d84446f7e4887e3bea7ceff0a7df23790b5ed625f830e79ace88ebefb/ruamel.yaml.clib-0.2.12-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2c59aa6170b990d8d2719323e628aaf36f3bfbc1c26279c0eeeb24d05d2d11c7", size = 666365, upload-time = "2024-12-11T19:58:20.444Z" }, + { url = "https://files.pythonhosted.org/packages/6e/b3/7feb99a00bfaa5c6868617bb7651308afde85e5a0b23cd187fe5de65feeb/ruamel.yaml.clib-0.2.12-cp39-cp39-win32.whl", hash = "sha256:beffaed67936fbbeffd10966a4eb53c402fafd3d6833770516bf7314bc6ffa12", size = 100863, upload-time = "2024-10-20T10:13:17.244Z" }, + { url = "https://files.pythonhosted.org/packages/93/07/de635108684b7a5bb06e432b0930c5a04b6c59efe73bd966d8db3cc208f2/ruamel.yaml.clib-0.2.12-cp39-cp39-win_amd64.whl", hash = "sha256:040ae85536960525ea62868b642bdb0c2cc6021c9f9d507810c0c604e66f5a7b", size = 118653, upload-time = "2024-10-20T10:13:18.289Z" }, +] + +[[package]] +name = "ruff" +version = "0.12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/90/5255432602c0b196a0da6720f6f76b93eb50baef46d3c9b0025e2f9acbf3/ruff-0.12.0.tar.gz", hash = "sha256:4d047db3662418d4a848a3fdbfaf17488b34b62f527ed6f10cb8afd78135bc5c", size = 4376101, upload-time = "2025-06-17T15:19:26.217Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/fd/b46bb20e14b11ff49dbc74c61de352e0dc07fb650189513631f6fb5fc69f/ruff-0.12.0-py3-none-linux_armv6l.whl", hash = "sha256:5652a9ecdb308a1754d96a68827755f28d5dfb416b06f60fd9e13f26191a8848", size = 10311554, upload-time = "2025-06-17T15:18:45.792Z" }, + { url = "https://files.pythonhosted.org/packages/e7/d3/021dde5a988fa3e25d2468d1dadeea0ae89dc4bc67d0140c6e68818a12a1/ruff-0.12.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:05ed0c914fabc602fc1f3b42c53aa219e5736cb030cdd85640c32dbc73da74a6", size = 11118435, upload-time = "2025-06-17T15:18:49.064Z" }, + { url = "https://files.pythonhosted.org/packages/07/a2/01a5acf495265c667686ec418f19fd5c32bcc326d4c79ac28824aecd6a32/ruff-0.12.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:07a7aa9b69ac3fcfda3c507916d5d1bca10821fe3797d46bad10f2c6de1edda0", size = 10466010, upload-time = "2025-06-17T15:18:51.341Z" }, + { url = "https://files.pythonhosted.org/packages/4c/57/7caf31dd947d72e7aa06c60ecb19c135cad871a0a8a251723088132ce801/ruff-0.12.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e7731c3eec50af71597243bace7ec6104616ca56dda2b99c89935fe926bdcd48", size = 10661366, upload-time = "2025-06-17T15:18:53.29Z" }, + { url = "https://files.pythonhosted.org/packages/e9/ba/aa393b972a782b4bc9ea121e0e358a18981980856190d7d2b6187f63e03a/ruff-0.12.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:952d0630eae628250ab1c70a7fffb641b03e6b4a2d3f3ec6c1d19b4ab6c6c807", size = 10173492, upload-time = "2025-06-17T15:18:55.262Z" }, + { url = "https://files.pythonhosted.org/packages/d7/50/9349ee777614bc3062fc6b038503a59b2034d09dd259daf8192f56c06720/ruff-0.12.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c021f04ea06966b02614d442e94071781c424ab8e02ec7af2f037b4c1e01cc82", size = 11761739, upload-time = "2025-06-17T15:18:58.906Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/ad459de67c70ec112e2ba7206841c8f4eb340a03ee6a5cabc159fe558b8e/ruff-0.12.0-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:7d235618283718ee2fe14db07f954f9b2423700919dc688eacf3f8797a11315c", size = 12537098, upload-time = "2025-06-17T15:19:01.316Z" }, + { url = "https://files.pythonhosted.org/packages/ed/50/15ad9c80ebd3c4819f5bd8883e57329f538704ed57bac680d95cb6627527/ruff-0.12.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0c0758038f81beec8cc52ca22de9685b8ae7f7cc18c013ec2050012862cc9165", size = 12154122, upload-time = "2025-06-17T15:19:03.727Z" }, + { url = "https://files.pythonhosted.org/packages/76/e6/79b91e41bc8cc3e78ee95c87093c6cacfa275c786e53c9b11b9358026b3d/ruff-0.12.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:139b3d28027987b78fc8d6cfb61165447bdf3740e650b7c480744873688808c2", size = 11363374, upload-time = "2025-06-17T15:19:05.875Z" }, + { url = "https://files.pythonhosted.org/packages/db/c3/82b292ff8a561850934549aa9dc39e2c4e783ab3c21debe55a495ddf7827/ruff-0.12.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68853e8517b17bba004152aebd9dd77d5213e503a5f2789395b25f26acac0da4", size = 11587647, upload-time = "2025-06-17T15:19:08.246Z" }, + { url = "https://files.pythonhosted.org/packages/2b/42/d5760d742669f285909de1bbf50289baccb647b53e99b8a3b4f7ce1b2001/ruff-0.12.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:3a9512af224b9ac4757f7010843771da6b2b0935a9e5e76bb407caa901a1a514", size = 10527284, upload-time = "2025-06-17T15:19:10.37Z" }, + { url = "https://files.pythonhosted.org/packages/19/f6/fcee9935f25a8a8bba4adbae62495c39ef281256693962c2159e8b284c5f/ruff-0.12.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b08df3d96db798e5beb488d4df03011874aff919a97dcc2dd8539bb2be5d6a88", size = 10158609, upload-time = "2025-06-17T15:19:12.286Z" }, + { url = "https://files.pythonhosted.org/packages/37/fb/057febf0eea07b9384787bfe197e8b3384aa05faa0d6bd844b94ceb29945/ruff-0.12.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:6a315992297a7435a66259073681bb0d8647a826b7a6de45c6934b2ca3a9ed51", size = 11141462, upload-time = "2025-06-17T15:19:15.195Z" }, + { url = "https://files.pythonhosted.org/packages/10/7c/1be8571011585914b9d23c95b15d07eec2d2303e94a03df58294bc9274d4/ruff-0.12.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1e55e44e770e061f55a7dbc6e9aed47feea07731d809a3710feda2262d2d4d8a", size = 11641616, upload-time = "2025-06-17T15:19:17.6Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ef/b960ab4818f90ff59e571d03c3f992828d4683561095e80f9ef31f3d58b7/ruff-0.12.0-py3-none-win32.whl", hash = "sha256:7162a4c816f8d1555eb195c46ae0bd819834d2a3f18f98cc63819a7b46f474fb", size = 10525289, upload-time = "2025-06-17T15:19:19.688Z" }, + { url = "https://files.pythonhosted.org/packages/34/93/8b16034d493ef958a500f17cda3496c63a537ce9d5a6479feec9558f1695/ruff-0.12.0-py3-none-win_amd64.whl", hash = "sha256:d00b7a157b8fb6d3827b49d3324da34a1e3f93492c1f97b08e222ad7e9b291e0", size = 11598311, upload-time = "2025-06-17T15:19:21.785Z" }, + { url = "https://files.pythonhosted.org/packages/d0/33/4d3e79e4a84533d6cd526bfb42c020a23256ae5e4265d858bd1287831f7d/ruff-0.12.0-py3-none-win_arm64.whl", hash = "sha256:8cd24580405ad8c1cc64d61725bca091d6b6da7eb3d36f72cc605467069d7e8b", size = 10724946, upload-time = "2025-06-17T15:19:23.952Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "snowballstemmer" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/75/a7/9810d872919697c9d01295633f5d574fb416d47e535f258272ca1f01f447/snowballstemmer-3.0.1.tar.gz", hash = "sha256:6d5eeeec8e9f84d4d56b847692bacf79bc2c8e90c7f80ca4444ff8b6f2e52895", size = 105575, upload-time = "2025-05-09T16:34:51.843Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/78/3565d011c61f5a43488987ee32b6f3f656e7f107ac2782dd57bdd7d91d9a/snowballstemmer-3.0.1-py3-none-any.whl", hash = "sha256:6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064", size = 103274, upload-time = "2025-05-09T16:34:50.371Z" }, +] + +[[package]] +name = "soupsieve" +version = "2.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3f/f4/4a80cd6ef364b2e8b65b15816a843c0980f7a5a2b4dc701fc574952aa19f/soupsieve-2.7.tar.gz", hash = "sha256:ad282f9b6926286d2ead4750552c8a6142bc4c783fd66b0293547c8fe6ae126a", size = 103418, upload-time = "2025-04-20T18:50:08.518Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/9c/0e6afc12c269578be5c0c1c9f4b49a8d32770a080260c333ac04cc1c832d/soupsieve-2.7-py3-none-any.whl", hash = "sha256:6e60cc5c1ffaf1cebcc12e8188320b72071e922c2e897f737cadce79ad5d30c4", size = 36677, upload-time = "2025-04-20T18:50:07.196Z" }, +] + +[[package]] +name = "sphinx" +version = "7.4.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "alabaster" }, + { name = "babel" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "docutils" }, + { name = "imagesize" }, + { name = "importlib-metadata", marker = "python_full_version < '3.10'" }, + { name = "jinja2" }, + { name = "packaging" }, + { name = "pygments" }, + { name = "requests" }, + { name = "snowballstemmer" }, + { name = "sphinxcontrib-applehelp" }, + { name = "sphinxcontrib-devhelp" }, + { name = "sphinxcontrib-htmlhelp" }, + { name = "sphinxcontrib-jsmath" }, + { name = "sphinxcontrib-qthelp" }, + { name = "sphinxcontrib-serializinghtml" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/be/50e50cb4f2eff47df05673d361095cafd95521d2a22521b920c67a372dcb/sphinx-7.4.7.tar.gz", hash = "sha256:242f92a7ea7e6c5b406fdc2615413890ba9f699114a9c09192d7dfead2ee9cfe", size = 8067911, upload-time = "2024-07-20T14:46:56.059Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/ef/153f6803c5d5f8917dbb7f7fcf6d34a871ede3296fa89c2c703f5f8a6c8e/sphinx-7.4.7-py3-none-any.whl", hash = "sha256:c2419e2135d11f1951cd994d6eb18a1835bd8fdd8429f9ca375dc1f3281bd239", size = 3401624, upload-time = "2024-07-20T14:46:52.142Z" }, +] + +[[package]] +name = "sphinx-autodoc-typehints" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/cd/03e7b917230dc057922130a79ba0240df1693bfd76727ea33fae84b39138/sphinx_autodoc_typehints-2.3.0.tar.gz", hash = "sha256:535c78ed2d6a1bad393ba9f3dfa2602cf424e2631ee207263e07874c38fde084", size = 40709, upload-time = "2024-08-29T16:25:48.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f3/e0a4ce49da4b6f4e4ce84b3c39a0677831884cb9d8a87ccbf1e9e56e53ac/sphinx_autodoc_typehints-2.3.0-py3-none-any.whl", hash = "sha256:3098e2c6d0ba99eacd013eb06861acc9b51c6e595be86ab05c08ee5506ac0c67", size = 19836, upload-time = "2024-08-29T16:25:46.707Z" }, +] + +[[package]] +name = "sphinx-copybutton" +version = "0.5.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/2b/a964715e7f5295f77509e59309959f4125122d648f86b4fe7d70ca1d882c/sphinx-copybutton-0.5.2.tar.gz", hash = "sha256:4cf17c82fb9646d1bc9ca92ac280813a3b605d8c421225fd9913154103ee1fbd", size = 23039, upload-time = "2023-04-14T08:10:22.998Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/48/1ea60e74949eecb12cdd6ac43987f9fd331156388dcc2319b45e2ebb81bf/sphinx_copybutton-0.5.2-py3-none-any.whl", hash = "sha256:fb543fd386d917746c9a2c50360c7905b605726b9355cd26e9974857afeae06e", size = 13343, upload-time = "2023-04-14T08:10:20.844Z" }, +] + +[[package]] +name = "sphinx-jinja2-compat" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "standard-imghdr", marker = "python_full_version >= '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/df/27282da6f8c549f765beca9de1a5fc56f9651ed87711a5cac1e914137753/sphinx_jinja2_compat-0.3.0.tar.gz", hash = "sha256:f3c1590b275f42e7a654e081db5e3e5fb97f515608422bde94015ddf795dfe7c", size = 4998, upload-time = "2024-06-19T10:27:00.781Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/42/2fd09d672eaaa937d6893d8b747d07943f97a6e5e30653aee6ebd339b704/sphinx_jinja2_compat-0.3.0-py3-none-any.whl", hash = "sha256:b1e4006d8e1ea31013fa9946d1b075b0c8d2a42c6e3425e63542c1e9f8be9084", size = 7883, upload-time = "2024-06-19T10:26:59.121Z" }, +] + +[[package]] +name = "sphinx-prompt" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "pygments" }, + { name = "sphinx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e7/fb/7a07b8df1ca2418147a6b13e3f6b445071f2565198b45efa631d0d6ef0cd/sphinx_prompt-1.8.0.tar.gz", hash = "sha256:47482f86fcec29662fdfd23e7c04ef03582714195d01f5d565403320084372ed", size = 5121, upload-time = "2023-09-14T12:46:13.449Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/49/f890a2668b7cbf375f5528b549c8d36dd2e801b0fbb7b2b5ef65663ecb6c/sphinx_prompt-1.8.0-py3-none-any.whl", hash = "sha256:369ecc633f0711886f9b3a078c83264245be1adf46abeeb9b88b5519e4b51007", size = 7298, upload-time = "2023-09-14T12:46:12.373Z" }, +] + +[[package]] +name = "sphinx-rtd-theme" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "sphinx" }, + { name = "sphinxcontrib-jquery" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fe/33/2a35a9cdbfda9086bda11457bcc872173ab3565b16b6d7f6b3efaa6dc3d6/sphinx_rtd_theme-2.0.0.tar.gz", hash = "sha256:bd5d7b80622406762073a04ef8fadc5f9151261563d47027de09910ce03afe6b", size = 2785005, upload-time = "2023-11-28T04:14:03.104Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/46/00fda84467815c29951a9c91e3ae7503c409ddad04373e7cfc78daad4300/sphinx_rtd_theme-2.0.0-py2.py3-none-any.whl", hash = "sha256:ec93d0856dc280cf3aee9a4c9807c60e027c7f7b461b77aeffed682e68f0e586", size = 2824721, upload-time = "2023-11-28T04:13:59.589Z" }, +] + +[[package]] +name = "sphinx-tabs" +version = "3.4.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "pygments" }, + { name = "sphinx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/27/32/ab475e252dc2b704e82a91141fa404cdd8901a5cf34958fd22afacebfccd/sphinx-tabs-3.4.5.tar.gz", hash = "sha256:ba9d0c1e3e37aaadd4b5678449eb08176770e0fc227e769b6ce747df3ceea531", size = 16070, upload-time = "2024-01-21T12:13:39.392Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/9f/4ac7dbb9f23a2ff5a10903a4f9e9f43e0ff051f63a313e989c962526e305/sphinx_tabs-3.4.5-py3-none-any.whl", hash = "sha256:92cc9473e2ecf1828ca3f6617d0efc0aa8acb06b08c56ba29d1413f2f0f6cf09", size = 9904, upload-time = "2024-01-21T12:13:37.67Z" }, +] + +[[package]] +name = "sphinx-toolbox" +version = "3.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "apeye" }, + { name = "autodocsumm" }, + { name = "beautifulsoup4" }, + { name = "cachecontrol", extra = ["filecache"] }, + { name = "dict2css" }, + { name = "docutils" }, + { name = "domdf-python-tools" }, + { name = "filelock" }, + { name = "html5lib" }, + { name = "ruamel-yaml" }, + { name = "sphinx" }, + { name = "sphinx-autodoc-typehints" }, + { name = "sphinx-jinja2-compat" }, + { name = "sphinx-prompt" }, + { name = "sphinx-tabs" }, + { name = "tabulate" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f4/2d/d916dc5a70bc7b006af8a31bba1a2767e99cdb884f3dfa47aa79a60cc1e9/sphinx_toolbox-3.10.0.tar.gz", hash = "sha256:6afea9ac9afabe76bd5bd4d2b01edfdad81d653a1a34768e776e6a56d5a6f572", size = 113656, upload-time = "2025-05-06T17:36:50.926Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/ec/d09521ae2059fe89d8b59b2b34f5a1713b82a14e70a9a018fca8d3d514be/sphinx_toolbox-3.10.0-py3-none-any.whl", hash = "sha256:675e5978eaee31adf21701054fa75bacf820459d56e93ac30ad01eaee047a6ef", size = 195622, upload-time = "2025-05-06T17:36:48.81Z" }, +] + +[[package]] +name = "sphinxcontrib-applehelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/6e/b837e84a1a704953c62ef8776d45c3e8d759876b4a84fe14eba2859106fe/sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1", size = 20053, upload-time = "2024-07-29T01:09:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5", size = 119300, upload-time = "2024-07-29T01:08:58.99Z" }, +] + +[[package]] +name = "sphinxcontrib-devhelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/d2/5beee64d3e4e747f316bae86b55943f51e82bb86ecd325883ef65741e7da/sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad", size = 12967, upload-time = "2024-07-29T01:09:23.417Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2", size = 82530, upload-time = "2024-07-29T01:09:21.945Z" }, +] + +[[package]] +name = "sphinxcontrib-htmlhelp" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/93/983afd9aa001e5201eab16b5a444ed5b9b0a7a010541e0ddfbbfd0b2470c/sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9", size = 22617, upload-time = "2024-07-29T01:09:37.889Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8", size = 98705, upload-time = "2024-07-29T01:09:36.407Z" }, +] + +[[package]] +name = "sphinxcontrib-jquery" +version = "4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/f3/aa67467e051df70a6330fe7770894b3e4f09436dea6881ae0b4f3d87cad8/sphinxcontrib-jquery-4.1.tar.gz", hash = "sha256:1620739f04e36a2c779f1a131a2dfd49b2fd07351bf1968ced074365933abc7a", size = 122331, upload-time = "2023-03-14T15:01:01.944Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/85/749bd22d1a68db7291c89e2ebca53f4306c3f205853cf31e9de279034c3c/sphinxcontrib_jquery-4.1-py2.py3-none-any.whl", hash = "sha256:f936030d7d0147dd026a4f2b5a57343d233f1fc7b363f68b3d4f1cb0993878ae", size = 121104, upload-time = "2023-03-14T15:01:00.356Z" }, +] + +[[package]] +name = "sphinxcontrib-jsmath" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/e8/9ed3830aeed71f17c026a07a5097edcf44b692850ef215b161b8ad875729/sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8", size = 5787, upload-time = "2019-01-21T16:10:16.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", size = 5071, upload-time = "2019-01-21T16:10:14.333Z" }, +] + +[[package]] +name = "sphinxcontrib-qthelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/68/bc/9104308fc285eb3e0b31b67688235db556cd5b0ef31d96f30e45f2e51cae/sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab", size = 17165, upload-time = "2024-07-29T01:09:56.435Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb", size = 88743, upload-time = "2024-07-29T01:09:54.885Z" }, +] + +[[package]] +name = "sphinxcontrib-serializinghtml" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/44/6716b257b0aa6bfd51a1b31665d1c205fb12cb5ad56de752dfa15657de2f/sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d", size = 16080, upload-time = "2024-07-29T01:10:09.332Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z" }, +] + +[[package]] +name = "standard-imghdr" +version = "3.10.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/09/d2/2eb5521072c9598886035c65c023f39f7384bcb73eed70794f469e34efac/standard_imghdr-3.10.14.tar.gz", hash = "sha256:2598fe2e7c540dbda34b233295e10957ab8dc8ac6f3bd9eaa8d38be167232e52", size = 5474, upload-time = "2024-04-21T18:55:10.859Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/d0/9852f70eb01f814843530c053542b72d30e9fbf74da7abb0107e71938389/standard_imghdr-3.10.14-py3-none-any.whl", hash = "sha256:cdf6883163349624dee9a81d2853a20260337c4cd41c04e99c082e01833a08e2", size = 5598, upload-time = "2024-04-21T18:54:48.587Z" }, +] + +[[package]] +name = "tabulate" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/fe/802052aecb21e3797b8f7902564ab6ea0d60ff8ca23952079064155d1ae1/tabulate-0.9.0.tar.gz", hash = "sha256:0095b12bf5966de529c0feb1fa08671671b3368eec77d7ef7ab114be2c068b3c", size = 81090, upload-time = "2022-10-06T17:21:48.54Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/44/4a5f08c96eb108af5cb50b41f76142f0afa346dfa99d5296fe7202a11854/tabulate-0.9.0-py3-none-any.whl", hash = "sha256:024ca478df22e9340661486f85298cff5f6dcdba14f3813e8830015b9ed1948f", size = 35252, upload-time = "2022-10-06T17:21:44.262Z" }, +] + +[[package]] +name = "tomli" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175, upload-time = "2024-11-27T22:38:36.873Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077, upload-time = "2024-11-27T22:37:54.956Z" }, + { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429, upload-time = "2024-11-27T22:37:56.698Z" }, + { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067, upload-time = "2024-11-27T22:37:57.63Z" }, + { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030, upload-time = "2024-11-27T22:37:59.344Z" }, + { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898, upload-time = "2024-11-27T22:38:00.429Z" }, + { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894, upload-time = "2024-11-27T22:38:02.094Z" }, + { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319, upload-time = "2024-11-27T22:38:03.206Z" }, + { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273, upload-time = "2024-11-27T22:38:04.217Z" }, + { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310, upload-time = "2024-11-27T22:38:05.908Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309, upload-time = "2024-11-27T22:38:06.812Z" }, + { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762, upload-time = "2024-11-27T22:38:07.731Z" }, + { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453, upload-time = "2024-11-27T22:38:09.384Z" }, + { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486, upload-time = "2024-11-27T22:38:10.329Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349, upload-time = "2024-11-27T22:38:11.443Z" }, + { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159, upload-time = "2024-11-27T22:38:13.099Z" }, + { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243, upload-time = "2024-11-27T22:38:14.766Z" }, + { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645, upload-time = "2024-11-27T22:38:15.843Z" }, + { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584, upload-time = "2024-11-27T22:38:17.645Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875, upload-time = "2024-11-27T22:38:19.159Z" }, + { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418, upload-time = "2024-11-27T22:38:20.064Z" }, + { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708, upload-time = "2024-11-27T22:38:21.659Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582, upload-time = "2024-11-27T22:38:22.693Z" }, + { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543, upload-time = "2024-11-27T22:38:24.367Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691, upload-time = "2024-11-27T22:38:26.081Z" }, + { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170, upload-time = "2024-11-27T22:38:27.921Z" }, + { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530, upload-time = "2024-11-27T22:38:29.591Z" }, + { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666, upload-time = "2024-11-27T22:38:30.639Z" }, + { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954, upload-time = "2024-11-27T22:38:31.702Z" }, + { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724, upload-time = "2024-11-27T22:38:32.837Z" }, + { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383, upload-time = "2024-11-27T22:38:34.455Z" }, + { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257, upload-time = "2024-11-27T22:38:35.385Z" }, +] + +[[package]] +name = "tomli-w" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021", size = 7184, upload-time = "2025-01-15T12:07:24.262Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d1/bc/51647cd02527e87d05cb083ccc402f93e441606ff1f01739a62c8ad09ba5/typing_extensions-4.14.0.tar.gz", hash = "sha256:8676b788e32f02ab42d9e7c61324048ae4c6d844a399eebace3d4979d75ceef4", size = 107423, upload-time = "2025-06-02T14:52:11.399Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/e0/552843e0d356fbb5256d21449fa957fa4eff3bbc135a74a691ee70c7c5da/typing_extensions-4.14.0-py3-none-any.whl", hash = "sha256:a1514509136dd0b477638fc68d6a91497af5076466ad0fa6c338e44e359944af", size = 43839, upload-time = "2025-06-02T14:52:10.026Z" }, +] + +[[package]] +name = "urllib3" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, +] + +[[package]] +name = "virtualenv" +version = "20.31.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/56/2c/444f465fb2c65f40c3a104fd0c495184c4f2336d65baf398e3c75d72ea94/virtualenv-20.31.2.tar.gz", hash = "sha256:e10c0a9d02835e592521be48b332b6caee6887f332c111aa79a09b9e79efc2af", size = 6076316, upload-time = "2025-05-08T17:58:23.811Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/40/b1c265d4b2b62b58576588510fc4d1fe60a86319c8de99fd8e9fec617d2c/virtualenv-20.31.2-py3-none-any.whl", hash = "sha256:36efd0d9650ee985f0cad72065001e66d49a6f24eb44d98980f630686243cf11", size = 6057982, upload-time = "2025-05-08T17:58:21.15Z" }, +] + +[[package]] +name = "webencodings" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/02/ae6ceac1baeda530866a85075641cec12989bd8d31af6d5ab4a3e8c92f47/webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923", size = 9721, upload-time = "2017-04-05T20:21:34.189Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", size = 11774, upload-time = "2017-04-05T20:21:32.581Z" }, +] + +[[package]] +name = "yarl" +version = "1.20.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3c/fb/efaa23fa4e45537b827620f04cf8f3cd658b76642205162e072703a5b963/yarl-1.20.1.tar.gz", hash = "sha256:d017a4997ee50c91fd5466cef416231bb82177b93b029906cefc542ce14c35ac", size = 186428, upload-time = "2025-06-10T00:46:09.923Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/65/7fed0d774abf47487c64be14e9223749468922817b5e8792b8a64792a1bb/yarl-1.20.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:6032e6da6abd41e4acda34d75a816012717000fa6839f37124a47fcefc49bec4", size = 132910, upload-time = "2025-06-10T00:42:31.108Z" }, + { url = "https://files.pythonhosted.org/packages/8a/7b/988f55a52da99df9e56dc733b8e4e5a6ae2090081dc2754fc8fd34e60aa0/yarl-1.20.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2c7b34d804b8cf9b214f05015c4fee2ebe7ed05cf581e7192c06555c71f4446a", size = 90644, upload-time = "2025-06-10T00:42:33.851Z" }, + { url = "https://files.pythonhosted.org/packages/f7/de/30d98f03e95d30c7e3cc093759982d038c8833ec2451001d45ef4854edc1/yarl-1.20.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0c869f2651cc77465f6cd01d938d91a11d9ea5d798738c1dc077f3de0b5e5fed", size = 89322, upload-time = "2025-06-10T00:42:35.688Z" }, + { url = "https://files.pythonhosted.org/packages/e0/7a/f2f314f5ebfe9200724b0b748de2186b927acb334cf964fd312eb86fc286/yarl-1.20.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:62915e6688eb4d180d93840cda4110995ad50c459bf931b8b3775b37c264af1e", size = 323786, upload-time = "2025-06-10T00:42:37.817Z" }, + { url = "https://files.pythonhosted.org/packages/15/3f/718d26f189db96d993d14b984ce91de52e76309d0fd1d4296f34039856aa/yarl-1.20.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:41ebd28167bc6af8abb97fec1a399f412eec5fd61a3ccbe2305a18b84fb4ca73", size = 319627, upload-time = "2025-06-10T00:42:39.937Z" }, + { url = "https://files.pythonhosted.org/packages/a5/76/8fcfbf5fa2369157b9898962a4a7d96764b287b085b5b3d9ffae69cdefd1/yarl-1.20.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:21242b4288a6d56f04ea193adde174b7e347ac46ce6bc84989ff7c1b1ecea84e", size = 339149, upload-time = "2025-06-10T00:42:42.627Z" }, + { url = "https://files.pythonhosted.org/packages/3c/95/d7fc301cc4661785967acc04f54a4a42d5124905e27db27bb578aac49b5c/yarl-1.20.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bea21cdae6c7eb02ba02a475f37463abfe0a01f5d7200121b03e605d6a0439f8", size = 333327, upload-time = "2025-06-10T00:42:44.842Z" }, + { url = "https://files.pythonhosted.org/packages/65/94/e21269718349582eee81efc5c1c08ee71c816bfc1585b77d0ec3f58089eb/yarl-1.20.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f8a891e4a22a89f5dde7862994485e19db246b70bb288d3ce73a34422e55b23", size = 326054, upload-time = "2025-06-10T00:42:47.149Z" }, + { url = "https://files.pythonhosted.org/packages/32/ae/8616d1f07853704523519f6131d21f092e567c5af93de7e3e94b38d7f065/yarl-1.20.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dd803820d44c8853a109a34e3660e5a61beae12970da479cf44aa2954019bf70", size = 315035, upload-time = "2025-06-10T00:42:48.852Z" }, + { url = "https://files.pythonhosted.org/packages/48/aa/0ace06280861ef055855333707db5e49c6e3a08840a7ce62682259d0a6c0/yarl-1.20.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b982fa7f74c80d5c0c7b5b38f908971e513380a10fecea528091405f519b9ebb", size = 338962, upload-time = "2025-06-10T00:42:51.024Z" }, + { url = "https://files.pythonhosted.org/packages/20/52/1e9d0e6916f45a8fb50e6844f01cb34692455f1acd548606cbda8134cd1e/yarl-1.20.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:33f29ecfe0330c570d997bcf1afd304377f2e48f61447f37e846a6058a4d33b2", size = 335399, upload-time = "2025-06-10T00:42:53.007Z" }, + { url = "https://files.pythonhosted.org/packages/f2/65/60452df742952c630e82f394cd409de10610481d9043aa14c61bf846b7b1/yarl-1.20.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:835ab2cfc74d5eb4a6a528c57f05688099da41cf4957cf08cad38647e4a83b30", size = 338649, upload-time = "2025-06-10T00:42:54.964Z" }, + { url = "https://files.pythonhosted.org/packages/7b/f5/6cd4ff38dcde57a70f23719a838665ee17079640c77087404c3d34da6727/yarl-1.20.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:46b5e0ccf1943a9a6e766b2c2b8c732c55b34e28be57d8daa2b3c1d1d4009309", size = 358563, upload-time = "2025-06-10T00:42:57.28Z" }, + { url = "https://files.pythonhosted.org/packages/d1/90/c42eefd79d0d8222cb3227bdd51b640c0c1d0aa33fe4cc86c36eccba77d3/yarl-1.20.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:df47c55f7d74127d1b11251fe6397d84afdde0d53b90bedb46a23c0e534f9d24", size = 357609, upload-time = "2025-06-10T00:42:59.055Z" }, + { url = "https://files.pythonhosted.org/packages/03/c8/cea6b232cb4617514232e0f8a718153a95b5d82b5290711b201545825532/yarl-1.20.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:76d12524d05841276b0e22573f28d5fbcb67589836772ae9244d90dd7d66aa13", size = 350224, upload-time = "2025-06-10T00:43:01.248Z" }, + { url = "https://files.pythonhosted.org/packages/ce/a3/eaa0ab9712f1f3d01faf43cf6f1f7210ce4ea4a7e9b28b489a2261ca8db9/yarl-1.20.1-cp310-cp310-win32.whl", hash = "sha256:6c4fbf6b02d70e512d7ade4b1f998f237137f1417ab07ec06358ea04f69134f8", size = 81753, upload-time = "2025-06-10T00:43:03.486Z" }, + { url = "https://files.pythonhosted.org/packages/8f/34/e4abde70a9256465fe31c88ed02c3f8502b7b5dead693a4f350a06413f28/yarl-1.20.1-cp310-cp310-win_amd64.whl", hash = "sha256:aef6c4d69554d44b7f9d923245f8ad9a707d971e6209d51279196d8e8fe1ae16", size = 86817, upload-time = "2025-06-10T00:43:05.231Z" }, + { url = "https://files.pythonhosted.org/packages/b1/18/893b50efc2350e47a874c5c2d67e55a0ea5df91186b2a6f5ac52eff887cd/yarl-1.20.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:47ee6188fea634bdfaeb2cc420f5b3b17332e6225ce88149a17c413c77ff269e", size = 133833, upload-time = "2025-06-10T00:43:07.393Z" }, + { url = "https://files.pythonhosted.org/packages/89/ed/b8773448030e6fc47fa797f099ab9eab151a43a25717f9ac043844ad5ea3/yarl-1.20.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d0f6500f69e8402d513e5eedb77a4e1818691e8f45e6b687147963514d84b44b", size = 91070, upload-time = "2025-06-10T00:43:09.538Z" }, + { url = "https://files.pythonhosted.org/packages/e3/e3/409bd17b1e42619bf69f60e4f031ce1ccb29bd7380117a55529e76933464/yarl-1.20.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7a8900a42fcdaad568de58887c7b2f602962356908eedb7628eaf6021a6e435b", size = 89818, upload-time = "2025-06-10T00:43:11.575Z" }, + { url = "https://files.pythonhosted.org/packages/f8/77/64d8431a4d77c856eb2d82aa3de2ad6741365245a29b3a9543cd598ed8c5/yarl-1.20.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bad6d131fda8ef508b36be3ece16d0902e80b88ea7200f030a0f6c11d9e508d4", size = 347003, upload-time = "2025-06-10T00:43:14.088Z" }, + { url = "https://files.pythonhosted.org/packages/8d/d2/0c7e4def093dcef0bd9fa22d4d24b023788b0a33b8d0088b51aa51e21e99/yarl-1.20.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:df018d92fe22aaebb679a7f89fe0c0f368ec497e3dda6cb81a567610f04501f1", size = 336537, upload-time = "2025-06-10T00:43:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f3/fc514f4b2cf02cb59d10cbfe228691d25929ce8f72a38db07d3febc3f706/yarl-1.20.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f969afbb0a9b63c18d0feecf0db09d164b7a44a053e78a7d05f5df163e43833", size = 362358, upload-time = "2025-06-10T00:43:18.704Z" }, + { url = "https://files.pythonhosted.org/packages/ea/6d/a313ac8d8391381ff9006ac05f1d4331cee3b1efaa833a53d12253733255/yarl-1.20.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:812303eb4aa98e302886ccda58d6b099e3576b1b9276161469c25803a8db277d", size = 357362, upload-time = "2025-06-10T00:43:20.888Z" }, + { url = "https://files.pythonhosted.org/packages/00/70/8f78a95d6935a70263d46caa3dd18e1f223cf2f2ff2037baa01a22bc5b22/yarl-1.20.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98c4a7d166635147924aa0bf9bfe8d8abad6fffa6102de9c99ea04a1376f91e8", size = 348979, upload-time = "2025-06-10T00:43:23.169Z" }, + { url = "https://files.pythonhosted.org/packages/cb/05/42773027968968f4f15143553970ee36ead27038d627f457cc44bbbeecf3/yarl-1.20.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:12e768f966538e81e6e7550f9086a6236b16e26cd964cf4df35349970f3551cf", size = 337274, upload-time = "2025-06-10T00:43:27.111Z" }, + { url = "https://files.pythonhosted.org/packages/05/be/665634aa196954156741ea591d2f946f1b78ceee8bb8f28488bf28c0dd62/yarl-1.20.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fe41919b9d899661c5c28a8b4b0acf704510b88f27f0934ac7a7bebdd8938d5e", size = 363294, upload-time = "2025-06-10T00:43:28.96Z" }, + { url = "https://files.pythonhosted.org/packages/eb/90/73448401d36fa4e210ece5579895731f190d5119c4b66b43b52182e88cd5/yarl-1.20.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:8601bc010d1d7780592f3fc1bdc6c72e2b6466ea34569778422943e1a1f3c389", size = 358169, upload-time = "2025-06-10T00:43:30.701Z" }, + { url = "https://files.pythonhosted.org/packages/c3/b0/fce922d46dc1eb43c811f1889f7daa6001b27a4005587e94878570300881/yarl-1.20.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:daadbdc1f2a9033a2399c42646fbd46da7992e868a5fe9513860122d7fe7a73f", size = 362776, upload-time = "2025-06-10T00:43:32.51Z" }, + { url = "https://files.pythonhosted.org/packages/f1/0d/b172628fce039dae8977fd22caeff3eeebffd52e86060413f5673767c427/yarl-1.20.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:03aa1e041727cb438ca762628109ef1333498b122e4c76dd858d186a37cec845", size = 381341, upload-time = "2025-06-10T00:43:34.543Z" }, + { url = "https://files.pythonhosted.org/packages/6b/9b/5b886d7671f4580209e855974fe1cecec409aa4a89ea58b8f0560dc529b1/yarl-1.20.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:642980ef5e0fa1de5fa96d905c7e00cb2c47cb468bfcac5a18c58e27dbf8d8d1", size = 379988, upload-time = "2025-06-10T00:43:36.489Z" }, + { url = "https://files.pythonhosted.org/packages/73/be/75ef5fd0fcd8f083a5d13f78fd3f009528132a1f2a1d7c925c39fa20aa79/yarl-1.20.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:86971e2795584fe8c002356d3b97ef6c61862720eeff03db2a7c86b678d85b3e", size = 371113, upload-time = "2025-06-10T00:43:38.592Z" }, + { url = "https://files.pythonhosted.org/packages/50/4f/62faab3b479dfdcb741fe9e3f0323e2a7d5cd1ab2edc73221d57ad4834b2/yarl-1.20.1-cp311-cp311-win32.whl", hash = "sha256:597f40615b8d25812f14562699e287f0dcc035d25eb74da72cae043bb884d773", size = 81485, upload-time = "2025-06-10T00:43:41.038Z" }, + { url = "https://files.pythonhosted.org/packages/f0/09/d9c7942f8f05c32ec72cd5c8e041c8b29b5807328b68b4801ff2511d4d5e/yarl-1.20.1-cp311-cp311-win_amd64.whl", hash = "sha256:26ef53a9e726e61e9cd1cda6b478f17e350fb5800b4bd1cd9fe81c4d91cfeb2e", size = 86686, upload-time = "2025-06-10T00:43:42.692Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9a/cb7fad7d73c69f296eda6815e4a2c7ed53fc70c2f136479a91c8e5fbdb6d/yarl-1.20.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdcc4cd244e58593a4379fe60fdee5ac0331f8eb70320a24d591a3be197b94a9", size = 133667, upload-time = "2025-06-10T00:43:44.369Z" }, + { url = "https://files.pythonhosted.org/packages/67/38/688577a1cb1e656e3971fb66a3492501c5a5df56d99722e57c98249e5b8a/yarl-1.20.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b29a2c385a5f5b9c7d9347e5812b6f7ab267193c62d282a540b4fc528c8a9d2a", size = 91025, upload-time = "2025-06-10T00:43:46.295Z" }, + { url = "https://files.pythonhosted.org/packages/50/ec/72991ae51febeb11a42813fc259f0d4c8e0507f2b74b5514618d8b640365/yarl-1.20.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1112ae8154186dfe2de4732197f59c05a83dc814849a5ced892b708033f40dc2", size = 89709, upload-time = "2025-06-10T00:43:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/99/da/4d798025490e89426e9f976702e5f9482005c548c579bdae792a4c37769e/yarl-1.20.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90bbd29c4fe234233f7fa2b9b121fb63c321830e5d05b45153a2ca68f7d310ee", size = 352287, upload-time = "2025-06-10T00:43:49.924Z" }, + { url = "https://files.pythonhosted.org/packages/1a/26/54a15c6a567aac1c61b18aa0f4b8aa2e285a52d547d1be8bf48abe2b3991/yarl-1.20.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:680e19c7ce3710ac4cd964e90dad99bf9b5029372ba0c7cbfcd55e54d90ea819", size = 345429, upload-time = "2025-06-10T00:43:51.7Z" }, + { url = "https://files.pythonhosted.org/packages/d6/95/9dcf2386cb875b234353b93ec43e40219e14900e046bf6ac118f94b1e353/yarl-1.20.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4a979218c1fdb4246a05efc2cc23859d47c89af463a90b99b7c56094daf25a16", size = 365429, upload-time = "2025-06-10T00:43:53.494Z" }, + { url = "https://files.pythonhosted.org/packages/91/b2/33a8750f6a4bc224242a635f5f2cff6d6ad5ba651f6edcccf721992c21a0/yarl-1.20.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:255b468adf57b4a7b65d8aad5b5138dce6a0752c139965711bdcb81bc370e1b6", size = 363862, upload-time = "2025-06-10T00:43:55.766Z" }, + { url = "https://files.pythonhosted.org/packages/98/28/3ab7acc5b51f4434b181b0cee8f1f4b77a65919700a355fb3617f9488874/yarl-1.20.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a97d67108e79cfe22e2b430d80d7571ae57d19f17cda8bb967057ca8a7bf5bfd", size = 355616, upload-time = "2025-06-10T00:43:58.056Z" }, + { url = "https://files.pythonhosted.org/packages/36/a3/f666894aa947a371724ec7cd2e5daa78ee8a777b21509b4252dd7bd15e29/yarl-1.20.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8570d998db4ddbfb9a590b185a0a33dbf8aafb831d07a5257b4ec9948df9cb0a", size = 339954, upload-time = "2025-06-10T00:43:59.773Z" }, + { url = "https://files.pythonhosted.org/packages/f1/81/5f466427e09773c04219d3450d7a1256138a010b6c9f0af2d48565e9ad13/yarl-1.20.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:97c75596019baae7c71ccf1d8cc4738bc08134060d0adfcbe5642f778d1dca38", size = 365575, upload-time = "2025-06-10T00:44:02.051Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e3/e4b0ad8403e97e6c9972dd587388940a032f030ebec196ab81a3b8e94d31/yarl-1.20.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1c48912653e63aef91ff988c5432832692ac5a1d8f0fb8a33091520b5bbe19ef", size = 365061, upload-time = "2025-06-10T00:44:04.196Z" }, + { url = "https://files.pythonhosted.org/packages/ac/99/b8a142e79eb86c926f9f06452eb13ecb1bb5713bd01dc0038faf5452e544/yarl-1.20.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4c3ae28f3ae1563c50f3d37f064ddb1511ecc1d5584e88c6b7c63cf7702a6d5f", size = 364142, upload-time = "2025-06-10T00:44:06.527Z" }, + { url = "https://files.pythonhosted.org/packages/34/f2/08ed34a4a506d82a1a3e5bab99ccd930a040f9b6449e9fd050320e45845c/yarl-1.20.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c5e9642f27036283550f5f57dc6156c51084b458570b9d0d96100c8bebb186a8", size = 381894, upload-time = "2025-06-10T00:44:08.379Z" }, + { url = "https://files.pythonhosted.org/packages/92/f8/9a3fbf0968eac704f681726eff595dce9b49c8a25cd92bf83df209668285/yarl-1.20.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:2c26b0c49220d5799f7b22c6838409ee9bc58ee5c95361a4d7831f03cc225b5a", size = 383378, upload-time = "2025-06-10T00:44:10.51Z" }, + { url = "https://files.pythonhosted.org/packages/af/85/9363f77bdfa1e4d690957cd39d192c4cacd1c58965df0470a4905253b54f/yarl-1.20.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:564ab3d517e3d01c408c67f2e5247aad4019dcf1969982aba3974b4093279004", size = 374069, upload-time = "2025-06-10T00:44:12.834Z" }, + { url = "https://files.pythonhosted.org/packages/35/99/9918c8739ba271dcd935400cff8b32e3cd319eaf02fcd023d5dcd487a7c8/yarl-1.20.1-cp312-cp312-win32.whl", hash = "sha256:daea0d313868da1cf2fac6b2d3a25c6e3a9e879483244be38c8e6a41f1d876a5", size = 81249, upload-time = "2025-06-10T00:44:14.731Z" }, + { url = "https://files.pythonhosted.org/packages/eb/83/5d9092950565481b413b31a23e75dd3418ff0a277d6e0abf3729d4d1ce25/yarl-1.20.1-cp312-cp312-win_amd64.whl", hash = "sha256:48ea7d7f9be0487339828a4de0360d7ce0efc06524a48e1810f945c45b813698", size = 86710, upload-time = "2025-06-10T00:44:16.716Z" }, + { url = "https://files.pythonhosted.org/packages/8a/e1/2411b6d7f769a07687acee88a062af5833cf1966b7266f3d8dfb3d3dc7d3/yarl-1.20.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:0b5ff0fbb7c9f1b1b5ab53330acbfc5247893069e7716840c8e7d5bb7355038a", size = 131811, upload-time = "2025-06-10T00:44:18.933Z" }, + { url = "https://files.pythonhosted.org/packages/b2/27/584394e1cb76fb771371770eccad35de400e7b434ce3142c2dd27392c968/yarl-1.20.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:14f326acd845c2b2e2eb38fb1346c94f7f3b01a4f5c788f8144f9b630bfff9a3", size = 90078, upload-time = "2025-06-10T00:44:20.635Z" }, + { url = "https://files.pythonhosted.org/packages/bf/9a/3246ae92d4049099f52d9b0fe3486e3b500e29b7ea872d0f152966fc209d/yarl-1.20.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f60e4ad5db23f0b96e49c018596707c3ae89f5d0bd97f0ad3684bcbad899f1e7", size = 88748, upload-time = "2025-06-10T00:44:22.34Z" }, + { url = "https://files.pythonhosted.org/packages/a3/25/35afe384e31115a1a801fbcf84012d7a066d89035befae7c5d4284df1e03/yarl-1.20.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:49bdd1b8e00ce57e68ba51916e4bb04461746e794e7c4d4bbc42ba2f18297691", size = 349595, upload-time = "2025-06-10T00:44:24.314Z" }, + { url = "https://files.pythonhosted.org/packages/28/2d/8aca6cb2cabc8f12efcb82749b9cefecbccfc7b0384e56cd71058ccee433/yarl-1.20.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:66252d780b45189975abfed839616e8fd2dbacbdc262105ad7742c6ae58f3e31", size = 342616, upload-time = "2025-06-10T00:44:26.167Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/1312633d16b31acf0098d30440ca855e3492d66623dafb8e25b03d00c3da/yarl-1.20.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:59174e7332f5d153d8f7452a102b103e2e74035ad085f404df2e40e663a22b28", size = 361324, upload-time = "2025-06-10T00:44:27.915Z" }, + { url = "https://files.pythonhosted.org/packages/bc/a0/688cc99463f12f7669eec7c8acc71ef56a1521b99eab7cd3abb75af887b0/yarl-1.20.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e3968ec7d92a0c0f9ac34d5ecfd03869ec0cab0697c91a45db3fbbd95fe1b653", size = 359676, upload-time = "2025-06-10T00:44:30.041Z" }, + { url = "https://files.pythonhosted.org/packages/af/44/46407d7f7a56e9a85a4c207724c9f2c545c060380718eea9088f222ba697/yarl-1.20.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1a4fbb50e14396ba3d375f68bfe02215d8e7bc3ec49da8341fe3157f59d2ff5", size = 352614, upload-time = "2025-06-10T00:44:32.171Z" }, + { url = "https://files.pythonhosted.org/packages/b1/91/31163295e82b8d5485d31d9cf7754d973d41915cadce070491778d9c9825/yarl-1.20.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:11a62c839c3a8eac2410e951301309426f368388ff2f33799052787035793b02", size = 336766, upload-time = "2025-06-10T00:44:34.494Z" }, + { url = "https://files.pythonhosted.org/packages/b4/8e/c41a5bc482121f51c083c4c2bcd16b9e01e1cf8729e380273a952513a21f/yarl-1.20.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:041eaa14f73ff5a8986b4388ac6bb43a77f2ea09bf1913df7a35d4646db69e53", size = 364615, upload-time = "2025-06-10T00:44:36.856Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5b/61a3b054238d33d70ea06ebba7e58597891b71c699e247df35cc984ab393/yarl-1.20.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:377fae2fef158e8fd9d60b4c8751387b8d1fb121d3d0b8e9b0be07d1b41e83dc", size = 360982, upload-time = "2025-06-10T00:44:39.141Z" }, + { url = "https://files.pythonhosted.org/packages/df/a3/6a72fb83f8d478cb201d14927bc8040af901811a88e0ff2da7842dd0ed19/yarl-1.20.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1c92f4390e407513f619d49319023664643d3339bd5e5a56a3bebe01bc67ec04", size = 369792, upload-time = "2025-06-10T00:44:40.934Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/4cc3c36dfc7c077f8dedb561eb21f69e1e9f2456b91b593882b0b18c19dc/yarl-1.20.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d25ddcf954df1754ab0f86bb696af765c5bfaba39b74095f27eececa049ef9a4", size = 382049, upload-time = "2025-06-10T00:44:42.854Z" }, + { url = "https://files.pythonhosted.org/packages/19/3a/e54e2c4752160115183a66dc9ee75a153f81f3ab2ba4bf79c3c53b33de34/yarl-1.20.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:909313577e9619dcff8c31a0ea2aa0a2a828341d92673015456b3ae492e7317b", size = 384774, upload-time = "2025-06-10T00:44:45.275Z" }, + { url = "https://files.pythonhosted.org/packages/9c/20/200ae86dabfca89060ec6447649f219b4cbd94531e425e50d57e5f5ac330/yarl-1.20.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:793fd0580cb9664548c6b83c63b43c477212c0260891ddf86809e1c06c8b08f1", size = 374252, upload-time = "2025-06-10T00:44:47.31Z" }, + { url = "https://files.pythonhosted.org/packages/83/75/11ee332f2f516b3d094e89448da73d557687f7d137d5a0f48c40ff211487/yarl-1.20.1-cp313-cp313-win32.whl", hash = "sha256:468f6e40285de5a5b3c44981ca3a319a4b208ccc07d526b20b12aeedcfa654b7", size = 81198, upload-time = "2025-06-10T00:44:49.164Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ba/39b1ecbf51620b40ab402b0fc817f0ff750f6d92712b44689c2c215be89d/yarl-1.20.1-cp313-cp313-win_amd64.whl", hash = "sha256:495b4ef2fea40596bfc0affe3837411d6aa3371abcf31aac0ccc4bdd64d4ef5c", size = 86346, upload-time = "2025-06-10T00:44:51.182Z" }, + { url = "https://files.pythonhosted.org/packages/43/c7/669c52519dca4c95153c8ad96dd123c79f354a376346b198f438e56ffeb4/yarl-1.20.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:f60233b98423aab21d249a30eb27c389c14929f47be8430efa7dbd91493a729d", size = 138826, upload-time = "2025-06-10T00:44:52.883Z" }, + { url = "https://files.pythonhosted.org/packages/6a/42/fc0053719b44f6ad04a75d7f05e0e9674d45ef62f2d9ad2c1163e5c05827/yarl-1.20.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6f3eff4cc3f03d650d8755c6eefc844edde99d641d0dcf4da3ab27141a5f8ddf", size = 93217, upload-time = "2025-06-10T00:44:54.658Z" }, + { url = "https://files.pythonhosted.org/packages/4f/7f/fa59c4c27e2a076bba0d959386e26eba77eb52ea4a0aac48e3515c186b4c/yarl-1.20.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:69ff8439d8ba832d6bed88af2c2b3445977eba9a4588b787b32945871c2444e3", size = 92700, upload-time = "2025-06-10T00:44:56.784Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d4/062b2f48e7c93481e88eff97a6312dca15ea200e959f23e96d8ab898c5b8/yarl-1.20.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3cf34efa60eb81dd2645a2e13e00bb98b76c35ab5061a3989c7a70f78c85006d", size = 347644, upload-time = "2025-06-10T00:44:59.071Z" }, + { url = "https://files.pythonhosted.org/packages/89/47/78b7f40d13c8f62b499cc702fdf69e090455518ae544c00a3bf4afc9fc77/yarl-1.20.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8e0fe9364ad0fddab2688ce72cb7a8e61ea42eff3c7caeeb83874a5d479c896c", size = 323452, upload-time = "2025-06-10T00:45:01.605Z" }, + { url = "https://files.pythonhosted.org/packages/eb/2b/490d3b2dc66f52987d4ee0d3090a147ea67732ce6b4d61e362c1846d0d32/yarl-1.20.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f64fbf81878ba914562c672024089e3401974a39767747691c65080a67b18c1", size = 346378, upload-time = "2025-06-10T00:45:03.946Z" }, + { url = "https://files.pythonhosted.org/packages/66/ad/775da9c8a94ce925d1537f939a4f17d782efef1f973039d821cbe4bcc211/yarl-1.20.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f6342d643bf9a1de97e512e45e4b9560a043347e779a173250824f8b254bd5ce", size = 353261, upload-time = "2025-06-10T00:45:05.992Z" }, + { url = "https://files.pythonhosted.org/packages/4b/23/0ed0922b47a4f5c6eb9065d5ff1e459747226ddce5c6a4c111e728c9f701/yarl-1.20.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56dac5f452ed25eef0f6e3c6a066c6ab68971d96a9fb441791cad0efba6140d3", size = 335987, upload-time = "2025-06-10T00:45:08.227Z" }, + { url = "https://files.pythonhosted.org/packages/3e/49/bc728a7fe7d0e9336e2b78f0958a2d6b288ba89f25a1762407a222bf53c3/yarl-1.20.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7d7f497126d65e2cad8dc5f97d34c27b19199b6414a40cb36b52f41b79014be", size = 329361, upload-time = "2025-06-10T00:45:10.11Z" }, + { url = "https://files.pythonhosted.org/packages/93/8f/b811b9d1f617c83c907e7082a76e2b92b655400e61730cd61a1f67178393/yarl-1.20.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:67e708dfb8e78d8a19169818eeb5c7a80717562de9051bf2413aca8e3696bf16", size = 346460, upload-time = "2025-06-10T00:45:12.055Z" }, + { url = "https://files.pythonhosted.org/packages/70/fd/af94f04f275f95da2c3b8b5e1d49e3e79f1ed8b6ceb0f1664cbd902773ff/yarl-1.20.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:595c07bc79af2494365cc96ddeb772f76272364ef7c80fb892ef9d0649586513", size = 334486, upload-time = "2025-06-10T00:45:13.995Z" }, + { url = "https://files.pythonhosted.org/packages/84/65/04c62e82704e7dd0a9b3f61dbaa8447f8507655fd16c51da0637b39b2910/yarl-1.20.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7bdd2f80f4a7df852ab9ab49484a4dee8030023aa536df41f2d922fd57bf023f", size = 342219, upload-time = "2025-06-10T00:45:16.479Z" }, + { url = "https://files.pythonhosted.org/packages/91/95/459ca62eb958381b342d94ab9a4b6aec1ddec1f7057c487e926f03c06d30/yarl-1.20.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c03bfebc4ae8d862f853a9757199677ab74ec25424d0ebd68a0027e9c639a390", size = 350693, upload-time = "2025-06-10T00:45:18.399Z" }, + { url = "https://files.pythonhosted.org/packages/a6/00/d393e82dd955ad20617abc546a8f1aee40534d599ff555ea053d0ec9bf03/yarl-1.20.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:344d1103e9c1523f32a5ed704d576172d2cabed3122ea90b1d4e11fe17c66458", size = 355803, upload-time = "2025-06-10T00:45:20.677Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ed/c5fb04869b99b717985e244fd93029c7a8e8febdfcffa06093e32d7d44e7/yarl-1.20.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:88cab98aa4e13e1ade8c141daeedd300a4603b7132819c484841bb7af3edce9e", size = 341709, upload-time = "2025-06-10T00:45:23.221Z" }, + { url = "https://files.pythonhosted.org/packages/24/fd/725b8e73ac2a50e78a4534ac43c6addf5c1c2d65380dd48a9169cc6739a9/yarl-1.20.1-cp313-cp313t-win32.whl", hash = "sha256:b121ff6a7cbd4abc28985b6028235491941b9fe8fe226e6fdc539c977ea1739d", size = 86591, upload-time = "2025-06-10T00:45:25.793Z" }, + { url = "https://files.pythonhosted.org/packages/94/c3/b2e9f38bc3e11191981d57ea08cab2166e74ea770024a646617c9cddd9f6/yarl-1.20.1-cp313-cp313t-win_amd64.whl", hash = "sha256:541d050a355bbbc27e55d906bc91cb6fe42f96c01413dd0f4ed5a5240513874f", size = 93003, upload-time = "2025-06-10T00:45:27.752Z" }, + { url = "https://files.pythonhosted.org/packages/01/75/0d37402d208d025afa6b5b8eb80e466d267d3fd1927db8e317d29a94a4cb/yarl-1.20.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e42ba79e2efb6845ebab49c7bf20306c4edf74a0b20fc6b2ccdd1a219d12fad3", size = 134259, upload-time = "2025-06-10T00:45:29.882Z" }, + { url = "https://files.pythonhosted.org/packages/73/84/1fb6c85ae0cf9901046f07d0ac9eb162f7ce6d95db541130aa542ed377e6/yarl-1.20.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:41493b9b7c312ac448b7f0a42a089dffe1d6e6e981a2d76205801a023ed26a2b", size = 91269, upload-time = "2025-06-10T00:45:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/f3/9c/eae746b24c4ea29a5accba9a06c197a70fa38a49c7df244e0d3951108861/yarl-1.20.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f5a5928ff5eb13408c62a968ac90d43f8322fd56d87008b8f9dabf3c0f6ee983", size = 89995, upload-time = "2025-06-10T00:45:35.066Z" }, + { url = "https://files.pythonhosted.org/packages/fb/30/693e71003ec4bc1daf2e4cf7c478c417d0985e0a8e8f00b2230d517876fc/yarl-1.20.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30c41ad5d717b3961b2dd785593b67d386b73feca30522048d37298fee981805", size = 325253, upload-time = "2025-06-10T00:45:37.052Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a2/5264dbebf90763139aeb0b0b3154763239398400f754ae19a0518b654117/yarl-1.20.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:59febc3969b0781682b469d4aca1a5cab7505a4f7b85acf6db01fa500fa3f6ba", size = 320897, upload-time = "2025-06-10T00:45:39.962Z" }, + { url = "https://files.pythonhosted.org/packages/e7/17/77c7a89b3c05856489777e922f41db79ab4faf58621886df40d812c7facd/yarl-1.20.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d2b6fb3622b7e5bf7a6e5b679a69326b4279e805ed1699d749739a61d242449e", size = 340696, upload-time = "2025-06-10T00:45:41.915Z" }, + { url = "https://files.pythonhosted.org/packages/6d/55/28409330b8ef5f2f681f5b478150496ec9cf3309b149dab7ec8ab5cfa3f0/yarl-1.20.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:749d73611db8d26a6281086f859ea7ec08f9c4c56cec864e52028c8b328db723", size = 335064, upload-time = "2025-06-10T00:45:43.893Z" }, + { url = "https://files.pythonhosted.org/packages/85/58/cb0257cbd4002828ff735f44d3c5b6966c4fd1fc8cc1cd3cd8a143fbc513/yarl-1.20.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9427925776096e664c39e131447aa20ec738bdd77c049c48ea5200db2237e000", size = 327256, upload-time = "2025-06-10T00:45:46.393Z" }, + { url = "https://files.pythonhosted.org/packages/53/f6/c77960370cfa46f6fb3d6a5a79a49d3abfdb9ef92556badc2dcd2748bc2a/yarl-1.20.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ff70f32aa316393eaf8222d518ce9118148eddb8a53073c2403863b41033eed5", size = 316389, upload-time = "2025-06-10T00:45:48.358Z" }, + { url = "https://files.pythonhosted.org/packages/64/ab/be0b10b8e029553c10905b6b00c64ecad3ebc8ace44b02293a62579343f6/yarl-1.20.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:c7ddf7a09f38667aea38801da8b8d6bfe81df767d9dfc8c88eb45827b195cd1c", size = 340481, upload-time = "2025-06-10T00:45:50.663Z" }, + { url = "https://files.pythonhosted.org/packages/c5/c3/3f327bd3905a4916029bf5feb7f86dcf864c7704f099715f62155fb386b2/yarl-1.20.1-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:57edc88517d7fc62b174fcfb2e939fbc486a68315d648d7e74d07fac42cec240", size = 336941, upload-time = "2025-06-10T00:45:52.554Z" }, + { url = "https://files.pythonhosted.org/packages/d1/42/040bdd5d3b3bb02b4a6ace4ed4075e02f85df964d6e6cb321795d2a6496a/yarl-1.20.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:dab096ce479d5894d62c26ff4f699ec9072269d514b4edd630a393223f45a0ee", size = 339936, upload-time = "2025-06-10T00:45:54.919Z" }, + { url = "https://files.pythonhosted.org/packages/0d/1c/911867b8e8c7463b84dfdc275e0d99b04b66ad5132b503f184fe76be8ea4/yarl-1.20.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:14a85f3bd2d7bb255be7183e5d7d6e70add151a98edf56a770d6140f5d5f4010", size = 360163, upload-time = "2025-06-10T00:45:56.87Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/8c389f6c6ca0379b57b2da87f1f126c834777b4931c5ee8427dd65d0ff6b/yarl-1.20.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:2c89b5c792685dd9cd3fa9761c1b9f46fc240c2a3265483acc1565769996a3f8", size = 359108, upload-time = "2025-06-10T00:45:58.869Z" }, + { url = "https://files.pythonhosted.org/packages/7f/09/ae4a649fb3964324c70a3e2b61f45e566d9ffc0affd2b974cbf628957673/yarl-1.20.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:69e9b141de5511021942a6866990aea6d111c9042235de90e08f94cf972ca03d", size = 351875, upload-time = "2025-06-10T00:46:01.45Z" }, + { url = "https://files.pythonhosted.org/packages/8d/43/bbb4ed4c34d5bb62b48bf957f68cd43f736f79059d4f85225ab1ef80f4b9/yarl-1.20.1-cp39-cp39-win32.whl", hash = "sha256:b5f307337819cdfdbb40193cad84978a029f847b0a357fbe49f712063cfc4f06", size = 82293, upload-time = "2025-06-10T00:46:03.763Z" }, + { url = "https://files.pythonhosted.org/packages/d7/cd/ce185848a7dba68ea69e932674b5c1a42a1852123584bccc5443120f857c/yarl-1.20.1-cp39-cp39-win_amd64.whl", hash = "sha256:eae7bfe2069f9c1c5b05fc7fe5d612e5bbc089a39309904ee8b829e322dcad00", size = 87385, upload-time = "2025-06-10T00:46:05.655Z" }, + { url = "https://files.pythonhosted.org/packages/b4/2d/2345fce04cfd4bee161bf1e7d9cdc702e3e16109021035dbb24db654a622/yarl-1.20.1-py3-none-any.whl", hash = "sha256:83b8eb083fe4683c6115795d9fc1cfaf2cbbefb19b3a1cb68f6527460f483a77", size = 46542, upload-time = "2025-06-10T00:46:07.521Z" }, +] + +[[package]] +name = "zipp" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, +] From f07ac398a60351cc8f9883a4afd69dc2c65cd2ca Mon Sep 17 00:00:00 2001 From: Jared Casey Date: Thu, 17 Jul 2025 18:22:10 -0600 Subject: [PATCH 11/18] PYCO-53: Analytics SDK - Logging Changes ======= * Add loggers to `acouchbase_analytics` and `couchbase_analytics` * Remove print/todos related to logging and replace with log messaging * Allow logging to be configured via `PYCBAC_LOG_LEVEL` environment variable * General clean-up --- acouchbase_analytics/__init__.py | 75 +-------------- acouchbase_analytics/protocol/__init__.py | 72 +++++++++++++++ .../protocol/_core/async_json_stream.py | 29 ++++-- .../protocol/_core/async_json_token_parser.py | 8 +- .../protocol/_core/client_adapter.py | 57 ++++++++++-- .../protocol/_core/net_utils.py | 8 +- .../protocol/_core/request_context.py | 91 +++++++++++++++---- .../protocol/_core/retries.py | 11 +++ acouchbase_analytics/protocol/cluster.py | 3 +- acouchbase_analytics/protocol/errors.py | 14 +-- acouchbase_analytics/protocol/streaming.py | 6 ++ couchbase_analytics/__init__.py | 5 +- couchbase_analytics/common/__init__.py | 3 + .../common/_core/error_context.py | 2 - .../common/_core/json_token_parser_base.py | 21 ++++- couchbase_analytics/common/logging.py | 48 ++++++++++ couchbase_analytics/common/request.py | 2 +- couchbase_analytics/protocol/__init__.py | 55 +++-------- .../protocol/_core/client_adapter.py | 57 ++++++++++-- .../protocol/_core/json_stream.py | 30 ++++-- .../protocol/_core/json_token_parser.py | 8 +- .../protocol/_core/net_utils.py | 8 +- .../protocol/_core/request_context.py | 69 ++++++++++++-- couchbase_analytics/protocol/_core/retries.py | 11 +++ couchbase_analytics/protocol/cluster.py | 7 +- couchbase_analytics/protocol/connection.py | 19 +++- couchbase_analytics/protocol/errors.py | 21 ++--- couchbase_analytics/protocol/streaming.py | 5 + tests/utils/_async_client_adapter.py | 38 +------- tests/utils/_client_adapter.py | 35 +------ 30 files changed, 532 insertions(+), 286 deletions(-) create mode 100644 couchbase_analytics/common/logging.py diff --git a/acouchbase_analytics/__init__.py b/acouchbase_analytics/__init__.py index 06d8d05..0222448 100644 --- a/acouchbase_analytics/__init__.py +++ b/acouchbase_analytics/__init__.py @@ -13,77 +13,8 @@ # See the License for the specific language governing permissions and # limitations under the License. -import asyncio -import selectors -from asyncio import AbstractEventLoop -from typing import Optional +from acouchbase_analytics.protocol import get_event_loop as get_event_loop # noqa: F401 +from couchbase_analytics.common import LOG_DATE_FORMAT as LOG_DATE_FORMAT # noqa: F401 +from couchbase_analytics.common import LOG_FORMAT as LOG_FORMAT # noqa: F401 from couchbase_analytics.common import JSONType as JSONType # noqa: F401 - - -class _LoopValidator: - """ - **INTERNAL** - """ - - REQUIRED_METHODS = {'add_reader', 'remove_reader', 'add_writer', 'remove_writer'} - - @staticmethod - def _get_working_loop() -> AbstractEventLoop: - """ - **INTERNAL** - """ - evloop = asyncio.get_event_loop() - gen_new_loop = not _LoopValidator._is_valid_loop(evloop) - if gen_new_loop: - evloop.close() - selector = selectors.SelectSelector() - new_loop = asyncio.SelectorEventLoop(selector) - asyncio.set_event_loop(new_loop) - return new_loop - - return evloop - - @staticmethod - def _is_valid_loop(evloop: Optional[AbstractEventLoop] = None) -> bool: - """ - **INTERNAL** - """ - if not evloop: - return False - for meth in _LoopValidator.REQUIRED_METHODS: - abs_meth, actual_meth = (getattr(asyncio.AbstractEventLoop, meth), getattr(evloop.__class__, meth)) - if abs_meth == actual_meth: - return False - return True - - @staticmethod - def get_event_loop(evloop: Optional[AbstractEventLoop] = None) -> AbstractEventLoop: - """ - **INTERNAL** - """ - if evloop and _LoopValidator._is_valid_loop(evloop): - return evloop - return _LoopValidator._get_working_loop() - - @staticmethod - def close_loop() -> None: - """ - **INTERNAL** - """ - evloop = asyncio.get_event_loop() - evloop.close() - - -def get_event_loop(evloop: Optional[AbstractEventLoop] = None) -> AbstractEventLoop: - """ - Get an event loop compatible with acouchbase_analytics. - Some Event loops, such as ProactorEventLoop (the default asyncio event - loop for Python 3.8 on Windows) are not compatible with acouchbase_analytics as - they don't implement all members in the abstract base class. - - :param evloop: preferred event loop - :return: The preferred event loop, if compatible, otherwise, a compatible - alternative event loop. - """ - return _LoopValidator.get_event_loop(evloop) diff --git a/acouchbase_analytics/protocol/__init__.py b/acouchbase_analytics/protocol/__init__.py index 72df2de..e880775 100644 --- a/acouchbase_analytics/protocol/__init__.py +++ b/acouchbase_analytics/protocol/__init__.py @@ -12,3 +12,75 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. + +import asyncio +import selectors +from typing import Optional + + +class _LoopValidator: + """ + **INTERNAL** + """ + + REQUIRED_METHODS = {'add_reader', 'remove_reader', 'add_writer', 'remove_writer'} + + @staticmethod + def _get_working_loop() -> asyncio.AbstractEventLoop: + """ + **INTERNAL** + """ + evloop = asyncio.get_event_loop() + gen_new_loop = not _LoopValidator._is_valid_loop(evloop) + if gen_new_loop: + evloop.close() + selector = selectors.SelectSelector() + new_loop = asyncio.SelectorEventLoop(selector) + asyncio.set_event_loop(new_loop) + return new_loop + + return evloop + + @staticmethod + def _is_valid_loop(evloop: Optional[asyncio.AbstractEventLoop] = None) -> bool: + """ + **INTERNAL** + """ + if not evloop: + return False + for meth in _LoopValidator.REQUIRED_METHODS: + abs_meth, actual_meth = (getattr(asyncio.AbstractEventLoop, meth), getattr(evloop.__class__, meth)) + if abs_meth == actual_meth: + return False + return True + + @staticmethod + def get_event_loop(evloop: Optional[asyncio.AbstractEventLoop] = None) -> asyncio.AbstractEventLoop: + """ + **INTERNAL** + """ + if evloop and _LoopValidator._is_valid_loop(evloop): + return evloop + return _LoopValidator._get_working_loop() + + @staticmethod + def close_loop() -> None: + """ + **INTERNAL** + """ + evloop = asyncio.get_event_loop() + evloop.close() + + +def get_event_loop(evloop: Optional[asyncio.AbstractEventLoop] = None) -> asyncio.AbstractEventLoop: + """ + Get an event loop compatible with acouchbase_analytics. + Some Event loops, such as ProactorEventLoop (the default asyncio event + loop for Python 3.8 on Windows) are not compatible with acouchbase_analytics as + they don't implement all members in the abstract base class. + + :param evloop: preferred event loop + :return: The preferred event loop, if compatible, otherwise, a compatible + alternative event loop. + """ + return _LoopValidator.get_event_loop(evloop) diff --git a/acouchbase_analytics/protocol/_core/async_json_stream.py b/acouchbase_analytics/protocol/_core/async_json_stream.py index 6e2c9f9..93576ef 100644 --- a/acouchbase_analytics/protocol/_core/async_json_stream.py +++ b/acouchbase_analytics/protocol/_core/async_json_stream.py @@ -15,14 +15,16 @@ from __future__ import annotations -from typing import AsyncIterator, Optional +from typing import AsyncIterator, Callable, Optional import ijson from anyio import EndOfStream, Event, create_memory_object_stream from acouchbase_analytics.protocol._core.async_json_token_parser import AsyncJsonTokenParser from couchbase_analytics.common._core.json_parsing import JsonStreamConfig, ParsedResult, ParsedResultType +from couchbase_analytics.common._core.json_token_parser_base import JsonTokenParsingError from couchbase_analytics.common.errors import AnalyticsError +from couchbase_analytics.common.logging import LogLevel class AsyncJsonStream: @@ -31,6 +33,7 @@ def __init__( http_stream_iter: AsyncIterator[bytes], *, stream_config: Optional[JsonStreamConfig] = None, + logger_handler: Optional[Callable[[str, LogLevel], None]] = None, ) -> None: # HTTP stream handling if stream_config is None: @@ -40,10 +43,13 @@ def __init__( self._http_response_buffer = bytearray() self._http_stream_exhausted = False + # logging + self._log_handler = logger_handler + # results handling self._send_stream, self._receive_stream = create_memory_object_stream[ParsedResult]( max_buffer_size=stream_config.buffered_row_max - ) # noqa: E501 + ) self._json_stream_parser = None self._buffer_entire_result = stream_config.buffer_entire_result handler = None if self._buffer_entire_result is True else self._handle_json_result @@ -87,6 +93,10 @@ def _continue_processing(self) -> bool: return False return True + def _log_message(self, message: str, level: LogLevel) -> None: + if self._log_handler is not None: + self._log_handler(message, level) + async def _send_to_stream(self, result: ParsedResult, close: Optional[bool] = False) -> None: """ **INTERNAL** @@ -130,10 +140,18 @@ async def _process_token_stream(self) -> None: await self._json_token_parser.parse_token(event, value) except StopAsyncIteration: self._token_stream_exhausted = True + except JsonTokenParsingError as ex: + ex_str = str(ex) + self._log_message(f'JSON token parsing error encountered: {ex_str}', LogLevel.ERROR) + self._token_stream_exhausted = True + await self._send_to_stream(ParsedResult(ex_str.encode('utf-8'), ParsedResultType.ERROR), close=True) + self._handle_notification(ParsedResultType.ERROR) + return except ijson.common.IncompleteJSONError as ex: - # TODO: log this error + ex_str = str(ex) + self._log_message(f'Incomplete JSON error encountered: {ex_str}', LogLevel.ERROR) self._token_stream_exhausted = True - await self._send_to_stream(ParsedResult(str(ex).encode('utf-8'), ParsedResultType.ERROR), close=True) + await self._send_to_stream(ParsedResult(ex_str.encode('utf-8'), ParsedResultType.ERROR), close=True) self._handle_notification(ParsedResultType.ERROR) return @@ -176,10 +194,9 @@ async def get_result(self) -> ParsedResult: async def start_parsing(self) -> None: if self._json_stream_parser is not None: - # TODO: logging; I don't think this is an error... + self._log_message('JSON stream parser already exists', LogLevel.WARNING) return await self._process_token_stream() async def continue_parsing(self) -> None: - # TODO: error is _json_stream_parser is None? await self._process_token_stream() diff --git a/acouchbase_analytics/protocol/_core/async_json_token_parser.py b/acouchbase_analytics/protocol/_core/async_json_token_parser.py index 3a75c28..6983f68 100644 --- a/acouchbase_analytics/protocol/_core/async_json_token_parser.py +++ b/acouchbase_analytics/protocol/_core/async_json_token_parser.py @@ -22,6 +22,7 @@ START_EVENTS, VALUE_TOKENS, JsonTokenParserBase, + JsonTokenParsingError, ParsingState, TokenType, ) @@ -49,7 +50,9 @@ async def _handle_pop_event(self, token_type: TokenType) -> None: next_token = self._pop() if next_token.type == matching_token.type: should_emit = self._handle_pop_transition(next_token.state) - # I think obj_pairs.reverse() is O(n); while reversed is O(1) + # NOTE: obj_pairs.reverse() vs. reversed(obj_pairs) are essentially the same _because_ we convert + # the obj_pairs to a string (e.g. ",".join(...)); using reversed() in this case is slightly + # more convenient as it returns an iterator if matching_token.type == TokenType.START_ARRAY: obj = f'[{",".join(reversed(obj_pairs))}]' else: @@ -88,5 +91,4 @@ async def parse_token(self, token: str, value: str) -> None: elif token_type in POP_EVENTS: await self._handle_pop_event(token_type) else: - # TODO: custom exception - raise ValueError(f'Invalid token type: {token_type}; {value=}') + raise JsonTokenParsingError(f'Invalid token type: {token_type}; {value=}') diff --git a/acouchbase_analytics/protocol/_core/client_adapter.py b/acouchbase_analytics/protocol/_core/client_adapter.py index 5cdcf31..27a8ab8 100644 --- a/acouchbase_analytics/protocol/_core/client_adapter.py +++ b/acouchbase_analytics/protocol/_core/client_adapter.py @@ -15,14 +15,15 @@ from __future__ import annotations -import socket -from typing import TYPE_CHECKING, Optional +import logging +from typing import TYPE_CHECKING, Optional, cast from uuid import uuid4 from httpx import URL, AsyncClient, BasicAuth, Response from couchbase_analytics.common.credential import Credential from couchbase_analytics.common.deserializer import Deserializer +from couchbase_analytics.common.logging import LogLevel, log_message from couchbase_analytics.protocol.connection import _ConnectionDetails from couchbase_analytics.protocol.options import OptionsBuilder @@ -35,13 +36,17 @@ class _AsyncClientAdapter: **INTERNAL** """ - _ANALYTICS_PATH = '/api/v1/request' + ANALYTICS_PATH = '/api/v1/request' + LOGGER_NAME = 'acouchbase_analytics' def __init__( self, http_endpoint: str, credential: Credential, options: Optional[object] = None, **kwargs: object ) -> None: self._client_id = str(uuid4()) + self._prefix = '' + self._cluster_id = cast(str, kwargs.pop('cluster_id', '')) self._opts_builder = OptionsBuilder() + kwargs['logger_name'] = self.logger_name self._conn_details = _ConnectionDetails.create(self._opts_builder, http_endpoint, credential, options, **kwargs) # TODO: do we want to support custom HTTP transports for the async client? self._http_transport_cls = None @@ -51,7 +56,7 @@ def analytics_path(self) -> str: """ **INTERNAL** """ - return self._ANALYTICS_PATH + return self.ANALYTICS_PATH @property def client(self) -> AsyncClient: @@ -88,6 +93,30 @@ def has_client(self) -> bool: """ return hasattr(self, '_client') + @property + def log_prefix(self) -> str: + """ + **INTERNAL** + """ + if self._prefix: + return self._prefix + self._prefix = f'[{self._cluster_id}' + if self.has_client: + self._prefix += f'/{self._client_id}' + if self.connection_details.is_secure(): + self._prefix += '/https]' + else: + self._prefix += '/http]' + + return self._prefix + + @property + def logger_name(self) -> str: + """ + **INTERNAL** + """ + return self.LOGGER_NAME + @property def options_builder(self) -> OptionsBuilder: """ @@ -101,6 +130,7 @@ async def close_client(self) -> None: """ if hasattr(self, '_client'): await self._client.aclose() + self.log_message('Cluster HTTP client closed', LogLevel.INFO) async def create_client(self) -> None: """ @@ -123,7 +153,15 @@ async def create_client(self) -> None: if self._http_transport_cls is not None: transport = self._http_transport_cls() self._client = AsyncClient(auth=BasicAuth(*self._conn_details.credential), transport=transport) - # TODO: log message + self.log_message( + (f'Cluster HTTP client created: connection_details={self._conn_details.get_init_details()}'), + LogLevel.INFO, + ) + else: + self.log_message('Cluster HTTP client already exists, skipping creation.', LogLevel.INFO) + + def log_message(self, message: str, log_level: LogLevel) -> None: + log_message(logger, f'{self.log_prefix} {message}', log_level) async def send_request(self, request: QueryRequest) -> Response: """ @@ -139,11 +177,7 @@ async def send_request(self, request: QueryRequest) -> Response: path=request.url.path, ) req = self._client.build_request(request.method, url, json=request.body, extensions=request.extensions) - try: - return await self._client.send(req, stream=True) - except socket.gaierror as err: - req_url = self._conn_details.url.get_formatted_url() - raise RuntimeError(f'Unable to connect to {req_url}') from err + return await self._client.send(req, stream=True) def reset_client(self) -> None: """ @@ -151,3 +185,6 @@ def reset_client(self) -> None: """ if hasattr(self, '_client'): del self._client + + +logger = logging.getLogger(_AsyncClientAdapter.LOGGER_NAME) diff --git a/acouchbase_analytics/protocol/_core/net_utils.py b/acouchbase_analytics/protocol/_core/net_utils.py index 079b229..cc75af7 100644 --- a/acouchbase_analytics/protocol/_core/net_utils.py +++ b/acouchbase_analytics/protocol/_core/net_utils.py @@ -18,15 +18,16 @@ import socket from ipaddress import IPv4Address, IPv6Address, ip_address from random import choice -from typing import Optional, Union +from typing import Callable, Optional, Union import anyio from acouchbase_analytics.protocol.errors import ErrorMapper +from couchbase_analytics.common.logging import LogLevel @ErrorMapper.handle_socket_error_async -async def get_request_ip_async(host: str, port: int) -> str: +async def get_request_ip_async(host: str, port: int, logger_handler: Optional[Callable[..., None]] = None) -> str: # Lets not call getaddrinfo, if the host is already an IP address try: ip: Optional[Union[IPv4Address, IPv6Address, str]] = ip_address(host) @@ -42,6 +43,9 @@ async def get_request_ip_async(host: str, port: int) -> str: result = await anyio.getaddrinfo(host, port, type=socket.SOCK_STREAM, family=socket.AF_UNSPEC) res_ip = choice([addr[4][0] for addr in result]) # nosec B311 ip = str(res_ip) + if logger_handler: + message_data = {'results': [f'{addr[4][0]}' for addr in result], 'selected_ip': ip} + logger_handler(f'getaddrinfo() returned {len(result)} results', LogLevel.DEBUG, message_data=message_data) else: ip = str(ip) diff --git a/acouchbase_analytics/protocol/_core/request_context.py b/acouchbase_analytics/protocol/_core/request_context.py index a5480cd..4bb07a9 100644 --- a/acouchbase_analytics/protocol/_core/request_context.py +++ b/acouchbase_analytics/protocol/_core/request_context.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import math from asyncio import CancelledError, Task from types import TracebackType from typing import TYPE_CHECKING, Any, Awaitable, Callable, Coroutine, Dict, List, Optional, Type, Union @@ -17,6 +18,7 @@ from couchbase_analytics.common._core.error_context import ErrorContext from couchbase_analytics.common.backoff_calculator import DefaultBackoffCalculator from couchbase_analytics.common.errors import AnalyticsError +from couchbase_analytics.common.logging import LogLevel from couchbase_analytics.common.request import RequestState from couchbase_analytics.protocol.connection import DEFAULT_TIMEOUTS from couchbase_analytics.protocol.errors import ErrorMapper @@ -52,10 +54,11 @@ def __init__( self._connect_deadline = get_time() + connect_timeout self._cancel_scope_deadline_updated = False self._shutdown = False + self._request_deadline = math.inf @property def cancelled(self) -> bool: - self._check_cancelled_or_timed_out() + self._check_timed_out() return self._request_state in [RequestState.Cancelled, RequestState.AsyncCancelledPriorToTimeout] @property @@ -72,12 +75,12 @@ def is_shutdown(self) -> bool: @property def okay_to_iterate(self) -> bool: - self._check_cancelled_or_timed_out() + self._check_timed_out() return RequestState.okay_to_iterate(self._request_state) @property def okay_to_stream(self) -> bool: - self._check_cancelled_or_timed_out() + self._check_timed_out() return RequestState.okay_to_stream(self._request_state) @property @@ -98,10 +101,10 @@ def results_or_errors_type(self) -> ParsedResultType: @property def timed_out(self) -> bool: - self._check_cancelled_or_timed_out() + self._check_timed_out() return self._request_state == RequestState.Timeout - def _check_cancelled_or_timed_out(self) -> None: + def _check_timed_out(self) -> None: if self._request_state in [RequestState.Timeout, RequestState.Cancelled, RequestState.Error]: return @@ -115,6 +118,8 @@ def _check_cancelled_or_timed_out(self) -> None: timed_out = current_time >= self._request_deadline if timed_out: + message_data = {'current_time': f'{current_time}', 'request_deadline': f'{self._request_deadline}'} + self.log_message('Request has timed out', LogLevel.DEBUG, message_data=message_data) if self._request_state == RequestState.Cancelled: self._request_state = RequestState.AsyncCancelledPriorToTimeout else: @@ -128,7 +133,7 @@ async def _execute(self, fn: Callable[..., Awaitable[Any]], *args: object) -> No def _maybe_set_request_error( self, exc_type: Optional[Type[BaseException]] = None, exc_val: Optional[BaseException] = None ) -> None: - self._check_cancelled_or_timed_out() + self._check_timed_out() if exc_val is None: return if not RequestState.is_timeout_or_cancelled(self._request_state): @@ -193,11 +198,22 @@ async def _trace_handler(self, event_name: str, _: str) -> None: def _update_cancel_scope_deadline(self, deadline: float, is_absolute: Optional[bool] = False) -> None: # TODO: confirm scenario of get_time() < self._taskgroup.cancel_scope.deadline is handled by anyio new_deadline = deadline if is_absolute else get_time() + deadline - # TODO: Useful debug log message - # print(f'Updating cancel scope deadline: {self._taskgroup.cancel_scope.deadline} -> {new_deadline}') - if get_time() >= new_deadline: + current_time = get_time() + if current_time >= new_deadline: + self.log_message( + 'Deadline already exceeded, cancelling request', + LogLevel.DEBUG, + message_data={ + 'current_time': f'{current_time}', + 'new_deadline': f'{new_deadline}', + }, + ) self._taskgroup.cancel_scope.cancel() else: + self.log_message( + f'Updating cancel scope deadline: {self._taskgroup.cancel_scope.deadline} -> {new_deadline}', + LogLevel.DEBUG, + ) self._taskgroup.cancel_scope.deadline = new_deadline async def _wait_for_stage_to_complete(self) -> None: @@ -245,10 +261,13 @@ async def get_result_from_stream(self) -> ParsedResult: return await self._json_stream.get_result() async def initialize(self) -> None: - # TODO: Add useful logging messages if self._request_state == RequestState.ResetAndNotStarted: self._update_cancel_scope_deadline(self._connect_deadline, is_absolute=True) - # print('Skipping initialization as request is a retry') + self.log_message( + 'Request is a retry, skipping initialization', + LogLevel.DEBUG, + message_data={'request_deadline': f'{self._request_deadline}'}, + ) return await self.__aenter__() self._request_state = RequestState.Started @@ -258,7 +277,22 @@ async def initialize(self) -> None: current_time = get_time() self._request_deadline = current_time + (timeouts.get('read', None) or DEFAULT_TIMEOUTS['query_timeout']) self._update_cancel_scope_deadline(self._connect_deadline, is_absolute=True) - # print(f'initialize request ctx: {current_time=}; req_deadline={self._request_deadline}') + message_data = {'current_time': f'{current_time}', 'request_deadline': f'{self._request_deadline}'} + self.log_message('Request context initialized', LogLevel.DEBUG, message_data=message_data) + + def log_message( + self, + message: str, + log_level: LogLevel, + message_data: Optional[Dict[str, str]] = None, + append_ctx: Optional[bool] = True, + ) -> None: + if append_ctx is True: + message = f'{message}: ctx={self._id}' + if message_data is not None: + message_data_str = ', '.join(f'{k}={v}' for k, v in message_data.items()) + message = f'{message}, {message_data_str}' + self._client_adapter.log_message(message, log_level) def maybe_continue_to_process_stream(self) -> None: if not self.has_stage_completed: @@ -270,20 +304,29 @@ def maybe_continue_to_process_stream(self) -> None: self._start_next_stage(self._json_stream.continue_parsing, reset_previous_stage=True) def okay_to_delay_and_retry(self, delay: float) -> bool: - # TODO: Add useful logging messages - self._check_cancelled_or_timed_out() + self._check_timed_out() if self._request_state in [RequestState.Timeout, RequestState.Cancelled]: return False current_time = get_time() delay_time = current_time + delay will_time_out = self._request_deadline < delay_time - # print(f'{current_time=}; {delay_time=}; req_deadline={self._request_deadline}; {will_time_out=}') if will_time_out: self._request_state = RequestState.Timeout + message_data = { + 'current_time': f'{current_time}', + 'delay_time': f'{delay_time}', + 'request_deadline': f'{self._request_deadline}', + } + self.log_message('Request will timeout after delay', LogLevel.DEBUG, message_data=message_data) return False elif self.retry_limit_exceeded: self._request_state = RequestState.Error + message_data = { + 'num_attempts': f'{self.error_context.num_attempts}', + 'max_retries': f'{self._request.max_retries}', + } + self.log_message('Request has exceeded max retries', LogLevel.DEBUG, message_data=message_data) return False else: self._reset_stream() @@ -330,7 +373,7 @@ async def reraise_after_shutdown(self, err: Exception) -> None: async def send_request(self, enable_trace_handling: Optional[bool] = False) -> HttpCoreResponse: self._error_ctx.update_num_attempts() - ip = await get_request_ip_async(self._request.url.host, self._request.url.port) + ip = await get_request_ip_async(self._request.url.host, self._request.url.port, self.log_message) if enable_trace_handling is True: ( self._request.update_url(ip, self._client_adapter.analytics_path).add_trace_to_extensions( @@ -339,10 +382,22 @@ async def send_request(self, enable_trace_handling: Optional[bool] = False) -> H ) else: self._request.update_url(ip, self._client_adapter.analytics_path) - # TODO: add logging; provide request details (to/from, deadlines, etc.) self._error_ctx.update_request_context(self._request) + message_data = { + 'url': f'{self._request.url.get_formatted_url()}', + 'body': f'{self._request.body}', + 'request_deadline': f'{self._request_deadline}', + } + self.log_message('HTTP request', LogLevel.DEBUG, message_data=message_data) response = await self._client_adapter.send_request(self._request) self._error_ctx.update_response_context(response) + message_data = { + 'status_code': f'{response.status_code}', + 'last_dispatched_to': f'{self._error_ctx.last_dispatched_to}', + 'last_dispatched_from': f'{self._error_ctx.last_dispatched_from}', + 'request_deadline': f'{self._request_deadline}', + } + self.log_message('HTTP response', LogLevel.DEBUG, message_data=message_data) return response async def shutdown( @@ -352,6 +407,7 @@ async def shutdown( exc_tb: Optional[TracebackType] = None, ) -> None: if self.is_shutdown: + self.log_message('Request context already shutdown', LogLevel.WARNING) return if hasattr(self, '_taskgroup'): await self.__aexit__(exc_type, exc_val, exc_tb) @@ -361,6 +417,7 @@ async def shutdown( if RequestState.is_okay(self._request_state): self._request_state = RequestState.Completed self._shutdown = True + self.log_message('Request context shutdown complete', LogLevel.INFO) def start_stream(self, core_response: HttpCoreResponse) -> None: if hasattr(self, '_json_stream'): diff --git a/acouchbase_analytics/protocol/_core/retries.py b/acouchbase_analytics/protocol/_core/retries.py index ea606c4..2bf3740 100644 --- a/acouchbase_analytics/protocol/_core/retries.py +++ b/acouchbase_analytics/protocol/_core/retries.py @@ -23,6 +23,7 @@ from acouchbase_analytics.protocol._core.anyio_utils import sleep from couchbase_analytics.common.errors import AnalyticsError, InternalSDKError, TimeoutError +from couchbase_analytics.common.logging import LogLevel from couchbase_analytics.common.request import RequestState from couchbase_analytics.protocol.errors import WrappedError @@ -54,6 +55,11 @@ async def handle_httpx_retry( if err: return err await sleep(delay) + ctx.log_message( + 'Retrying request', + LogLevel.DEBUG, + {'num_attempts': f'{ctx.error_context.num_attempts}', 'delay': f'{delay}s'}, + ) return None @staticmethod @@ -76,6 +82,11 @@ async def handle_retry(ex: WrappedError, ctx: AsyncRequestContext) -> Optional[U if err: return err await sleep(delay) + ctx.log_message( + 'Retrying request', + LogLevel.DEBUG, + {'num_attempts': f'{ctx.error_context.num_attempts}', 'delay': f'{delay}s'}, + ) return None ex.maybe_set_cause_context(ctx.error_context) return ex.unwrap() diff --git a/acouchbase_analytics/protocol/cluster.py b/acouchbase_analytics/protocol/cluster.py index 5a3851c..8e4ef32 100644 --- a/acouchbase_analytics/protocol/cluster.py +++ b/acouchbase_analytics/protocol/cluster.py @@ -40,8 +40,9 @@ class AsyncCluster: def __init__( self, connstr: str, credential: Credential, options: Optional[ClusterOptions] = None, **kwargs: object ) -> None: - self._client_adapter = _AsyncClientAdapter(connstr, credential, options, **kwargs) self._cluster_id = str(uuid4()) + kwargs['cluster_id'] = self._cluster_id + self._client_adapter = _AsyncClientAdapter(connstr, credential, options, **kwargs) self._request_builder = _RequestBuilder(self._client_adapter) self._backend = current_async_library() diff --git a/acouchbase_analytics/protocol/errors.py b/acouchbase_analytics/protocol/errors.py index f3b1443..5cd0e2e 100644 --- a/acouchbase_analytics/protocol/errors.py +++ b/acouchbase_analytics/protocol/errors.py @@ -17,23 +17,25 @@ import socket from functools import wraps -from typing import Any, Callable, Coroutine +from typing import Any, Callable, Coroutine, Optional from couchbase_analytics.common.errors import AnalyticsError +from couchbase_analytics.common.logging import LogLevel from couchbase_analytics.protocol.errors import WrappedError class ErrorMapper: @staticmethod def handle_socket_error_async( - fn: Callable[[str, int], Coroutine[Any, Any, str]], - ) -> Callable[[str, int], Coroutine[Any, Any, str]]: + fn: Callable[[str, int, Optional[Callable[..., None]]], Coroutine[Any, Any, str]], + ) -> Callable[[str, int, Optional[Callable[..., None]]], Coroutine[Any, Any, str]]: @wraps(fn) - async def wrapped_fn(host: str, port: int) -> str: + async def wrapped_fn(host: str, port: int, logger_handler: Optional[Callable[..., None]] = None) -> str: try: - return await fn(host, port) + return await fn(host, port, logger_handler) except socket.gaierror as ex: - # print(f'getaddrinfo failed for {host}:{port} with error: {ex}') + if logger_handler: + logger_handler(f'getaddrinfo() failed for {host}:{port} with error: {ex}', LogLevel.ERROR) msg = 'Connection error occurred while sending request.' raise WrappedError(AnalyticsError(cause=ex, message=msg), retriable=True) from None diff --git a/acouchbase_analytics/protocol/streaming.py b/acouchbase_analytics/protocol/streaming.py index b931a60..5025047 100644 --- a/acouchbase_analytics/protocol/streaming.py +++ b/acouchbase_analytics/protocol/streaming.py @@ -24,6 +24,7 @@ from couchbase_analytics.common._core import ParsedResult, ParsedResultType from couchbase_analytics.common._core.query import build_query_metadata from couchbase_analytics.common.errors import AnalyticsError, InternalSDKError, TimeoutError +from couchbase_analytics.common.logging import LogLevel from couchbase_analytics.common.query import QueryMetadata @@ -46,6 +47,7 @@ async def _handle_iteration_abort(self) -> None: """ await self.close() if self._request_context.cancelled: + self._request_context.log_message('Request canceled, aborting iteration', LogLevel.DEBUG) await self._request_context.shutdown() raise StopAsyncIteration elif self._request_context.timed_out: @@ -55,6 +57,7 @@ async def _handle_iteration_abort(self) -> None: ) await self._request_context.reraise_after_shutdown(err) else: + self._request_context.log_message('Aborting iteration', LogLevel.DEBUG) await self._request_context.shutdown() raise StopAsyncIteration @@ -75,18 +78,21 @@ async def close(self) -> None: """ if hasattr(self, '_core_response'): await self._core_response.aclose() + self._request_context.log_message('HTTP core response closed', LogLevel.INFO) del self._core_response def cancel(self) -> None: """ **INTERNAL** """ + self._request_context.log_message('AsyncHttpStreamingResponse cancelling request in background', LogLevel.DEBUG) self._request_context.cancel_request(self._close_in_background) async def cancel_async(self) -> None: """ **INTERNAL** """ + self._request_context.log_message('AsyncHttpStreamingResponse cancelling request', LogLevel.DEBUG) await self.close() self._request_context.cancel_request() await self._request_context.shutdown() diff --git a/couchbase_analytics/__init__.py b/couchbase_analytics/__init__.py index a2d88e4..7ed5405 100644 --- a/couchbase_analytics/__init__.py +++ b/couchbase_analytics/__init__.py @@ -13,7 +13,6 @@ # See the License for the specific language governing permissions and # limitations under the License. +from couchbase_analytics.common import LOG_DATE_FORMAT as LOG_DATE_FORMAT # noqa: F401 +from couchbase_analytics.common import LOG_FORMAT as LOG_FORMAT # noqa: F401 from couchbase_analytics.common import JSONType as JSONType # noqa: F401 - -# TODO: logging -# from couchbase_analytics.protocol import configure_logging as configure_logging # noqa: F401 diff --git a/couchbase_analytics/common/__init__.py b/couchbase_analytics/common/__init__.py index 962f59f..c14c1f9 100644 --- a/couchbase_analytics/common/__init__.py +++ b/couchbase_analytics/common/__init__.py @@ -15,4 +15,7 @@ from typing import Any, Dict, List, Union +from .logging import LOG_DATE_FORMAT as LOG_DATE_FORMAT # noqa: F401 +from .logging import LOG_FORMAT as LOG_FORMAT # noqa: F401 + JSONType = Union[str, int, float, bool, None, Dict[str, Any], List[Any]] diff --git a/couchbase_analytics/common/_core/error_context.py b/couchbase_analytics/common/_core/error_context.py index 4722ffc..6270fea 100644 --- a/couchbase_analytics/common/_core/error_context.py +++ b/couchbase_analytics/common/_core/error_context.py @@ -55,13 +55,11 @@ def update_request_context(self, request: QueryRequest) -> None: def update_response_context(self, response: HttpCoreResponse) -> None: network_stream = response.extensions.get('network_stream', None) - # TODO: what if network_stream is None? if network_stream is not None: addr, port = network_stream.get_extra_info('client_addr') self.last_dispatched_from = f'{addr}:{port}' addr, port = network_stream.get_extra_info('server_addr') self.last_dispatched_to = f'{addr}:{port}' - print(f'Client address: {self.last_dispatched_from}, Server address: {self.last_dispatched_to}') self.status_code = response.status_code def _ctx_details(self) -> Dict[str, str]: diff --git a/couchbase_analytics/common/_core/json_token_parser_base.py b/couchbase_analytics/common/_core/json_token_parser_base.py index 029c164..4548a2f 100644 --- a/couchbase_analytics/common/_core/json_token_parser_base.py +++ b/couchbase_analytics/common/_core/json_token_parser_base.py @@ -116,6 +116,19 @@ class Token(NamedTuple): ] +class JsonTokenParsingError(Exception): + """ + Exception raised when there is an error parsing JSON tokens. + """ + + def __init__(self, message: str) -> None: + super().__init__(message) + self.message = message + + def __str__(self) -> str: + return f'JsonTokenParsingError: {self.message}' + + class JsonTokenParserBase: def __init__(self, emit_results_enabled: bool) -> None: self._stack: Deque[Token] = deque() @@ -149,7 +162,7 @@ def _get_matching_token(self, token_type: TokenType) -> Token: elif token_type == TokenType.END_MAP: return EVENT_TOKENS[TokenType.START_MAP] else: - raise ValueError(f'Invalid token type (cannot match): {token_type}') + raise JsonTokenParsingError(f'Invalid token type (cannot match): {token_type}') def _handle_map_key_token(self, value: str) -> None: if self._state == ParsingState.PROCESSING: @@ -193,7 +206,7 @@ def _handle_push_transition(self) -> Optional[TokenState]: self._previous_state = self._state self._state = ParsingState.PROCESSING_ERROR return TokenState.ERROR_START - raise ValueError(f'Invalid state for push transition: {self._state}') + raise JsonTokenParsingError(f'Invalid state for push transition: {self._state}') def _handle_start_event(self, token_type: TokenType) -> None: transition = False @@ -222,7 +235,7 @@ def _handle_value_token(self, token_type: TokenType, value: str) -> Optional[str val = f'{value}' if pair_key is not None: if self.results_type == TokenType.VALUE and self._state != ParsingState.PROCESSING: - raise RuntimeError('JsonTokenParser: Cannot return value when pair key is present.') + raise JsonTokenParsingError('Cannot return value when pair_key is present.') self._push(TokenType.PAIR, f'{pair_key}:{val}') else: if self._emit_results_enabled is True and self.results_type == TokenType.VALUE: @@ -240,7 +253,7 @@ def _push(self, token_type: TokenType, value: str, transition: Optional[bool] = def _pop(self) -> Token: if self._stack: return self._stack.pop() - raise ValueError('Stack is empty') + raise JsonTokenParsingError('Stack is empty') def _should_push_pair(self, token: Token) -> bool: # when a results object is complete, the state will have transactioned back to PROCESSING diff --git a/couchbase_analytics/common/logging.py b/couchbase_analytics/common/logging.py new file mode 100644 index 0000000..3960252 --- /dev/null +++ b/couchbase_analytics/common/logging.py @@ -0,0 +1,48 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import logging +from enum import Enum + +LOG_FORMAT_ARR = ['[%(asctime)s.%(msecs)03d]', + '%(relativeCreated)dms', + '[%(levelname)s]', + '[%(process)d, %(threadName)s (%(thread)d)]' + ' %(name)s', + '- %(message)s'] +LOG_FORMAT = ' '.join(LOG_FORMAT_ARR) +LOG_DATE_FORMAT = '%Y-%m-%d %H:%M:%S' + +class LogLevel(Enum): + DEBUG = logging.DEBUG + INFO = logging.INFO + WARNING = logging.WARNING + ERROR = logging.ERROR + CRITICAL = logging.CRITICAL + +def log_message(logger: logging.Logger, message: str, log_level: LogLevel) -> None: + if not logger or not logger.hasHandlers(): + return + + if log_level == LogLevel.DEBUG: + logger.debug(message) + elif log_level == LogLevel.INFO: + logger.info(message) + elif log_level == LogLevel.WARNING: + logger.warning(message) + elif log_level == LogLevel.ERROR: + logger.error(message) + elif log_level == LogLevel.CRITICAL: + logger.critical(message) \ No newline at end of file diff --git a/couchbase_analytics/common/request.py b/couchbase_analytics/common/request.py index 1b9399f..098dbf9 100644 --- a/couchbase_analytics/common/request.py +++ b/couchbase_analytics/common/request.py @@ -80,8 +80,8 @@ class RequestURL: def get_formatted_url(self) -> str: """Get the formatted URL for this request.""" + host = self.ip if self.ip else self.host if self.path is None: - host = self.ip if self.ip else self.host return f'{self.scheme}://{host}:{self.port}' return f'{self.scheme}://{host}:{self.port}{self.path}' diff --git a/couchbase_analytics/protocol/__init__.py b/couchbase_analytics/protocol/__init__.py index cb45b7a..8c4f837 100644 --- a/couchbase_analytics/protocol/__init__.py +++ b/couchbase_analytics/protocol/__init__.py @@ -13,7 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -# TODO: versioning +import logging import sys try: @@ -30,51 +30,18 @@ except Exception: # nosec pass -""" -pycbac teardown methods +def configure_logger() -> None: + import os -""" -# import atexit # nopep8 # isort:skip # noqa: E402 + log_level = os.getenv('PYCBAC_LOG_LEVEL', None) + if log_level: + logger = logging.getLogger() + if not logger.hasHandlers(): + from couchbase_analytics.common.logging import LOG_DATE_FORMAT, LOG_FORMAT + logging.basicConfig(format=LOG_FORMAT, datefmt=LOG_DATE_FORMAT, level=log_level.upper()) + logger.info(f'Python Couchbase Analytics Client ({PYCBAC_VERSION})') -# def _pycbac_teardown(**kwargs: object) -> None: -# """**INTERNAL**""" -# global _PYCBAC_LOGGER -# if _PYCBAC_LOGGER: -# # TODO: see about synchronizing the logger's shutdown here -# _PYCBAC_LOGGER = None # type: ignore - -# atexit.register(_pycbac_teardown) - - -""" - -Logging methods - -""" -# TODO: logging - -# def configure_console_logger() -> None: -# import os -# log_level = os.getenv('PYCBAC_LOG_LEVEL', None) -# if log_level: -# _PYCBAC_LOGGER.create_console_logger(log_level.lower()) -# logger = logging.getLogger() -# logger.info(f'Python Couchbase Analytics Client ({PYCBAC_VERSION})') -# logging.getLogger().debug(get_metadata(as_str=True)) - - -# def configure_logging(name: str, -# level: Optional[int] = logging.INFO, -# parent_logger: Optional[logging.Logger] = None) -> None: -# if parent_logger: -# name = f'{parent_logger.name}.{name}' -# logger = logging.getLogger(name) -# _PYCBAC_LOGGER.configure_logging_sink(logger, level) -# logger.info(f'Python Couchbase Analytics Client ({PYCBAC_VERSION})') -# logger.debug(get_metadata(as_str=True)) - - -# configure_console_logger() +configure_logger() diff --git a/couchbase_analytics/protocol/_core/client_adapter.py b/couchbase_analytics/protocol/_core/client_adapter.py index 15f8a35..8386f36 100644 --- a/couchbase_analytics/protocol/_core/client_adapter.py +++ b/couchbase_analytics/protocol/_core/client_adapter.py @@ -15,14 +15,15 @@ from __future__ import annotations -import socket -from typing import TYPE_CHECKING, Optional +import logging +from typing import TYPE_CHECKING, Optional, cast from uuid import uuid4 from httpx import URL, BasicAuth, Client, Response from couchbase_analytics.common.credential import Credential from couchbase_analytics.common.deserializer import Deserializer +from couchbase_analytics.common.logging import LogLevel, log_message from couchbase_analytics.protocol.connection import _ConnectionDetails from couchbase_analytics.protocol.options import OptionsBuilder @@ -37,17 +38,21 @@ class _ClientAdapter: **INTERNAL** """ - _ANALYTICS_PATH = '/api/v1/request' + ANALYTICS_PATH = '/api/v1/request' + LOGGER_NAME = 'couchbase_analytics' def __init__( self, http_endpoint: str, credential: Credential, options: Optional[object] = None, **kwargs: object ) -> None: self._client_id = str(uuid4()) + self._prefix = '' + self._cluster_id = cast(str, kwargs.pop('cluster_id', '')) self._opts_builder = OptionsBuilder() # TODO: We should limit the allowed transports to the ones we support # Question is how do we want to limit the transports? Should users even need to override? # self._http_transport_cls = kwargs.pop('http_transport_cls', AnalyticsHTTPTransport) self._http_transport_cls = None + kwargs['logger_name'] = self.logger_name self._conn_details = _ConnectionDetails.create(self._opts_builder, http_endpoint, credential, options, **kwargs) @property @@ -55,7 +60,7 @@ def analytics_path(self) -> str: """ **INTERNAL** """ - return self._ANALYTICS_PATH + return self.ANALYTICS_PATH @property def client(self) -> Client: @@ -92,6 +97,30 @@ def has_client(self) -> bool: """ return hasattr(self, '_client') + @property + def log_prefix(self) -> str: + """ + **INTERNAL** + """ + if self._prefix: + return self._prefix + self._prefix = f'[{self._cluster_id}' + if self.has_client: + self._prefix += f'/{self._client_id}' + if self.connection_details.is_secure(): + self._prefix += '/https]' + else: + self._prefix += '/http]' + + return self._prefix + + @property + def logger_name(self) -> str: + """ + **INTERNAL** + """ + return self.LOGGER_NAME + @property def options_builder(self) -> OptionsBuilder: """ @@ -105,6 +134,7 @@ def close_client(self) -> None: """ if hasattr(self, '_client'): self._client.close() + self.log_message('Cluster HTTP client closed', LogLevel.INFO) def create_client(self) -> None: """ @@ -125,6 +155,16 @@ def create_client(self) -> None: transport = self._http_transport_cls() self._client = Client(auth=auth, transport=transport) + self.log_message( + (f'Cluster HTTP client created: connection_details={self._conn_details.get_init_details()}'), + LogLevel.INFO, + ) + else: + self.log_message('Cluster HTTP client already exists, skipping creation.', LogLevel.INFO) + + def log_message(self, message: str, log_level: LogLevel) -> None: + log_message(logger, f'{self.log_prefix} {message}', log_level) + def send_request(self, request: QueryRequest) -> Response: """ **INTERNAL** @@ -134,11 +174,7 @@ def send_request(self, request: QueryRequest) -> Response: url = URL(scheme=request.url.scheme, host=request.url.ip, port=request.url.port, path=request.url.path) req = self._client.build_request(request.method, url, json=request.body, extensions=request.extensions) - try: - return self._client.send(req, stream=True) - except socket.gaierror as err: - req_url = self._conn_details.url.get_formatted_url() - raise RuntimeError(f'Unable to connect to {req_url}') from err + return self._client.send(req, stream=True) def reset_client(self) -> None: """ @@ -146,3 +182,6 @@ def reset_client(self) -> None: """ if hasattr(self, '_client'): del self._client + + +logger = logging.getLogger(_ClientAdapter.LOGGER_NAME) diff --git a/couchbase_analytics/protocol/_core/json_stream.py b/couchbase_analytics/protocol/_core/json_stream.py index 2808725..aa33ed5 100644 --- a/couchbase_analytics/protocol/_core/json_stream.py +++ b/couchbase_analytics/protocol/_core/json_stream.py @@ -19,11 +19,13 @@ from queue import Empty as QueueEmpty from queue import Full as QueueFull from queue import Queue -from typing import TYPE_CHECKING, Iterator, Optional +from typing import TYPE_CHECKING, Callable, Iterator, Optional import ijson from couchbase_analytics.common._core.json_parsing import JsonStreamConfig, ParsedResult, ParsedResultType +from couchbase_analytics.common._core.json_token_parser_base import JsonTokenParsingError +from couchbase_analytics.common.logging import LogLevel from couchbase_analytics.protocol._core.json_token_parser import JsonTokenParser if TYPE_CHECKING: @@ -38,6 +40,7 @@ def __init__( http_stream_iter: Iterator[bytes], *, stream_config: Optional[JsonStreamConfig] = None, + logger_handler: Optional[Callable[[str, LogLevel], None]] = None, ) -> None: # HTTP stream handling if stream_config is None: @@ -47,6 +50,9 @@ def __init__( self._http_response_buffer = bytearray() self._http_stream_exhausted = False + # logging + self._log_handler = logger_handler + # results handling self._buffered_row_max = stream_config.buffered_row_max self._buffered_row_threshold = int(self._buffered_row_max * stream_config.buffered_row_threshold_percent) @@ -96,7 +102,7 @@ def _put(self, result: ParsedResult) -> None: self._results_queue.put(result, timeout=self._queue_timeout) break except QueueFull: - # TODO: log error as this is unexpected + self._log_message('Encountered QueueFull error', LogLevel.ERROR) pass def _handle_json_result(self, row: bytes) -> None: @@ -114,6 +120,10 @@ def _handle_notification(self, result_type: ParsedResultType) -> None: self._notify_on_results_or_error.set_result(result_type) + def _log_message(self, message: str, level: LogLevel) -> None: + if self._log_handler is not None: + self._log_handler(message, level) + def _process_token_stream(self, request_context: Optional[RequestContext] = None) -> None: """ **INTERNAL** @@ -127,10 +137,18 @@ def _process_token_stream(self, request_context: Optional[RequestContext] = None self._json_token_parser.parse_token(event, value) except StopIteration: self._token_stream_exhausted = True + except JsonTokenParsingError as ex: + ex_str = str(ex) + self._log_message(f'JSON token parsing error encountered: {ex_str}', LogLevel.ERROR) + self._token_stream_exhausted = True + self._put(ParsedResult(ex_str.encode('utf-8'), ParsedResultType.ERROR)) + self._handle_notification(ParsedResultType.ERROR) + return except ijson.common.IncompleteJSONError as ex: - # TODO: log this error + ex_str = str(ex) + self._log_message(f'Incomplete JSON error encountered: {ex_str}', LogLevel.ERROR) self._token_stream_exhausted = True - self._put(ParsedResult(str(ex).encode('utf-8'), ParsedResultType.ERROR)) + self._put(ParsedResult(ex_str.encode('utf-8'), ParsedResultType.ERROR)) self._handle_notification(ParsedResultType.ERROR) return @@ -169,7 +187,7 @@ def get_result(self, timeout: float) -> Optional[ParsedResult]: try: return self._results_queue.get(timeout=timeout) except QueueEmpty: - # TODO: log a message here as indication the stream is slow + self._log_message(f'Results queue empty after waiting {timeout} seconds', LogLevel.WARNING) return None def start_parsing( @@ -178,7 +196,7 @@ def start_parsing( notify_on_results_or_error: Optional[Future[ParsedResultType]] = None, ) -> None: if self._json_stream_parser is not None: - # TODO: logging; I don't think this is an error... + self._log_message('JSON stream parser already exists', LogLevel.WARNING) return self._notify_on_results_or_error = notify_on_results_or_error self._process_token_stream(request_context=request_context) diff --git a/couchbase_analytics/protocol/_core/json_token_parser.py b/couchbase_analytics/protocol/_core/json_token_parser.py index 61a9855..ebe6e2c 100644 --- a/couchbase_analytics/protocol/_core/json_token_parser.py +++ b/couchbase_analytics/protocol/_core/json_token_parser.py @@ -22,6 +22,7 @@ START_EVENTS, VALUE_TOKENS, JsonTokenParserBase, + JsonTokenParsingError, ParsingState, TokenType, ) @@ -49,7 +50,9 @@ def _handle_pop_event(self, token_type: TokenType) -> None: next_token = self._pop() if next_token.type == matching_token.type: should_emit = self._handle_pop_transition(next_token.state) - # I think obj_pairs.reverse() is O(n); while reversed is O(1) + # NOTE: obj_pairs.reverse() vs. reversed(obj_pairs) are essentially the same _because_ we convert + # the obj_pairs to a string (e.g. ",".join(...)); using reversed() in this case is slightly + # more convenient as it returns an iterator if matching_token.type == TokenType.START_ARRAY: obj = f'[{",".join(reversed(obj_pairs))}]' else: @@ -85,5 +88,4 @@ def parse_token(self, token: str, value: str) -> None: elif token_type in POP_EVENTS: self._handle_pop_event(token_type) else: - # TODO: custom exception - raise ValueError(f'Invalid token type: {token_type}; {value=}') + raise JsonTokenParsingError(f'Invalid token type: {token_type}; {value=}') diff --git a/couchbase_analytics/protocol/_core/net_utils.py b/couchbase_analytics/protocol/_core/net_utils.py index 7d7a5d3..b311058 100644 --- a/couchbase_analytics/protocol/_core/net_utils.py +++ b/couchbase_analytics/protocol/_core/net_utils.py @@ -18,13 +18,14 @@ import socket from ipaddress import IPv4Address, IPv6Address, ip_address from random import choice -from typing import Optional, Union +from typing import Callable, Optional, Union +from couchbase_analytics.common.logging import LogLevel from couchbase_analytics.protocol.errors import ErrorMapper @ErrorMapper.handle_socket_error -def get_request_ip(host: str, port: int) -> str: +def get_request_ip(host: str, port: int, logger_handler: Optional[Callable[..., None]] = None) -> str: # Lets not call getaddrinfo, if the host is already an IP address try: ip: Optional[Union[IPv4Address, IPv6Address, str]] = ip_address(host) @@ -40,6 +41,9 @@ def get_request_ip(host: str, port: int) -> str: result = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM, family=socket.AF_UNSPEC) res_ip = choice([addr[4][0] for addr in result]) # nosec B311 ip = str(res_ip) + if logger_handler: + message_data = {'results': [f'{addr[4][0]}' for addr in result], 'selected_ip': ip} + logger_handler(f'getaddrinfo() returned {len(result)} results', LogLevel.DEBUG, message_data=message_data) else: ip = str(ip) diff --git a/couchbase_analytics/protocol/_core/request_context.py b/couchbase_analytics/protocol/_core/request_context.py index 4a71fc9..479ea7e 100644 --- a/couchbase_analytics/protocol/_core/request_context.py +++ b/couchbase_analytics/protocol/_core/request_context.py @@ -14,6 +14,7 @@ from couchbase_analytics.common._core.error_context import ErrorContext from couchbase_analytics.common.backoff_calculator import DefaultBackoffCalculator from couchbase_analytics.common.errors import AnalyticsError, TimeoutError +from couchbase_analytics.common.logging import LogLevel from couchbase_analytics.common.request import RequestState from couchbase_analytics.common.result import BlockingQueryResult from couchbase_analytics.protocol._core.json_stream import JsonStream @@ -167,11 +168,17 @@ def _check_cancelled_or_timed_out(self) -> None: self._background_request is not None and self._background_request.user_cancelled ): self._request_state = RequestState.Cancelled + if self._cancel_event.is_set(): + self.log_message('Request has been cancelled', LogLevel.DEBUG) + elif self._background_request is not None and self._background_request.user_cancelled: + self.log_message('Request has been cancelled via user background request', LogLevel.DEBUG) + return current_time = time.monotonic() timed_out = current_time >= self._request_deadline - # print(f'{current_time=}; req_deadline={self._request_deadline}; {timed_out=}') if timed_out: + message_data = {'current_time': f'{current_time}', 'request_deadline': f'{self._request_deadline}'} + self.log_message('Request has timed out', LogLevel.DEBUG, message_data=message_data) if self._request_state == RequestState.Cancelled: self._request_state = RequestState.SyncCancelledPriorToTimeout else: @@ -262,15 +269,33 @@ def get_result_from_stream(self) -> Optional[ParsedResult]: return self._json_stream.get_result(self._stream_config.queue_timeout) def initialize(self) -> None: - # TODO: Add useful logging messages if self._request_state == RequestState.ResetAndNotStarted: - # print('Skipping initialization as request is a retry') + self.log_message( + 'Request is a retry, skipping initialization', + LogLevel.DEBUG, + message_data={'request_deadline': f'{self._request_deadline}'}, + ) return self._request_state = RequestState.Started timeouts = self._request.get_request_timeouts() or {} current_time = time.monotonic() self._request_deadline = current_time + (timeouts.get('read', None) or DEFAULT_TIMEOUTS['query_timeout']) - # print(f'initialize request ctx: {current_time=}; req_deadline={self._request_deadline}') + message_data = {'current_time': f'{current_time}', 'request_deadline': f'{self._request_deadline}'} + self.log_message('Request context initialized', LogLevel.DEBUG, message_data=message_data) + + def log_message( + self, + message: str, + log_level: LogLevel, + message_data: Optional[Dict[str, str]] = None, + append_ctx: Optional[bool] = True, + ) -> None: + if append_ctx is True: + message = f'{message}: ctx={self._id}' + if message_data is not None: + message_data_str = ', '.join(f'{k}={v}' for k, v in message_data.items()) + message = f'{message}, {message_data_str}' + self._client_adapter.log_message(message, log_level) def maybe_continue_to_process_stream(self) -> None: if not self.has_stage_completed: @@ -293,12 +318,22 @@ def okay_to_delay_and_retry(self, delay: float) -> bool: current_time = time.monotonic() delay_time = current_time + delay will_time_out = self._request_deadline < delay_time - # print(f'{current_time=}; {delay_time=}; req_deadline={self._request_deadline}; {will_time_out=}') if will_time_out: self._request_state = RequestState.Timeout + message_data = { + 'current_time': f'{current_time}', + 'delay_time': f'{delay_time}', + 'request_deadline': f'{self._request_deadline}', + } + self.log_message('Request will timeout after delay', LogLevel.DEBUG, message_data=message_data) return False elif self.retry_limit_exceeded: self._request_state = RequestState.Error + message_data = { + 'num_attempts': f'{self.error_context.num_attempts}', + 'max_retries': f'{self._request.max_retries}', + } + self.log_message('Request has exceeded max retries', LogLevel.DEBUG, message_data=message_data) return False else: self._reset_stream() @@ -337,7 +372,7 @@ def process_response( def send_request(self, enable_trace_handling: Optional[bool] = False) -> HttpCoreResponse: self._error_ctx.update_num_attempts() - ip = get_request_ip(self._request.url.host, self._request.url.port) + ip = get_request_ip(self._request.url.host, self._request.url.port, self.log_message) if enable_trace_handling is True: ( self._request.update_url(ip, self._client_adapter.analytics_path).add_trace_to_extensions( @@ -347,9 +382,21 @@ def send_request(self, enable_trace_handling: Optional[bool] = False) -> HttpCor else: self._request.update_url(ip, self._client_adapter.analytics_path) self._error_ctx.update_request_context(self._request) + message_data = { + 'url': f'{self._request.url.get_formatted_url()}', + 'body': f'{self._request.body}', + 'request_deadline': f'{self._request_deadline}', + } + self.log_message('HTTP request', LogLevel.DEBUG, message_data=message_data) response = self._client_adapter.send_request(self._request) self._error_ctx.update_response_context(response) - # print(f'Response received: {response.status_code} for request {self._id}, body={self._request.body}.') + message_data = { + 'status_code': f'{response.status_code}', + 'last_dispatched_to': f'{self._error_ctx.last_dispatched_to}', + 'last_dispatched_from': f'{self._error_ctx.last_dispatched_from}', + 'request_deadline': f'{self._request_deadline}', + } + self.log_message('HTTP response', LogLevel.DEBUG, message_data=message_data) return response def send_request_in_background( @@ -370,6 +417,7 @@ def set_state_to_streaming(self) -> None: def shutdown(self, exc_val: Optional[BaseException] = None) -> None: if self.is_shutdown: + self.log_message('Request context already shutdown', LogLevel.WARNING) return if isinstance(exc_val, CancelledError): self._request_state = RequestState.Cancelled @@ -385,14 +433,17 @@ def shutdown(self, exc_val: Optional[BaseException] = None) -> None: if RequestState.is_okay(self._request_state): self._request_state = RequestState.Completed self._shutdown = True + self.log_message('Request context shutdown complete', LogLevel.INFO) def start_stream(self, core_response: HttpCoreResponse) -> None: if hasattr(self, '_json_stream'): - # TODO: logging; I don't think this is an error... + self.log_message('JSON stream already exists', LogLevel.WARNING) return # TODO: need to confirm if the httpx Response iterator is thread-safe - self._json_stream = JsonStream(core_response.iter_bytes(), stream_config=self._stream_config) + self._json_stream = JsonStream( + core_response.iter_bytes(), stream_config=self._stream_config, logger_handler=self.log_message + ) self._start_next_stage(self._json_stream.start_parsing, create_notification=True) def wait_for_stage_notification(self) -> None: diff --git a/couchbase_analytics/protocol/_core/retries.py b/couchbase_analytics/protocol/_core/retries.py index 5412859..d1daec9 100644 --- a/couchbase_analytics/protocol/_core/retries.py +++ b/couchbase_analytics/protocol/_core/retries.py @@ -23,6 +23,7 @@ from httpx import ConnectError, ConnectTimeout from couchbase_analytics.common.errors import AnalyticsError, InternalSDKError, TimeoutError +from couchbase_analytics.common.logging import LogLevel from couchbase_analytics.common.request import RequestState from couchbase_analytics.protocol.errors import WrappedError @@ -52,6 +53,11 @@ def handle_httpx_retry(ex: Union[ConnectError, ConnectTimeout], ctx: RequestCont if err: return err sleep(delay) + ctx.log_message( + 'Retrying request', + LogLevel.DEBUG, + {'num_attempts': f'{ctx.error_context.num_attempts}', 'delay': f'{delay}s'}, + ) return None @staticmethod @@ -74,6 +80,11 @@ def handle_retry(ex: WrappedError, ctx: RequestContext) -> Optional[Union[BaseEx if err: return err sleep(delay) + ctx.log_message( + 'Retrying request', + LogLevel.DEBUG, + {'num_attempts': f'{ctx.error_context.num_attempts}', 'delay': f'{delay}s'}, + ) return None ex.maybe_set_cause_context(ctx.error_context) return ex.unwrap() diff --git a/couchbase_analytics/protocol/cluster.py b/couchbase_analytics/protocol/cluster.py index 88c6c6c..f1e676d 100644 --- a/couchbase_analytics/protocol/cluster.py +++ b/couchbase_analytics/protocol/cluster.py @@ -20,6 +20,7 @@ from typing import TYPE_CHECKING, Optional, Union from uuid import uuid4 +from couchbase_analytics.common.logging import LogLevel from couchbase_analytics.common.result import BlockingQueryResult from couchbase_analytics.protocol._core.client_adapter import _ClientAdapter from couchbase_analytics.protocol._core.request import _RequestBuilder @@ -35,9 +36,10 @@ class Cluster: def __init__( self, http_endpoint: str, credential: Credential, options: Optional[ClusterOptions] = None, **kwargs: object ) -> None: + self._cluster_id = str(uuid4()) + kwargs['cluster_id'] = self._cluster_id self._client_adapter = _ClientAdapter(http_endpoint, credential, options, **kwargs) self._request_builder = _RequestBuilder(self._client_adapter) - self._cluster_id = str(uuid4()) self._create_client() # TODO: make a custom ThreadPoolExecutor, so that we can override submit and have a way to get # a "plain" future as the docs say we should create a future via an executor @@ -108,8 +110,7 @@ def shutdown(self) -> None: if self.has_client: self._shutdown() else: - # TODO: log warning and/or exception? - print('Cluster does not have a connection. Ignoring') + self._client_adapter.log_message('Cluster does not have a connection, no need to shutdown.', LogLevel.INFO) def execute_query( self, statement: str, *args: object, **kwargs: object diff --git a/couchbase_analytics/protocol/connection.py b/couchbase_analytics/protocol/connection.py index f8b8f2e..c90948b 100644 --- a/couchbase_analytics/protocol/connection.py +++ b/couchbase_analytics/protocol/connection.py @@ -15,6 +15,7 @@ from __future__ import annotations +import logging import ssl from dataclasses import dataclass from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, TypedDict, cast @@ -158,6 +159,7 @@ class _ConnectionDetails: default_deserializer: Deserializer ssl_context: Optional[ssl.SSLContext] = None sni_hostname: Optional[str] = None + logger_name: Optional[str] = None def get_connect_timeout(self) -> float: timeout_opts: Optional[TimeoutOptionsTransformedKwargs] = self.cluster_options.get('timeout_options') @@ -170,6 +172,10 @@ def get_connect_timeout(self) -> float: def get_max_retries(self) -> int: return self.cluster_options.get('max_retries', None) or DEFAULT_MAX_RETRIES + def get_init_details(self) -> str: + details = {'url': self.url.get_formatted_url(), 'cluster_options': self.cluster_options} + return f'{details}' + def get_query_timeout(self) -> float: timeout_opts: Optional[TimeoutOptionsTransformedKwargs] = self.cluster_options.get('timeout_options') if timeout_opts is not None: @@ -181,7 +187,7 @@ def get_query_timeout(self) -> float: def is_secure(self) -> bool: return self.url.scheme == 'https' - def validate_security_options(self) -> None: + def validate_security_options(self) -> None: # noqa: C901 security_opts: Optional[SecurityOptionsTransformedKwargs] = self.cluster_options.get('security_options') # TODO: security settings if security_opts is not None: @@ -190,7 +196,7 @@ def validate_security_options(self) -> None: trust_capella = security_opts.get('trust_only_capella', None) security_opt_count = sum( (1 if security_opts.get(opt, None) is not None else 0 for opt in solo_security_opts) - ) # noqa: E501 + ) if security_opt_count > 1 or (security_opt_count == 1 and trust_capella is True): raise ValueError( ( @@ -223,8 +229,10 @@ def validate_security_options(self) -> None: security_opts['trust_only_capella'] = False if security_opts is not None and security_opts.get('disable_server_certificate_verification', False): - # TODO: log warning - print('Warning: Server certificate verification is disabled. This is not recommended for production use.') + if self.logger_name is not None: + logger = logging.getLogger(self.logger_name) + msg = 'Server certificate verification is disabled. This is not recommended for production use.' + logger.warning(msg) self.ssl_context.check_hostname = False self.ssl_context.verify_mode = ssl.CERT_NONE else: @@ -242,6 +250,7 @@ def create( ) -> _ConnectionDetails: url, query_str_opts = parse_http_endpoint(http_endpoint) + logger_name = cast(Optional[str], kwargs.pop('logger_name', None)) cluster_opts = opts_builder.build_cluster_options( ClusterOptions, ClusterOptionsTransformedKwargs, @@ -254,6 +263,6 @@ def create( if default_deserializer is None: default_deserializer = DefaultJsonDeserializer() - conn_dtls = cls(url, cluster_opts, credential.astuple(), default_deserializer) + conn_dtls = cls(url, cluster_opts, credential.astuple(), default_deserializer, logger_name=logger_name) conn_dtls.validate_security_options() return conn_dtls diff --git a/couchbase_analytics/protocol/errors.py b/couchbase_analytics/protocol/errors.py index b25417b..afb67cf 100644 --- a/couchbase_analytics/protocol/errors.py +++ b/couchbase_analytics/protocol/errors.py @@ -16,25 +16,17 @@ from __future__ import annotations import socket -import sys from functools import wraps from typing import Any, Callable, Dict, List, NamedTuple, Optional, Union -if sys.version_info < (3, 10): - from typing_extensions import TypeAlias -else: - from typing import TypeAlias - from couchbase_analytics.common._core.error_context import ErrorContext from couchbase_analytics.common.errors import ( AnalyticsError, - InternalSDKError, InvalidCredentialError, QueryError, TimeoutError, ) - -AnalyticsClientError: TypeAlias = Union[AnalyticsError, InternalSDKError, QueryError, RuntimeError, ValueError] +from couchbase_analytics.common.logging import LogLevel class ServerQueryError(NamedTuple): @@ -164,13 +156,16 @@ def build_error_from_json(json_data: List[Dict[str, Any]], context: ErrorContext return WrappedError(q_err, retriable=retriable) @staticmethod - def handle_socket_error(fn: Callable[[str, int], str]) -> Callable[[str, int], str]: + def handle_socket_error( + fn: Callable[[str, int, Optional[Callable[..., None]]], str], + ) -> Callable[[str, int, Optional[Callable[..., None]]], str]: @wraps(fn) - def wrapped_fn(host: str, port: int) -> str: + def wrapped_fn(host: str, port: int, logger_handler: Optional[Callable[..., None]] = None) -> str: try: - return fn(host, port) + return fn(host, port, logger_handler) except socket.gaierror as ex: - print(f'getaddrinfo failed for {host}:{port} with error: {ex}') + if logger_handler: + logger_handler(f'getaddrinfo() failed for {host}:{port} with error: {ex}', LogLevel.ERROR) msg = 'Connection error occurred while sending request.' raise WrappedError(AnalyticsError(cause=ex, message=msg), retriable=True) from None diff --git a/couchbase_analytics/protocol/streaming.py b/couchbase_analytics/protocol/streaming.py index 950962d..6f27bbf 100644 --- a/couchbase_analytics/protocol/streaming.py +++ b/couchbase_analytics/protocol/streaming.py @@ -23,6 +23,7 @@ from couchbase_analytics.common._core import ParsedResult, ParsedResultType from couchbase_analytics.common._core.query import build_query_metadata from couchbase_analytics.common.errors import AnalyticsError, InternalSDKError, TimeoutError +from couchbase_analytics.common.logging import LogLevel from couchbase_analytics.common.query import QueryMetadata from couchbase_analytics.protocol._core.request_context import RequestContext from couchbase_analytics.protocol._core.retries import RetryHandler @@ -48,6 +49,7 @@ def lazy_execute(self) -> bool: def _handle_iteration_abort(self) -> None: self.close() if self._request_context.cancelled: + self._request_context.log_message('Request canceled, aborting iteration', LogLevel.DEBUG) self._request_context.shutdown() raise StopIteration elif self._request_context.timed_out: @@ -58,6 +60,7 @@ def _handle_iteration_abort(self) -> None: self._request_context.shutdown(err) raise err else: + self._request_context.log_message('Aborting iteration', LogLevel.DEBUG) self._request_context.shutdown() raise StopIteration @@ -75,12 +78,14 @@ def close(self) -> None: """ if hasattr(self, '_core_response'): self._core_response.close() + self._request_context.log_message('HTTP core response closed', LogLevel.INFO) del self._core_response def cancel(self) -> None: """ **INTERNAL** """ + self._request_context.log_message('HttpStreamingResponse cancelling request', LogLevel.DEBUG) self.close() self._request_context.cancel_request() self._request_context.shutdown() diff --git a/tests/utils/_async_client_adapter.py b/tests/utils/_async_client_adapter.py index ac909f5..a935101 100644 --- a/tests/utils/_async_client_adapter.py +++ b/tests/utils/_async_client_adapter.py @@ -1,4 +1,3 @@ -import socket from typing import Dict from httpx import URL, Response @@ -14,41 +13,19 @@ def client_adapter_init_override(self, *args, **kwargs) -> None: # type: ignore if self._http_transport_cls is not None and not hasattr(self._http_transport_cls, 'PYCBAC_TESTING'): raise RuntimeError('http_transport_cls must be a test transport') adapter: _AsyncClientAdapter = kwargs.pop('adapter', None) - # adapter.close_client() - print(f'current client_id={adapter._client_id}') self._client_id = adapter._client_id - print(f'client_id={self._client_id}') + self._prefix = adapter._prefix + self._cluster_id = adapter._cluster_id self._opts_builder = adapter._opts_builder self._conn_details = adapter._conn_details if self._http_transport_cls is None: self._http_transport_cls = adapter._http_transport_cls -# async def create_client_override(self: _AsyncClientAdapter) -> None: -# if not hasattr(self, '_client'): -# auth = BasicAuth(*self._conn_details.credential) -# if self._conn_details.is_secure(): -# transport = None -# if self._http_transport_cls is not None: -# transport = self._http_transport_cls(verify=self._conn_details.ssl_context) -# self._client = AsyncClient(verify=self._conn_details.ssl_context, -# auth=auth, -# transport=transport) -# else: -# transport = None -# if self._http_transport_cls is not None: -# transport = self._http_transport_cls() -# self._client = AsyncClient(auth=auth, transport=transport) - - async def send_request_override(self: _AsyncClientAdapter, request: QueryRequest) -> Response: if not hasattr(self, '_client'): raise RuntimeError('Client not created yet') - # if request.url is None: - # raise ValueError('Request URL cannot be None') - - print(f'Sending request: {request.method} {request.url}') request_json = request.body if hasattr(self, '_request_json') and self._request_json is not None: request_json.update(self._request_json) @@ -61,19 +38,13 @@ async def send_request_override(self: _AsyncClientAdapter, request: QueryRequest if 'timeout' in self._request_extensions: request_extensions['timeout'].update(self._request_extensions['timeout']) - print(f'{request_extensions=}') - url = URL(scheme=request.url.scheme, host=request.url.host, port=request.url.port, path=request.url.path) req = self._client.build_request(request.method, url, json=request_json, extensions=request_extensions) - try: - return await self._client.send(req, stream=True) - except socket.gaierror as err: - req_url = self._conn_details.url.get_formatted_url() - raise RuntimeError(f'Unable to connect to {req_url}') from err + return await self._client.send(req, stream=True) def set_request_path(self: _AsyncClientAdapter, path: str) -> None: - self._ANALYTICS_PATH = path + self.ANALYTICS_PATH = path def update_request_json(self: _AsyncClientAdapter, json: Dict[str, object]) -> None: @@ -89,7 +60,6 @@ class _TestAsyncClientAdapter(_AsyncClientAdapter): _TestAsyncClientAdapter.__init__ = client_adapter_init_override # type: ignore[method-assign] -# _TestAsyncClientAdapter.create_client = create_client_override # type: ignore[method-assign] _TestAsyncClientAdapter.send_request = send_request_override # type: ignore[method-assign] _TestAsyncClientAdapter.set_request_path = set_request_path _TestAsyncClientAdapter.update_request_json = update_request_json diff --git a/tests/utils/_client_adapter.py b/tests/utils/_client_adapter.py index bbc74b1..36b3048 100644 --- a/tests/utils/_client_adapter.py +++ b/tests/utils/_client_adapter.py @@ -1,4 +1,3 @@ -import socket from typing import Dict from httpx import URL, Response @@ -16,37 +15,18 @@ def client_adapter_init_override(self, *args, **kwargs) -> None: # type: ignore adapter: _ClientAdapter = kwargs.pop('adapter', None) adapter.close_client() self._client_id = adapter._client_id + self._prefix = adapter._prefix + self._cluster_id = adapter._cluster_id self._opts_builder = adapter._opts_builder self._conn_details = adapter._conn_details if self._http_transport_cls is None: self._http_transport_cls = adapter._http_transport_cls -# def create_client_override(self) -> None: -# if not hasattr(self, '_client'): -# if self._conn_details.is_secure(): -# transport = None -# if self._http_transport_cls is not None: -# transport = self._http_transport_cls(verify=self._conn_details.ssl_context) -# self._client = Client(verify=self._conn_details.ssl_context, -# auth=BasicAuth(*self._conn_details.credential), -# transport=transport) -# else: -# transport = None -# if self._http_transport_cls is not None: -# transport = self._http_transport_cls() -# self._client = Client(auth=BasicAuth(*self._conn_details.credential), -# transport=transport) - - def send_request_override(self: _ClientAdapter, request: QueryRequest) -> Response: if not hasattr(self, '_client'): raise RuntimeError('Client not created yet') - # if request.url is None: - # raise ValueError('Request URL cannot be None') - - print(f'Sending request: {request.method} {request.url}') request_json = request.body if hasattr(self, '_request_json') and self._request_json is not None: request_json.update(self._request_json) @@ -59,19 +39,13 @@ def send_request_override(self: _ClientAdapter, request: QueryRequest) -> Respon if 'timeout' in self._request_extensions: request_extensions['timeout'].update(self._request_extensions['timeout']) - print(f'{request_extensions=}') - url = URL(scheme=request.url.scheme, host=request.url.host, port=request.url.port, path=request.url.path) req = self._client.build_request(request.method, url, json=request_json, extensions=request_extensions) - try: - return self._client.send(req, stream=True) - except socket.gaierror as err: - req_url = self._conn_details.url.get_formatted_url() - raise RuntimeError(f'Unable to connect to {req_url}') from err + return self._client.send(req, stream=True) def set_request_path(self: _ClientAdapter, path: str) -> None: - self._ANALYTICS_PATH = path + self.ANALYTICS_PATH = path def update_request_json(self: _ClientAdapter, json: Dict[str, object]) -> None: @@ -87,7 +61,6 @@ class _TestClientAdapter(_ClientAdapter): _TestClientAdapter.__init__ = client_adapter_init_override # type: ignore[method-assign] -# _TestClientAdapter.create_client = create_client_override _TestClientAdapter.send_request = send_request_override # type: ignore[method-assign] _TestClientAdapter.set_request_path = set_request_path _TestClientAdapter.update_request_json = update_request_json From 32cf84edc3901bd850b941ae9c1c814449a7a5f0 Mon Sep 17 00:00:00 2001 From: Jared Casey Date: Tue, 22 Jul 2025 11:35:11 -0600 Subject: [PATCH 12/18] Add simple examples Changes ======= * Cluster/Scope examples added for both sync and async APIs * Backoff exponent set to 2 (changed from 1.5) * Cleaned up test suite test server request/response functionality --- .../protocol/_core/request_context.py | 4 +- couchbase_analytics/_version.py | 6 +- .../common/backoff_calculator.py | 2 +- couchbase_analytics/common/logging.py | 19 +-- examples/async/basic_cluster_query_anyio.py | 73 +++++++++++ examples/async/basic_cluster_query_asyncio.py | 70 ++++++++++ examples/async/basic_scope_query_anyio.py | 72 +++++++++++ examples/async/basic_scope_query_asyncio.py | 69 ++++++++++ examples/sync/basic_cluster_query.py | 66 ++++++++++ examples/sync/basic_scope_query.py | 67 ++++++++++ tests/test_server/response.py | 14 +- tests/test_server/web_server.py | 121 ++++++++++++------ tests/utils/__init__.py | 42 +++--- 13 files changed, 546 insertions(+), 79 deletions(-) create mode 100644 examples/async/basic_cluster_query_anyio.py create mode 100644 examples/async/basic_cluster_query_asyncio.py create mode 100644 examples/async/basic_scope_query_anyio.py create mode 100644 examples/async/basic_scope_query_asyncio.py create mode 100644 examples/sync/basic_cluster_query.py create mode 100644 examples/sync/basic_scope_query.py diff --git a/acouchbase_analytics/protocol/_core/request_context.py b/acouchbase_analytics/protocol/_core/request_context.py index 4bb07a9..f9e4105 100644 --- a/acouchbase_analytics/protocol/_core/request_context.py +++ b/acouchbase_analytics/protocol/_core/request_context.py @@ -424,7 +424,9 @@ def start_stream(self, core_response: HttpCoreResponse) -> None: # TODO: logging; I don't think this is an error... return - self._json_stream = AsyncJsonStream(core_response.aiter_bytes(), stream_config=self._stream_config) + self._json_stream = AsyncJsonStream( + core_response.aiter_bytes(), stream_config=self._stream_config, logger_handler=self.log_message + ) self._start_next_stage(self._json_stream.start_parsing) async def wait_for_results_or_errors(self) -> None: diff --git a/couchbase_analytics/_version.py b/couchbase_analytics/_version.py index 2c590d1..7a04b87 100644 --- a/couchbase_analytics/_version.py +++ b/couchbase_analytics/_version.py @@ -1,5 +1,5 @@ # This file automatically generated by -# /Users/jaredcasey/GIT/couchbase/clients/python/analytics-python-client/couchbase_analytics_version.py +# /Users/jaredcasey/GIT/couchbase/clients/python/analytics-python-client/./couchbase_analytics_version.py # at -# 2025-07-16 15:02:22.211821 -__version__ = '1.0.0.dev1' +# 2025-07-21 18:36:10.973732 +__version__ = '0.0.1' diff --git a/couchbase_analytics/common/backoff_calculator.py b/couchbase_analytics/common/backoff_calculator.py index 9417b76..a071d15 100644 --- a/couchbase_analytics/common/backoff_calculator.py +++ b/couchbase_analytics/common/backoff_calculator.py @@ -27,7 +27,7 @@ def calculate_backoff(self, retry_count: int) -> float: class DefaultBackoffCalculator(BackoffCalculator): MIN = 100 MAX = 60 * 1000 - EXPONENT_BASE = 1.5 + EXPONENT_BASE = 2 def __init__( self, min: Optional[int] = None, max: Optional[int] = None, exponent_base: Optional[int] = None diff --git a/couchbase_analytics/common/logging.py b/couchbase_analytics/common/logging.py index 3960252..110fb97 100644 --- a/couchbase_analytics/common/logging.py +++ b/couchbase_analytics/common/logging.py @@ -16,15 +16,17 @@ import logging from enum import Enum -LOG_FORMAT_ARR = ['[%(asctime)s.%(msecs)03d]', - '%(relativeCreated)dms', - '[%(levelname)s]', - '[%(process)d, %(threadName)s (%(thread)d)]' - ' %(name)s', - '- %(message)s'] +LOG_FORMAT_ARR = [ + '[%(asctime)s.%(msecs)03d]', + '%(relativeCreated)dms', + '[%(levelname)s]', + '[%(process)d, %(threadName)s (%(thread)d)] %(name)s', + '- %(message)s', +] LOG_FORMAT = ' '.join(LOG_FORMAT_ARR) LOG_DATE_FORMAT = '%Y-%m-%d %H:%M:%S' + class LogLevel(Enum): DEBUG = logging.DEBUG INFO = logging.INFO @@ -32,10 +34,11 @@ class LogLevel(Enum): ERROR = logging.ERROR CRITICAL = logging.CRITICAL + def log_message(logger: logging.Logger, message: str, log_level: LogLevel) -> None: if not logger or not logger.hasHandlers(): return - + if log_level == LogLevel.DEBUG: logger.debug(message) elif log_level == LogLevel.INFO: @@ -45,4 +48,4 @@ def log_message(logger: logging.Logger, message: str, log_level: LogLevel) -> No elif log_level == LogLevel.ERROR: logger.error(message) elif log_level == LogLevel.CRITICAL: - logger.critical(message) \ No newline at end of file + logger.critical(message) diff --git a/examples/async/basic_cluster_query_anyio.py b/examples/async/basic_cluster_query_anyio.py new file mode 100644 index 0000000..84cd32b --- /dev/null +++ b/examples/async/basic_cluster_query_anyio.py @@ -0,0 +1,73 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +from datetime import timedelta + +# NOTE: anyio is a dependency of acouchbase_analytics +import anyio + +from acouchbase_analytics.cluster import AsyncCluster +from acouchbase_analytics.credential import Credential +from acouchbase_analytics.options import ClusterOptions, QueryOptions, TimeoutOptions + + +async def main() -> None: + # Update this to your cluster + endpoint = 'https://--your-instance--' + username = 'username' + pw = 'Password!123' + # User Input ends here. + + cred = Credential.from_username_and_password(username, pw) + # NOTE: Only an example on how to use options. Not a recommendation. + timeout_opts = TimeoutOptions(query_timeout=timedelta(seconds=30)) + cluster = AsyncCluster.create_instance(endpoint, cred, ClusterOptions(timeout_options=timeout_opts)) + + # Execute a query and buffer all result rows in client memory. + statement = 'SELECT * FROM `travel-sample`.inventory.airline LIMIT 10;' + res = await cluster.execute_query(statement) + all_rows = await res.get_all_rows() + # NOTE: all_rows is a list, _do not_ use `async for` + for row in all_rows: + print(f'Found row: {row}') + print(f'metadata={res.metadata()}') + + # Execute a query and process rows as they arrive from server. + statement = 'SELECT * FROM `travel-sample`.inventory.airline WHERE country="United States" LIMIT 10;' + res = await cluster.execute_query(statement) + async for row in res.rows(): + print(f'Found row: {row}') + print(f'metadata={res.metadata()}') + + # Execute a streaming query with positional arguments. + statement = 'SELECT * FROM `travel-sample`.inventory.airline WHERE country=$1 LIMIT $2;' + res = await cluster.execute_query(statement, QueryOptions(positional_parameters=['United States', 10])) + async for row in res: + print(f'Found row: {row}') + print(f'metadata={res.metadata()}') + + # Execute a streaming query with named arguments. + statement = 'SELECT * FROM `travel-sample`.inventory.airline WHERE country=$country LIMIT $limit;' + res = await cluster.execute_query( + statement, QueryOptions(named_parameters={'country': 'United States', 'limit': 10}) + ) + async for row in res.rows(): + print(f'Found row: {row}') + print(f'metadata={res.metadata()}') + + +if __name__ == '__main__': + anyio.run(main) diff --git a/examples/async/basic_cluster_query_asyncio.py b/examples/async/basic_cluster_query_asyncio.py new file mode 100644 index 0000000..39b9113 --- /dev/null +++ b/examples/async/basic_cluster_query_asyncio.py @@ -0,0 +1,70 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import asyncio +from datetime import timedelta + +from acouchbase_analytics.cluster import AsyncCluster +from acouchbase_analytics.credential import Credential +from acouchbase_analytics.options import ClusterOptions, QueryOptions, TimeoutOptions + + +async def main() -> None: + # Update this to your cluster + endpoint = 'https://--your-instance--' + username = 'username' + pw = 'Password!123' + # User Input ends here. + + cred = Credential.from_username_and_password(username, pw) + # NOTE: Only an example on how to use options. Not a recommendation. + timeout_opts = TimeoutOptions(query_timeout=timedelta(seconds=30)) + cluster = AsyncCluster.create_instance(endpoint, cred, ClusterOptions(timeout_options=timeout_opts)) + + # Execute a query and buffer all result rows in client memory. + statement = 'SELECT * FROM `travel-sample`.inventory.airline LIMIT 10;' + res = await cluster.execute_query(statement) + all_rows = await res.get_all_rows() + # NOTE: all_rows is a list, _do not_ use `async for` + for row in all_rows: + print(f'Found row: {row}') + print(f'metadata={res.metadata()}') + + # Execute a query and process rows as they arrive from server. + statement = 'SELECT * FROM `travel-sample`.inventory.airline WHERE country="United States" LIMIT 10;' + res = await cluster.execute_query(statement) + async for row in res.rows(): + print(f'Found row: {row}') + print(f'metadata={res.metadata()}') + + # Execute a streaming query with positional arguments. + statement = 'SELECT * FROM `travel-sample`.inventory.airline WHERE country=$1 LIMIT $2;' + res = await cluster.execute_query(statement, QueryOptions(positional_parameters=['United States', 10])) + async for row in res: + print(f'Found row: {row}') + print(f'metadata={res.metadata()}') + + # Execute a streaming query with named arguments. + statement = 'SELECT * FROM `travel-sample`.inventory.airline WHERE country=$country LIMIT $limit;' + res = await cluster.execute_query( + statement, QueryOptions(named_parameters={'country': 'United States', 'limit': 10}) + ) + async for row in res.rows(): + print(f'Found row: {row}') + print(f'metadata={res.metadata()}') + + +if __name__ == '__main__': + asyncio.run(main()) diff --git a/examples/async/basic_scope_query_anyio.py b/examples/async/basic_scope_query_anyio.py new file mode 100644 index 0000000..10e6961 --- /dev/null +++ b/examples/async/basic_scope_query_anyio.py @@ -0,0 +1,72 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +from datetime import timedelta + +# NOTE: anyio is a dependency of acouchbase_analytics +import anyio + +from acouchbase_analytics.cluster import AsyncCluster +from acouchbase_analytics.credential import Credential +from acouchbase_analytics.options import ClusterOptions, QueryOptions, TimeoutOptions + + +async def main() -> None: + # Update this to your cluster + endpoint = 'https://--your-instance--' + username = 'username' + pw = 'Password!123' + # User Input ends here. + + cred = Credential.from_username_and_password(username, pw) + # NOTE: Only an example on how to use options. Not a recommendation. + timeout_opts = TimeoutOptions(query_timeout=timedelta(seconds=30)) + opts = ClusterOptions(timeout_options=timeout_opts) + scope = AsyncCluster.create_instance(endpoint, cred, opts).database('travel-sample').scope('inventory') + + # Execute a scope-level query and buffer all result rows in client memory. + statement = 'SELECT * FROM airline LIMIT 10;' + res = await scope.execute_query(statement) + all_rows = await res.get_all_rows() + # NOTE: all_rows is a list, _do not_ use `async for` + for row in all_rows: + print(f'Found row: {row}') + print(f'metadata={res.metadata()}') + + # Execute a scope-level query and process rows as they arrive from server. + statement = 'SELECT * FROM airline WHERE country="United States" LIMIT 10;' + res = await scope.execute_query(statement) + async for row in res.rows(): + print(f'Found row: {row}') + print(f'metadata={res.metadata()}') + + # Execute a streaming scope-level query with positional arguments. + statement = 'SELECT * FROM airline WHERE country=$1 LIMIT $2;' + res = await scope.execute_query(statement, QueryOptions(positional_parameters=['United States', 10])) + async for row in res: + print(f'Found row: {row}') + print(f'metadata={res.metadata()}') + + # Execute a streaming scope-level query with named arguments. + statement = 'SELECT * FROM airline WHERE country=$country LIMIT $limit;' + res = await scope.execute_query(statement, QueryOptions(named_parameters={'country': 'United States', 'limit': 10})) + async for row in res.rows(): + print(f'Found row: {row}') + print(f'metadata={res.metadata()}') + + +if __name__ == '__main__': + anyio.run(main) diff --git a/examples/async/basic_scope_query_asyncio.py b/examples/async/basic_scope_query_asyncio.py new file mode 100644 index 0000000..368bc08 --- /dev/null +++ b/examples/async/basic_scope_query_asyncio.py @@ -0,0 +1,69 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import asyncio +from datetime import timedelta + +from acouchbase_analytics.cluster import AsyncCluster +from acouchbase_analytics.credential import Credential +from acouchbase_analytics.options import ClusterOptions, QueryOptions, TimeoutOptions + + +async def main() -> None: + # Update this to your cluster + endpoint = 'https://--your-instance--' + username = 'username' + pw = 'Password!123' + # User Input ends here. + + cred = Credential.from_username_and_password(username, pw) + # NOTE: Only an example on how to use options. Not a recommendation. + timeout_opts = TimeoutOptions(query_timeout=timedelta(seconds=30)) + opts = ClusterOptions(timeout_options=timeout_opts) + scope = AsyncCluster.create_instance(endpoint, cred, opts).database('travel-sample').scope('inventory') + + # Execute a scope-level query and buffer all result rows in client memory. + statement = 'SELECT * FROM airline LIMIT 10;' + res = await scope.execute_query(statement) + all_rows = await res.get_all_rows() + # NOTE: all_rows is a list, _do not_ use `async for` + for row in all_rows: + print(f'Found row: {row}') + print(f'metadata={res.metadata()}') + + # Execute a scope-level query and process rows as they arrive from server. + statement = 'SELECT * FROM airline WHERE country="United States" LIMIT 10;' + res = await scope.execute_query(statement) + async for row in res.rows(): + print(f'Found row: {row}') + print(f'metadata={res.metadata()}') + + # Execute a streaming scope-level query with positional arguments. + statement = 'SELECT * FROM airline WHERE country=$1 LIMIT $2;' + res = await scope.execute_query(statement, QueryOptions(positional_parameters=['United States', 10])) + async for row in res: + print(f'Found row: {row}') + print(f'metadata={res.metadata()}') + + # Execute a streaming scope-level query with named arguments. + statement = 'SELECT * FROM airline WHERE country=$country LIMIT $limit;' + res = await scope.execute_query(statement, QueryOptions(named_parameters={'country': 'United States', 'limit': 10})) + async for row in res.rows(): + print(f'Found row: {row}') + print(f'metadata={res.metadata()}') + + +if __name__ == '__main__': + asyncio.run(main()) diff --git a/examples/sync/basic_cluster_query.py b/examples/sync/basic_cluster_query.py new file mode 100644 index 0000000..9147e81 --- /dev/null +++ b/examples/sync/basic_cluster_query.py @@ -0,0 +1,66 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from datetime import timedelta + +from couchbase_analytics.cluster import Cluster +from couchbase_analytics.credential import Credential +from couchbase_analytics.options import ClusterOptions, QueryOptions, TimeoutOptions + + +def main() -> None: + # Update this to your cluster + endpoint = 'couchbases://--your-instance--' + username = 'username' + pw = 'Password!123' + # User Input ends here. + + cred = Credential.from_username_and_password(username, pw) + # NOTE: Only an example on how to use options. Not a recommendation. + timeout_opts = TimeoutOptions(query_timeout=timedelta(seconds=30)) + cluster = Cluster.create_instance(endpoint, cred, ClusterOptions(timeout_options=timeout_opts)) + + # Execute a query and buffer all result rows in client memory. + statement = 'SELECT * FROM `travel-sample`.inventory.airline LIMIT 10;' + res = cluster.execute_query(statement) + all_rows = res.get_all_rows() + for row in all_rows: + print(f'Found row: {row}') + print(f'metadata={res.metadata()}') + + # Execute a query and process rows as they arrive from server. + statement = 'SELECT * FROM `travel-sample`.inventory.airline WHERE country="United States" LIMIT 10;' + res = cluster.execute_query(statement) + for row in res.rows(): + print(f'Found row: {row}') + print(f'metadata={res.metadata()}') + + # Execute a streaming query with positional arguments. + statement = 'SELECT * FROM `travel-sample`.inventory.airline WHERE country=$1 LIMIT $2;' + res = cluster.execute_query(statement, QueryOptions(positional_parameters=['United States', 10])) + for row in res: + print(f'Found row: {row}') + print(f'metadata={res.metadata()}') + + # Execute a streaming query with named arguments. + statement = 'SELECT * FROM `travel-sample`.inventory.airline WHERE country=$country LIMIT $limit;' + res = cluster.execute_query(statement, QueryOptions(named_parameters={'country': 'United States', 'limit': 10})) + for row in res.rows(): + print(f'Found row: {row}') + print(f'metadata={res.metadata()}') + + +if __name__ == '__main__': + main() diff --git a/examples/sync/basic_scope_query.py b/examples/sync/basic_scope_query.py new file mode 100644 index 0000000..8b74cac --- /dev/null +++ b/examples/sync/basic_scope_query.py @@ -0,0 +1,67 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from datetime import timedelta + +from couchbase_analytics.cluster import Cluster +from couchbase_analytics.credential import Credential +from couchbase_analytics.options import ClusterOptions, QueryOptions, TimeoutOptions + + +def main() -> None: + # Update this to your cluster + endpoint = 'couchbases://--your-instance--' + username = 'username' + pw = 'Password!123' + # User Input ends here. + + cred = Credential.from_username_and_password(username, pw) + # NOTE: Only an example on how to use options. Not a recommendation. + timeout_opts = TimeoutOptions(query_timeout=timedelta(seconds=30)) + opts = ClusterOptions(timeout_options=timeout_opts) + scope = Cluster.create_instance(endpoint, cred, opts).database('travel-sample').scope('inventory') + + # Execute a scope-level query and buffer all result rows in client memory. + statement = 'SELECT * FROM airline LIMIT 10;' + res = scope.execute_query(statement) + all_rows = res.get_all_rows() + for row in all_rows: + print(f'Found row: {row}') + print(f'metadata={res.metadata()}') + + # Execute a scope-level query and process rows as they arrive from server. + statement = 'SELECT * FROM airline WHERE country="United States" LIMIT 10;' + res = scope.execute_query(statement) + for row in res.rows(): + print(f'Found row: {row}') + print(f'metadata={res.metadata()}') + + # Execute a streaming scope-level query with positional arguments. + statement = 'SELECT * FROM airline WHERE country=$1 LIMIT $2;' + res = scope.execute_query(statement, QueryOptions(positional_parameters=['United States', 10])) + for row in res: + print(f'Found row: {row}') + print(f'metadata={res.metadata()}') + + # Execute a streaming scope-level query with named arguments. + statement = 'SELECT * FROM airline WHERE country=$country LIMIT $limit;' + res = scope.execute_query(statement, QueryOptions(named_parameters={'country': 'United States', 'limit': 10})) + for row in res.rows(): + print(f'Found row: {row}') + print(f'metadata={res.metadata()}') + + +if __name__ == '__main__': + main() diff --git a/tests/test_server/response.py b/tests/test_server/response.py index dc60a7c..31cf753 100644 --- a/tests/test_server/response.py +++ b/tests/test_server/response.py @@ -293,7 +293,7 @@ def build_results(resp: ServerResponse, row_count: int, result_type: ResultType) raise RuntimeError(f'Unrecognized result type. Got type: {result_type}') @staticmethod - def get_result_genetaotr(result_type: ResultType) -> Callable[[], Union[Generator[bytes, None, None]]]: + def get_result_generator(result_type: ResultType) -> Callable[[], Union[Generator[bytes, None, None]]]: if result_type == ResultType.Object: def obj_generator() -> Generator[bytes, None, None]: @@ -301,7 +301,7 @@ def obj_generator() -> Generator[bytes, None, None]: while True: name = choice(NAMES) city = choice(US_CITIES) - yield bytes(json.dumps({'id': idx + 1, 'name': name, 'city': city}), 'utf-8') + yield json.dumps({'id': idx + 1, 'name': name, 'city': city}).encode('utf-8') idx += 1 return obj_generator @@ -327,12 +327,8 @@ class ServerResponse: results: Optional[ServerResponseResults] = None errors: Optional[List[ServerResponseError]] = None - def to_json_repr(self) -> Dict[str, Any]: - output: Dict[str, Any] = { - 'requestID': self.request_id, - 'status': self.status, - 'metrics': self.metrics.to_json_repr(), - } + def to_json_repr(self, exclude_metrics: Optional[bool] = False) -> Dict[str, Any]: + output: Dict[str, Any] = {'requestID': self.request_id, 'status': self.status} if self.signature is not None: output['signature'] = self.signature if self.plans is not None: @@ -341,6 +337,8 @@ def to_json_repr(self) -> Dict[str, Any]: output['results'] = self.results.to_json_repr() if self.errors is not None: output['errors'] = [e.to_json_repr() for e in self.errors] + if exclude_metrics is False: + output['metrics'] = self.metrics.to_json_repr() return output def update_elapsed_time(self, t: float) -> None: diff --git a/tests/test_server/web_server.py b/tests/test_server/web_server.py index c748b9d..999fe7e 100644 --- a/tests/test_server/web_server.py +++ b/tests/test_server/web_server.py @@ -117,55 +117,98 @@ async def _handle_retry_error_request(self, request: ServerErrorRequest) -> web. resp.update_elapsed_time(elapsed) return web.json_response(resp.to_json_repr()) - async def _handle_results_request( + async def _handle_timed_streaming_request( self, request: ServerResultsRequest, web_request: web.Request - ) -> Union[web.Response, web.StreamResponse]: + ) -> web.StreamResponse: + if request.until is None: + raise ValueError('Missing "until" in JSON data.') resp = ServerResponse.create() start = perf_counter() - if request.until is not None: - response = web.StreamResponse() - await response.prepare(web_request) + response = web.StreamResponse( + status=200, + headers={ + 'Content-Type': 'application/json', + 'Transfer-Encoding': 'chunked', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + }, + ) + await response.prepare(web_request) + now = asyncio.get_running_loop().time() + deadline = now + request.until + chunk_size = request.chunk_size or 100 + bytes_generator = ServerResponseResults.get_result_generator(request.result_type) + initial_data = json.dumps({'requestID': resp.request_id, 'status': resp.status}).encode('utf-8') + async_inf_iterator = AsyncInfiniteBytesIterator( + bytes_generator(), initial_data=initial_data, chunk_size=chunk_size + ) + num_bytes_sent = 0 + while deadline > now: + chunk = await async_inf_iterator.__anext__() + num_bytes_sent += len(chunk) + logger.info(f'Writing chunk of size {len(chunk)}; {chunk=}; {num_bytes_sent=}') + await response.write(chunk) now = asyncio.get_running_loop().time() - deadline = now + request.until - chunk_size = request.chunk_size or 100 - bytes_generator = ServerResponseResults.get_result_genetaotr(request.result_type) - initial_data = bytes(json.dumps({'requestID': resp.request_id, 'status': resp.status}), 'utf-8') - async_inf_iterator = AsyncInfiniteBytesIterator( - bytes_generator(), initial_data=initial_data, chunk_size=chunk_size - ) - while deadline > now: - chunk = await async_inf_iterator.__anext__() - await response.write(chunk) - now = asyncio.get_running_loop().time() - end = perf_counter() - elapsed = end - start - resp.update_elapsed_time(elapsed) - metrics = resp.metrics - metrics.result_count = async_inf_iterator.get_data_count() - meta = bytes(json.dumps({'metrics': metrics.to_json_repr()}), 'utf-8') - async_inf_iterator.stop_iterating(end_data=meta) - async for chunk in async_inf_iterator: - await response.write(chunk) - await response.write_eof() - return response + end = perf_counter() + elapsed = end - start + resp.update_elapsed_time(elapsed) + metrics = resp.metrics + metrics.result_count = async_inf_iterator.get_data_count() + meta = json.dumps({'metrics': metrics.to_json_repr()}).encode('utf-8') + async_inf_iterator.stop_iterating(end_data=meta) + async for chunk in async_inf_iterator: + num_bytes_sent += len(chunk) + logger.info(f'Writing chunk of size {len(chunk)}; {chunk=}; {num_bytes_sent=}') + await response.write(chunk) + logger.info(f'Writing EOF; {num_bytes_sent=}') + await response.write_eof() + logger.info('returning response') + return response + async def _handle_count_streaming_request( + self, request: ServerResultsRequest, web_request: web.Request + ) -> web.StreamResponse: if request.row_count is None: raise ValueError('Missing "row_count" in JSON data.') + response = web.StreamResponse( + status=200, + headers={ + 'Content-Type': 'application/json', + 'Transfer-Encoding': 'chunked', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + }, + ) + await response.prepare(web_request) + resp = ServerResponse.create() + start = perf_counter() ServerResponseResults.build_results(resp, request.row_count, request.result_type) end = perf_counter() elapsed = end - start resp.update_elapsed_time(elapsed) - if request.stream: - response = web.StreamResponse() - await response.prepare(web_request) - chunk_size = request.chunk_size or 100 - async_iterator = AsyncBytesIterator(bytes(json.dumps(resp.to_json_repr()), 'utf-8'), chunk_size=chunk_size) - async for chunk in async_iterator: - await response.write(chunk) - await response.write_eof() - return response + chunk_size = request.chunk_size or 100 + async_iterator = AsyncBytesIterator(bytes(json.dumps(resp.to_json_repr()), 'utf-8'), chunk_size=chunk_size) + async for chunk in async_iterator: + logger.info(f'Writing chunk of size {len(chunk)}; {chunk=}') + await response.write(chunk) + logger.info('Writing EOF') + await response.write_eof() + logger.info('returning response') + return response + + def _handle_basic_results_request( + self, request: ServerResultsRequest, web_request: web.Request + ) -> Union[web.Response, web.StreamResponse]: + if request.row_count is None: + raise ValueError('Missing "row_count" in JSON data.') + resp = ServerResponse.create() + start = perf_counter() + ServerResponseResults.build_results(resp, request.row_count, request.result_type) + end = perf_counter() + elapsed = end - start + resp.update_elapsed_time(elapsed) res = resp.to_json_repr() return web.json_response(res) @@ -202,7 +245,11 @@ async def handle_results_request(self, request: web.Request) -> Union[web.Respon try: received_json = await request.json() result_req = ServerResultsRequest.from_json(received_json) - return await self._handle_results_request(result_req, request) + if result_req.stream: + if result_req.until is not None: + return await self._handle_timed_streaming_request(result_req, request) + return await self._handle_count_streaming_request(result_req, request) + return self._handle_basic_results_request(result_req, request) except json.JSONDecodeError: received_text = await request.text() msg = 'POST request received, but data is not valid JSON. Showing as plain text.' diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py index 73aa224..07ae16b 100644 --- a/tests/utils/__init__.py +++ b/tests/utils/__init__.py @@ -44,7 +44,7 @@ def __init__( self._initial_data = bytearray(initial_data)[:-1] else: self._initial_data = bytearray(initial_data, 'utf-8')[:-1] - self._initial_data += b',"results":[' + self._initial_data += b', "results": [' self._end_data = bytearray() self._data = bytearray() if self._initial_data is None else bytearray(self._initial_data) @@ -52,7 +52,6 @@ def __init__( self._simulate_delay = simulate_delay or False self._simulate_delay_range = simulate_delay_range or (0.01, 0.1) self._start = 0 - self._stop = self._chunk_size self._stop_iterating = False self._data_count = 0 @@ -77,32 +76,33 @@ async def __anext__(self) -> bytes: while True: await anyio.sleep(0.5) - if len(self._data) == 0: + if len(self._data) < self._chunk_size: if self._stop_iterating: - if len(self._end_data) == 0: + if len(self._data) == 0: raise StopAsyncIteration - self._data += b'],' - self._data += bytearray(self._end_data) - self._data += b'}' - self._end_data = bytearray() + if len(self._end_data) > 0: + print(f'end_data={self._end_data}') + # ending a results array + self._data += b'], ' + self._data += bytearray(self._end_data) + # ending the overall JSON object + self._data += b'}' + # reset end_data + self._end_data = bytearray() else: - self._stop = self._chunk_size while len(self._data) < (2 * self._chunk_size): - self._data += b',' + if self._data_count > 0: + self._data += b', ' + # the data generator should yields whole JSON objects self._data += next(self._data_generator) self._data_count += 1 - # if self._start >= len(self._data): - # self._start = 0 - # self._stop = self._chunk_size - # self._data += next(self._data_generator) - - if self._stop >= len(self._data): - self._stop = len(self._data) - - chunk = bytes(self._data[: self._stop]) - del self._data[: self._stop] - self._stop += self._chunk_size + if len(self._data) > self._chunk_size: + chunk = bytes(self._data[: self._chunk_size]) + del self._data[: self._chunk_size] + else: + chunk = bytes(self._data[:]) + del self._data[:] return chunk From ddd63cecbb794bec1ff3b01754ff0a54d836dd7a Mon Sep 17 00:00:00 2001 From: Jared Casey Date: Thu, 24 Jul 2025 17:10:40 -0600 Subject: [PATCH 13/18] PYCO-62: Async API connect timeout results in CancelledError Changes ======= * Allow httpx to handle timeouts instead of using anyio `CancelScope` deadline * Add tests to confirm functionality * Add logging helpful logging messages --- .gitignore | 7 +- .../protocol/_core/request_context.py | 13 +-- acouchbase_analytics/protocol/streaming.py | 2 +- .../tests/connect_integration_t.py | 93 +++++++++++++++++++ acouchbase_analytics/tests/test_server_t.py | 1 - conftest.py | 2 + couchbase_analytics/_version.py | 4 +- couchbase_analytics/protocol/_core/request.py | 4 - .../tests/connect_integration_t.py | 93 +++++++++++++++++++ tests/analytics_config.py | 6 +- 10 files changed, 200 insertions(+), 25 deletions(-) create mode 100644 acouchbase_analytics/tests/connect_integration_t.py create mode 100644 couchbase_analytics/tests/connect_integration_t.py diff --git a/.gitignore b/.gitignore index 18378ca..2511da8 100644 --- a/.gitignore +++ b/.gitignore @@ -161,12 +161,7 @@ cython_debug/ # Distribution / packaging build/ -couchbase_columnar/_version.py -couchbase_columnar/*.so -couchbase_columnar/*.dylib*.* -couchbase_columnar/*.dll -couchbase_columnar/*.pyd -deps/couchbase-cxx-cache/ +couchbase_analytics/_version.py # Sphinx docs/_build/ diff --git a/acouchbase_analytics/protocol/_core/request_context.py b/acouchbase_analytics/protocol/_core/request_context.py index f9e4105..e8bc22d 100644 --- a/acouchbase_analytics/protocol/_core/request_context.py +++ b/acouchbase_analytics/protocol/_core/request_context.py @@ -112,11 +112,7 @@ def _check_timed_out(self) -> None: return current_time = get_time() - if self._cancel_scope_deadline_updated is False: - timed_out = current_time >= self._connect_deadline - else: - timed_out = current_time >= self._request_deadline - + timed_out = current_time >= self._request_deadline if timed_out: message_data = {'current_time': f'{current_time}', 'request_deadline': f'{self._request_deadline}'} self.log_message('Request has timed out', LogLevel.DEBUG, message_data=message_data) @@ -262,11 +258,11 @@ async def get_result_from_stream(self) -> ParsedResult: async def initialize(self) -> None: if self._request_state == RequestState.ResetAndNotStarted: - self._update_cancel_scope_deadline(self._connect_deadline, is_absolute=True) + current_time = get_time() self.log_message( 'Request is a retry, skipping initialization', LogLevel.DEBUG, - message_data={'request_deadline': f'{self._request_deadline}'}, + message_data={'current_time': f'{current_time}', 'request_deadline': f'{self._request_deadline}'}, ) return await self.__aenter__() @@ -276,7 +272,6 @@ async def initialize(self) -> None: timeouts = self._request.get_request_timeouts() or {} current_time = get_time() self._request_deadline = current_time + (timeouts.get('read', None) or DEFAULT_TIMEOUTS['query_timeout']) - self._update_cancel_scope_deadline(self._connect_deadline, is_absolute=True) message_data = {'current_time': f'{current_time}', 'request_deadline': f'{self._request_deadline}'} self.log_message('Request context initialized', LogLevel.DEBUG, message_data=message_data) @@ -437,6 +432,8 @@ async def wait_for_results_or_errors(self) -> None: async def __aenter__(self) -> AsyncRequestContext: self._taskgroup = anyio.create_task_group() + message_data = {'cancel_scope': f'{id(self._taskgroup.cancel_scope):x}'} + self.log_message('Task group created', LogLevel.DEBUG, message_data=message_data) await self._taskgroup.__aenter__() return self diff --git a/acouchbase_analytics/protocol/streaming.py b/acouchbase_analytics/protocol/streaming.py index 5025047..b3c51d3 100644 --- a/acouchbase_analytics/protocol/streaming.py +++ b/acouchbase_analytics/protocol/streaming.py @@ -159,7 +159,7 @@ async def send_request(self) -> None: # start cancel scope await self._request_context.initialize() - self._core_response = await self._request_context.send_request(enable_trace_handling=True) + self._core_response = await self._request_context.send_request() self._request_context.start_stream(self._core_response) # block until we either know we have rows or we have an error await self._request_context.wait_for_results_or_errors() diff --git a/acouchbase_analytics/tests/connect_integration_t.py b/acouchbase_analytics/tests/connect_integration_t.py new file mode 100644 index 0000000..48180a3 --- /dev/null +++ b/acouchbase_analytics/tests/connect_integration_t.py @@ -0,0 +1,93 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from datetime import timedelta +from typing import TYPE_CHECKING + +import pytest + +from acouchbase_analytics.cluster import AsyncCluster +from acouchbase_analytics.credential import Credential +from acouchbase_analytics.errors import AnalyticsError, TimeoutError +from acouchbase_analytics.options import QueryOptions +from tests import AsyncYieldFixture + +if TYPE_CHECKING: + from tests.environments.base_environment import AsyncTestEnvironment + + +class ConnectTestSuite: + TEST_MANIFEST = [ + 'test_connect_timeout_max_retry_limit', + 'test_connect_timeout_query_timeout', + ] + + async def test_connect_timeout_max_retry_limit(self, test_env: AsyncTestEnvironment) -> None: + statement = 'SELECT sleep("some value", 10000) AS some_field;' + + username, pw = test_env.config.get_username_and_pw() + cred = Credential.from_username_and_password(username, pw) + # ignoring the port enables the failure + connstr = test_env.config.get_connection_string(ignore_port=True) + cluster = AsyncCluster.create_instance(connstr, cred) + + allowed_retries = 5 + q_opts = QueryOptions(max_retries=allowed_retries, timeout=timedelta(seconds=10)) + with pytest.raises(AnalyticsError) as ex: + await cluster.execute_query(statement, q_opts) + + assert ex.value._message is not None + assert 'Retry limit exceeded' in ex.value._message + test_env.assert_error_context_num_attempts(allowed_retries + 1, ex.value._context) + + async def test_connect_timeout_query_timeout(self, test_env: AsyncTestEnvironment) -> None: + statement = 'SELECT sleep("some value", 10000) AS some_field;' + + username, pw = test_env.config.get_username_and_pw() + cred = Credential.from_username_and_password(username, pw) + # ignoring the port enables the failure + connstr = test_env.config.get_connection_string(ignore_port=True) + cluster = AsyncCluster.create_instance(connstr, cred) + + q_opts = QueryOptions(timeout=timedelta(seconds=3)) + with pytest.raises(TimeoutError) as ex: + await cluster.execute_query(statement, q_opts) + + assert ex.value._message is not None + assert 'Request timed out during retry delay' in ex.value._message + test_env.assert_error_context_num_attempts(2, ex.value._context, exact=False) + + +class ConnectTests(ConnectTestSuite): + @pytest.fixture(scope='class', autouse=True) + def validate_test_manifest(self) -> None: + def valid_test_method(meth: str) -> bool: + attr = getattr(ConnectTests, meth) + return callable(attr) and not meth.startswith('__') and meth.startswith('test') + + method_list = [meth for meth in dir(ConnectTests) if valid_test_method(meth)] + test_list = set(ConnectTestSuite.TEST_MANIFEST).symmetric_difference(method_list) + if test_list: + pytest.fail(f'Test manifest invalid. Missing/extra tests: {test_list}.') + + @pytest.fixture(scope='class', name='test_env') + async def couchbase_test_environment( + self, async_test_env: AsyncTestEnvironment + ) -> AsyncYieldFixture[AsyncTestEnvironment]: + await async_test_env.setup() + yield async_test_env + await async_test_env.teardown() diff --git a/acouchbase_analytics/tests/test_server_t.py b/acouchbase_analytics/tests/test_server_t.py index e7ac690..81b2da6 100644 --- a/acouchbase_analytics/tests/test_server_t.py +++ b/acouchbase_analytics/tests/test_server_t.py @@ -121,7 +121,6 @@ async def test_error_retriable_response_retries_exceeded(self, test_env: AsyncTe with pytest.raises(QueryError) as ex: await test_env.cluster_or_scope.execute_query(statement, q_opts) - print(ex.value) test_env.assert_error_context_num_attempts(allowed_retries + 1, ex.value._context) test_env.assert_error_context_contains_last_dispatch(ex.value._context) diff --git a/conftest.py b/conftest.py index 417471e..d7b108a 100644 --- a/conftest.py +++ b/conftest.py @@ -42,8 +42,10 @@ ] _INTEGRATRION_TESTS = [ + 'acouchbase_analytics/tests/connect_integration_t.py::ConnectTests', 'acouchbase_analytics/tests/query_integration_t.py::ClusterQueryTests', 'acouchbase_analytics/tests/query_integration_t.py::ScopeQueryTests', + 'couchbase_analytics/tests/connect_integration_t.py::ConnectTests', 'couchbase_analytics/tests/query_integration_t.py::ClusterQueryTests', 'couchbase_analytics/tests/query_integration_t.py::ScopeQueryTests', ] diff --git a/couchbase_analytics/_version.py b/couchbase_analytics/_version.py index 7a04b87..081f83c 100644 --- a/couchbase_analytics/_version.py +++ b/couchbase_analytics/_version.py @@ -1,5 +1,5 @@ # This file automatically generated by -# /Users/jaredcasey/GIT/couchbase/clients/python/analytics-python-client/./couchbase_analytics_version.py +# /Users/jaredcasey/GIT/couchbase/clients/python/analytics-python-client/couchbase_analytics_version.py # at -# 2025-07-21 18:36:10.973732 +# 2025-07-24 17:08:38.315069 __version__ = '0.0.1' diff --git a/couchbase_analytics/protocol/_core/request.py b/couchbase_analytics/protocol/_core/request.py index ee3cfd5..29b9e9e 100644 --- a/couchbase_analytics/protocol/_core/request.py +++ b/couchbase_analytics/protocol/_core/request.py @@ -167,10 +167,6 @@ def build_base_query_request( # noqa: C901 extensions = deepcopy(self._extensions) if timeout is not None and timeout != self._default_query_timeout: extensions['timeout']['read'] = timeout - # in the async world we have our own cancel scope that handles the connect timeout - if is_async: - del extensions['timeout']['pool'] - del extensions['timeout']['connect'] # we add 5 seconds to the server timeout to ensure we always trigger a client side timeout timeout_ms = (timeout + 5) * 1e3 # convert to milliseconds body['timeout'] = f'{timeout_ms}ms' diff --git a/couchbase_analytics/tests/connect_integration_t.py b/couchbase_analytics/tests/connect_integration_t.py new file mode 100644 index 0000000..4b83859 --- /dev/null +++ b/couchbase_analytics/tests/connect_integration_t.py @@ -0,0 +1,93 @@ +# Copyright 2016-2024. Couchbase, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from datetime import timedelta +from typing import TYPE_CHECKING + +import pytest + +from couchbase_analytics.cluster import Cluster +from couchbase_analytics.credential import Credential +from couchbase_analytics.errors import AnalyticsError, TimeoutError +from couchbase_analytics.options import QueryOptions +from tests import YieldFixture + +if TYPE_CHECKING: + from tests.environments.base_environment import BlockingTestEnvironment + + +class ConnectTestSuite: + TEST_MANIFEST = [ + 'test_connect_timeout_max_retry_limit', + 'test_connect_timeout_query_timeout', + ] + + def test_connect_timeout_max_retry_limit(self, test_env: BlockingTestEnvironment) -> None: + statement = 'SELECT sleep("some value", 10000) AS some_field;' + + username, pw = test_env.config.get_username_and_pw() + cred = Credential.from_username_and_password(username, pw) + # ignoring the port enables the failure + connstr = test_env.config.get_connection_string(ignore_port=True) + cluster = Cluster.create_instance(connstr, cred) + + allowed_retries = 5 + q_opts = QueryOptions(max_retries=allowed_retries, timeout=timedelta(seconds=10)) + with pytest.raises(AnalyticsError) as ex: + cluster.execute_query(statement, q_opts) + + assert ex.value._message is not None + assert 'Retry limit exceeded' in ex.value._message + test_env.assert_error_context_num_attempts(allowed_retries + 1, ex.value._context) + + def test_connect_timeout_query_timeout(self, test_env: BlockingTestEnvironment) -> None: + statement = 'SELECT sleep("some value", 10000) AS some_field;' + + username, pw = test_env.config.get_username_and_pw() + cred = Credential.from_username_and_password(username, pw) + # ignoring the port enables the failure + connstr = test_env.config.get_connection_string(ignore_port=True) + cluster = Cluster.create_instance(connstr, cred) + + q_opts = QueryOptions(timeout=timedelta(seconds=3)) + with pytest.raises(TimeoutError) as ex: + cluster.execute_query(statement, q_opts) + + assert ex.value._message is not None + assert 'Request timed out during retry delay' in ex.value._message + test_env.assert_error_context_num_attempts(2, ex.value._context, exact=False) + + +class ConnectTests(ConnectTestSuite): + @pytest.fixture(scope='class', autouse=True) + def validate_test_manifest(self) -> None: + def valid_test_method(meth: str) -> bool: + attr = getattr(ConnectTests, meth) + return callable(attr) and not meth.startswith('__') and meth.startswith('test') + + method_list = [meth for meth in dir(ConnectTests) if valid_test_method(meth)] + test_list = set(ConnectTestSuite.TEST_MANIFEST).symmetric_difference(method_list) + if test_list: + pytest.fail(f'Test manifest invalid. Missing/extra tests: {test_list}.') + + @pytest.fixture(scope='class', name='test_env') + def couchbase_test_environment( + self, sync_test_env: BlockingTestEnvironment + ) -> YieldFixture[BlockingTestEnvironment]: + sync_test_env.setup() + yield sync_test_env + sync_test_env.teardown() diff --git a/tests/analytics_config.py b/tests/analytics_config.py index 170daca..2dfea05 100644 --- a/tests/analytics_config.py +++ b/tests/analytics_config.py @@ -18,7 +18,7 @@ import os import pathlib from configparser import ConfigParser -from typing import Tuple +from typing import Optional, Tuple from uuid import uuid4 import pytest @@ -72,8 +72,8 @@ def disable_server_certificate_verification(self) -> bool: def scope_name(self) -> str: return self._scope_name - def get_connection_string(self) -> str: - if self._port is not None: + def get_connection_string(self, ignore_port: Optional[bool] = False) -> str: + if ignore_port is None or ignore_port is False and self._port is not None: return f'{self._scheme}://{self._host}:{self._port}' return f'{self._scheme}://{self._host}' From b166df08770b1f553af5953e792322f1d5a73dbe Mon Sep 17 00:00:00 2001 From: Jared Casey Date: Mon, 28 Jul 2025 18:46:17 -0600 Subject: [PATCH 14/18] PYCO-55: Add GHA linting and tests Changes ======= * Add test.yml workflow * Update error handling to take into account timeout scenarios after `send_request` is called again after retry * Update error handling to take into account httpx ReadTimeout, WriteError and WriteTimeout * Update JSON streaming/parsing to handle when ijson uses Python as the backend and thus can raise different errors * Update ijson from 3.3.0 to 3.4.0 to get Python 3.13 wheel --- .github/workflows/tests.yml | 564 ++++++++++++++++++ .../protocol/_core/async_json_stream.py | 14 + .../protocol/_core/retries.py | 19 +- acouchbase_analytics/tests/json_parsing_t.py | 15 +- acouchbase_analytics/tests/test_server_t.py | 2 +- .../protocol/_core/json_stream.py | 14 + .../protocol/_core/request_context.py | 4 + couchbase_analytics/protocol/_core/retries.py | 21 +- couchbase_analytics/tests/json_parsing_t.py | 15 +- couchbase_analytics/tests/test_server_t.py | 2 +- mypy.ini | 1 + pyproject.toml | 2 +- requirements.in | 2 +- requirements.txt | 2 +- tests/environments/base_environment.py | 39 ++ uv.lock | 488 ++++++++------- 16 files changed, 969 insertions(+), 235 deletions(-) create mode 100644 .github/workflows/tests.yml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..ea2b503 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,564 @@ +name: tests + +on: + push: + branches: + - main + - dev + pull_request: + branches: + - main + - dev + workflow_dispatch: + inputs: + is_release: + description: "Set to true if a release version." + required: true + default: false + type: boolean + sha: + description: "The git SHA to use for release. Only set if needing to publish" + required: false + default: "" + type: string + version: + description: "The Release version. Allowed format: x.y.z[-alphaN | -betaN | -rcN | -devN | -postN]" + required: false + default: "" + type: string + config: + description: "JSON formatted object representing various build system input parameters." + required: false + default: "" + type: string + workflow_call: + inputs: + is_release: + description: "Set to true if a release version." + required: true + default: false + type: boolean + sha: + description: "The git SHA to use for release. Only set if needing to publish" + required: false + default: "" + type: string + version: + description: "The Release version. Allowed format: x.y.z[-alphaN | -betaN | -rcN | -devN | -postN]" + required: false + default: "" + type: string + config: + description: "JSON formatted object representing various build system input parameters." + required: false + default: "" + type: string + outputs: + workflow_run_id: + description: "The workflow run ID" + value: ${{ github.run_id }} + +env: + CBCI_PROJECT_TYPE: "ANALYTICS" + CBCI_DEFAULT_PYTHON: "3.9" + CBCI_SUPPORTED_PYTHON_VERSIONS: "3.9 3.10 3.11 3.12 3.13" + CBCI_SUPPORTED_X86_64_PLATFORMS: "linux alpine macos windows" + CBCI_SUPPORTED_ARM64_PLATFORMS: "linux macos" + CBCI_DEFAULT_LINUX_PLATFORM: "ubuntu-22.04" + CBCI_DEFAULT_MACOS_X86_64_PLATFORM: "macos-13" + CBCI_DEFAULT_MACOS_ARM64_PLATFORM: "macos-14" + CBCI_DEFAULT_WINDOWS_PLATFORM: "windows-2022" + CBCI_DEFAULT_LINUX_CONTAINER: "slim-bookworm" + CBCI_DEFAULT_ALPINE_CONTAINER: "alpine" + CBCI_CBDINO_VERSION: "v0.0.80" + CI_SCRIPTS_URL: "https://raw.githubusercontent.com/couchbaselabs/sdkbuild-jenkinsfiles/master/python/ci_scripts_v1" + +jobs: + ci-scripts: + runs-on: ubuntu-22.04 + steps: + - name: Download CI Scripts + run: | + mkdir ci_scripts + cd ci_scripts + curl -o gha.sh ${CI_SCRIPTS_URL}/gha.sh + curl -o pygha.py ${CI_SCRIPTS_URL}/pygha.py + ls -alh + - name: Upload CI scripts + uses: actions/upload-artifact@v4 + with: + retention-days: 1 + name: ci_scripts + path: | + ci_scripts/ + + validate-input: + runs-on: ubuntu-22.04 + needs: ci-scripts + env: + CBCI_IS_RELEASE: ${{ inputs.is_release }} + CBCI_SHA: ${{ inputs.sha }} + CBCI_VERSION: ${{ inputs.version }} + CBCI_CONFIG: ${{ inputs.config }} + steps: + - name: Download CI scripts + uses: actions/download-artifact@v4 + with: + name: ci_scripts + path: ci_scripts + - name: Verify Scripts + run: | + ls -alh ci_scripts + chmod +x ci_scripts/gha.sh + ls -alh ci_scripts + - name: Display workflow info + run: | + ./ci_scripts/gha.sh display_info + - name: Validate workflow info + run: | + ./ci_scripts/gha.sh validate_input ${{ github.workflow }} + + setup: + runs-on: ubuntu-22.04 + needs: validate-input + env: + CBCI_CONFIG: ${{ inputs.config }} + outputs: + stage_matrices: ${{ steps.build_matrices.outputs.stage_matrices }} + steps: + - uses: actions/checkout@v4 + - name: Download CI scripts + uses: actions/download-artifact@v4 + with: + name: ci_scripts + path: ci_scripts + - name: Enable CI Scripts + run: | + chmod +x ci_scripts/gha.sh + - name: Setup Python ${{ env.CBCI_DEFAULT_PYTHON }} + uses: actions/setup-python@v5 + with: + python-version: ${{ env.CBCI_DEFAULT_PYTHON }} + - name: Confirm Python version + run: python -c "import sys; print(sys.version)" + - name: Build stage matrices + id: build_matrices + run: | + exit_code=0 + STAGE_MATRICES=$(./ci_scripts/gha.sh get_stage_matrices) || exit_code=$? + if [ $exit_code -ne 0 ]; then + echo "Failed to obtain stage matrices." + exit 1 + fi + stage_matrices_json=$(jq -cn --argjson matrices "$STAGE_MATRICES" '$matrices') + echo "STAGE_MATRICES_JSON=$stage_matrices_json" + echo "stage_matrices=$stage_matrices_json" >> "$GITHUB_OUTPUT" + + confirm-matrices: + runs-on: ubuntu-22.04 + needs: setup + steps: + - name: Linux Test Unit Stage + if: ${{ fromJson(needs.setup.outputs.stage_matrices).test_unit.has_linux }} + run: | + echo "${{ toJson(fromJson(needs.setup.outputs.stage_matrices).test_unit.linux) }}" + - name: Macos Test Unit Stage + if: ${{ fromJson(needs.setup.outputs.stage_matrices).test_unit.has_macos }} + run: | + echo "${{ toJson(fromJson(needs.setup.outputs.stage_matrices).test_unit.macos) }}" + - name: Windows Test Unit Stage + if: ${{ fromJson(needs.setup.outputs.stage_matrices).test_unit.has_windows }} + run: | + echo "${{ toJson(fromJson(needs.setup.outputs.stage_matrices).test_unit.windows) }}" + - name: Linux cbdino Stage + if: >- + ${{ fromJson(needs.setup.outputs.stage_matrices).test_integration.has_linux_cbdino + && !fromJson(needs.setup.outputs.stage_matrices).test_integration.skip_cbdino }} + run: | + echo cbdino config: + echo "${{ toJson(fromJson(needs.setup.outputs.stage_matrices).test_integration.cbdino_config) }}" + echo cbdino linux: + echo "${{ toJson(fromJson(needs.setup.outputs.stage_matrices).test_integration.linux_cbdino) }}" + - name: Linux Integration Stage + if: >- + ${{ fromJson(needs.setup.outputs.stage_matrices).test_integration.has_linux + && !fromJson(needs.setup.outputs.stage_matrices).test_integration.skip_integration }} + run: | + echo "${{ toJson(fromJson(needs.setup.outputs.stage_matrices).test_integration.test_config) }}" + + + test-setup: + runs-on: ubuntu-22.04 + needs: confirm-matrices + env: + CBCI_CONFIG: ${{ inputs.config }} + steps: + - uses: actions/checkout@v4 + - name: Download CI scripts + uses: actions/download-artifact@v4 + with: + name: ci_scripts + path: ci_scripts + - name: Enable CI Scripts + run: | + chmod +x ci_scripts/gha.sh + - name: Setup Python ${{ env.CBCI_DEFAULT_PYTHON }} + uses: actions/setup-python@v5 + with: + python-version: ${{ env.CBCI_DEFAULT_PYTHON }} + - name: Confirm Python version + run: python -c "import sys; print(sys.version)" + - name: Build test setup + run: | + ./ci_scripts/gha.sh build_test_setup + - name: Confirm test setup + run: | + echo "pycbac_test directory contents:" + ls -alh pycbac_test + echo "pycbac_test/acb/tests contents:" + ls -alh pycbac_test/acb/tests + echo "pycbac_test/cb/tests contents:" + ls -alh pycbac_test/cb/tests + echo "pycbac_test/tests contents:" + ls -alh pycbac_test/tests + echo "pycbac_test/conftest.py contents:" + cat pycbac_test/conftest.py + echo "pycbac_test/requirements-test.txt contents:" + cat pycbac_test/requirements-test.txt + echo "pycbac_test/pytest.ini contents:" + cat pycbac_test/pytest.ini + echo "pycbac_test/test_config.ini contents:" + cat pycbac_test/test_config.ini + - name: Upload test setup + uses: actions/upload-artifact@v4 + with: + retention-days: 1 + name: pycbac-test-setup + path: | + pycbac_test/ + + lint: + runs-on: ubuntu-22.04 + needs: validate-input + env: + CBCI_VERSION: ${{ inputs.version }} + steps: + - uses: actions/checkout@v4 + - name: Download CI scripts + uses: actions/download-artifact@v4 + with: + name: ci_scripts + path: ci_scripts + - name: Enable CI Scripts + run: | + chmod +x ci_scripts/gha.sh + - name: Setup Python ${{ env.CBCI_DEFAULT_PYTHON }} + uses: actions/setup-python@v5 + with: + python-version: ${{ env.CBCI_DEFAULT_PYTHON }} + - name: Confirm Python version + run: python -c "import sys; print(sys.version)" + - name: Execute linting + run: | + ls -alh + ./ci_scripts/gha.sh lint + + sdist-wheel: + runs-on: ubuntu-22.04 + needs: lint + env: + CBCI_VERSION: ${{ inputs.version }} + CBCI_CONFIG: ${{ inputs.config }} + outputs: + sdist_name: ${{ steps.create_sdist.outputs.sdist_name }} + wheel_name: ${{ steps.create_wheel.outputs.wheel_name }} + steps: + - name: Checkout (with SHA) + if: inputs.sha != '' + uses: actions/checkout@v4 + with: + ref: ${{ inputs.sha }} + - name: Checkout (no SHA) + if: inputs.sha == '' + uses: actions/checkout@v4 + - name: Download CI scripts + uses: actions/download-artifact@v4 + with: + name: ci_scripts + path: ci_scripts + - name: Enable CI Scripts + run: | + chmod +x ci_scripts/gha.sh + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: ${{ env.CBCI_DEFAULT_PYTHON }} + - name: Confirm Python version + run: python -c "import sys; print(sys.version)" + - name: Create sdist + id: create_sdist + run: | + ./ci_scripts/gha.sh sdist + exit_code=0 + sdist_name=$(./ci_scripts/gha.sh get_sdist_name) || exit_code=$? + if [ $exit_code -ne 0 ]; then + echo "Failed to obtain sdist name." + exit 1 + fi + echo "SDIST_NAME=$sdist_name" + echo "sdist_name=$sdist_name" >> "$GITHUB_OUTPUT" + - name: Create wheel + id: create_wheel + run: | + ./ci_scripts/gha.sh wheel + wheel_name=$(find ./dist -name '*.whl' | cut -c 8-) + echo "WHEEL_NAME=$wheel_name" + echo "wheel_name=$wheel_name" >> "$GITHUB_OUTPUT" + - name: Upload Python sdk + uses: actions/upload-artifact@v4 + with: + retention-days: 1 + name: pycbac-artifact-sdist + path: | + ./dist/*.tar.gz + - name: Upload Python wheel + uses: actions/upload-artifact@v4 + with: + retention-days: 1 + name: pycbac-artifact-wheel + path: | + ./dist/*.whl + + linux-unit-tests: + needs: [setup, test-setup, sdist-wheel] + if: ${{ fromJson(needs.setup.outputs.stage_matrices).test_unit.has_linux }} + name: Run unit tests; Python ${{ matrix.python-version }} - ${{ matrix.linux-type }} (${{ matrix.arch }}) + runs-on: ubuntu-22.04 + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.setup.outputs.stage_matrices).test_unit.linux }} + steps: + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Confirm Python version + run: python -c "import sys; print(sys.version)" + - name: Set up QEMU + if: ${{ matrix.arch == 'aarch64' }} + uses: docker/setup-qemu-action@v3 + with: + platforms: arm64 + - name: Download sdist + if: ${{ fromJson(needs.setup.outputs.stage_matrices).test_unit.test_sdist_install }} + uses: actions/download-artifact@v4 + with: + name: pycbac-artifact-sdist + path: pycbac + - name: Download wheel + if: ${{ fromJson(needs.setup.outputs.stage_matrices).test_unit.test_wheel_install }} + uses: actions/download-artifact@v4 + with: + name: pycbac-artifact-wheel + path: pycbac + - name: Download test setup + uses: actions/download-artifact@v4 + with: + name: pycbac-test-setup + path: pycbac + - name: Run unit tests in docker via sdist install + if: ${{ fromJson(needs.setup.outputs.stage_matrices).test_unit.test_sdist_install }} + uses: addnab/docker-run-action@v3 + with: + image: python:${{ matrix.python-version }}-${{ matrix.linux-type == 'manylinux' && env.CBCI_DEFAULT_LINUX_CONTAINER || env.CBCI_DEFAULT_ALPINE_CONTAINER }} + options: >- + --platform linux/${{ matrix.arch == 'aarch64' && 'arm64' || 'amd64'}} + -v ${{ github.workspace }}/pycbac:/pycbac + run: | + apt-get update && apt-get install -y jq + python -m pip install --upgrade pip setuptools wheel + cd pycbac + ls -alh + python -m pip install -r requirements-test.txt + SDIST_NAME=${{ needs.sdist-wheel.outputs.sdist_name }} + echo "SDIST_NAME=$SDIST_NAME.tar.gz" + python -m pip install ${SDIST_NAME}.tar.gz + python -m pip list + TEST_ACOUCHBASE_API=${{ fromJson(needs.setup.outputs.stage_matrices).test_unit.test_acouchbase_api }} + if [ "$TEST_ACOUCHBASE_API" = "true" ]; then + python -m pytest -m "pycbac_acouchbase and pycbac_unit" -rA -vv + fi + TEST_COUCHBASE_API=${{ fromJson(needs.setup.outputs.stage_matrices).test_unit.test_couchbase_api }} + if [ "$TEST_COUCHBASE_API" = "true" ]; then + python -m pytest -m "pycbac_couchbase and pycbac_unit" -rA -vv + fi + - name: Run unit tests in docker via wheel install + if: ${{ fromJson(needs.setup.outputs.stage_matrices).test_unit.test_wheel_install }} + uses: addnab/docker-run-action@v3 + with: + image: python:${{ matrix.python-version }}-${{ matrix.linux-type == 'manylinux' && env.CBCI_DEFAULT_LINUX_CONTAINER || env.CBCI_DEFAULT_ALPINE_CONTAINER }} + options: >- + --platform linux/${{ matrix.arch == 'aarch64' && 'arm64' || 'amd64'}} + -v ${{ github.workspace }}/pycbac:/pycbac + run: | + apt-get update && apt-get install -y jq + python -m pip install --upgrade pip setuptools wheel + cd pycbac + ls -alh + python -m pip install -r requirements-test.txt + WHEEL_NAME=${{ needs.sdist-wheel.outputs.wheel_name }} + echo "WHEEL_NAME=$WHEEL_NAME" + python -m pip install ${WHEEL_NAME} + python -m pip list + TEST_ACOUCHBASE_API=${{ fromJson(needs.setup.outputs.stage_matrices).test_unit.test_acouchbase_api }} + if [ "$TEST_ACOUCHBASE_API" = "true" ]; then + python -m pytest -m "pycbac_acouchbase and pycbac_unit" -rA -vv + fi + TEST_COUCHBASE_API=${{ fromJson(needs.setup.outputs.stage_matrices).test_unit.test_couchbase_api }} + if [ "$TEST_COUCHBASE_API" = "true" ]; then + python -m pytest -m "pycbac_couchbase and pycbac_unit" -rA -vv + fi + + macos-unit-tests: + needs: [setup, test-setup, sdist-wheel] + if: ${{ fromJson(needs.setup.outputs.stage_matrices).test_unit.has_macos }} + name: Run unit tests; Python ${{ matrix.python-version }} - ${{ matrix.arch }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.setup.outputs.stage_matrices).test_unit.macos }} + steps: + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Confirm Python version + run: python -c "import sys; print(sys.version)" + - name: Download sdist + if: ${{ fromJson(needs.setup.outputs.stage_matrices).test_unit.test_sdist_install }} + uses: actions/download-artifact@v4 + with: + name: pycbac-artifact-sdist + path: pycbac + - name: Download wheel + if: ${{ fromJson(needs.setup.outputs.stage_matrices).test_unit.test_wheel_install }} + uses: actions/download-artifact@v4 + with: + name: pycbac-artifact-wheel + path: pycbac + - name: Download test setup + uses: actions/download-artifact@v4 + with: + name: pycbac-test-setup + path: pycbac + - name: Run unit tests via sdist install + if: ${{ fromJson(needs.setup.outputs.stage_matrices).test_unit.test_sdist_install }} + run: | + python -m pip install --upgrade pip setuptools wheel + cd pycbac + ls -alh + python -m pip install -r requirements-test.txt + SDIST_NAME=${{ needs.sdist-wheel.outputs.sdist_name }} + echo "SDIST_NAME=$SDIST_NAME.tar.gz" + python -m pip install ${SDIST_NAME}.tar.gz + python -m pip list + TEST_ACOUCHBASE_API=${{ fromJson(needs.setup.outputs.stage_matrices).test_unit.test_acouchbase_api }} + if [ "$TEST_ACOUCHBASE_API" = "true" ]; then + python -m pytest -m "pycbac_acouchbase and pycbac_unit" -rA -vv --log-cli-level=DEBUG + fi + TEST_COUCHBASE_API=${{ fromJson(needs.setup.outputs.stage_matrices).test_unit.test_couchbase_api }} + if [ "$TEST_COUCHBASE_API" = "true" ]; then + python -m pytest -m "pycbac_couchbase and pycbac_unit" -rA -vv --log-cli-level=DEBUG + fi + python -m pip uninstall couchbase-analytics -y + - name: Run unit tests via wheel install + if: ${{ fromJson(needs.setup.outputs.stage_matrices).test_unit.test_wheel_install }} + run: | + python -m pip install --upgrade pip setuptools wheel + cd pycbac + ls -alh + python -m pip install -r requirements-test.txt + WHEEL_NAME=${{ needs.sdist-wheel.outputs.wheel_name }} + echo "WHEEL_NAME=$WHEEL_NAME" + python -m pip install ${WHEEL_NAME} --no-cache-dir + python -m pip list + TEST_ACOUCHBASE_API=${{ fromJson(needs.setup.outputs.stage_matrices).test_unit.test_acouchbase_api }} + if [ "$TEST_ACOUCHBASE_API" = "true" ]; then + python -m pytest -m "pycbac_acouchbase and pycbac_unit" -rA -vv --log-cli-level=DEBUG + fi + TEST_COUCHBASE_API=${{ fromJson(needs.setup.outputs.stage_matrices).test_unit.test_couchbase_api }} + if [ "$TEST_COUCHBASE_API" = "true" ]; then + python -m pytest -m "pycbac_couchbase and pycbac_unit" -rA -vv --log-cli-level=DEBUG + fi + + windows-unit-tests: + needs: [setup, test-setup, sdist-wheel] + if: ${{ fromJson(needs.setup.outputs.stage_matrices).test_unit.has_windows }} + name: Run unit tests; Python ${{ matrix.python-version }} - ${{ matrix.arch }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.setup.outputs.stage_matrices).test_unit.windows }} + steps: + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Confirm Python version + run: python -c "import sys; print(sys.version)" + - name: Download sdist + if: ${{ fromJson(needs.setup.outputs.stage_matrices).test_unit.test_sdist_install }} + uses: actions/download-artifact@v4 + with: + name: pycbac-artifact-sdist + path: pycbac + - name: Download wheel + if: ${{ fromJson(needs.setup.outputs.stage_matrices).test_unit.test_wheel_install }} + uses: actions/download-artifact@v4 + with: + name: pycbac-artifact-wheel + path: pycbac + - name: Download test setup + uses: actions/download-artifact@v4 + with: + name: pycbac-test-setup + path: pycbac + - name: Run unit tests via sdist install + if: ${{ fromJson(needs.setup.outputs.stage_matrices).test_unit.test_sdist_install }} + run: | + python -m pip install --upgrade pip setuptools wheel + cd pycbac + dir + python -m pip install -r requirements-test.txt + $SDIST_NAME="${{ needs.sdist-wheel.outputs.sdist_name }}" + ".tar.gz" + echo "SDIST_NAME=$SDIST_NAME" + python -m pip install "$SDIST_NAME" + python -m pip list + $TEST_ACOUCHBASE_API="${{ fromJson(needs.setup.outputs.stage_matrices).test_unit.test_acouchbase_api }}" + if ( $TEST_ACOUCHBASE_API -eq "true" ) { + python -m pytest -m "pycbac_acouchbase and pycbac_unit" -rA -vv + } + $TEST_COUCHBASE_API="${{ fromJson(needs.setup.outputs.stage_matrices).test_unit.test_couchbase_api }}" + if ( $TEST_COUCHBASE_API = "true" ) { + python -m pytest -m "pycbac_couchbase and pycbac_unit" -rA -vv + } + python -m pip uninstall couchbase-analytics -y + - name: Run unit tests via wheel install + if: ${{ fromJson(needs.setup.outputs.stage_matrices).test_unit.test_wheel_install }} + run: | + python -m pip install --upgrade pip setuptools wheel + cd pycbac + dir + python -m pip install -r requirements-test.txt + $WHEEL_NAME="${{ needs.sdist-wheel.outputs.wheel_name }}" + echo "WHEEL_NAME=$WHEEL_NAME" + python -m pip install "$WHEEL_NAME" --no-cache-dir + python -m pip list + $TEST_ACOUCHBASE_API="${{ fromJson(needs.setup.outputs.stage_matrices).test_unit.test_acouchbase_api }}" + if ( $TEST_ACOUCHBASE_API -eq "true" ) { + python -m pytest -m "pycbac_acouchbase and pycbac_unit" -rA -vv + } + $TEST_COUCHBASE_API="${{ fromJson(needs.setup.outputs.stage_matrices).test_unit.test_couchbase_api }}" + if ( $TEST_COUCHBASE_API = "true" ) { + python -m pytest -m "pycbac_couchbase and pycbac_unit" -rA -vv + } diff --git a/acouchbase_analytics/protocol/_core/async_json_stream.py b/acouchbase_analytics/protocol/_core/async_json_stream.py index 93576ef..474638e 100644 --- a/acouchbase_analytics/protocol/_core/async_json_stream.py +++ b/acouchbase_analytics/protocol/_core/async_json_stream.py @@ -154,6 +154,20 @@ async def _process_token_stream(self) -> None: await self._send_to_stream(ParsedResult(ex_str.encode('utf-8'), ParsedResultType.ERROR), close=True) self._handle_notification(ParsedResultType.ERROR) return + except ijson.common.JSONError as ex: + ex_str = str(ex) + self._log_message(f'JSON error encountered: {ex_str}', LogLevel.ERROR) + self._token_stream_exhausted = True + await self._send_to_stream(ParsedResult(ex_str.encode('utf-8'), ParsedResultType.ERROR), close=True) + self._handle_notification(ParsedResultType.ERROR) + return + except ijson.backends.python.UnexpectedSymbol as ex: + ex_str = str(ex) + self._log_message(f'Unexpected symbol encountered: {ex_str}', LogLevel.ERROR) + self._token_stream_exhausted = True + await self._send_to_stream(ParsedResult(ex_str.encode('utf-8'), ParsedResultType.ERROR), close=True) + self._handle_notification(ParsedResultType.ERROR) + return if self._token_stream_exhausted: result_type = ParsedResultType.ERROR if self._json_token_parser.has_errors else ParsedResultType.END diff --git a/acouchbase_analytics/protocol/_core/retries.py b/acouchbase_analytics/protocol/_core/retries.py index 2bf3740..e480db5 100644 --- a/acouchbase_analytics/protocol/_core/retries.py +++ b/acouchbase_analytics/protocol/_core/retries.py @@ -19,7 +19,7 @@ from functools import wraps from typing import TYPE_CHECKING, Any, Callable, Coroutine, Optional, Union -from httpx import ConnectError, ConnectTimeout +from httpx import ConnectError, ConnectTimeout, ReadTimeout, WriteError, WriteTimeout from acouchbase_analytics.protocol._core.anyio_utils import sleep from couchbase_analytics.common.errors import AnalyticsError, InternalSDKError, TimeoutError @@ -39,7 +39,7 @@ class AsyncRetryHandler: @staticmethod async def handle_httpx_retry( - ex: Union[ConnectError, ConnectTimeout], ctx: AsyncRequestContext + ex: Union[ConnectError, ConnectTimeout, WriteError, WriteTimeout], ctx: AsyncRequestContext ) -> Optional[Exception]: err_str = str(ex) if 'SSL:' in err_str: @@ -107,17 +107,30 @@ async def wrapped_fn(self: AsyncHttpStreamingResponse) -> None: # noqa: C901 continue await self._request_context.shutdown(type(ex), ex, ex.__traceback__) raise err from None - except (ConnectError, ConnectTimeout) as ex: + except (ConnectError, ConnectTimeout, WriteError, WriteTimeout) as ex: err = await AsyncRetryHandler.handle_httpx_retry(ex, self._request_context) if err is None: continue await self._request_context.shutdown(type(ex), ex, ex.__traceback__) raise err from None + except ReadTimeout as ex: + # we set the read timeout to the query timeout, so if we get a ReadTimeout, + # it means the request timed out from the httpx client + await self._request_context.shutdown(type(ex), ex, ex.__traceback__) + raise TimeoutError( + message='Request timed out.', context=str(self._request_context.error_context) + ) from None except AnalyticsError: # if an AnalyticsError is raised, we have already shut down the request context raise except RuntimeError as ex: await self._request_context.shutdown(type(ex), ex, ex.__traceback__) + if self._request_context.timed_out: + raise TimeoutError( + message='Request timeout.', context=str(self._request_context.error_context) + ) from None + if self._request_context.cancelled: + raise CancelledError('Request was cancelled.') from None raise ex except BaseException as ex: await self._request_context.shutdown(type(ex), ex, ex.__traceback__) diff --git a/acouchbase_analytics/tests/json_parsing_t.py b/acouchbase_analytics/tests/json_parsing_t.py index 5bace7d..dc26a88 100644 --- a/acouchbase_analytics/tests/json_parsing_t.py +++ b/acouchbase_analytics/tests/json_parsing_t.py @@ -312,7 +312,8 @@ async def test_invalid_empty(self) -> None: res = await parser.get_result() assert res.result_type == ParsedResultType.ERROR assert res.value is not None - assert 'parse error' in str(res.value.decode('utf-8')) + decoded_value = res.value.decode('utf-8') + assert ('parse error' in decoded_value or 'Incomplete JSON content' in decoded_value) is True @pytest.mark.anyio async def test_invalid_garbage_between_objects(self) -> None: @@ -324,7 +325,8 @@ async def test_invalid_garbage_between_objects(self) -> None: res = await parser.get_result() assert res.result_type == ParsedResultType.ERROR assert res.value is not None - assert 'lexical error' in str(res.value.decode('utf-8')) + decoded_value = res.value.decode('utf-8') + assert ('lexical error' in decoded_value or 'Unexpected symbol' in decoded_value) is True @pytest.mark.anyio async def test_invalid_leading_garbage(self) -> None: @@ -336,7 +338,8 @@ async def test_invalid_leading_garbage(self) -> None: res = await parser.get_result() assert res.result_type == ParsedResultType.ERROR assert res.value is not None - assert 'lexical error' in str(res.value.decode('utf-8')) + decoded_value = res.value.decode('utf-8') + assert ('lexical error' in decoded_value or 'Unexpected symbol' in decoded_value) is True @pytest.mark.anyio async def test_invalid_trailing_garbage(self) -> None: @@ -348,7 +351,8 @@ async def test_invalid_trailing_garbage(self) -> None: res = await parser.get_result() assert res.result_type == ParsedResultType.ERROR assert res.value is not None - assert 'parse error' in str(res.value.decode('utf-8')) + decoded_value = res.value.decode('utf-8') + assert ('parse error' in decoded_value or 'Additional data found' in decoded_value) is True @pytest.mark.anyio async def test_invalid_whitespace_only(self) -> None: @@ -360,7 +364,8 @@ async def test_invalid_whitespace_only(self) -> None: res = await parser.get_result() assert res.result_type == ParsedResultType.ERROR assert res.value is not None - assert 'parse error' in str(res.value.decode('utf-8')) + decoded_value = res.value.decode('utf-8') + assert ('parse error' in decoded_value or 'Incomplete JSON content' in decoded_value) is True @pytest.mark.anyio async def test_value_bool(self) -> None: diff --git a/acouchbase_analytics/tests/test_server_t.py b/acouchbase_analytics/tests/test_server_t.py index 81b2da6..0657dc7 100644 --- a/acouchbase_analytics/tests/test_server_t.py +++ b/acouchbase_analytics/tests/test_server_t.py @@ -148,7 +148,7 @@ async def test_error_timeout(self, test_env: AsyncTestEnvironment, server_side: if server_side: req_json = {'error_type': ErrorType.Timeout.value, 'timeout': 1, 'server_side': True} else: - req_json = {'error_type': ErrorType.Timeout.value, 'timeout': 2} + req_json = {'error_type': ErrorType.Timeout.value, 'timeout': 3} test_env.update_request_json(req_json) statement = 'SELECT "Hello, data!" AS greeting' diff --git a/couchbase_analytics/protocol/_core/json_stream.py b/couchbase_analytics/protocol/_core/json_stream.py index aa33ed5..b598c2f 100644 --- a/couchbase_analytics/protocol/_core/json_stream.py +++ b/couchbase_analytics/protocol/_core/json_stream.py @@ -151,6 +151,20 @@ def _process_token_stream(self, request_context: Optional[RequestContext] = None self._put(ParsedResult(ex_str.encode('utf-8'), ParsedResultType.ERROR)) self._handle_notification(ParsedResultType.ERROR) return + except ijson.common.JSONError as ex: + ex_str = str(ex) + self._log_message(f'JSON error encountered: {ex_str}', LogLevel.ERROR) + self._token_stream_exhausted = True + self._put(ParsedResult(ex_str.encode('utf-8'), ParsedResultType.ERROR)) + self._handle_notification(ParsedResultType.ERROR) + return + except ijson.backends.python.UnexpectedSymbol as ex: + ex_str = str(ex) + self._log_message(f'Unexpected symbol encountered: {ex_str}', LogLevel.ERROR) + self._token_stream_exhausted = True + self._put(ParsedResult(ex_str.encode('utf-8'), ParsedResultType.ERROR)) + self._handle_notification(ParsedResultType.ERROR) + return if self._token_stream_exhausted: result_type = ParsedResultType.ERROR if self._json_token_parser.has_errors else ParsedResultType.END diff --git a/couchbase_analytics/protocol/_core/request_context.py b/couchbase_analytics/protocol/_core/request_context.py index 479ea7e..991f2a4 100644 --- a/couchbase_analytics/protocol/_core/request_context.py +++ b/couchbase_analytics/protocol/_core/request_context.py @@ -212,6 +212,7 @@ def _reset_stream(self) -> None: del self._json_stream self._request_state = RequestState.ResetAndNotStarted self._stage_notification_ft = None + self.log_message('Request state has been reset', LogLevel.DEBUG) def _start_next_stage( self, @@ -454,5 +455,8 @@ def wait_for_stage_notification(self) -> None: raise TimeoutError(message='Request timed out waiting for stage notification', context=str(self._error_ctx)) result_type = self._stage_notification_ft.result(timeout=deadline) if result_type == ParsedResultType.ROW: + self.log_message('Received row, setting status to streaming', LogLevel.DEBUG) # we move to iterating rows self._request_state = RequestState.StreamingResults + else: + self.log_message(f'Received result type {result_type.name}', LogLevel.DEBUG) diff --git a/couchbase_analytics/protocol/_core/retries.py b/couchbase_analytics/protocol/_core/retries.py index d1daec9..5aeb837 100644 --- a/couchbase_analytics/protocol/_core/retries.py +++ b/couchbase_analytics/protocol/_core/retries.py @@ -20,7 +20,7 @@ from time import sleep from typing import TYPE_CHECKING, Callable, Optional, Union -from httpx import ConnectError, ConnectTimeout +from httpx import ConnectError, ConnectTimeout, ReadTimeout, WriteError, WriteTimeout from couchbase_analytics.common.errors import AnalyticsError, InternalSDKError, TimeoutError from couchbase_analytics.common.logging import LogLevel @@ -38,7 +38,9 @@ class RetryHandler: """ @staticmethod - def handle_httpx_retry(ex: Union[ConnectError, ConnectTimeout], ctx: RequestContext) -> Optional[Exception]: + def handle_httpx_retry( + ex: Union[ConnectError, ConnectTimeout, WriteError, WriteTimeout], ctx: RequestContext + ) -> Optional[Exception]: err_str = str(ex) if 'SSL:' in err_str: message = 'TLS connection error occurred.' @@ -103,17 +105,30 @@ def wrapped_fn(self: HttpStreamingResponse) -> None: # noqa: C901 continue self._request_context.shutdown(ex) raise err from None - except (ConnectError, ConnectTimeout) as ex: + except (ConnectError, ConnectTimeout, WriteError, WriteTimeout) as ex: err = RetryHandler.handle_httpx_retry(ex, self._request_context) if err is None: continue self._request_context.shutdown(ex) raise err from None + except ReadTimeout as ex: + # we set the read timeout to the query timeout, so if we get a ReadTimeout, + # it means the request timed out from the httpx client + self._request_context.shutdown(ex) + raise TimeoutError( + message='Request timed out.', context=str(self._request_context.error_context) + ) from None except AnalyticsError: # if an AnalyticsError is raised, we have already shut down the request context raise except RuntimeError as ex: self._request_context.shutdown(ex) + if self._request_context.timed_out: + raise TimeoutError( + message='Request timeout.', context=str(self._request_context.error_context) + ) from None + if self._request_context.cancelled: + raise CancelledError('Request was cancelled.') from None raise ex except BaseException as ex: self._request_context.shutdown(ex) diff --git a/couchbase_analytics/tests/json_parsing_t.py b/couchbase_analytics/tests/json_parsing_t.py index bf761c0..d540682 100644 --- a/couchbase_analytics/tests/json_parsing_t.py +++ b/couchbase_analytics/tests/json_parsing_t.py @@ -256,7 +256,8 @@ def test_invalid_empty(self) -> None: assert isinstance(res, ParsedResult) assert res.result_type == ParsedResultType.ERROR assert res.value is not None - assert 'parse error' in str(res.value.decode('utf-8')) + decoded_value = res.value.decode('utf-8') + assert ('parse error' in decoded_value or 'Incomplete JSON content' in decoded_value) is True def test_invalid_garbage_between_objects(self) -> None: data = '[{"id":1,"name":"Alice"},garbage,{"id":2,"name":"Bob"}]' @@ -268,7 +269,8 @@ def test_invalid_garbage_between_objects(self) -> None: assert isinstance(res, ParsedResult) assert res.result_type == ParsedResultType.ERROR assert res.value is not None - assert 'lexical error' in str(res.value.decode('utf-8')) + decoded_value = res.value.decode('utf-8') + assert ('lexical error' in decoded_value or 'Unexpected symbol' in decoded_value) is True def test_invalid_leading_garbage(self) -> None: data = 'garbage{"key":"value"}' @@ -280,7 +282,8 @@ def test_invalid_leading_garbage(self) -> None: assert isinstance(res, ParsedResult) assert res.result_type == ParsedResultType.ERROR assert res.value is not None - assert 'lexical error' in str(res.value.decode('utf-8')) + decoded_value = res.value.decode('utf-8') + assert ('lexical error' in decoded_value or 'Unexpected symbol' in decoded_value) is True def test_invalid_trailing_garbage(self) -> None: data = '{"key":"value"}garbage' @@ -292,7 +295,8 @@ def test_invalid_trailing_garbage(self) -> None: assert isinstance(res, ParsedResult) assert res.result_type == ParsedResultType.ERROR assert res.value is not None - assert 'parse error' in str(res.value.decode('utf-8')) + decoded_value = res.value.decode('utf-8') + assert ('parse error' in decoded_value or 'Additional data found' in decoded_value) is True def test_invalid_whitespace_only(self) -> None: data = ' \n\t ' @@ -304,7 +308,8 @@ def test_invalid_whitespace_only(self) -> None: assert isinstance(res, ParsedResult) assert res.result_type == ParsedResultType.ERROR assert res.value is not None - assert 'parse error' in str(res.value.decode('utf-8')) + decoded_value = res.value.decode('utf-8') + assert ('parse error' in decoded_value or 'Incomplete JSON content' in decoded_value) is True def test_object(self) -> None: data = '{"name":"John","age":30,"city":"New York"}' diff --git a/couchbase_analytics/tests/test_server_t.py b/couchbase_analytics/tests/test_server_t.py index 11b3388..74ddbd4 100644 --- a/couchbase_analytics/tests/test_server_t.py +++ b/couchbase_analytics/tests/test_server_t.py @@ -150,7 +150,7 @@ def test_error_timeout(self, test_env: BlockingTestEnvironment, server_side: boo if server_side: req_json = {'error_type': ErrorType.Timeout.value, 'timeout': 1, 'server_side': True} else: - req_json = {'error_type': ErrorType.Timeout.value, 'timeout': 2} + req_json = {'error_type': ErrorType.Timeout.value, 'timeout': 3} test_env.update_request_json(req_json) statement = 'SELECT "Hello, data!" AS greeting' diff --git a/mypy.ini b/mypy.ini index c279f52..7a07981 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1,6 +1,7 @@ [mypy] exclude = (?x)( setup\.py$ + | ci_scripts/ | docs/ | tests/utils/ ) diff --git a/pyproject.toml b/pyproject.toml index b16848b..471cd09 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ version = "1.0.0.dev1" dependencies = [ "anyio~=4.9.0", "httpx~=0.28.1", - "ijson~=3.3.0", + "ijson~=3.4.0", "sniffio~=1.3.1", "typing-extensions~=4.11; python_version<'3.11'", ] diff --git a/requirements.in b/requirements.in index 8ebee13..43c2e1d 100644 --- a/requirements.in +++ b/requirements.in @@ -1,6 +1,6 @@ anyio~=4.9.0 sniffio~=1.3.1 httpx~=0.28.1 -ijson~=3.3.0 +ijson~=3.4.0 # Typing support typing-extensions~=4.11; python_version<"3.11.0" diff --git a/requirements.txt b/requirements.txt index 35d292a..a97f0ed 100644 --- a/requirements.txt +++ b/requirements.txt @@ -20,7 +20,7 @@ idna==3.10 # via # anyio # httpx -ijson==3.3.0 +ijson==3.4.0 # via -r requirements.in sniffio==1.3.1 # via diff --git a/tests/environments/base_environment.py b/tests/environments/base_environment.py index a18cf73..8afd4ab 100644 --- a/tests/environments/base_environment.py +++ b/tests/environments/base_environment.py @@ -38,6 +38,7 @@ from couchbase_analytics.result import BlockingQueryResult from couchbase_analytics.scope import Scope from tests import AnalyticsTestEnvironmentError +from tests.test_server import ResultType from tests.utils._run_web_server import WebServerHandler if TYPE_CHECKING: @@ -208,6 +209,7 @@ def enable_test_server(self) -> BlockingTestEnvironment: url = self._cluster._impl.client_adapter.connection_details.url.get_formatted_url() print(f'Connecting to test server at {url}') self._server_handler.start_server() + self.warmup_test_server() return self def setup(self) -> None: @@ -289,6 +291,24 @@ def update_request_json(self, json: Dict[str, object]) -> None: raise AnalyticsTestEnvironmentError('No cluster available, cannot enable test server.') self._cluster._impl._client_adapter.update_request_json(json) + def warmup_test_server(self) -> None: + row_count = 5 + self.set_url_path('/test_results') + self.update_request_json({'result_type': ResultType.Object.value, 'row_count': 5, 'stream': True}) + statement = 'SELECT "Hello, data!" AS greeting' + exc = None + for _ in range(3): + exc = None + try: + res = self.cluster.execute_query(statement) + rows = list(res.rows()) + if len(rows) == row_count: + break + except Exception as ex: + exc = AnalyticsTestEnvironmentError(f'Unable to execute statement={statement}. Error: {ex}') + if exc is not None: + raise exc + @classmethod def get_environment( cls, config: AnalyticsConfig, server_handler: Optional[WebServerHandler] = None @@ -407,6 +427,7 @@ async def enable_test_server(self) -> AsyncTestEnvironment: url = self._async_cluster._impl.client_adapter.connection_details.url.get_formatted_url() print(f'Connecting to test server at {url}') self._server_handler.start_server() + await self.warmup_test_server() return self async def setup(self) -> None: @@ -491,6 +512,24 @@ def update_request_json(self, json: Dict[str, object]) -> None: raise AnalyticsTestEnvironmentError('No cluster available, cannot enable test server.') self._async_cluster._impl._client_adapter.update_request_json(json) + async def warmup_test_server(self) -> None: + row_count = 5 + self.set_url_path('/test_results') + self.update_request_json({'result_type': ResultType.Object.value, 'row_count': 5, 'stream': True}) + statement = 'SELECT "Hello, data!" AS greeting' + exc = None + for _ in range(3): + exc = None + try: + res = await self.cluster.execute_query(statement) + rows = [r async for r in res.rows()] + if len(rows) == row_count: + break + except Exception as ex: + exc = AnalyticsTestEnvironmentError(f'Unable to execute statement={statement}. Error: {ex}') + if exc is not None: + raise exc + @classmethod def get_environment( cls, config: AnalyticsConfig, server_handler: Optional[WebServerHandler] = None, backend: Optional[str] = None diff --git a/uv.lock b/uv.lock index 5a55883..1ca1a7f 100644 --- a/uv.lock +++ b/uv.lock @@ -4,7 +4,8 @@ requires-python = ">=3.9" resolution-markers = [ "python_full_version >= '3.13'", "python_full_version >= '3.11' and python_full_version < '3.13'", - "python_full_version < '3.11'", + "python_full_version == '3.10.*'", + "python_full_version < '3.10'", ] [[package]] @@ -116,14 +117,15 @@ wheels = [ [[package]] name = "aiosignal" -version = "1.3.2" +version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "frozenlist" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ba/b5/6d55e80f6d8a08ce22b982eafa278d823b541c925f11ee774b0b9c43473d/aiosignal-1.3.2.tar.gz", hash = "sha256:a8c255c66fafb1e499c9351d0bf32ff2d8a0321595ebac3b93713656d2436f54", size = 19424, upload-time = "2024-12-13T17:10:40.86Z" } +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/6a/bc7e17a3e87a2985d3e8f4da4cd0f481060eb78fb08596c42be62c90a4d9/aiosignal-1.3.2-py2.py3-none-any.whl", hash = "sha256:45cde58e409a301715980c2b01d0c28bdde3770d8290b5eb2173759d9acb31a5", size = 7597, upload-time = "2024-12-13T17:10:38.469Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, ] [[package]] @@ -250,11 +252,11 @@ filecache = [ [[package]] name = "certifi" -version = "2025.6.15" +version = "2025.7.14" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/73/f7/f14b46d4bcd21092d7d3ccef689615220d8a08fb25e564b65d20738e672e/certifi-2025.6.15.tar.gz", hash = "sha256:d747aa5a8b9bbbb1bb8c22bb13e22bd1f18e9796defa16bab421f7f7a317323b", size = 158753, upload-time = "2025-06-15T02:45:51.329Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/76/52c535bcebe74590f296d6c77c86dabf761c41980e1347a2422e4aa2ae41/certifi-2025.7.14.tar.gz", hash = "sha256:8ea99dbdfaaf2ba2f9bac77b9249ef62ec5218e7c2b2e903378ed5fccf765995", size = 163981, upload-time = "2025-07-14T03:29:28.449Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/84/ae/320161bd181fc06471eed047ecce67b693fd7515b16d495d8932db763426/certifi-2025.6.15-py3-none-any.whl", hash = "sha256:2e0c7ce7cb5d8f8634ca55d2ba7e6ec2689a2fd6537d8dec1296a477a4910057", size = 157650, upload-time = "2025-06-15T02:45:49.977Z" }, + { url = "https://files.pythonhosted.org/packages/4f/52/34c6cf5bb9285074dc3531c437b3919e825d976fde097a7a73f79e726d03/certifi-2025.7.14-py3-none-any.whl", hash = "sha256:6b31f564a415d79ee77df69d757bb49a5bb53bd9f756cbbe24394ffd6fc1f4b2", size = 162722, upload-time = "2025-07-14T03:29:26.863Z" }, ] [[package]] @@ -383,7 +385,7 @@ sphinx = [ requires-dist = [ { name = "anyio", specifier = "~=4.9.0" }, { name = "httpx", specifier = "~=0.28.1" }, - { name = "ijson", specifier = "~=3.3.0" }, + { name = "ijson", specifier = "~=3.4.0" }, { name = "sniffio", specifier = "~=1.3.1" }, { name = "typing-extensions", marker = "python_full_version < '3.11'", specifier = "~=4.11" }, ] @@ -433,11 +435,11 @@ wheels = [ [[package]] name = "distlib" -version = "0.3.9" +version = "0.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0d/dd/1bec4c5ddb504ca60fc29472f3d27e8d4da1257a854e1d96742f15c1d02d/distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403", size = 613923, upload-time = "2024-10-09T18:35:47.551Z" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/91/a1/cf2472db20f7ce4a6be1253a81cfdf85ad9c7885ffbed7047fb72c24cf87/distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87", size = 468973, upload-time = "2024-10-09T18:35:44.272Z" }, + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, ] [[package]] @@ -480,7 +482,7 @@ name = "exceptiongroup" version = "1.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } wheels = [ @@ -677,64 +679,94 @@ wheels = [ [[package]] name = "ijson" -version = "3.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6c/83/28e9e93a3a61913e334e3a2e78ea9924bb9f9b1ac45898977f9d9dd6133f/ijson-3.3.0.tar.gz", hash = "sha256:7f172e6ba1bee0d4c8f8ebd639577bfe429dee0f3f96775a067b8bae4492d8a0", size = 60079, upload-time = "2024-06-06T08:37:13.026Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ad/89/96e3608499b4a500b9bc27aa8242704e675849dd65bdfa8682b00a92477e/ijson-3.3.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7f7a5250599c366369fbf3bc4e176f5daa28eb6bc7d6130d02462ed335361675", size = 85009, upload-time = "2024-06-06T08:34:37.172Z" }, - { url = "https://files.pythonhosted.org/packages/e4/7e/1098503500f5316c5f7912a51c91aca5cbc609c09ce4ecd9c4809983c560/ijson-3.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f87a7e52f79059f9c58f6886c262061065eb6f7554a587be7ed3aa63e6b71b34", size = 57796, upload-time = "2024-06-06T08:34:39.35Z" }, - { url = "https://files.pythonhosted.org/packages/78/f7/27b8c27a285628719ff55b68507581c86b551eb162ce810fe51e3e1a25f2/ijson-3.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b73b493af9e947caed75d329676b1b801d673b17481962823a3e55fe529c8b8b", size = 57218, upload-time = "2024-06-06T08:34:41.651Z" }, - { url = "https://files.pythonhosted.org/packages/0c/c5/1698094cb6a336a223c30e1167cc1b15cdb4bfa75399c1a2eb82fa76cc3c/ijson-3.3.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d5576415f3d76290b160aa093ff968f8bf6de7d681e16e463a0134106b506f49", size = 117153, upload-time = "2024-06-06T08:34:43.463Z" }, - { url = "https://files.pythonhosted.org/packages/4b/21/c206dda0945bd832cc9b0894596b0efc2cb1819a0ac61d8be1429ac09494/ijson-3.3.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4e9ffe358d5fdd6b878a8a364e96e15ca7ca57b92a48f588378cef315a8b019e", size = 110781, upload-time = "2024-06-06T08:34:45.412Z" }, - { url = "https://files.pythonhosted.org/packages/f4/f5/2d733e64577109a9b255d14d031e44a801fa20df9ccc58b54a31e8ecf9e6/ijson-3.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8643c255a25824ddd0895c59f2319c019e13e949dc37162f876c41a283361527", size = 114527, upload-time = "2024-06-06T08:34:46.66Z" }, - { url = "https://files.pythonhosted.org/packages/8d/a8/78bfee312aa23417b86189a65f30b0edbceaee96dc6a616cc15f611187d1/ijson-3.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:df3ab5e078cab19f7eaeef1d5f063103e1ebf8c26d059767b26a6a0ad8b250a3", size = 116824, upload-time = "2024-06-06T08:34:48.471Z" }, - { url = "https://files.pythonhosted.org/packages/5d/a4/aff410f7d6aa1a77ee2ab2d6a2d2758422726270cb149c908a9baf33cf58/ijson-3.3.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3dc1fb02c6ed0bae1b4bf96971258bf88aea72051b6e4cebae97cff7090c0607", size = 112647, upload-time = "2024-06-06T08:34:50.339Z" }, - { url = "https://files.pythonhosted.org/packages/77/ee/2b5122dc4713f5a954267147da36e7156240ca21b04ed5295bc0cabf0fbe/ijson-3.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e9afd97339fc5a20f0542c971f90f3ca97e73d3050cdc488d540b63fae45329a", size = 114156, upload-time = "2024-06-06T08:34:51.598Z" }, - { url = "https://files.pythonhosted.org/packages/b3/d7/ad3b266490b60c6939e8a07fd8e4b7e2002aea08eaa9572a016c3e3a9129/ijson-3.3.0-cp310-cp310-win32.whl", hash = "sha256:844c0d1c04c40fd1b60f148dc829d3f69b2de789d0ba239c35136efe9a386529", size = 48931, upload-time = "2024-06-06T08:34:53.995Z" }, - { url = "https://files.pythonhosted.org/packages/0b/68/b9e1c743274c8a23dddb12d2ed13b5f021f6d21669d51ff7fa2e9e6c19df/ijson-3.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:d654d045adafdcc6c100e8e911508a2eedbd2a1b5f93f930ba13ea67d7704ee9", size = 50965, upload-time = "2024-06-06T08:34:55.206Z" }, - { url = "https://files.pythonhosted.org/packages/fd/df/565ba72a6f4b2c833d051af8e2228cfa0b1fef17bb44995c00ad27470c52/ijson-3.3.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:501dce8eaa537e728aa35810656aa00460a2547dcb60937c8139f36ec344d7fc", size = 85041, upload-time = "2024-06-06T08:34:56.479Z" }, - { url = "https://files.pythonhosted.org/packages/f0/42/1361eaa57ece921d0239881bae6a5e102333be5b6e0102a05ec3caadbd5a/ijson-3.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:658ba9cad0374d37b38c9893f4864f284cdcc7d32041f9808fba8c7bcaadf134", size = 57829, upload-time = "2024-06-06T08:34:57.632Z" }, - { url = "https://files.pythonhosted.org/packages/f5/b0/143dbfe12e1d1303ea8d8cd6f40e95cea8f03bcad5b79708614a7856c22e/ijson-3.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2636cb8c0f1023ef16173f4b9a233bcdb1df11c400c603d5f299fac143ca8d70", size = 57217, upload-time = "2024-06-06T08:34:59.397Z" }, - { url = "https://files.pythonhosted.org/packages/0d/80/b3b60c5e5be2839365b03b915718ca462c544fdc71e7a79b7262837995ef/ijson-3.3.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cd174b90db68c3bcca273e9391934a25d76929d727dc75224bf244446b28b03b", size = 121878, upload-time = "2024-06-06T08:35:01.024Z" }, - { url = "https://files.pythonhosted.org/packages/8d/eb/7560fafa4d40412efddf690cb65a9bf2d3429d6035e544103acbf5561dc4/ijson-3.3.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:97a9aea46e2a8371c4cf5386d881de833ed782901ac9f67ebcb63bb3b7d115af", size = 115620, upload-time = "2024-06-06T08:35:02.896Z" }, - { url = "https://files.pythonhosted.org/packages/51/2b/5a34c7841388dce161966e5286931518de832067cd83e6f003d93271e324/ijson-3.3.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c594c0abe69d9d6099f4ece17763d53072f65ba60b372d8ba6de8695ce6ee39e", size = 119200, upload-time = "2024-06-06T08:35:06.291Z" }, - { url = "https://files.pythonhosted.org/packages/3e/b7/1d64fbec0d0a7b0c02e9ad988a89614532028ead8bb52a2456c92e6ee35a/ijson-3.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8e0ff16c224d9bfe4e9e6bd0395826096cda4a3ef51e6c301e1b61007ee2bd24", size = 121107, upload-time = "2024-06-06T08:35:08.261Z" }, - { url = "https://files.pythonhosted.org/packages/d4/b9/01044f09850bc545ffc85b35aaec473d4f4ca2b6667299033d252c1b60dd/ijson-3.3.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0015354011303175eae7e2ef5136414e91de2298e5a2e9580ed100b728c07e51", size = 116658, upload-time = "2024-06-06T08:35:09.99Z" }, - { url = "https://files.pythonhosted.org/packages/fb/0d/53856b61f3d952d299d1695c487e8e28058d01fa2adfba3d6d4b4660c242/ijson-3.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:034642558afa57351a0ffe6de89e63907c4cf6849070cc10a3b2542dccda1afe", size = 118186, upload-time = "2024-06-06T08:35:11.561Z" }, - { url = "https://files.pythonhosted.org/packages/95/2d/5bd86e2307dd594840ee51c4e32de953fee837f028acf0f6afb08914cd06/ijson-3.3.0-cp311-cp311-win32.whl", hash = "sha256:192e4b65495978b0bce0c78e859d14772e841724d3269fc1667dc6d2f53cc0ea", size = 48938, upload-time = "2024-06-06T08:35:13.212Z" }, - { url = "https://files.pythonhosted.org/packages/55/e1/4ba2b65b87f67fb19d698984d92635e46d9ce9dd748ce7d009441a586710/ijson-3.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:72e3488453754bdb45c878e31ce557ea87e1eb0f8b4fc610373da35e8074ce42", size = 50972, upload-time = "2024-06-06T08:35:14.698Z" }, - { url = "https://files.pythonhosted.org/packages/8a/4d/3992f7383e26a950e02dc704bc6c5786a080d5c25fe0fc5543ef477c1883/ijson-3.3.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:988e959f2f3d59ebd9c2962ae71b97c0df58323910d0b368cc190ad07429d1bb", size = 84550, upload-time = "2024-06-06T08:35:16.756Z" }, - { url = "https://files.pythonhosted.org/packages/1b/cc/3d4372e0d0b02a821b982f1fdf10385512dae9b9443c1597719dd37769a9/ijson-3.3.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b2f73f0d0fce5300f23a1383d19b44d103bb113b57a69c36fd95b7c03099b181", size = 57572, upload-time = "2024-06-06T08:35:18.077Z" }, - { url = "https://files.pythonhosted.org/packages/02/de/970d48b1ff9da5d9513c86fdd2acef5cb3415541c8069e0d92a151b84adb/ijson-3.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0ee57a28c6bf523d7cb0513096e4eb4dac16cd935695049de7608ec110c2b751", size = 56902, upload-time = "2024-06-06T08:35:20.065Z" }, - { url = "https://files.pythonhosted.org/packages/5e/a0/4537722c8b3b05e82c23dfe09a3a64dd1e44a013a5ca58b1e77dfe48b2f1/ijson-3.3.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e0155a8f079c688c2ccaea05de1ad69877995c547ba3d3612c1c336edc12a3a5", size = 127400, upload-time = "2024-06-06T08:35:21.81Z" }, - { url = "https://files.pythonhosted.org/packages/b2/96/54956062a99cf49f7a7064b573dcd756da0563ce57910dc34e27a473d9b9/ijson-3.3.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7ab00721304af1ae1afa4313ecfa1bf16b07f55ef91e4a5b93aeaa3e2bd7917c", size = 118786, upload-time = "2024-06-06T08:35:23.496Z" }, - { url = "https://files.pythonhosted.org/packages/07/74/795319531c5b5504508f595e631d592957f24bed7ff51a15bc4c61e7b24c/ijson-3.3.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40ee3821ee90be0f0e95dcf9862d786a7439bd1113e370736bfdf197e9765bfb", size = 126288, upload-time = "2024-06-06T08:35:25.473Z" }, - { url = "https://files.pythonhosted.org/packages/69/6a/e0cec06fbd98851d5d233b59058c1dc2ea767c9bb6feca41aa9164fff769/ijson-3.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:da3b6987a0bc3e6d0f721b42c7a0198ef897ae50579547b0345f7f02486898f5", size = 129569, upload-time = "2024-06-06T08:35:26.871Z" }, - { url = "https://files.pythonhosted.org/packages/2a/4f/82c0d896d8dcb175f99ced7d87705057bcd13523998b48a629b90139a0dc/ijson-3.3.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:63afea5f2d50d931feb20dcc50954e23cef4127606cc0ecf7a27128ed9f9a9e6", size = 121508, upload-time = "2024-06-06T08:35:28.236Z" }, - { url = "https://files.pythonhosted.org/packages/2b/b6/8973474eba4a917885e289d9e138267d3d1f052c2d93b8c968755661a42d/ijson-3.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b5c3e285e0735fd8c5a26d177eca8b52512cdd8687ca86ec77a0c66e9c510182", size = 127896, upload-time = "2024-06-06T08:35:29.61Z" }, - { url = "https://files.pythonhosted.org/packages/94/25/00e66af887adbbe70002e0479c3c2340bdfa17a168e25d4ab5a27b53582d/ijson-3.3.0-cp312-cp312-win32.whl", hash = "sha256:907f3a8674e489abdcb0206723e5560a5cb1fa42470dcc637942d7b10f28b695", size = 49272, upload-time = "2024-06-06T08:35:31.137Z" }, - { url = "https://files.pythonhosted.org/packages/25/a2/e187beee237808b2c417109ae0f4f7ee7c81ecbe9706305d6ac2a509cc45/ijson-3.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:8f890d04ad33262d0c77ead53c85f13abfb82f2c8f078dfbf24b78f59534dfdd", size = 51272, upload-time = "2024-06-06T08:35:32.38Z" }, - { url = "https://files.pythonhosted.org/packages/43/ba/d7a3259db956332f17ba93be2980db020e10c1bd01f610ff7d980b281fbd/ijson-3.3.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:3c556f5553368dff690c11d0a1fb435d4ff1f84382d904ccc2dc53beb27ba62e", size = 85069, upload-time = "2024-06-06T08:36:22.352Z" }, - { url = "https://files.pythonhosted.org/packages/a4/79/97b47b9110fc5ef92d004e615526de6d16af436e7374098004fa79242440/ijson-3.3.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e4396b55a364a03ff7e71a34828c3ed0c506814dd1f50e16ebed3fc447d5188e", size = 57818, upload-time = "2024-06-06T08:36:24.054Z" }, - { url = "https://files.pythonhosted.org/packages/9d/e7/69ddad6389f4d96c095e89c80b765189facfa2cb51f72f3b6fdfe4dcb815/ijson-3.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e6850ae33529d1e43791b30575070670070d5fe007c37f5d06aebc1dd152ab3f", size = 57228, upload-time = "2024-06-06T08:36:25.561Z" }, - { url = "https://files.pythonhosted.org/packages/88/84/ba713c8e4f13b0642d7295cc94924fb21e9f26c1fbf71d47fe16f03904f6/ijson-3.3.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:36aa56d68ea8def26778eb21576ae13f27b4a47263a7a2581ab2ef58b8de4451", size = 116369, upload-time = "2024-06-06T08:36:27.355Z" }, - { url = "https://files.pythonhosted.org/packages/a0/27/ed16f80f7be403f2e4892b1c5eecf18c5bff57cbb23c4b059b9eb0b369cc/ijson-3.3.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7ec759c4a0fc820ad5dc6a58e9c391e7b16edcb618056baedbedbb9ea3b1524", size = 109994, upload-time = "2024-06-06T08:36:29.319Z" }, - { url = "https://files.pythonhosted.org/packages/5d/90/5071a6f491663d3bf1f4f59acfc6d29ea0e0d1aa13a16f06f03fcc4f3497/ijson-3.3.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b51bab2c4e545dde93cb6d6bb34bf63300b7cd06716f195dd92d9255df728331", size = 113745, upload-time = "2024-06-06T08:36:30.75Z" }, - { url = "https://files.pythonhosted.org/packages/de/e3/e39b7a24c156a5d70c39ffb8383231593e549d2e42dda834758f3934fea8/ijson-3.3.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:92355f95a0e4da96d4c404aa3cff2ff033f9180a9515f813255e1526551298c1", size = 115930, upload-time = "2024-06-06T08:36:32.303Z" }, - { url = "https://files.pythonhosted.org/packages/f3/7a/cd669bf1c65b6b99f4d326e425ef89c02abe62abc36c134e021d8193ecfd/ijson-3.3.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:8795e88adff5aa3c248c1edce932db003d37a623b5787669ccf205c422b91e4a", size = 111869, upload-time = "2024-06-06T08:36:34.658Z" }, - { url = "https://files.pythonhosted.org/packages/dd/34/69074a83f3769f527c81952c002ae55e7c43814d1fb71621ada79f2e57b7/ijson-3.3.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:8f83f553f4cde6d3d4eaf58ec11c939c94a0ec545c5b287461cafb184f4b3a14", size = 113322, upload-time = "2024-06-06T08:36:36.603Z" }, - { url = "https://files.pythonhosted.org/packages/e3/d8/2762aac7d749ed443a7c3e25ad071fe143f21ea5f3f33e184e2cf8026c86/ijson-3.3.0-cp39-cp39-win32.whl", hash = "sha256:ead50635fb56577c07eff3e557dac39533e0fe603000684eea2af3ed1ad8f941", size = 48961, upload-time = "2024-06-06T08:36:38.009Z" }, - { url = "https://files.pythonhosted.org/packages/b0/9a/16a68841edea8168a58b200d7b46a7670349ecd35a75bcb96fd84092f603/ijson-3.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:c8a9befb0c0369f0cf5c1b94178d0d78f66d9cebb9265b36be6e4f66236076b8", size = 50985, upload-time = "2024-06-06T08:36:39.369Z" }, - { url = "https://files.pythonhosted.org/packages/c3/28/2e1cf00abe5d97aef074e7835b86a94c9a06be4629a0e2c12600792b51ba/ijson-3.3.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:2af323a8aec8a50fa9effa6d640691a30a9f8c4925bd5364a1ca97f1ac6b9b5c", size = 54308, upload-time = "2024-06-06T08:36:41.127Z" }, - { url = "https://files.pythonhosted.org/packages/04/d2/8c541c28da4f931bac8177e251efe2b6902f7c486d2d4bdd669eed4ff5c0/ijson-3.3.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f64f01795119880023ba3ce43072283a393f0b90f52b66cc0ea1a89aa64a9ccb", size = 66010, upload-time = "2024-06-06T08:36:43.079Z" }, - { url = "https://files.pythonhosted.org/packages/d0/02/8fec0b9037a368811dba7901035e8e0973ebda308f57f30c42101a16a5f7/ijson-3.3.0-pp310-pypy310_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a716e05547a39b788deaf22725490855337fc36613288aa8ae1601dc8c525553", size = 66770, upload-time = "2024-06-06T08:36:44.468Z" }, - { url = "https://files.pythonhosted.org/packages/47/23/90c61f978c83647112460047ea0137bde9c7fe26600ce255bb3e17ea7a21/ijson-3.3.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:473f5d921fadc135d1ad698e2697025045cd8ed7e5e842258295012d8a3bc702", size = 64159, upload-time = "2024-06-06T08:36:45.887Z" }, - { url = "https://files.pythonhosted.org/packages/20/af/aab1a36072590af62d848f03981f1c587ca40a391fc61e418e388d8b0d46/ijson-3.3.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:dd26b396bc3a1e85f4acebeadbf627fa6117b97f4c10b177d5779577c6607744", size = 51095, upload-time = "2024-06-06T08:36:47.414Z" }, - { url = "https://files.pythonhosted.org/packages/ee/38/7e1988ff3b6eb4fc9f3639ac7bbb7ae3a37d574f212635e3bf0106b6d78d/ijson-3.3.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:891f95c036df1bc95309951940f8eea8537f102fa65715cdc5aae20b8523813b", size = 54336, upload-time = "2024-06-06T08:37:04.942Z" }, - { url = "https://files.pythonhosted.org/packages/e6/8d/556e94b4f7e0c68a35597036ad9329b3edadfc6da260c749e2b55b310798/ijson-3.3.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed1336a2a6e5c427f419da0154e775834abcbc8ddd703004108121c6dd9eba9d", size = 66028, upload-time = "2024-06-06T08:37:06.648Z" }, - { url = "https://files.pythonhosted.org/packages/ba/bb/3ef5b0298e8e4524ed9aa338ec224cb159b5f9b8cace05be3a6c5c01bd10/ijson-3.3.0-pp39-pypy39_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f0c819f83e4f7b7f7463b2dc10d626a8be0c85fbc7b3db0edc098c2b16ac968e", size = 66796, upload-time = "2024-06-06T08:37:08.104Z" }, - { url = "https://files.pythonhosted.org/packages/2e/c1/d1507639ad7a9f1673a16a6e0993524a65d85e4f65cde1097039c3dfdaba/ijson-3.3.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33afc25057377a6a43c892de34d229a86f89ea6c4ca3dd3db0dcd17becae0dbb", size = 64215, upload-time = "2024-06-06T08:37:09.81Z" }, - { url = "https://files.pythonhosted.org/packages/1b/36/92ea416ff6383e66d83a576347b7edd9b0aa22cd3bd16c42dbb3608a105b/ijson-3.3.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7914d0cf083471856e9bc2001102a20f08e82311dfc8cf1a91aa422f9414a0d6", size = 51107, upload-time = "2024-06-06T08:37:11.494Z" }, +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/4f/1cfeada63f5fce87536651268ddf5cca79b8b4bbb457aee4e45777964a0a/ijson-3.4.0.tar.gz", hash = "sha256:5f74dcbad9d592c428d3ca3957f7115a42689ee7ee941458860900236ae9bb13", size = 65782, upload-time = "2025-05-08T02:37:20.135Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/6b/a247ba44004154aaa71f9e6bd9f05ba412f490cc4043618efb29314f035e/ijson-3.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e27e50f6dcdee648f704abc5d31b976cd2f90b4642ed447cf03296d138433d09", size = 87609, upload-time = "2025-05-08T02:35:20.535Z" }, + { url = "https://files.pythonhosted.org/packages/3c/1d/8d2009d74373b7dec2a49b1167e396debb896501396c70a674bb9ccc41ff/ijson-3.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2a753be681ac930740a4af9c93cfb4edc49a167faed48061ea650dc5b0f406f1", size = 59243, upload-time = "2025-05-08T02:35:21.958Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b2/a85a21ebaba81f64a326c303a94625fb94b84890c52d9efdd8acb38b6312/ijson-3.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a07c47aed534e0ec198e6a2d4360b259d32ac654af59c015afc517ad7973b7fb", size = 59309, upload-time = "2025-05-08T02:35:23.317Z" }, + { url = "https://files.pythonhosted.org/packages/b1/35/273dfa1f27c38eeaba105496ecb54532199f76c0120177b28315daf5aec3/ijson-3.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9c55f48181e11c597cd7146fb31edc8058391201ead69f8f40d2ecbb0b3e4fc6", size = 131213, upload-time = "2025-05-08T02:35:24.735Z" }, + { url = "https://files.pythonhosted.org/packages/4d/37/9d3bb0e200a103ca9f8e9315c4d96ecaca43a3c1957c1ac069ea9dc9c6ba/ijson-3.4.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:abd5669f96f79d8a2dd5ae81cbd06770a4d42c435fd4a75c74ef28d9913b697d", size = 125456, upload-time = "2025-05-08T02:35:25.896Z" }, + { url = "https://files.pythonhosted.org/packages/00/54/8f015c4df30200fd14435dec9c67bf675dff0fee44a16c084a8ec0f82922/ijson-3.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e3ddd46d16b8542c63b1b8af7006c758d4e21cc1b86122c15f8530fae773461", size = 130192, upload-time = "2025-05-08T02:35:27.367Z" }, + { url = "https://files.pythonhosted.org/packages/88/01/46a0540ad3461332edcc689a8874fa13f0a4c00f60f02d155b70e36f5e0b/ijson-3.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1504cec7fe04be2bb0cc33b50c9dd3f83f98c0540ad4991d4017373b7853cfe6", size = 132217, upload-time = "2025-05-08T02:35:28.545Z" }, + { url = "https://files.pythonhosted.org/packages/d7/da/8f8df42f3fd7ef279e20eae294738eed62d41ed5b6a4baca5121abc7cf0f/ijson-3.4.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:2f2ff456adeb216603e25d7915f10584c1b958b6eafa60038d76d08fc8a5fb06", size = 127118, upload-time = "2025-05-08T02:35:29.726Z" }, + { url = "https://files.pythonhosted.org/packages/82/0a/a410d9d3b082cc2ec9738d54935a589974cbe54c0f358e4d17465594d660/ijson-3.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:0ab00d75d61613a125fbbb524551658b1ad6919a52271ca16563ca5bc2737bb1", size = 129808, upload-time = "2025-05-08T02:35:31.247Z" }, + { url = "https://files.pythonhosted.org/packages/2e/c6/a3e2a446b8bd2cf91cb4ca7439f128d2b379b5a79794d0ea25e379b0f4f3/ijson-3.4.0-cp310-cp310-win32.whl", hash = "sha256:ada421fd59fe2bfa4cfa64ba39aeba3f0753696cdcd4d50396a85f38b1d12b01", size = 51160, upload-time = "2025-05-08T02:35:32.964Z" }, + { url = "https://files.pythonhosted.org/packages/18/7c/e6620603df42d2ef8a92076eaa5cd2b905366e86e113adf49e7b79970bd3/ijson-3.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:8c75e82cec05d00ed3a4af5f4edf08f59d536ed1a86ac7e84044870872d82a33", size = 53710, upload-time = "2025-05-08T02:35:34.033Z" }, + { url = "https://files.pythonhosted.org/packages/1a/0d/3e2998f4d7b7d2db2d511e4f0cf9127b6e2140c325c3cb77be46ae46ff1d/ijson-3.4.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9e369bf5a173ca51846c243002ad8025d32032532523b06510881ecc8723ee54", size = 87643, upload-time = "2025-05-08T02:35:35.693Z" }, + { url = "https://files.pythonhosted.org/packages/e9/7b/afef2b08af2fee5ead65fcd972fadc3e31f9ae2b517fe2c378d50a9bf79b/ijson-3.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:26e7da0a3cd2a56a1fde1b34231867693f21c528b683856f6691e95f9f39caec", size = 59260, upload-time = "2025-05-08T02:35:37.166Z" }, + { url = "https://files.pythonhosted.org/packages/da/4a/39f583a2a13096f5063028bb767622f09cafc9ec254c193deee6c80af59f/ijson-3.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1c28c7f604729be22aa453e604e9617b665fa0c24cd25f9f47a970e8130c571a", size = 59311, upload-time = "2025-05-08T02:35:38.538Z" }, + { url = "https://files.pythonhosted.org/packages/3c/58/5b80efd54b093e479c98d14b31d7794267281f6a8729f2c94fbfab661029/ijson-3.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0bed8bcb84d3468940f97869da323ba09ae3e6b950df11dea9b62e2b231ca1e3", size = 136125, upload-time = "2025-05-08T02:35:39.976Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f5/f37659b1647ecc3992216277cd8a45e2194e84e8818178f77c99e1d18463/ijson-3.4.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:296bc824f4088f2af814aaf973b0435bc887ce3d9f517b1577cc4e7d1afb1cb7", size = 130699, upload-time = "2025-05-08T02:35:41.483Z" }, + { url = "https://files.pythonhosted.org/packages/ee/2f/4c580ac4bb5eda059b672ad0a05e4bafdae5182a6ec6ab43546763dafa91/ijson-3.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8145f8f40617b6a8aa24e28559d0adc8b889e56a203725226a8a60fa3501073f", size = 134963, upload-time = "2025-05-08T02:35:43.017Z" }, + { url = "https://files.pythonhosted.org/packages/6d/9e/64ec39718609faab6ed6e1ceb44f9c35d71210ad9c87fff477c03503e8f8/ijson-3.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b674a97bd503ea21bc85103e06b6493b1b2a12da3372950f53e1c664566a33a4", size = 137405, upload-time = "2025-05-08T02:35:44.618Z" }, + { url = "https://files.pythonhosted.org/packages/71/b2/f0bf0e4a0962845597996de6de59c0078bc03a1f899e03908220039f4cf6/ijson-3.4.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8bc731cf1c3282b021d3407a601a5a327613da9ad3c4cecb1123232623ae1826", size = 131861, upload-time = "2025-05-08T02:35:46.22Z" }, + { url = "https://files.pythonhosted.org/packages/17/83/4a2e3611e2b4842b413ec84d2e54adea55ab52e4408ea0f1b1b927e19536/ijson-3.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:42ace5e940e0cf58c9de72f688d6829ddd815096d07927ee7e77df2648006365", size = 134297, upload-time = "2025-05-08T02:35:47.401Z" }, + { url = "https://files.pythonhosted.org/packages/38/75/2d332911ac765b44cd7da0cb2b06143521ad5e31dfcc8d8587e6e6168bc8/ijson-3.4.0-cp311-cp311-win32.whl", hash = "sha256:5be39a0df4cd3f02b304382ea8885391900ac62e95888af47525a287c50005e9", size = 51161, upload-time = "2025-05-08T02:35:49.164Z" }, + { url = "https://files.pythonhosted.org/packages/7d/ba/4ad571f9f7fcf5906b26e757b130c1713c5f0198a1e59568f05d53a0816c/ijson-3.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:0b1be1781792291e70d2e177acf564ec672a7907ba74f313583bdf39fe81f9b7", size = 53710, upload-time = "2025-05-08T02:35:50.323Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ec/317ee5b2d13e50448833ead3aa906659a32b376191f6abc2a7c6112d2b27/ijson-3.4.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:956b148f88259a80a9027ffbe2d91705fae0c004fbfba3e5a24028fbe72311a9", size = 87212, upload-time = "2025-05-08T02:35:51.835Z" }, + { url = "https://files.pythonhosted.org/packages/f8/43/b06c96ced30cacecc5d518f89b0fd1c98c294a30ff88848b70ed7b7f72a1/ijson-3.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:06b89960f5c721106394c7fba5760b3f67c515b8eb7d80f612388f5eca2f4621", size = 59175, upload-time = "2025-05-08T02:35:52.988Z" }, + { url = "https://files.pythonhosted.org/packages/e9/df/b4aeafb7ecde463130840ee9be36130823ec94a00525049bf700883378b8/ijson-3.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9a0bb591cf250dd7e9dfab69d634745a7f3272d31cfe879f9156e0a081fd97ee", size = 59011, upload-time = "2025-05-08T02:35:54.394Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/a80b8e361641609507f62022089626d4b8067f0826f51e1c09e4ba86eba8/ijson-3.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:72e92de999977f4c6b660ffcf2b8d59604ccd531edcbfde05b642baf283e0de8", size = 146094, upload-time = "2025-05-08T02:35:55.601Z" }, + { url = "https://files.pythonhosted.org/packages/01/44/fa416347b9a802e3646c6ff377fc3278bd7d6106e17beb339514b6a3184e/ijson-3.4.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9e9602157a5b869d44b6896e64f502c712a312fcde044c2e586fccb85d3e316e", size = 137903, upload-time = "2025-05-08T02:35:56.814Z" }, + { url = "https://files.pythonhosted.org/packages/24/c6/41a9ad4d42df50ff6e70fdce79b034f09b914802737ebbdc141153d8d791/ijson-3.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1e83660edb931a425b7ff662eb49db1f10d30ca6d4d350e5630edbed098bc01", size = 148339, upload-time = "2025-05-08T02:35:58.595Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/7d01efda415b8502dce67e067ed9e8a124f53e763002c02207e542e1a2f1/ijson-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:49bf8eac1c7b7913073865a859c215488461f7591b4fa6a33c14b51cb73659d0", size = 149383, upload-time = "2025-05-08T02:36:00.197Z" }, + { url = "https://files.pythonhosted.org/packages/95/6c/0d67024b9ecb57916c5e5ab0350251c9fe2f86dc9c8ca2b605c194bdad6a/ijson-3.4.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:160b09273cb42019f1811469508b0a057d19f26434d44752bde6f281da6d3f32", size = 141580, upload-time = "2025-05-08T02:36:01.998Z" }, + { url = "https://files.pythonhosted.org/packages/06/43/e10edcc1c6a3b619294de835e7678bfb3a1b8a75955f3689fd66a1e9e7b4/ijson-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2019ff4e6f354aa00c76c8591bd450899111c61f2354ad55cc127e2ce2492c44", size = 150280, upload-time = "2025-05-08T02:36:03.926Z" }, + { url = "https://files.pythonhosted.org/packages/07/84/1cbeee8e8190a1ebe6926569a92cf1fa80ddb380c129beb6f86559e1bb24/ijson-3.4.0-cp312-cp312-win32.whl", hash = "sha256:931c007bf6bb8330705429989b2deed6838c22b63358a330bf362b6e458ba0bf", size = 51512, upload-time = "2025-05-08T02:36:05.595Z" }, + { url = "https://files.pythonhosted.org/packages/66/13/530802bc391c95be6fe9f96e9aa427d94067e7c0b7da7a9092344dc44c4b/ijson-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:71523f2b64cb856a820223e94d23e88369f193017ecc789bb4de198cc9d349eb", size = 54081, upload-time = "2025-05-08T02:36:07.099Z" }, + { url = "https://files.pythonhosted.org/packages/77/b3/b1d2eb2745e5204ec7a25365a6deb7868576214feb5e109bce368fb692c9/ijson-3.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e8d96f88d75196a61c9d9443de2b72c2d4a7ba9456ff117b57ae3bba23a54256", size = 87216, upload-time = "2025-05-08T02:36:08.414Z" }, + { url = "https://files.pythonhosted.org/packages/b1/cd/cd6d340087617f8cc9bedbb21d974542fe2f160ed0126b8288d3499a469b/ijson-3.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c45906ce2c1d3b62f15645476fc3a6ca279549127f01662a39ca5ed334a00cf9", size = 59170, upload-time = "2025-05-08T02:36:09.604Z" }, + { url = "https://files.pythonhosted.org/packages/3e/4d/32d3a9903b488d3306e3c8288f6ee4217d2eea82728261db03a1045eb5d1/ijson-3.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4ab4bc2119b35c4363ea49f29563612237cae9413d2fbe54b223be098b97bc9e", size = 59013, upload-time = "2025-05-08T02:36:10.696Z" }, + { url = "https://files.pythonhosted.org/packages/d5/c8/db15465ab4b0b477cee5964c8bfc94bf8c45af8e27a23e1ad78d1926e587/ijson-3.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:97b0a9b5a15e61dfb1f14921ea4e0dba39f3a650df6d8f444ddbc2b19b479ff1", size = 146564, upload-time = "2025-05-08T02:36:11.916Z" }, + { url = "https://files.pythonhosted.org/packages/c4/d8/0755545bc122473a9a434ab90e0f378780e603d75495b1ca3872de757873/ijson-3.4.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e3047bb994dabedf11de11076ed1147a307924b6e5e2df6784fb2599c4ad8c60", size = 137917, upload-time = "2025-05-08T02:36:13.532Z" }, + { url = "https://files.pythonhosted.org/packages/d0/c6/aeb89c8939ebe3f534af26c8c88000c5e870dbb6ae33644c21a4531f87d2/ijson-3.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68c83161b052e9f5dc8191acbc862bb1e63f8a35344cb5cd0db1afd3afd487a6", size = 148897, upload-time = "2025-05-08T02:36:14.813Z" }, + { url = "https://files.pythonhosted.org/packages/be/0e/7ef6e9b372106f2682a4a32b3c65bf86bb471a1670e4dac242faee4a7d3f/ijson-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1eebd9b6c20eb1dffde0ae1f0fbb4aeacec2eb7b89adb5c7c0449fc9fd742760", size = 149711, upload-time = "2025-05-08T02:36:16.476Z" }, + { url = "https://files.pythonhosted.org/packages/d1/5d/9841c3ed75bcdabf19b3202de5f862a9c9c86ce5c7c9d95fa32347fdbf5f/ijson-3.4.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:13fb6d5c35192c541421f3ee81239d91fc15a8d8f26c869250f941f4b346a86c", size = 141691, upload-time = "2025-05-08T02:36:18.044Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d2/ce74e17218dba292e9be10a44ed0c75439f7958cdd263adb0b5b92d012d5/ijson-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:28b7196ff7b37c4897c547a28fa4876919696739fc91c1f347651c9736877c69", size = 150738, upload-time = "2025-05-08T02:36:19.483Z" }, + { url = "https://files.pythonhosted.org/packages/4e/43/dcc480f94453b1075c9911d4755b823f3ace275761bb37b40139f22109ca/ijson-3.4.0-cp313-cp313-win32.whl", hash = "sha256:3c2691d2da42629522140f77b99587d6f5010440d58d36616f33bc7bdc830cc3", size = 51512, upload-time = "2025-05-08T02:36:20.99Z" }, + { url = "https://files.pythonhosted.org/packages/35/dd/d8c5f15efd85ba51e6e11451ebe23d779361a9ec0d192064c2a8c3cdfcb8/ijson-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:c4554718c275a044c47eb3874f78f2c939f300215d9031e785a6711cc51b83fc", size = 54074, upload-time = "2025-05-08T02:36:22.075Z" }, + { url = "https://files.pythonhosted.org/packages/79/73/24ad8cd106203419c4d22bed627e02e281d66b83e91bc206a371893d0486/ijson-3.4.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:915a65e3f3c0eee2ea937bc62aaedb6c14cc1e8f0bb9f3f4fb5a9e2bbfa4b480", size = 91694, upload-time = "2025-05-08T02:36:23.289Z" }, + { url = "https://files.pythonhosted.org/packages/17/2d/f7f680984bcb7324a46a4c2df3bd73cf70faef0acfeb85a3f811abdfd590/ijson-3.4.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:afbe9748707684b6c5adc295c4fdcf27765b300aec4d484e14a13dca4e5c0afa", size = 61390, upload-time = "2025-05-08T02:36:24.42Z" }, + { url = "https://files.pythonhosted.org/packages/09/a1/f3ca7bab86f95bdb82494739e71d271410dfefce4590785d511669127145/ijson-3.4.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d823f8f321b4d8d5fa020d0a84f089fec5d52b7c0762430476d9f8bf95bbc1a9", size = 61140, upload-time = "2025-05-08T02:36:26.708Z" }, + { url = "https://files.pythonhosted.org/packages/51/79/dd340df3d4fc7771c95df29997956b92ed0570fe7b616d1792fea9ad93f2/ijson-3.4.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b8a0a2c54f3becf76881188beefd98b484b1d3bd005769a740d5b433b089fa23", size = 214739, upload-time = "2025-05-08T02:36:27.973Z" }, + { url = "https://files.pythonhosted.org/packages/59/f0/85380b7f51d1f5fb7065d76a7b623e02feca920cc678d329b2eccc0011e0/ijson-3.4.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ced19a83ab09afa16257a0b15bc1aa888dbc555cb754be09d375c7f8d41051f2", size = 198338, upload-time = "2025-05-08T02:36:29.496Z" }, + { url = "https://files.pythonhosted.org/packages/a5/cd/313264cf2ec42e0f01d198c49deb7b6fadeb793b3685e20e738eb6b3fa13/ijson-3.4.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8100f9885eff1f38d35cef80ef759a1bbf5fc946349afa681bd7d0e681b7f1a0", size = 207515, upload-time = "2025-05-08T02:36:30.981Z" }, + { url = "https://files.pythonhosted.org/packages/12/94/bf14457aa87ea32641f2db577c9188ef4e4ae373478afef422b31fc7f309/ijson-3.4.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d7bcc3f7f21b0f703031ecd15209b1284ea51b2a329d66074b5261de3916c1eb", size = 210081, upload-time = "2025-05-08T02:36:32.403Z" }, + { url = "https://files.pythonhosted.org/packages/7d/b4/eaee39e290e40e52d665db9bd1492cfdce86bd1e47948e0440db209c6023/ijson-3.4.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2dcb190227b09dd171bdcbfe4720fddd574933c66314818dfb3960c8a6246a77", size = 199253, upload-time = "2025-05-08T02:36:33.861Z" }, + { url = "https://files.pythonhosted.org/packages/c5/9c/e09c7b9ac720a703ab115b221b819f149ed54c974edfff623c1e925e57da/ijson-3.4.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:eda4cfb1d49c6073a901735aaa62e39cb7ab47f3ad7bb184862562f776f1fa8a", size = 203816, upload-time = "2025-05-08T02:36:35.348Z" }, + { url = "https://files.pythonhosted.org/packages/7c/14/acd304f412e32d16a2c12182b9d78206bb0ae35354d35664f45db05c1b3b/ijson-3.4.0-cp313-cp313t-win32.whl", hash = "sha256:0772638efa1f3b72b51736833404f1cbd2f5beeb9c1a3d392e7d385b9160cba7", size = 53760, upload-time = "2025-05-08T02:36:36.608Z" }, + { url = "https://files.pythonhosted.org/packages/2f/24/93dd0a467191590a5ed1fc2b35842bca9d09900d001e00b0b497c0208ef6/ijson-3.4.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3d8a0d67f36e4fb97c61a724456ef0791504b16ce6f74917a31c2e92309bbeb9", size = 56948, upload-time = "2025-05-08T02:36:37.849Z" }, + { url = "https://files.pythonhosted.org/packages/77/bc/a6777b5c3505b12fa9c5c0b9b3601418ae664653b032697ff465a4ecf508/ijson-3.4.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8a990401dc7350c1739f42187823e68d2ef6964b55040c6e9f3a29461f9929e2", size = 87662, upload-time = "2025-05-08T02:36:39.378Z" }, + { url = "https://files.pythonhosted.org/packages/eb/89/adc0ac5c24fc6524d52893d951a66120416ced4ceee9fa53de649624fa5d/ijson-3.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:80f50e0f5da4cd6b65e2d8ff38cb61b26559608a05dd3a3f9cfa6f19848e6f22", size = 59262, upload-time = "2025-05-08T02:36:40.8Z" }, + { url = "https://files.pythonhosted.org/packages/ea/c4/22e4eb1c12dde0a1c59ff321793ca8b796d85fa2ff638ec06a8e66f98b02/ijson-3.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:2d9ca52f5650d820a2e7aa672dea1c560f609e165337e5b3ed7cf56d696bf309", size = 59323, upload-time = "2025-05-08T02:36:41.95Z" }, + { url = "https://files.pythonhosted.org/packages/c5/64/83457822e41fb9ecaf36e50d149978c4bf693cc9e14a72a34afe6ca5d133/ijson-3.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:940c8c5fd20fb89b56dde9194a4f1c7b779149f1ab26af6d8dc1da51a95d26dd", size = 130202, upload-time = "2025-05-08T02:36:43.202Z" }, + { url = "https://files.pythonhosted.org/packages/9e/a0/ce14ccfcddb039c115fc879380695bad5e8d8f3ba092454df5cb6ed4771c/ijson-3.4.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:41dbb525666017ad856ac9b4f0f4b87d3e56b7dfde680d5f6d123556b22e2172", size = 124547, upload-time = "2025-05-08T02:36:44.761Z" }, + { url = "https://files.pythonhosted.org/packages/59/7c/f78870bf57daa578542b2ea46da336d03de7c2971d2b2fcfed3773757a17/ijson-3.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a9f84f5e2eea5c2d271c97221c382db005534294d1175ddd046a12369617c41c", size = 129407, upload-time = "2025-05-08T02:36:46.319Z" }, + { url = "https://files.pythonhosted.org/packages/02/08/693a327b50f9036026e062016d6417cd2ce31699cc56c27fe82fb9185140/ijson-3.4.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:c0cd126c11835839bba8ac0baaba568f67d701fc4f717791cf37b10b74a2ebd7", size = 130991, upload-time = "2025-05-08T02:36:47.595Z" }, + { url = "https://files.pythonhosted.org/packages/83/22/96ff12c3ca91613bb020bcf9b3aaee510324af999b08b7e7d2e7acb14123/ijson-3.4.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:f9a9d3bbc6d91c24a2524a189d2aca703cb5f7e8eb34ad0aff3c91702404a983", size = 126175, upload-time = "2025-05-08T02:36:48.992Z" }, + { url = "https://files.pythonhosted.org/packages/e9/59/3b37550686448fc053c456b9af47aa407e6ac4183015f435c0ea11db5849/ijson-3.4.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:56679ee133470d0f1f598a8ad109d760fcfebeef4819531e29335aefb7e4cb1a", size = 128775, upload-time = "2025-05-08T02:36:50.54Z" }, + { url = "https://files.pythonhosted.org/packages/5b/27/6922201d19427c1c6d1f970de3ede105d52ab87654c4d2c76920815bc57a/ijson-3.4.0-cp39-cp39-win32.whl", hash = "sha256:583c15ded42ba80104fa1d0fa0dfdd89bb47922f3bb893a931bb843aeb55a3f3", size = 51250, upload-time = "2025-05-08T02:36:51.811Z" }, + { url = "https://files.pythonhosted.org/packages/c3/70/9939dbbe3541d7cca69c95f64201cd2fd6dba7a6488e3b55e6227d6f6e42/ijson-3.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:4563e603e56f4451572d96b47311dffef5b933d825f3417881d4d3630c6edac2", size = 53737, upload-time = "2025-05-08T02:36:53.369Z" }, + { url = "https://files.pythonhosted.org/packages/a7/22/da919f16ca9254f8a9ea0ba482d2c1d012ce6e4c712dcafd8adb16b16c63/ijson-3.4.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:54e989c35dba9cf163d532c14bcf0c260897d5f465643f0cd1fba9c908bed7ef", size = 56480, upload-time = "2025-05-08T02:36:54.942Z" }, + { url = "https://files.pythonhosted.org/packages/6d/54/c2afd289e034d11c4909f4ea90c9dae55053bed358064f310c3dd5033657/ijson-3.4.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:494eeb8e87afef22fbb969a4cb81ac2c535f30406f334fb6136e9117b0bb5380", size = 55956, upload-time = "2025-05-08T02:36:56.178Z" }, + { url = "https://files.pythonhosted.org/packages/43/d6/18799b0fca9ecb8a47e22527eedcea3267e95d4567b564ef21d0299e2d12/ijson-3.4.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:81603de95de1688958af65cd2294881a4790edae7de540b70c65c8253c5dc44a", size = 69394, upload-time = "2025-05-08T02:36:57.699Z" }, + { url = "https://files.pythonhosted.org/packages/c2/d6/c58032c69e9e977bf6d954f22cad0cd52092db89c454ea98926744523665/ijson-3.4.0-pp310-pypy310_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8524be12c1773e1be466034cc49c1ecbe3d5b47bb86217bd2a57f73f970a6c19", size = 70378, upload-time = "2025-05-08T02:36:58.98Z" }, + { url = "https://files.pythonhosted.org/packages/da/03/07c6840454d5d228bb5b4509c9a7ac5b9c0b8258e2b317a53f97372be1eb/ijson-3.4.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17994696ec895d05e0cfa21b11c68c920c82634b4a3d8b8a1455d6fe9fdee8f7", size = 67770, upload-time = "2025-05-08T02:37:00.162Z" }, + { url = "https://files.pythonhosted.org/packages/32/c7/da58a9840380308df574dfdb0276c9d802b12f6125f999e92bcef36db552/ijson-3.4.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:0b67727aaee55d43b2e82b6a866c3cbcb2b66a5e9894212190cbd8773d0d9857", size = 53858, upload-time = "2025-05-08T02:37:01.691Z" }, + { url = "https://files.pythonhosted.org/packages/a3/9b/0bc0594d357600c03c3b5a3a34043d764fc3ad3f0757d2f3aae5b28f6c1c/ijson-3.4.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:cdc8c5ca0eec789ed99db29c68012dda05027af0860bb360afd28d825238d69d", size = 56483, upload-time = "2025-05-08T02:37:03.274Z" }, + { url = "https://files.pythonhosted.org/packages/00/1f/506cf2574673da1adcc8a794ebb85bf857cabe6294523978637e646814de/ijson-3.4.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:8e6b44b6ec45d5b1a0ee9d97e0e65ab7f62258727004cbbe202bf5f198bc21f7", size = 55957, upload-time = "2025-05-08T02:37:04.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/3d/a7cd8d8a6de0f3084fe4d457a8f76176e11b013867d1cad16c67d25e8bec/ijson-3.4.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b51e239e4cb537929796e840d349fc731fdc0d58b1a0683ce5465ad725321e0f", size = 69394, upload-time = "2025-05-08T02:37:06.142Z" }, + { url = "https://files.pythonhosted.org/packages/32/51/aa30abc02aabfc41c95887acf5f1f88da569642d7197fbe5aa105545226d/ijson-3.4.0-pp311-pypy311_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ed05d43ec02be8ddb1ab59579761f6656b25d241a77fd74f4f0f7ec09074318a", size = 70377, upload-time = "2025-05-08T02:37:07.353Z" }, + { url = "https://files.pythonhosted.org/packages/c7/37/7773659b8d8d98b34234e1237352f6b446a3c12941619686c7d4a8a5c69c/ijson-3.4.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cfeca1aaa59d93fd0a3718cbe5f7ef0effff85cf837e0bceb71831a47f39cc14", size = 67767, upload-time = "2025-05-08T02:37:08.587Z" }, + { url = "https://files.pythonhosted.org/packages/cd/1f/dd52a84ed140e31a5d226cd47d98d21aa559aead35ef7bae479eab4c494c/ijson-3.4.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:7ca72ca12e9a1dd4252c97d952be34282907f263f7e28fcdff3a01b83981e837", size = 53864, upload-time = "2025-05-08T02:37:10.044Z" }, + { url = "https://files.pythonhosted.org/packages/9f/08/0bbdce5e765fee9b5a29f8a9670c00adb54809122cdadd06cd2d33244d68/ijson-3.4.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0f79b2cd52bd220fff83b3ee4ef89b54fd897f57cc8564a6d8ab7ac669de3930", size = 56416, upload-time = "2025-05-08T02:37:11.23Z" }, + { url = "https://files.pythonhosted.org/packages/fd/33/3f62475b40ddb2bf9de1fb9e5f47d89748b4b91fe3c2cd645111d62438fb/ijson-3.4.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:d16eed737610ad5ad8989b5864fbe09c64133129734e840c29085bb0d497fb03", size = 55903, upload-time = "2025-05-08T02:37:12.476Z" }, + { url = "https://files.pythonhosted.org/packages/9f/25/c8955e4fef31f7d16635361ec9a2195845c45a2db1483d7790a57a640cc2/ijson-3.4.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b3aac1d7a27e1e3bdec5bd0689afe55c34aa499baa06a80852eda31f1ffa6dc", size = 69358, upload-time = "2025-05-08T02:37:14.854Z" }, + { url = "https://files.pythonhosted.org/packages/45/b1/900f5d9a868304ff571bab7d10491df17e92105a9846a619d6e4d806e60e/ijson-3.4.0-pp39-pypy39_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:784ae654aa9851851e87f323e9429b20b58a5399f83e6a7e348e080f2892081f", size = 70343, upload-time = "2025-05-08T02:37:16.115Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ed/2a6e467b4c403b0f182724929dd0c85da98e1d1b84e4766028d2c3220eea/ijson-3.4.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5d05bd8fa6a8adefb32bbf7b993d2a2f4507db08453dd1a444c281413a6d9685", size = 67710, upload-time = "2025-05-08T02:37:17.675Z" }, + { url = "https://files.pythonhosted.org/packages/11/c8/de4e995b17effb92f610efc3193393d05f8f233062a716d254d7b4e736c1/ijson-3.4.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:b5a05fd935cc28786b88c16976313086cd96414c6a3eb0a3822c47ab48b1793e", size = 53782, upload-time = "2025-05-08T02:37:18.894Z" }, ] [[package]] @@ -751,7 +783,7 @@ name = "importlib-metadata" version = "8.7.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "zipp", marker = "python_full_version < '3.11'" }, + { name = "zipp" }, ] sdist = { url = "https://files.pythonhosted.org/packages/76/66/650a33bd90f786193e4de4b3ad86ea60b53c89b669a5c7be931fac31cdb0/importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000", size = 56641, upload-time = "2025-04-27T15:29:01.736Z" } wheels = [ @@ -916,122 +948,122 @@ wheels = [ [[package]] name = "multidict" -version = "6.5.0" +version = "6.6.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/46/b5/59f27b4ce9951a4bce56b88ba5ff5159486797ab18863f2b4c1c5e8465bd/multidict-6.5.0.tar.gz", hash = "sha256:942bd8002492ba819426a8d7aefde3189c1b87099cdf18aaaefefcf7f3f7b6d2", size = 98512, upload-time = "2025-06-17T14:15:56.556Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/88/f8354ef1cb1121234c3461ff3d11eac5f4fe115f00552d3376306275c9ab/multidict-6.5.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2e118a202904623b1d2606d1c8614e14c9444b59d64454b0c355044058066469", size = 73858, upload-time = "2025-06-17T14:13:21.451Z" }, - { url = "https://files.pythonhosted.org/packages/49/04/634b49c7abe71bd1c61affaeaa0c2a46b6be8d599a07b495259615dbdfe0/multidict-6.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a42995bdcaff4e22cb1280ae7752c3ed3fbb398090c6991a2797a4a0e5ed16a9", size = 43186, upload-time = "2025-06-17T14:13:23.615Z" }, - { url = "https://files.pythonhosted.org/packages/3b/ff/091ff4830ec8f96378578bfffa7f324a9dd16f60274cec861ae65ba10be3/multidict-6.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2261b538145723ca776e55208640fffd7ee78184d223f37c2b40b9edfe0e818a", size = 43031, upload-time = "2025-06-17T14:13:24.725Z" }, - { url = "https://files.pythonhosted.org/packages/10/c1/1b4137845f8b8dbc2332af54e2d7761c6a29c2c33c8d47a0c8c70676bac1/multidict-6.5.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0e5b19f8cd67235fab3e195ca389490415d9fef5a315b1fa6f332925dc924262", size = 233588, upload-time = "2025-06-17T14:13:26.181Z" }, - { url = "https://files.pythonhosted.org/packages/c3/77/cbe9a1f58c6d4f822663788e414637f256a872bc352cedbaf7717b62db58/multidict-6.5.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:177b081e4dec67c3320b16b3aa0babc178bbf758553085669382c7ec711e1ec8", size = 222714, upload-time = "2025-06-17T14:13:27.482Z" }, - { url = "https://files.pythonhosted.org/packages/6c/37/39e1142c2916973818515adc13bbdb68d3d8126935e3855200e059a79bab/multidict-6.5.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4d30a2cc106a7d116b52ee046207614db42380b62e6b1dd2a50eba47c5ca5eb1", size = 242741, upload-time = "2025-06-17T14:13:28.92Z" }, - { url = "https://files.pythonhosted.org/packages/a3/aa/60c3ef0c87ccad3445bf01926a1b8235ee24c3dde483faef1079cc91706d/multidict-6.5.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a72933bc308d7a64de37f0d51795dbeaceebdfb75454f89035cdfc6a74cfd129", size = 235008, upload-time = "2025-06-17T14:13:30.587Z" }, - { url = "https://files.pythonhosted.org/packages/bf/5e/f7e0fd5f5b8a7b9a75b0f5642ca6b6dde90116266920d8cf63b513f3908b/multidict-6.5.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:96d109e663d032280ef8ef62b50924b2e887d5ddf19e301844a6cb7e91a172a6", size = 226627, upload-time = "2025-06-17T14:13:31.831Z" }, - { url = "https://files.pythonhosted.org/packages/b7/74/1bc0a3c6a9105051f68a6991fe235d7358836e81058728c24d5bbdd017cb/multidict-6.5.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b555329c9894332401f03b9a87016f0b707b6fccd4706793ec43b4a639e75869", size = 228232, upload-time = "2025-06-17T14:13:33.402Z" }, - { url = "https://files.pythonhosted.org/packages/99/e7/37118291cdc31f4cc680d54047cdea9b520e9a724a643919f71f8c2a2aeb/multidict-6.5.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6994bad9d471ef2156f2b6850b51e20ee409c6b9deebc0e57be096be9faffdce", size = 246616, upload-time = "2025-06-17T14:13:34.964Z" }, - { url = "https://files.pythonhosted.org/packages/ff/89/e2c08d6bdb21a1a55be4285510d058ace5f5acabe6b57900432e863d4c70/multidict-6.5.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:b15f817276c96cde9060569023808eec966bd8da56a97e6aa8116f34ddab6534", size = 235007, upload-time = "2025-06-17T14:13:36.428Z" }, - { url = "https://files.pythonhosted.org/packages/89/1e/e39a98e8e1477ec7a871b3c17265658fbe6d617048059ae7fa5011b224f3/multidict-6.5.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b4bf507c991db535a935b2127cf057a58dbc688c9f309c72080795c63e796f58", size = 244824, upload-time = "2025-06-17T14:13:37.982Z" }, - { url = "https://files.pythonhosted.org/packages/a3/ba/63e11edd45c31e708c5a1904aa7ac4de01e13135a04cfe96bc71eb359b85/multidict-6.5.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:60c3f8f13d443426c55f88cf3172547bbc600a86d57fd565458b9259239a6737", size = 257229, upload-time = "2025-06-17T14:13:39.554Z" }, - { url = "https://files.pythonhosted.org/packages/0f/00/bdcceb6af424936adfc8b92a79d3a95863585f380071393934f10a63f9e3/multidict-6.5.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:a10227168a24420c158747fc201d4279aa9af1671f287371597e2b4f2ff21879", size = 247118, upload-time = "2025-06-17T14:13:40.795Z" }, - { url = "https://files.pythonhosted.org/packages/b6/a0/4aa79e991909cca36ca821a9ba5e8e81e4cd5b887c81f89ded994e0f49df/multidict-6.5.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e3b1425fe54ccfde66b8cfb25d02be34d5dfd2261a71561ffd887ef4088b4b69", size = 243948, upload-time = "2025-06-17T14:13:42.477Z" }, - { url = "https://files.pythonhosted.org/packages/21/8b/e45e19ce43afb31ff6b0fd5d5816b4fcc1fcc2f37e8a82aefae06c40c7a6/multidict-6.5.0-cp310-cp310-win32.whl", hash = "sha256:b4e47ef51237841d1087e1e1548071a6ef22e27ed0400c272174fa585277c4b4", size = 40433, upload-time = "2025-06-17T14:13:43.972Z" }, - { url = "https://files.pythonhosted.org/packages/d2/6e/96e0ba4601343d9344e69503fca072ace19c35f7d4ca3d68401e59acdc8f/multidict-6.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:63b3b24fadc7067282c88fae5b2f366d5b3a7c15c021c2838de8c65a50eeefb4", size = 44423, upload-time = "2025-06-17T14:13:44.991Z" }, - { url = "https://files.pythonhosted.org/packages/eb/4a/9befa919d7a390f13a5511a69282b7437782071160c566de6e0ebf712c9f/multidict-6.5.0-cp310-cp310-win_arm64.whl", hash = "sha256:8b2d61afbafc679b7eaf08e9de4fa5d38bd5dc7a9c0a577c9f9588fb49f02dbb", size = 41481, upload-time = "2025-06-17T14:13:49.389Z" }, - { url = "https://files.pythonhosted.org/packages/75/ba/484f8e96ee58ec4fef42650eb9dbbedb24f9bc155780888398a4725d2270/multidict-6.5.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8b4bf6bb15a05796a07a248084e3e46e032860c899c7a9b981030e61368dba95", size = 73283, upload-time = "2025-06-17T14:13:50.406Z" }, - { url = "https://files.pythonhosted.org/packages/71/48/01d62ea6199d76934c87746695b3ed16aeedfdd564e8d89184577037baac/multidict-6.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:46bb05d50219655c42a4b8fcda9c7ee658a09adbb719c48e65a20284e36328ea", size = 42937, upload-time = "2025-06-17T14:13:51.45Z" }, - { url = "https://files.pythonhosted.org/packages/da/cf/bb462d920f26d9e2e0aff8a78aeb06af1225b826e9a5468870c57591910a/multidict-6.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:54f524d73f4d54e87e03c98f6af601af4777e4668a52b1bd2ae0a4d6fc7b392b", size = 42748, upload-time = "2025-06-17T14:13:52.505Z" }, - { url = "https://files.pythonhosted.org/packages/cd/b1/d5c11ea0fdad68d3ed45f0e2527de6496d2fac8afe6b8ca6d407c20ad00f/multidict-6.5.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:529b03600466480ecc502000d62e54f185a884ed4570dee90d9a273ee80e37b5", size = 236448, upload-time = "2025-06-17T14:13:53.562Z" }, - { url = "https://files.pythonhosted.org/packages/fc/69/c3ceb264994f5b338c812911a8d660084f37779daef298fc30bd817f75c7/multidict-6.5.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:69ad681ad7c93a41ee7005cc83a144b5b34a3838bcf7261e2b5356057b0f78de", size = 228695, upload-time = "2025-06-17T14:13:54.775Z" }, - { url = "https://files.pythonhosted.org/packages/81/3d/c23dcc0d34a35ad29974184db2878021d28fe170ecb9192be6bfee73f1f2/multidict-6.5.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fe9fada8bc0839466b09fa3f6894f003137942984843ec0c3848846329a36ae", size = 247434, upload-time = "2025-06-17T14:13:56.039Z" }, - { url = "https://files.pythonhosted.org/packages/06/b3/06cf7a049129ff52525a859277abb5648e61d7afae7fb7ed02e3806be34e/multidict-6.5.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f94c6ea6405fcf81baef1e459b209a78cda5442e61b5b7a57ede39d99b5204a0", size = 239431, upload-time = "2025-06-17T14:13:57.33Z" }, - { url = "https://files.pythonhosted.org/packages/8a/72/b2fe2fafa23af0c6123aebe23b4cd23fdad01dfe7009bb85624e4636d0dd/multidict-6.5.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:84ca75ad8a39ed75f079a8931435a5b51ee4c45d9b32e1740f99969a5d1cc2ee", size = 231542, upload-time = "2025-06-17T14:13:58.597Z" }, - { url = "https://files.pythonhosted.org/packages/a1/c9/a52ca0a342a02411a31b6af197a6428a5137d805293f10946eeab614ec06/multidict-6.5.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:be4c08f3a2a6cc42b414496017928d95898964fed84b1b2dace0c9ee763061f9", size = 233069, upload-time = "2025-06-17T14:13:59.834Z" }, - { url = "https://files.pythonhosted.org/packages/9b/55/a3328a3929b8e131e2678d5e65f552b0a6874fab62123e31f5a5625650b0/multidict-6.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:046a7540cfbb4d5dc846a1fd9843f3ba980c6523f2e0c5b8622b4a5c94138ae6", size = 250596, upload-time = "2025-06-17T14:14:01.178Z" }, - { url = "https://files.pythonhosted.org/packages/6c/b8/aa3905a38a8287013aeb0a54c73f79ccd8b32d2f1d53e5934643a36502c2/multidict-6.5.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:64306121171d988af77d74be0d8c73ee1a69cf6f96aea7fa6030c88f32a152dd", size = 237858, upload-time = "2025-06-17T14:14:03.232Z" }, - { url = "https://files.pythonhosted.org/packages/d3/eb/f11d5af028014f402e5dd01ece74533964fa4e7bfae4af4824506fa8c398/multidict-6.5.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:b4ac1dd5eb0ecf6f7351d5a9137f30a83f7182209c5d37f61614dfdce5714853", size = 249175, upload-time = "2025-06-17T14:14:04.561Z" }, - { url = "https://files.pythonhosted.org/packages/ac/57/d451905a62e5ef489cb4f92e8190d34ac5329427512afd7f893121da4e96/multidict-6.5.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:bab4a8337235365f4111a7011a1f028826ca683834ebd12de4b85e2844359c36", size = 259532, upload-time = "2025-06-17T14:14:05.798Z" }, - { url = "https://files.pythonhosted.org/packages/d3/90/ff82b5ac5cabe3c79c50cf62a62f3837905aa717e67b6b4b7872804f23c8/multidict-6.5.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:a05b5604c5a75df14a63eeeca598d11b2c3745b9008539b70826ea044063a572", size = 250554, upload-time = "2025-06-17T14:14:07.382Z" }, - { url = "https://files.pythonhosted.org/packages/d5/5a/0cabc50d4bc16e61d8b0a8a74499a1409fa7b4ef32970b7662a423781fc7/multidict-6.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:67c4a640952371c9ca65b6a710598be246ef3be5ca83ed38c16a7660d3980877", size = 248159, upload-time = "2025-06-17T14:14:08.65Z" }, - { url = "https://files.pythonhosted.org/packages/c0/1d/adeabae0771544f140d9f42ab2c46eaf54e793325999c36106078b7f6600/multidict-6.5.0-cp311-cp311-win32.whl", hash = "sha256:fdeae096ca36c12d8aca2640b8407a9d94e961372c68435bef14e31cce726138", size = 40357, upload-time = "2025-06-17T14:14:09.91Z" }, - { url = "https://files.pythonhosted.org/packages/e1/fe/bbd85ae65c96de5c9910c332ee1f4b7be0bf0fb21563895167bcb6502a1f/multidict-6.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:e2977ef8b7ce27723ee8c610d1bd1765da4f3fbe5a64f9bf1fd3b4770e31fbc0", size = 44432, upload-time = "2025-06-17T14:14:11.013Z" }, - { url = "https://files.pythonhosted.org/packages/96/af/f9052d9c4e65195b210da9f7afdea06d3b7592b3221cc0ef1b407f762faa/multidict-6.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:82d0cf0ea49bae43d9e8c3851e21954eff716259ff42da401b668744d1760bcb", size = 41408, upload-time = "2025-06-17T14:14:12.112Z" }, - { url = "https://files.pythonhosted.org/packages/0a/fa/18f4950e00924f7e84c8195f4fc303295e14df23f713d64e778b8fa8b903/multidict-6.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1bb986c8ea9d49947bc325c51eced1ada6d8d9b4c5b15fd3fcdc3c93edef5a74", size = 73474, upload-time = "2025-06-17T14:14:13.528Z" }, - { url = "https://files.pythonhosted.org/packages/6c/66/0392a2a8948bccff57e4793c9dde3e5c088f01e8b7f8867ee58a2f187fc5/multidict-6.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:03c0923da300120830fc467e23805d63bbb4e98b94032bd863bc7797ea5fa653", size = 43741, upload-time = "2025-06-17T14:14:15.188Z" }, - { url = "https://files.pythonhosted.org/packages/98/3e/f48487c91b2a070566cfbab876d7e1ebe7deb0a8002e4e896a97998ae066/multidict-6.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4c78d5ec00fdd35c91680ab5cf58368faad4bd1a8721f87127326270248de9bc", size = 42143, upload-time = "2025-06-17T14:14:16.612Z" }, - { url = "https://files.pythonhosted.org/packages/3f/49/439c6cc1cd00365cf561bdd3579cc3fa1a0d38effb3a59b8d9562839197f/multidict-6.5.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aadc3cb78be90a887f8f6b73945b840da44b4a483d1c9750459ae69687940c97", size = 239303, upload-time = "2025-06-17T14:14:17.707Z" }, - { url = "https://files.pythonhosted.org/packages/c4/24/491786269e90081cb536e4d7429508725bc92ece176d1204a4449de7c41c/multidict-6.5.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5b02e1ca495d71e07e652e4cef91adae3bf7ae4493507a263f56e617de65dafc", size = 236913, upload-time = "2025-06-17T14:14:18.981Z" }, - { url = "https://files.pythonhosted.org/packages/e8/76/bbe2558b820ebeca8a317ab034541790e8160ca4b1e450415383ac69b339/multidict-6.5.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7fe92a62326eef351668eec4e2dfc494927764a0840a1895cff16707fceffcd3", size = 250752, upload-time = "2025-06-17T14:14:20.297Z" }, - { url = "https://files.pythonhosted.org/packages/3e/e3/3977f2c1123f553ceff9f53cd4de04be2c1912333c6fabbcd51531655476/multidict-6.5.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7673ee4f63879ecd526488deb1989041abcb101b2d30a9165e1e90c489f3f7fb", size = 243937, upload-time = "2025-06-17T14:14:21.935Z" }, - { url = "https://files.pythonhosted.org/packages/b6/b8/7a6e9c13c79709cdd2f22ee849f058e6da76892d141a67acc0e6c30d845c/multidict-6.5.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa097ae2a29f573de7e2d86620cbdda5676d27772d4ed2669cfa9961a0d73955", size = 237419, upload-time = "2025-06-17T14:14:23.215Z" }, - { url = "https://files.pythonhosted.org/packages/84/9d/8557f5e88da71bc7e7a8ace1ada4c28197f3bfdc2dd6e51d3b88f2e16e8e/multidict-6.5.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:300da0fa4f8457d9c4bd579695496116563409e676ac79b5e4dca18e49d1c308", size = 237222, upload-time = "2025-06-17T14:14:24.516Z" }, - { url = "https://files.pythonhosted.org/packages/a3/3b/8f023ad60e7969cb6bc0683738d0e1618f5ff5723d6d2d7818dc6df6ad3d/multidict-6.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9a19bd108c35877b57393243d392d024cfbfdefe759fd137abb98f6fc910b64c", size = 247861, upload-time = "2025-06-17T14:14:25.839Z" }, - { url = "https://files.pythonhosted.org/packages/af/1c/9cf5a099ce7e3189906cf5daa72c44ee962dcb4c1983659f3a6f8a7446ab/multidict-6.5.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f32a1777465a35c35ddbbd7fc1293077938a69402fcc59e40b2846d04a120dd", size = 243917, upload-time = "2025-06-17T14:14:27.164Z" }, - { url = "https://files.pythonhosted.org/packages/6c/bb/88ee66ebeef56868044bac58feb1cc25658bff27b20e3cfc464edc181287/multidict-6.5.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:9cc1e10c14ce8112d1e6d8971fe3cdbe13e314f68bea0e727429249d4a6ce164", size = 249214, upload-time = "2025-06-17T14:14:28.795Z" }, - { url = "https://files.pythonhosted.org/packages/3e/ec/a90e88cc4a1309f33088ab1cdd5c0487718f49dfb82c5ffc845bb17c1973/multidict-6.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e95c5e07a06594bdc288117ca90e89156aee8cb2d7c330b920d9c3dd19c05414", size = 258682, upload-time = "2025-06-17T14:14:30.066Z" }, - { url = "https://files.pythonhosted.org/packages/d2/d8/16dd69a6811920a31f4e06114ebe67b1cd922c8b05c9c82b050706d0b6fe/multidict-6.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:40ff26f58323795f5cd2855e2718a1720a1123fb90df4553426f0efd76135462", size = 254254, upload-time = "2025-06-17T14:14:31.323Z" }, - { url = "https://files.pythonhosted.org/packages/ac/a8/90193a5f5ca1bdbf92633d69a25a2ef9bcac7b412b8d48c84d01a2732518/multidict-6.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:76803a29fd71869a8b59c2118c9dcfb3b8f9c8723e2cce6baeb20705459505cf", size = 247741, upload-time = "2025-06-17T14:14:32.717Z" }, - { url = "https://files.pythonhosted.org/packages/cd/43/29c7a747153c05b41d1f67455426af39ed88d6de3f21c232b8f2724bde13/multidict-6.5.0-cp312-cp312-win32.whl", hash = "sha256:df7ecbc65a53a2ce1b3a0c82e6ad1a43dcfe7c6137733f9176a92516b9f5b851", size = 41049, upload-time = "2025-06-17T14:14:33.941Z" }, - { url = "https://files.pythonhosted.org/packages/1e/e8/8f3fc32b7e901f3a2719764d64aeaf6ae77b4ba961f1c3a3cf3867766636/multidict-6.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:0ec1c3fbbb0b655a6540bce408f48b9a7474fd94ed657dcd2e890671fefa7743", size = 44700, upload-time = "2025-06-17T14:14:35.016Z" }, - { url = "https://files.pythonhosted.org/packages/24/e4/e250806adc98d524d41e69c8d4a42bc3513464adb88cb96224df12928617/multidict-6.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:2d24a00d34808b22c1f15902899b9d82d0faeca9f56281641c791d8605eacd35", size = 41703, upload-time = "2025-06-17T14:14:36.168Z" }, - { url = "https://files.pythonhosted.org/packages/1a/c9/092c4e9402b6d16de761cff88cb842a5c8cc50ccecaf9c4481ba53264b9e/multidict-6.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:53d92df1752df67a928fa7f884aa51edae6f1cf00eeb38cbcf318cf841c17456", size = 73486, upload-time = "2025-06-17T14:14:37.238Z" }, - { url = "https://files.pythonhosted.org/packages/08/f9/6f7ddb8213f5fdf4db48d1d640b78e8aef89b63a5de8a2313286db709250/multidict-6.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:680210de2c38eef17ce46b8df8bf2c1ece489261a14a6e43c997d49843a27c99", size = 43745, upload-time = "2025-06-17T14:14:38.32Z" }, - { url = "https://files.pythonhosted.org/packages/f3/a7/b9be0163bfeee3bb08a77a1705e24eb7e651d594ea554107fac8a1ca6a4d/multidict-6.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e279259bcb936732bfa1a8eec82b5d2352b3df69d2fa90d25808cfc403cee90a", size = 42135, upload-time = "2025-06-17T14:14:39.897Z" }, - { url = "https://files.pythonhosted.org/packages/8e/30/93c8203f943a417bda3c573a34d5db0cf733afdfffb0ca78545c7716dbd8/multidict-6.5.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1c185fc1069781e3fc8b622c4331fb3b433979850392daa5efbb97f7f9959bb", size = 238585, upload-time = "2025-06-17T14:14:41.332Z" }, - { url = "https://files.pythonhosted.org/packages/9d/fe/2582b56a1807604774f566eeef183b0d6b148f4b89d1612cd077567b2e1e/multidict-6.5.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6bb5f65ff91daf19ce97f48f63585e51595539a8a523258b34f7cef2ec7e0617", size = 236174, upload-time = "2025-06-17T14:14:42.602Z" }, - { url = "https://files.pythonhosted.org/packages/9b/c4/d8b66d42d385bd4f974cbd1eaa8b265e6b8d297249009f312081d5ded5c7/multidict-6.5.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d8646b4259450c59b9286db280dd57745897897284f6308edbdf437166d93855", size = 250145, upload-time = "2025-06-17T14:14:43.944Z" }, - { url = "https://files.pythonhosted.org/packages/bc/64/62feda5093ee852426aae3df86fab079f8bf1cdbe403e1078c94672ad3ec/multidict-6.5.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d245973d4ecc04eea0a8e5ebec7882cf515480036e1b48e65dffcfbdf86d00be", size = 243470, upload-time = "2025-06-17T14:14:45.343Z" }, - { url = "https://files.pythonhosted.org/packages/67/dc/9f6fa6e854625cf289c0e9f4464b40212a01f76b2f3edfe89b6779b4fb93/multidict-6.5.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a133e7ddc9bc7fb053733d0ff697ce78c7bf39b5aec4ac12857b6116324c8d75", size = 236968, upload-time = "2025-06-17T14:14:46.609Z" }, - { url = "https://files.pythonhosted.org/packages/46/ae/4b81c6e3745faee81a156f3f87402315bdccf04236f75c03e37be19c94ff/multidict-6.5.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:80d696fa38d738fcebfd53eec4d2e3aeb86a67679fd5e53c325756682f152826", size = 236575, upload-time = "2025-06-17T14:14:47.929Z" }, - { url = "https://files.pythonhosted.org/packages/8a/fa/4089d7642ea344226e1bfab60dd588761d4791754f8072e911836a39bedf/multidict-6.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:20d30c9410ac3908abbaa52ee5967a754c62142043cf2ba091e39681bd51d21a", size = 247632, upload-time = "2025-06-17T14:14:49.525Z" }, - { url = "https://files.pythonhosted.org/packages/16/ee/a353dac797de0f28fb7f078cc181c5f2eefe8dd16aa11a7100cbdc234037/multidict-6.5.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:6c65068cc026f217e815fa519d8e959a7188e94ec163ffa029c94ca3ef9d4a73", size = 243520, upload-time = "2025-06-17T14:14:50.83Z" }, - { url = "https://files.pythonhosted.org/packages/50/ec/560deb3d2d95822d6eb1bcb1f1cb728f8f0197ec25be7c936d5d6a5d133c/multidict-6.5.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e355ac668a8c3e49c2ca8daa4c92f0ad5b705d26da3d5af6f7d971e46c096da7", size = 248551, upload-time = "2025-06-17T14:14:52.229Z" }, - { url = "https://files.pythonhosted.org/packages/10/85/ddf277e67c78205f6695f2a7639be459bca9cc353b962fd8085a492a262f/multidict-6.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:08db204213d0375a91a381cae0677ab95dd8c67a465eb370549daf6dbbf8ba10", size = 258362, upload-time = "2025-06-17T14:14:53.934Z" }, - { url = "https://files.pythonhosted.org/packages/02/fc/d64ee1df9b87c5210f2d4c419cab07f28589c81b4e5711eda05a122d0614/multidict-6.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ffa58e3e215af8f6536dc837a990e456129857bb6fd546b3991be470abd9597a", size = 253862, upload-time = "2025-06-17T14:14:55.323Z" }, - { url = "https://files.pythonhosted.org/packages/c9/7c/a2743c00d9e25f4826d3a77cc13d4746398872cf21c843eef96bb9945665/multidict-6.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3e86eb90015c6f21658dbd257bb8e6aa18bdb365b92dd1fba27ec04e58cdc31b", size = 247391, upload-time = "2025-06-17T14:14:57.293Z" }, - { url = "https://files.pythonhosted.org/packages/9b/03/7773518db74c442904dbd349074f1e7f2a854cee4d9529fc59e623d3949e/multidict-6.5.0-cp313-cp313-win32.whl", hash = "sha256:f34a90fbd9959d0f857323bd3c52b3e6011ed48f78d7d7b9e04980b8a41da3af", size = 41115, upload-time = "2025-06-17T14:14:59.33Z" }, - { url = "https://files.pythonhosted.org/packages/eb/9a/6fc51b1dc11a7baa944bc101a92167d8b0f5929d376a8c65168fc0d35917/multidict-6.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:fcb2aa79ac6aef8d5b709bbfc2fdb1d75210ba43038d70fbb595b35af470ce06", size = 44768, upload-time = "2025-06-17T14:15:00.427Z" }, - { url = "https://files.pythonhosted.org/packages/82/2d/0d010be24b663b3c16e3d3307bbba2de5ae8eec496f6027d5c0515b371a8/multidict-6.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:6dcee5e7e92060b4bb9bb6f01efcbb78c13d0e17d9bc6eec71660dd71dc7b0c2", size = 41770, upload-time = "2025-06-17T14:15:01.854Z" }, - { url = "https://files.pythonhosted.org/packages/aa/d1/a71711a5f32f84b7b036e82182e3250b949a0ce70d51a2c6a4079e665449/multidict-6.5.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:cbbc88abea2388fde41dd574159dec2cda005cb61aa84950828610cb5010f21a", size = 80450, upload-time = "2025-06-17T14:15:02.968Z" }, - { url = "https://files.pythonhosted.org/packages/0f/a2/953a9eede63a98fcec2c1a2c1a0d88de120056219931013b871884f51b43/multidict-6.5.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:70b599f70ae6536e5976364d3c3cf36f40334708bd6cebdd1e2438395d5e7676", size = 46971, upload-time = "2025-06-17T14:15:04.149Z" }, - { url = "https://files.pythonhosted.org/packages/44/61/60250212953459edda2c729e1d85130912f23c67bd4f585546fe4bdb1578/multidict-6.5.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:828bab777aa8d29d59700018178061854e3a47727e0611cb9bec579d3882de3b", size = 45548, upload-time = "2025-06-17T14:15:05.666Z" }, - { url = "https://files.pythonhosted.org/packages/11/b6/e78ee82e96c495bc2582b303f68bed176b481c8d81a441fec07404fce2ca/multidict-6.5.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9695fc1462f17b131c111cf0856a22ff154b0480f86f539d24b2778571ff94d", size = 238545, upload-time = "2025-06-17T14:15:06.88Z" }, - { url = "https://files.pythonhosted.org/packages/5a/0f/6132ca06670c8d7b374c3a4fd1ba896fc37fbb66b0de903f61db7d1020ec/multidict-6.5.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b5ac6ebaf5d9814b15f399337ebc6d3a7f4ce9331edd404e76c49a01620b68d", size = 229931, upload-time = "2025-06-17T14:15:08.24Z" }, - { url = "https://files.pythonhosted.org/packages/c0/63/d9957c506e6df6b3e7a194f0eea62955c12875e454b978f18262a65d017b/multidict-6.5.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84a51e3baa77ded07be4766a9e41d977987b97e49884d4c94f6d30ab6acaee14", size = 248181, upload-time = "2025-06-17T14:15:09.907Z" }, - { url = "https://files.pythonhosted.org/packages/43/3f/7d5490579640db5999a948e2c41d4a0efd91a75989bda3e0a03a79c92be2/multidict-6.5.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8de67f79314d24179e9b1869ed15e88d6ba5452a73fc9891ac142e0ee018b5d6", size = 241846, upload-time = "2025-06-17T14:15:11.596Z" }, - { url = "https://files.pythonhosted.org/packages/e1/f7/252b1ce949ece52bba4c0de7aa2e3a3d5964e800bce71fb778c2e6c66f7c/multidict-6.5.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17f78a52c214481d30550ec18208e287dfc4736f0c0148208334b105fd9e0887", size = 232893, upload-time = "2025-06-17T14:15:12.946Z" }, - { url = "https://files.pythonhosted.org/packages/45/7e/0070bfd48c16afc26e056f2acce49e853c0d604a69c7124bc0bbdb1bcc0a/multidict-6.5.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2966d0099cb2e2039f9b0e73e7fd5eb9c85805681aa2a7f867f9d95b35356921", size = 228567, upload-time = "2025-06-17T14:15:14.267Z" }, - { url = "https://files.pythonhosted.org/packages/2a/31/90551c75322113ebf5fd9c5422e8641d6952f6edaf6b6c07fdc49b1bebdd/multidict-6.5.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:86fb42ed5ed1971c642cc52acc82491af97567534a8e381a8d50c02169c4e684", size = 246188, upload-time = "2025-06-17T14:15:15.985Z" }, - { url = "https://files.pythonhosted.org/packages/cc/e2/aa4b02a55e7767ff292871023817fe4db83668d514dab7ccbce25eaf7659/multidict-6.5.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:4e990cbcb6382f9eae4ec720bcac6a1351509e6fc4a5bb70e4984b27973934e6", size = 235178, upload-time = "2025-06-17T14:15:17.395Z" }, - { url = "https://files.pythonhosted.org/packages/7d/5c/f67e726717c4b138b166be1700e2b56e06fbbcb84643d15f9a9d7335ff41/multidict-6.5.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:d99a59d64bb1f7f2117bec837d9e534c5aeb5dcedf4c2b16b9753ed28fdc20a3", size = 243422, upload-time = "2025-06-17T14:15:18.939Z" }, - { url = "https://files.pythonhosted.org/packages/e5/1c/15fa318285e26a50aa3fa979bbcffb90f9b4d5ec58882d0590eda067d0da/multidict-6.5.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:e8ef15cc97c9890212e1caf90f0d63f6560e1e101cf83aeaf63a57556689fb34", size = 254898, upload-time = "2025-06-17T14:15:20.31Z" }, - { url = "https://files.pythonhosted.org/packages/ad/3d/d6c6d1c2e9b61ca80313912d30bb90d4179335405e421ef0a164eac2c0f9/multidict-6.5.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:b8a09aec921b34bd8b9f842f0bcfd76c6a8c033dc5773511e15f2d517e7e1068", size = 247129, upload-time = "2025-06-17T14:15:21.665Z" }, - { url = "https://files.pythonhosted.org/packages/29/15/1568258cf0090bfa78d44be66247cfdb16e27dfd935c8136a1e8632d3057/multidict-6.5.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:ff07b504c23b67f2044533244c230808a1258b3493aaf3ea2a0785f70b7be461", size = 243841, upload-time = "2025-06-17T14:15:23.38Z" }, - { url = "https://files.pythonhosted.org/packages/65/57/64af5dbcfd61427056e840c8e520b502879d480f9632fbe210929fd87393/multidict-6.5.0-cp313-cp313t-win32.whl", hash = "sha256:9232a117341e7e979d210e41c04e18f1dc3a1d251268df6c818f5334301274e1", size = 46761, upload-time = "2025-06-17T14:15:24.733Z" }, - { url = "https://files.pythonhosted.org/packages/26/a8/cac7f7d61e188ff44f28e46cb98f9cc21762e671c96e031f06c84a60556e/multidict-6.5.0-cp313-cp313t-win_amd64.whl", hash = "sha256:44cb5c53fb2d4cbcee70a768d796052b75d89b827643788a75ea68189f0980a1", size = 52112, upload-time = "2025-06-17T14:15:25.906Z" }, - { url = "https://files.pythonhosted.org/packages/51/9f/076533feb1b5488d22936da98b9c217205cfbf9f56f7174e8c5c86d86fe6/multidict-6.5.0-cp313-cp313t-win_arm64.whl", hash = "sha256:51d33fafa82640c0217391d4ce895d32b7e84a832b8aee0dcc1b04d8981ec7f4", size = 44358, upload-time = "2025-06-17T14:15:27.117Z" }, - { url = "https://files.pythonhosted.org/packages/68/0b/b024da30f18241e03a400aebdc3ca1bcbdc0561f9d48019cbe66549aea3e/multidict-6.5.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c0078358470da8dc90c37456f4a9cde9f86200949a048d53682b9cd21e5bbf2b", size = 73804, upload-time = "2025-06-17T14:15:28.305Z" }, - { url = "https://files.pythonhosted.org/packages/a3/8f/5e69092bb8a75b95dd27ed4d21220641ede7e127d8a0228cd5e1d5f2150e/multidict-6.5.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5cc7968b7d1bf8b973c307d38aa3a2f2c783f149bcac855944804252f1df5105", size = 43161, upload-time = "2025-06-17T14:15:29.47Z" }, - { url = "https://files.pythonhosted.org/packages/e1/d9/51968d296800285343055d482b65001bda4fa4950aad5575afe17906f16f/multidict-6.5.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0ad73a60e11aa92f1f2c9330efdeaac4531b719fc568eb8d312fd4112f34cc18", size = 42996, upload-time = "2025-06-17T14:15:30.622Z" }, - { url = "https://files.pythonhosted.org/packages/38/1c/19ce336cf8af2b7c530ea890496603eb9bbf0da4e3a8e0fcc3669ad30c21/multidict-6.5.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3233f21abdcd180b2624eb6988a1e1287210e99bca986d8320afca5005d85844", size = 231051, upload-time = "2025-06-17T14:15:32.296Z" }, - { url = "https://files.pythonhosted.org/packages/73/9b/2cf6eff5b30ff8a67ca231a741053c8cc8269fd860cac2c0e16b376de89d/multidict-6.5.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:bee5c0b79fca78fd2ab644ca4dc831ecf793eb6830b9f542ee5ed2c91bc35a0e", size = 219511, upload-time = "2025-06-17T14:15:33.602Z" }, - { url = "https://files.pythonhosted.org/packages/bf/ac/43c89a11d710ce6e5c824ece7b570fd79839e3d25a6a7d3b2526a77b290c/multidict-6.5.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e053a4d690f4352ce46583080fefade9a903ce0fa9d820db1be80bdb9304fa2f", size = 240287, upload-time = "2025-06-17T14:15:34.915Z" }, - { url = "https://files.pythonhosted.org/packages/16/94/1896d424324618f2e2adbf9acb049aeef8da3f31c109e37ffda63b58d1b5/multidict-6.5.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:42bdee30424c1f4dcda96e07ac60e2a4ede8a89f8ae2f48b5e4ccc060f294c52", size = 232748, upload-time = "2025-06-17T14:15:36.576Z" }, - { url = "https://files.pythonhosted.org/packages/e1/43/2f852c12622bda304a2e0c4419250de3cd0345776ae2e699416cbdc15c9f/multidict-6.5.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58b2ded1a7982cf7b8322b0645713a0086b2b3cf5bb9f7c01edfc1a9f98d20dc", size = 224910, upload-time = "2025-06-17T14:15:37.941Z" }, - { url = "https://files.pythonhosted.org/packages/31/68/9c32a0305a11aec71a85f354d739011221507bce977a3be8d9fa248763e7/multidict-6.5.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3f805b8b951d1fadc5bc18c3c93e509608ac5a883045ee33bc22e28806847c20", size = 225773, upload-time = "2025-06-17T14:15:39.645Z" }, - { url = "https://files.pythonhosted.org/packages/bc/81/488054827b644e615f59211fc26fd64b28a1366143e4985326802f18773b/multidict-6.5.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2540395b63723da748f850568357a39cd8d8d4403ca9439f9fcdad6dd423c780", size = 244097, upload-time = "2025-06-17T14:15:41.164Z" }, - { url = "https://files.pythonhosted.org/packages/9f/71/b9d96548da768dd7284c1f21187129a48906f526d5ed4f71bb050476d91f/multidict-6.5.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:c96aedff25f4e47b6697ba048b2c278f7caa6df82c7c3f02e077bcc8d47b4b76", size = 232831, upload-time = "2025-06-17T14:15:42.897Z" }, - { url = "https://files.pythonhosted.org/packages/fc/45/0c57c9bf9be7808252269f0d3964c1495413bcee36a7a7e836fdb778a578/multidict-6.5.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:e80de5ad995de210fd02a65c2350649b8321d09bd2e44717eaefb0f5814503e8", size = 242201, upload-time = "2025-06-17T14:15:44.286Z" }, - { url = "https://files.pythonhosted.org/packages/8b/d4/2441e56b32f7d25c917557641b35a89e0142a7412bc57182c80330975b8d/multidict-6.5.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:6cb9bcedd9391b313e5ec2fb3aa07c03e050550e7b9e4646c076d5c24ba01532", size = 254479, upload-time = "2025-06-17T14:15:45.718Z" }, - { url = "https://files.pythonhosted.org/packages/0d/93/acbc2fed235c7a7b2b21fe8c6ac1b612f7fee79dbddd9c73d42b1a65599c/multidict-6.5.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:a7d130ed7a112e25ab47309962ecafae07d073316f9d158bc7b3936b52b80121", size = 244179, upload-time = "2025-06-17T14:15:47.174Z" }, - { url = "https://files.pythonhosted.org/packages/aa/b2/07ce91400ee2b296de2d6d55f1d948d88d148182b35a3edcc480ddb0f99a/multidict-6.5.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:95750a9a9741cd1855d1b6cb4c6031ae01c01ad38d280217b64bfae986d39d56", size = 241173, upload-time = "2025-06-17T14:15:48.566Z" }, - { url = "https://files.pythonhosted.org/packages/a0/09/61c0b044065a1d2e1329b0e4f0f2afa992d3bb319129b63dd63c54c2cc15/multidict-6.5.0-cp39-cp39-win32.whl", hash = "sha256:7f78caf409914f108f4212b53a9033abfdc2cbab0647e9ac3a25bb0f21ab43d2", size = 40467, upload-time = "2025-06-17T14:15:50.285Z" }, - { url = "https://files.pythonhosted.org/packages/7f/43/48c2837046222ea6800824d576f110d7622c4048b3dd252ef62c51a0969b/multidict-6.5.0-cp39-cp39-win_amd64.whl", hash = "sha256:220c74009507e847a3a6fc5375875f2a2e05bd9ce28cf607be0e8c94600f4472", size = 44449, upload-time = "2025-06-17T14:15:51.84Z" }, - { url = "https://files.pythonhosted.org/packages/d2/4e/b61b006e75c6e071fac1bd0f32696ad1b052772493c4e9d0121ba604b215/multidict-6.5.0-cp39-cp39-win_arm64.whl", hash = "sha256:d98f4ac9c1ede7e9d04076e2e6d967e15df0079a6381b297270f6bcab661195e", size = 41477, upload-time = "2025-06-17T14:15:53.964Z" }, - { url = "https://files.pythonhosted.org/packages/44/d8/45e8fc9892a7386d074941429e033adb4640e59ff0780d96a8cf46fe788e/multidict-6.5.0-py3-none-any.whl", hash = "sha256:5634b35f225977605385f56153bd95a7133faffc0ffe12ad26e10517537e8dfc", size = 12181, upload-time = "2025-06-17T14:15:55.156Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/3d/2c/5dad12e82fbdf7470f29bff2171484bf07cb3b16ada60a6589af8f376440/multidict-6.6.3.tar.gz", hash = "sha256:798a9eb12dab0a6c2e29c1de6f3468af5cb2da6053a20dfa3344907eed0937cc", size = 101006, upload-time = "2025-06-30T15:53:46.929Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/67/414933982bce2efce7cbcb3169eaaf901e0f25baec69432b4874dfb1f297/multidict-6.6.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a2be5b7b35271f7fff1397204ba6708365e3d773579fe2a30625e16c4b4ce817", size = 77017, upload-time = "2025-06-30T15:50:58.931Z" }, + { url = "https://files.pythonhosted.org/packages/8a/fe/d8a3ee1fad37dc2ef4f75488b0d9d4f25bf204aad8306cbab63d97bff64a/multidict-6.6.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:12f4581d2930840295c461764b9a65732ec01250b46c6b2c510d7ee68872b140", size = 44897, upload-time = "2025-06-30T15:51:00.999Z" }, + { url = "https://files.pythonhosted.org/packages/1f/e0/265d89af8c98240265d82b8cbcf35897f83b76cd59ee3ab3879050fd8c45/multidict-6.6.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dd7793bab517e706c9ed9d7310b06c8672fd0aeee5781bfad612f56b8e0f7d14", size = 44574, upload-time = "2025-06-30T15:51:02.449Z" }, + { url = "https://files.pythonhosted.org/packages/e6/05/6b759379f7e8e04ccc97cfb2a5dcc5cdbd44a97f072b2272dc51281e6a40/multidict-6.6.3-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:72d8815f2cd3cf3df0f83cac3f3ef801d908b2d90409ae28102e0553af85545a", size = 225729, upload-time = "2025-06-30T15:51:03.794Z" }, + { url = "https://files.pythonhosted.org/packages/4e/f5/8d5a15488edd9a91fa4aad97228d785df208ed6298580883aa3d9def1959/multidict-6.6.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:531e331a2ee53543ab32b16334e2deb26f4e6b9b28e41f8e0c87e99a6c8e2d69", size = 242515, upload-time = "2025-06-30T15:51:05.002Z" }, + { url = "https://files.pythonhosted.org/packages/6e/b5/a8f317d47d0ac5bb746d6d8325885c8967c2a8ce0bb57be5399e3642cccb/multidict-6.6.3-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:42ca5aa9329a63be8dc49040f63817d1ac980e02eeddba763a9ae5b4027b9c9c", size = 222224, upload-time = "2025-06-30T15:51:06.148Z" }, + { url = "https://files.pythonhosted.org/packages/76/88/18b2a0d5e80515fa22716556061189c2853ecf2aa2133081ebbe85ebea38/multidict-6.6.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:208b9b9757060b9faa6f11ab4bc52846e4f3c2fb8b14d5680c8aac80af3dc751", size = 253124, upload-time = "2025-06-30T15:51:07.375Z" }, + { url = "https://files.pythonhosted.org/packages/62/bf/ebfcfd6b55a1b05ef16d0775ae34c0fe15e8dab570d69ca9941073b969e7/multidict-6.6.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:acf6b97bd0884891af6a8b43d0f586ab2fcf8e717cbd47ab4bdddc09e20652d8", size = 251529, upload-time = "2025-06-30T15:51:08.691Z" }, + { url = "https://files.pythonhosted.org/packages/44/11/780615a98fd3775fc309d0234d563941af69ade2df0bb82c91dda6ddaea1/multidict-6.6.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:68e9e12ed00e2089725669bdc88602b0b6f8d23c0c95e52b95f0bc69f7fe9b55", size = 241627, upload-time = "2025-06-30T15:51:10.605Z" }, + { url = "https://files.pythonhosted.org/packages/28/3d/35f33045e21034b388686213752cabc3a1b9d03e20969e6fa8f1b1d82db1/multidict-6.6.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:05db2f66c9addb10cfa226e1acb363450fab2ff8a6df73c622fefe2f5af6d4e7", size = 239351, upload-time = "2025-06-30T15:51:12.18Z" }, + { url = "https://files.pythonhosted.org/packages/6e/cc/ff84c03b95b430015d2166d9aae775a3985d757b94f6635010d0038d9241/multidict-6.6.3-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:0db58da8eafb514db832a1b44f8fa7906fdd102f7d982025f816a93ba45e3dcb", size = 233429, upload-time = "2025-06-30T15:51:13.533Z" }, + { url = "https://files.pythonhosted.org/packages/2e/f0/8cd49a0b37bdea673a4b793c2093f2f4ba8e7c9d6d7c9bd672fd6d38cd11/multidict-6.6.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:14117a41c8fdb3ee19c743b1c027da0736fdb79584d61a766da53d399b71176c", size = 243094, upload-time = "2025-06-30T15:51:14.815Z" }, + { url = "https://files.pythonhosted.org/packages/96/19/5d9a0cfdafe65d82b616a45ae950975820289069f885328e8185e64283c2/multidict-6.6.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:877443eaaabcd0b74ff32ebeed6f6176c71850feb7d6a1d2db65945256ea535c", size = 248957, upload-time = "2025-06-30T15:51:16.076Z" }, + { url = "https://files.pythonhosted.org/packages/e6/dc/c90066151da87d1e489f147b9b4327927241e65f1876702fafec6729c014/multidict-6.6.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:70b72e749a4f6e7ed8fb334fa8d8496384840319512746a5f42fa0aec79f4d61", size = 243590, upload-time = "2025-06-30T15:51:17.413Z" }, + { url = "https://files.pythonhosted.org/packages/ec/39/458afb0cccbb0ee9164365273be3e039efddcfcb94ef35924b7dbdb05db0/multidict-6.6.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:43571f785b86afd02b3855c5ac8e86ec921b760298d6f82ff2a61daf5a35330b", size = 237487, upload-time = "2025-06-30T15:51:19.039Z" }, + { url = "https://files.pythonhosted.org/packages/35/38/0016adac3990426610a081787011177e661875546b434f50a26319dc8372/multidict-6.6.3-cp310-cp310-win32.whl", hash = "sha256:20c5a0c3c13a15fd5ea86c42311859f970070e4e24de5a550e99d7c271d76318", size = 41390, upload-time = "2025-06-30T15:51:20.362Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d2/17897a8f3f2c5363d969b4c635aa40375fe1f09168dc09a7826780bfb2a4/multidict-6.6.3-cp310-cp310-win_amd64.whl", hash = "sha256:ab0a34a007704c625e25a9116c6770b4d3617a071c8a7c30cd338dfbadfe6485", size = 45954, upload-time = "2025-06-30T15:51:21.383Z" }, + { url = "https://files.pythonhosted.org/packages/2d/5f/d4a717c1e457fe44072e33fa400d2b93eb0f2819c4d669381f925b7cba1f/multidict-6.6.3-cp310-cp310-win_arm64.whl", hash = "sha256:769841d70ca8bdd140a715746199fc6473414bd02efd678d75681d2d6a8986c5", size = 42981, upload-time = "2025-06-30T15:51:22.809Z" }, + { url = "https://files.pythonhosted.org/packages/08/f0/1a39863ced51f639c81a5463fbfa9eb4df59c20d1a8769ab9ef4ca57ae04/multidict-6.6.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:18f4eba0cbac3546b8ae31e0bbc55b02c801ae3cbaf80c247fcdd89b456ff58c", size = 76445, upload-time = "2025-06-30T15:51:24.01Z" }, + { url = "https://files.pythonhosted.org/packages/c9/0e/a7cfa451c7b0365cd844e90b41e21fab32edaa1e42fc0c9f68461ce44ed7/multidict-6.6.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef43b5dd842382329e4797c46f10748d8c2b6e0614f46b4afe4aee9ac33159df", size = 44610, upload-time = "2025-06-30T15:51:25.158Z" }, + { url = "https://files.pythonhosted.org/packages/c6/bb/a14a4efc5ee748cc1904b0748be278c31b9295ce5f4d2ef66526f410b94d/multidict-6.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bf9bd1fd5eec01494e0f2e8e446a74a85d5e49afb63d75a9934e4a5423dba21d", size = 44267, upload-time = "2025-06-30T15:51:26.326Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f8/410677d563c2d55e063ef74fe578f9d53fe6b0a51649597a5861f83ffa15/multidict-6.6.3-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5bd8d6f793a787153956cd35e24f60485bf0651c238e207b9a54f7458b16d539", size = 230004, upload-time = "2025-06-30T15:51:27.491Z" }, + { url = "https://files.pythonhosted.org/packages/fd/df/2b787f80059314a98e1ec6a4cc7576244986df3e56b3c755e6fc7c99e038/multidict-6.6.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1bf99b4daf908c73856bd87ee0a2499c3c9a3d19bb04b9c6025e66af3fd07462", size = 247196, upload-time = "2025-06-30T15:51:28.762Z" }, + { url = "https://files.pythonhosted.org/packages/05/f2/f9117089151b9a8ab39f9019620d10d9718eec2ac89e7ca9d30f3ec78e96/multidict-6.6.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b9e59946b49dafaf990fd9c17ceafa62976e8471a14952163d10a7a630413a9", size = 225337, upload-time = "2025-06-30T15:51:30.025Z" }, + { url = "https://files.pythonhosted.org/packages/93/2d/7115300ec5b699faa152c56799b089a53ed69e399c3c2d528251f0aeda1a/multidict-6.6.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e2db616467070d0533832d204c54eea6836a5e628f2cb1e6dfd8cd6ba7277cb7", size = 257079, upload-time = "2025-06-30T15:51:31.716Z" }, + { url = "https://files.pythonhosted.org/packages/15/ea/ff4bab367623e39c20d3b07637225c7688d79e4f3cc1f3b9f89867677f9a/multidict-6.6.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7394888236621f61dcdd25189b2768ae5cc280f041029a5bcf1122ac63df79f9", size = 255461, upload-time = "2025-06-30T15:51:33.029Z" }, + { url = "https://files.pythonhosted.org/packages/74/07/2c9246cda322dfe08be85f1b8739646f2c4c5113a1422d7a407763422ec4/multidict-6.6.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f114d8478733ca7388e7c7e0ab34b72547476b97009d643644ac33d4d3fe1821", size = 246611, upload-time = "2025-06-30T15:51:34.47Z" }, + { url = "https://files.pythonhosted.org/packages/a8/62/279c13d584207d5697a752a66ffc9bb19355a95f7659140cb1b3cf82180e/multidict-6.6.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cdf22e4db76d323bcdc733514bf732e9fb349707c98d341d40ebcc6e9318ef3d", size = 243102, upload-time = "2025-06-30T15:51:36.525Z" }, + { url = "https://files.pythonhosted.org/packages/69/cc/e06636f48c6d51e724a8bc8d9e1db5f136fe1df066d7cafe37ef4000f86a/multidict-6.6.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e995a34c3d44ab511bfc11aa26869b9d66c2d8c799fa0e74b28a473a692532d6", size = 238693, upload-time = "2025-06-30T15:51:38.278Z" }, + { url = "https://files.pythonhosted.org/packages/89/a4/66c9d8fb9acf3b226cdd468ed009537ac65b520aebdc1703dd6908b19d33/multidict-6.6.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:766a4a5996f54361d8d5a9050140aa5362fe48ce51c755a50c0bc3706460c430", size = 246582, upload-time = "2025-06-30T15:51:39.709Z" }, + { url = "https://files.pythonhosted.org/packages/cf/01/c69e0317be556e46257826d5449feb4e6aa0d18573e567a48a2c14156f1f/multidict-6.6.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:3893a0d7d28a7fe6ca7a1f760593bc13038d1d35daf52199d431b61d2660602b", size = 253355, upload-time = "2025-06-30T15:51:41.013Z" }, + { url = "https://files.pythonhosted.org/packages/c0/da/9cc1da0299762d20e626fe0042e71b5694f9f72d7d3f9678397cbaa71b2b/multidict-6.6.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:934796c81ea996e61914ba58064920d6cad5d99140ac3167901eb932150e2e56", size = 247774, upload-time = "2025-06-30T15:51:42.291Z" }, + { url = "https://files.pythonhosted.org/packages/e6/91/b22756afec99cc31105ddd4a52f95ab32b1a4a58f4d417979c570c4a922e/multidict-6.6.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9ed948328aec2072bc00f05d961ceadfd3e9bfc2966c1319aeaf7b7c21219183", size = 242275, upload-time = "2025-06-30T15:51:43.642Z" }, + { url = "https://files.pythonhosted.org/packages/be/f1/adcc185b878036a20399d5be5228f3cbe7f823d78985d101d425af35c800/multidict-6.6.3-cp311-cp311-win32.whl", hash = "sha256:9f5b28c074c76afc3e4c610c488e3493976fe0e596dd3db6c8ddfbb0134dcac5", size = 41290, upload-time = "2025-06-30T15:51:45.264Z" }, + { url = "https://files.pythonhosted.org/packages/e0/d4/27652c1c6526ea6b4f5ddd397e93f4232ff5de42bea71d339bc6a6cc497f/multidict-6.6.3-cp311-cp311-win_amd64.whl", hash = "sha256:bc7f6fbc61b1c16050a389c630da0b32fc6d4a3d191394ab78972bf5edc568c2", size = 45942, upload-time = "2025-06-30T15:51:46.377Z" }, + { url = "https://files.pythonhosted.org/packages/16/18/23f4932019804e56d3c2413e237f866444b774b0263bcb81df2fdecaf593/multidict-6.6.3-cp311-cp311-win_arm64.whl", hash = "sha256:d4e47d8faffaae822fb5cba20937c048d4f734f43572e7079298a6c39fb172cb", size = 42880, upload-time = "2025-06-30T15:51:47.561Z" }, + { url = "https://files.pythonhosted.org/packages/0e/a0/6b57988ea102da0623ea814160ed78d45a2645e4bbb499c2896d12833a70/multidict-6.6.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:056bebbeda16b2e38642d75e9e5310c484b7c24e3841dc0fb943206a72ec89d6", size = 76514, upload-time = "2025-06-30T15:51:48.728Z" }, + { url = "https://files.pythonhosted.org/packages/07/7a/d1e92665b0850c6c0508f101f9cf0410c1afa24973e1115fe9c6a185ebf7/multidict-6.6.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e5f481cccb3c5c5e5de5d00b5141dc589c1047e60d07e85bbd7dea3d4580d63f", size = 45394, upload-time = "2025-06-30T15:51:49.986Z" }, + { url = "https://files.pythonhosted.org/packages/52/6f/dd104490e01be6ef8bf9573705d8572f8c2d2c561f06e3826b081d9e6591/multidict-6.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:10bea2ee839a759ee368b5a6e47787f399b41e70cf0c20d90dfaf4158dfb4e55", size = 43590, upload-time = "2025-06-30T15:51:51.331Z" }, + { url = "https://files.pythonhosted.org/packages/44/fe/06e0e01b1b0611e6581b7fd5a85b43dacc08b6cea3034f902f383b0873e5/multidict-6.6.3-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:2334cfb0fa9549d6ce2c21af2bfbcd3ac4ec3646b1b1581c88e3e2b1779ec92b", size = 237292, upload-time = "2025-06-30T15:51:52.584Z" }, + { url = "https://files.pythonhosted.org/packages/ce/71/4f0e558fb77696b89c233c1ee2d92f3e1d5459070a0e89153c9e9e804186/multidict-6.6.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8fee016722550a2276ca2cb5bb624480e0ed2bd49125b2b73b7010b9090e888", size = 258385, upload-time = "2025-06-30T15:51:53.913Z" }, + { url = "https://files.pythonhosted.org/packages/e3/25/cca0e68228addad24903801ed1ab42e21307a1b4b6dd2cf63da5d3ae082a/multidict-6.6.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5511cb35f5c50a2db21047c875eb42f308c5583edf96bd8ebf7d770a9d68f6d", size = 242328, upload-time = "2025-06-30T15:51:55.672Z" }, + { url = "https://files.pythonhosted.org/packages/6e/a3/46f2d420d86bbcb8fe660b26a10a219871a0fbf4d43cb846a4031533f3e0/multidict-6.6.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:712b348f7f449948e0a6c4564a21c7db965af900973a67db432d724619b3c680", size = 268057, upload-time = "2025-06-30T15:51:57.037Z" }, + { url = "https://files.pythonhosted.org/packages/9e/73/1c743542fe00794a2ec7466abd3f312ccb8fad8dff9f36d42e18fb1ec33e/multidict-6.6.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e4e15d2138ee2694e038e33b7c3da70e6b0ad8868b9f8094a72e1414aeda9c1a", size = 269341, upload-time = "2025-06-30T15:51:59.111Z" }, + { url = "https://files.pythonhosted.org/packages/a4/11/6ec9dcbe2264b92778eeb85407d1df18812248bf3506a5a1754bc035db0c/multidict-6.6.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8df25594989aebff8a130f7899fa03cbfcc5d2b5f4a461cf2518236fe6f15961", size = 256081, upload-time = "2025-06-30T15:52:00.533Z" }, + { url = "https://files.pythonhosted.org/packages/9b/2b/631b1e2afeb5f1696846d747d36cda075bfdc0bc7245d6ba5c319278d6c4/multidict-6.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:159ca68bfd284a8860f8d8112cf0521113bffd9c17568579e4d13d1f1dc76b65", size = 253581, upload-time = "2025-06-30T15:52:02.43Z" }, + { url = "https://files.pythonhosted.org/packages/bf/0e/7e3b93f79efeb6111d3bf9a1a69e555ba1d07ad1c11bceb56b7310d0d7ee/multidict-6.6.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:e098c17856a8c9ade81b4810888c5ad1914099657226283cab3062c0540b0643", size = 250750, upload-time = "2025-06-30T15:52:04.26Z" }, + { url = "https://files.pythonhosted.org/packages/ad/9e/086846c1d6601948e7de556ee464a2d4c85e33883e749f46b9547d7b0704/multidict-6.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:67c92ed673049dec52d7ed39f8cf9ebbadf5032c774058b4406d18c8f8fe7063", size = 251548, upload-time = "2025-06-30T15:52:06.002Z" }, + { url = "https://files.pythonhosted.org/packages/8c/7b/86ec260118e522f1a31550e87b23542294880c97cfbf6fb18cc67b044c66/multidict-6.6.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:bd0578596e3a835ef451784053cfd327d607fc39ea1a14812139339a18a0dbc3", size = 262718, upload-time = "2025-06-30T15:52:07.707Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bd/22ce8f47abb0be04692c9fc4638508b8340987b18691aa7775d927b73f72/multidict-6.6.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:346055630a2df2115cd23ae271910b4cae40f4e336773550dca4889b12916e75", size = 259603, upload-time = "2025-06-30T15:52:09.58Z" }, + { url = "https://files.pythonhosted.org/packages/07/9c/91b7ac1691be95cd1f4a26e36a74b97cda6aa9820632d31aab4410f46ebd/multidict-6.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:555ff55a359302b79de97e0468e9ee80637b0de1fce77721639f7cd9440b3a10", size = 251351, upload-time = "2025-06-30T15:52:10.947Z" }, + { url = "https://files.pythonhosted.org/packages/6f/5c/4d7adc739884f7a9fbe00d1eac8c034023ef8bad71f2ebe12823ca2e3649/multidict-6.6.3-cp312-cp312-win32.whl", hash = "sha256:73ab034fb8d58ff85c2bcbadc470efc3fafeea8affcf8722855fb94557f14cc5", size = 41860, upload-time = "2025-06-30T15:52:12.334Z" }, + { url = "https://files.pythonhosted.org/packages/6a/a3/0fbc7afdf7cb1aa12a086b02959307848eb6bcc8f66fcb66c0cb57e2a2c1/multidict-6.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:04cbcce84f63b9af41bad04a54d4cc4e60e90c35b9e6ccb130be2d75b71f8c17", size = 45982, upload-time = "2025-06-30T15:52:13.6Z" }, + { url = "https://files.pythonhosted.org/packages/b8/95/8c825bd70ff9b02462dc18d1295dd08d3e9e4eb66856d292ffa62cfe1920/multidict-6.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:0f1130b896ecb52d2a1e615260f3ea2af55fa7dc3d7c3003ba0c3121a759b18b", size = 43210, upload-time = "2025-06-30T15:52:14.893Z" }, + { url = "https://files.pythonhosted.org/packages/52/1d/0bebcbbb4f000751fbd09957257903d6e002943fc668d841a4cf2fb7f872/multidict-6.6.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:540d3c06d48507357a7d57721e5094b4f7093399a0106c211f33540fdc374d55", size = 75843, upload-time = "2025-06-30T15:52:16.155Z" }, + { url = "https://files.pythonhosted.org/packages/07/8f/cbe241b0434cfe257f65c2b1bcf9e8d5fb52bc708c5061fb29b0fed22bdf/multidict-6.6.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9c19cea2a690f04247d43f366d03e4eb110a0dc4cd1bbeee4d445435428ed35b", size = 45053, upload-time = "2025-06-30T15:52:17.429Z" }, + { url = "https://files.pythonhosted.org/packages/32/d2/0b3b23f9dbad5b270b22a3ac3ea73ed0a50ef2d9a390447061178ed6bdb8/multidict-6.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7af039820cfd00effec86bda5d8debef711a3e86a1d3772e85bea0f243a4bd65", size = 43273, upload-time = "2025-06-30T15:52:19.346Z" }, + { url = "https://files.pythonhosted.org/packages/fd/fe/6eb68927e823999e3683bc49678eb20374ba9615097d085298fd5b386564/multidict-6.6.3-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:500b84f51654fdc3944e936f2922114349bf8fdcac77c3092b03449f0e5bc2b3", size = 237124, upload-time = "2025-06-30T15:52:20.773Z" }, + { url = "https://files.pythonhosted.org/packages/e7/ab/320d8507e7726c460cb77117848b3834ea0d59e769f36fdae495f7669929/multidict-6.6.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3fc723ab8a5c5ed6c50418e9bfcd8e6dceba6c271cee6728a10a4ed8561520c", size = 256892, upload-time = "2025-06-30T15:52:22.242Z" }, + { url = "https://files.pythonhosted.org/packages/76/60/38ee422db515ac69834e60142a1a69111ac96026e76e8e9aa347fd2e4591/multidict-6.6.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:94c47ea3ade005b5976789baaed66d4de4480d0a0bf31cef6edaa41c1e7b56a6", size = 240547, upload-time = "2025-06-30T15:52:23.736Z" }, + { url = "https://files.pythonhosted.org/packages/27/fb/905224fde2dff042b030c27ad95a7ae744325cf54b890b443d30a789b80e/multidict-6.6.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dbc7cf464cc6d67e83e136c9f55726da3a30176f020a36ead246eceed87f1cd8", size = 266223, upload-time = "2025-06-30T15:52:25.185Z" }, + { url = "https://files.pythonhosted.org/packages/76/35/dc38ab361051beae08d1a53965e3e1a418752fc5be4d3fb983c5582d8784/multidict-6.6.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:900eb9f9da25ada070f8ee4a23f884e0ee66fe4e1a38c3af644256a508ad81ca", size = 267262, upload-time = "2025-06-30T15:52:26.969Z" }, + { url = "https://files.pythonhosted.org/packages/1f/a3/0a485b7f36e422421b17e2bbb5a81c1af10eac1d4476f2ff92927c730479/multidict-6.6.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c6df517cf177da5d47ab15407143a89cd1a23f8b335f3a28d57e8b0a3dbb884", size = 254345, upload-time = "2025-06-30T15:52:28.467Z" }, + { url = "https://files.pythonhosted.org/packages/b4/59/bcdd52c1dab7c0e0d75ff19cac751fbd5f850d1fc39172ce809a74aa9ea4/multidict-6.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4ef421045f13879e21c994b36e728d8e7d126c91a64b9185810ab51d474f27e7", size = 252248, upload-time = "2025-06-30T15:52:29.938Z" }, + { url = "https://files.pythonhosted.org/packages/bb/a4/2d96aaa6eae8067ce108d4acee6f45ced5728beda55c0f02ae1072c730d1/multidict-6.6.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:6c1e61bb4f80895c081790b6b09fa49e13566df8fbff817da3f85b3a8192e36b", size = 250115, upload-time = "2025-06-30T15:52:31.416Z" }, + { url = "https://files.pythonhosted.org/packages/25/d2/ed9f847fa5c7d0677d4f02ea2c163d5e48573de3f57bacf5670e43a5ffaa/multidict-6.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e5e8523bb12d7623cd8300dbd91b9e439a46a028cd078ca695eb66ba31adee3c", size = 249649, upload-time = "2025-06-30T15:52:32.996Z" }, + { url = "https://files.pythonhosted.org/packages/1f/af/9155850372563fc550803d3f25373308aa70f59b52cff25854086ecb4a79/multidict-6.6.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:ef58340cc896219e4e653dade08fea5c55c6df41bcc68122e3be3e9d873d9a7b", size = 261203, upload-time = "2025-06-30T15:52:34.521Z" }, + { url = "https://files.pythonhosted.org/packages/36/2f/c6a728f699896252cf309769089568a33c6439626648843f78743660709d/multidict-6.6.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fc9dc435ec8699e7b602b94fe0cd4703e69273a01cbc34409af29e7820f777f1", size = 258051, upload-time = "2025-06-30T15:52:35.999Z" }, + { url = "https://files.pythonhosted.org/packages/d0/60/689880776d6b18fa2b70f6cc74ff87dd6c6b9b47bd9cf74c16fecfaa6ad9/multidict-6.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9e864486ef4ab07db5e9cb997bad2b681514158d6954dd1958dfb163b83d53e6", size = 249601, upload-time = "2025-06-30T15:52:37.473Z" }, + { url = "https://files.pythonhosted.org/packages/75/5e/325b11f2222a549019cf2ef879c1f81f94a0d40ace3ef55cf529915ba6cc/multidict-6.6.3-cp313-cp313-win32.whl", hash = "sha256:5633a82fba8e841bc5c5c06b16e21529573cd654f67fd833650a215520a6210e", size = 41683, upload-time = "2025-06-30T15:52:38.927Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ad/cf46e73f5d6e3c775cabd2a05976547f3f18b39bee06260369a42501f053/multidict-6.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:e93089c1570a4ad54c3714a12c2cef549dc9d58e97bcded193d928649cab78e9", size = 45811, upload-time = "2025-06-30T15:52:40.207Z" }, + { url = "https://files.pythonhosted.org/packages/c5/c9/2e3fe950db28fb7c62e1a5f46e1e38759b072e2089209bc033c2798bb5ec/multidict-6.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:c60b401f192e79caec61f166da9c924e9f8bc65548d4246842df91651e83d600", size = 43056, upload-time = "2025-06-30T15:52:41.575Z" }, + { url = "https://files.pythonhosted.org/packages/3a/58/aaf8114cf34966e084a8cc9517771288adb53465188843d5a19862cb6dc3/multidict-6.6.3-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:02fd8f32d403a6ff13864b0851f1f523d4c988051eea0471d4f1fd8010f11134", size = 82811, upload-time = "2025-06-30T15:52:43.281Z" }, + { url = "https://files.pythonhosted.org/packages/71/af/5402e7b58a1f5b987a07ad98f2501fdba2a4f4b4c30cf114e3ce8db64c87/multidict-6.6.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:f3aa090106b1543f3f87b2041eef3c156c8da2aed90c63a2fbed62d875c49c37", size = 48304, upload-time = "2025-06-30T15:52:45.026Z" }, + { url = "https://files.pythonhosted.org/packages/39/65/ab3c8cafe21adb45b24a50266fd747147dec7847425bc2a0f6934b3ae9ce/multidict-6.6.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e924fb978615a5e33ff644cc42e6aa241effcf4f3322c09d4f8cebde95aff5f8", size = 46775, upload-time = "2025-06-30T15:52:46.459Z" }, + { url = "https://files.pythonhosted.org/packages/49/ba/9fcc1b332f67cc0c0c8079e263bfab6660f87fe4e28a35921771ff3eea0d/multidict-6.6.3-cp313-cp313t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:b9fe5a0e57c6dbd0e2ce81ca66272282c32cd11d31658ee9553849d91289e1c1", size = 229773, upload-time = "2025-06-30T15:52:47.88Z" }, + { url = "https://files.pythonhosted.org/packages/a4/14/0145a251f555f7c754ce2dcbcd012939bbd1f34f066fa5d28a50e722a054/multidict-6.6.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b24576f208793ebae00280c59927c3b7c2a3b1655e443a25f753c4611bc1c373", size = 250083, upload-time = "2025-06-30T15:52:49.366Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d4/d5c0bd2bbb173b586c249a151a26d2fb3ec7d53c96e42091c9fef4e1f10c/multidict-6.6.3-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:135631cb6c58eac37d7ac0df380294fecdc026b28837fa07c02e459c7fb9c54e", size = 228980, upload-time = "2025-06-30T15:52:50.903Z" }, + { url = "https://files.pythonhosted.org/packages/21/32/c9a2d8444a50ec48c4733ccc67254100c10e1c8ae8e40c7a2d2183b59b97/multidict-6.6.3-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:274d416b0df887aef98f19f21578653982cfb8a05b4e187d4a17103322eeaf8f", size = 257776, upload-time = "2025-06-30T15:52:52.764Z" }, + { url = "https://files.pythonhosted.org/packages/68/d0/14fa1699f4ef629eae08ad6201c6b476098f5efb051b296f4c26be7a9fdf/multidict-6.6.3-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e252017a817fad7ce05cafbe5711ed40faeb580e63b16755a3a24e66fa1d87c0", size = 256882, upload-time = "2025-06-30T15:52:54.596Z" }, + { url = "https://files.pythonhosted.org/packages/da/88/84a27570fbe303c65607d517a5f147cd2fc046c2d1da02b84b17b9bdc2aa/multidict-6.6.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e4cc8d848cd4fe1cdee28c13ea79ab0ed37fc2e89dd77bac86a2e7959a8c3bc", size = 247816, upload-time = "2025-06-30T15:52:56.175Z" }, + { url = "https://files.pythonhosted.org/packages/1c/60/dca352a0c999ce96a5d8b8ee0b2b9f729dcad2e0b0c195f8286269a2074c/multidict-6.6.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9e236a7094b9c4c1b7585f6b9cca34b9d833cf079f7e4c49e6a4a6ec9bfdc68f", size = 245341, upload-time = "2025-06-30T15:52:57.752Z" }, + { url = "https://files.pythonhosted.org/packages/50/ef/433fa3ed06028f03946f3993223dada70fb700f763f70c00079533c34578/multidict-6.6.3-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:e0cb0ab69915c55627c933f0b555a943d98ba71b4d1c57bc0d0a66e2567c7471", size = 235854, upload-time = "2025-06-30T15:52:59.74Z" }, + { url = "https://files.pythonhosted.org/packages/1b/1f/487612ab56fbe35715320905215a57fede20de7db40a261759690dc80471/multidict-6.6.3-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:81ef2f64593aba09c5212a3d0f8c906a0d38d710a011f2f42759704d4557d3f2", size = 243432, upload-time = "2025-06-30T15:53:01.602Z" }, + { url = "https://files.pythonhosted.org/packages/da/6f/ce8b79de16cd885c6f9052c96a3671373d00c59b3ee635ea93e6e81b8ccf/multidict-6.6.3-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:b9cbc60010de3562545fa198bfc6d3825df430ea96d2cc509c39bd71e2e7d648", size = 252731, upload-time = "2025-06-30T15:53:03.517Z" }, + { url = "https://files.pythonhosted.org/packages/bb/fe/a2514a6aba78e5abefa1624ca85ae18f542d95ac5cde2e3815a9fbf369aa/multidict-6.6.3-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:70d974eaaa37211390cd02ef93b7e938de564bbffa866f0b08d07e5e65da783d", size = 247086, upload-time = "2025-06-30T15:53:05.48Z" }, + { url = "https://files.pythonhosted.org/packages/8c/22/b788718d63bb3cce752d107a57c85fcd1a212c6c778628567c9713f9345a/multidict-6.6.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3713303e4a6663c6d01d648a68f2848701001f3390a030edaaf3fc949c90bf7c", size = 243338, upload-time = "2025-06-30T15:53:07.522Z" }, + { url = "https://files.pythonhosted.org/packages/22/d6/fdb3d0670819f2228f3f7d9af613d5e652c15d170c83e5f1c94fbc55a25b/multidict-6.6.3-cp313-cp313t-win32.whl", hash = "sha256:639ecc9fe7cd73f2495f62c213e964843826f44505a3e5d82805aa85cac6f89e", size = 47812, upload-time = "2025-06-30T15:53:09.263Z" }, + { url = "https://files.pythonhosted.org/packages/b6/d6/a9d2c808f2c489ad199723197419207ecbfbc1776f6e155e1ecea9c883aa/multidict-6.6.3-cp313-cp313t-win_amd64.whl", hash = "sha256:9f97e181f344a0ef3881b573d31de8542cc0dbc559ec68c8f8b5ce2c2e91646d", size = 53011, upload-time = "2025-06-30T15:53:11.038Z" }, + { url = "https://files.pythonhosted.org/packages/f2/40/b68001cba8188dd267590a111f9661b6256debc327137667e832bf5d66e8/multidict-6.6.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ce8b7693da41a3c4fde5871c738a81490cea5496c671d74374c8ab889e1834fb", size = 45254, upload-time = "2025-06-30T15:53:12.421Z" }, + { url = "https://files.pythonhosted.org/packages/d2/64/ba29bd6dfc895e592b2f20f92378e692ac306cf25dd0be2f8e0a0f898edb/multidict-6.6.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c8161b5a7778d3137ea2ee7ae8a08cce0010de3b00ac671c5ebddeaa17cefd22", size = 76959, upload-time = "2025-06-30T15:53:13.827Z" }, + { url = "https://files.pythonhosted.org/packages/ca/cd/872ae4c134257dacebff59834983c1615d6ec863b6e3d360f3203aad8400/multidict-6.6.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1328201ee930f069961ae707d59c6627ac92e351ed5b92397cf534d1336ce557", size = 44864, upload-time = "2025-06-30T15:53:15.658Z" }, + { url = "https://files.pythonhosted.org/packages/15/35/d417d8f62f2886784b76df60522d608aba39dfc83dd53b230ca71f2d4c53/multidict-6.6.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b1db4d2093d6b235de76932febf9d50766cf49a5692277b2c28a501c9637f616", size = 44540, upload-time = "2025-06-30T15:53:17.208Z" }, + { url = "https://files.pythonhosted.org/packages/85/59/25cddf781f12cddb2386baa29744a3fdd160eb705539b48065f0cffd86d5/multidict-6.6.3-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53becb01dd8ebd19d1724bebe369cfa87e4e7f29abbbe5c14c98ce4c383e16cd", size = 224075, upload-time = "2025-06-30T15:53:18.705Z" }, + { url = "https://files.pythonhosted.org/packages/c4/21/4055b6a527954c572498a8068c26bd3b75f2b959080e17e12104b592273c/multidict-6.6.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41bb9d1d4c303886e2d85bade86e59885112a7f4277af5ad47ab919a2251f306", size = 240535, upload-time = "2025-06-30T15:53:20.359Z" }, + { url = "https://files.pythonhosted.org/packages/58/98/17f1f80bdba0b2fef49cf4ba59cebf8a81797f745f547abb5c9a4039df62/multidict-6.6.3-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:775b464d31dac90f23192af9c291dc9f423101857e33e9ebf0020a10bfcf4144", size = 219361, upload-time = "2025-06-30T15:53:22.371Z" }, + { url = "https://files.pythonhosted.org/packages/f8/0e/a5e595fdd0820069f0c29911d5dc9dc3a75ec755ae733ce59a4e6962ae42/multidict-6.6.3-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d04d01f0a913202205a598246cf77826fe3baa5a63e9f6ccf1ab0601cf56eca0", size = 251207, upload-time = "2025-06-30T15:53:24.307Z" }, + { url = "https://files.pythonhosted.org/packages/66/9e/0f51e4cffea2daf24c137feabc9ec848ce50f8379c9badcbac00b41ab55e/multidict-6.6.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d25594d3b38a2e6cabfdcafef339f754ca6e81fbbdb6650ad773ea9775af35ab", size = 249749, upload-time = "2025-06-30T15:53:26.056Z" }, + { url = "https://files.pythonhosted.org/packages/49/a0/a7cfc13c9a71ceb8c1c55457820733af9ce01e121139271f7b13e30c29d2/multidict-6.6.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:35712f1748d409e0707b165bf49f9f17f9e28ae85470c41615778f8d4f7d9609", size = 239202, upload-time = "2025-06-30T15:53:28.096Z" }, + { url = "https://files.pythonhosted.org/packages/c7/50/7ae0d1149ac71cab6e20bb7faf2a1868435974994595dadfdb7377f7140f/multidict-6.6.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1c8082e5814b662de8589d6a06c17e77940d5539080cbab9fe6794b5241b76d9", size = 237269, upload-time = "2025-06-30T15:53:30.124Z" }, + { url = "https://files.pythonhosted.org/packages/b4/ac/2d0bf836c9c63a57360d57b773359043b371115e1c78ff648993bf19abd0/multidict-6.6.3-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:61af8a4b771f1d4d000b3168c12c3120ccf7284502a94aa58c68a81f5afac090", size = 232961, upload-time = "2025-06-30T15:53:31.766Z" }, + { url = "https://files.pythonhosted.org/packages/85/e1/68a65f069df298615591e70e48bfd379c27d4ecb252117c18bf52eebc237/multidict-6.6.3-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:448e4a9afccbf297577f2eaa586f07067441e7b63c8362a3540ba5a38dc0f14a", size = 240863, upload-time = "2025-06-30T15:53:33.488Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ab/702f1baca649f88ea1dc6259fc2aa4509f4ad160ba48c8e61fbdb4a5a365/multidict-6.6.3-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:233ad16999afc2bbd3e534ad8dbe685ef8ee49a37dbc2cdc9514e57b6d589ced", size = 246800, upload-time = "2025-06-30T15:53:35.21Z" }, + { url = "https://files.pythonhosted.org/packages/5e/0b/726e690bfbf887985a8710ef2f25f1d6dd184a35bd3b36429814f810a2fc/multidict-6.6.3-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:bb933c891cd4da6bdcc9733d048e994e22e1883287ff7540c2a0f3b117605092", size = 242034, upload-time = "2025-06-30T15:53:36.913Z" }, + { url = "https://files.pythonhosted.org/packages/73/bb/839486b27bcbcc2e0d875fb9d4012b4b6aa99639137343106aa7210e047a/multidict-6.6.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:37b09ca60998e87734699e88c2363abfd457ed18cfbf88e4009a4e83788e63ed", size = 235377, upload-time = "2025-06-30T15:53:38.618Z" }, + { url = "https://files.pythonhosted.org/packages/e3/46/574d75ab7b9ae8690fe27e89f5fcd0121633112b438edfb9ed2be8be096b/multidict-6.6.3-cp39-cp39-win32.whl", hash = "sha256:f54cb79d26d0cd420637d184af38f0668558f3c4bbe22ab7ad830e67249f2e0b", size = 41420, upload-time = "2025-06-30T15:53:40.309Z" }, + { url = "https://files.pythonhosted.org/packages/78/c3/8b3bc755508b777868349f4bfa844d3d31832f075ee800a3d6f1807338c5/multidict-6.6.3-cp39-cp39-win_amd64.whl", hash = "sha256:295adc9c0551e5d5214b45cf29ca23dbc28c2d197a9c30d51aed9e037cb7c578", size = 46124, upload-time = "2025-06-30T15:53:41.984Z" }, + { url = "https://files.pythonhosted.org/packages/b2/30/5a66e7e4550e80975faee5b5dd9e9bd09194d2fd8f62363119b9e46e204b/multidict-6.6.3-cp39-cp39-win_arm64.whl", hash = "sha256:15332783596f227db50fb261c2c251a58ac3873c457f3a550a95d5c0aa3c770d", size = 42973, upload-time = "2025-06-30T15:53:43.505Z" }, + { url = "https://files.pythonhosted.org/packages/d8/30/9aec301e9772b098c1f5c0ca0279237c9766d94b97802e9888010c64b0ed/multidict-6.6.3-py3-none-any.whl", hash = "sha256:8db10f29c7541fc5da4defd8cd697e1ca429db743fa716325f236079b96f775a", size = 12313, upload-time = "2025-06-30T15:53:45.437Z" }, ] [[package]] @@ -1424,27 +1456,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.12.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/24/90/5255432602c0b196a0da6720f6f76b93eb50baef46d3c9b0025e2f9acbf3/ruff-0.12.0.tar.gz", hash = "sha256:4d047db3662418d4a848a3fdbfaf17488b34b62f527ed6f10cb8afd78135bc5c", size = 4376101, upload-time = "2025-06-17T15:19:26.217Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e6/fd/b46bb20e14b11ff49dbc74c61de352e0dc07fb650189513631f6fb5fc69f/ruff-0.12.0-py3-none-linux_armv6l.whl", hash = "sha256:5652a9ecdb308a1754d96a68827755f28d5dfb416b06f60fd9e13f26191a8848", size = 10311554, upload-time = "2025-06-17T15:18:45.792Z" }, - { url = "https://files.pythonhosted.org/packages/e7/d3/021dde5a988fa3e25d2468d1dadeea0ae89dc4bc67d0140c6e68818a12a1/ruff-0.12.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:05ed0c914fabc602fc1f3b42c53aa219e5736cb030cdd85640c32dbc73da74a6", size = 11118435, upload-time = "2025-06-17T15:18:49.064Z" }, - { url = "https://files.pythonhosted.org/packages/07/a2/01a5acf495265c667686ec418f19fd5c32bcc326d4c79ac28824aecd6a32/ruff-0.12.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:07a7aa9b69ac3fcfda3c507916d5d1bca10821fe3797d46bad10f2c6de1edda0", size = 10466010, upload-time = "2025-06-17T15:18:51.341Z" }, - { url = "https://files.pythonhosted.org/packages/4c/57/7caf31dd947d72e7aa06c60ecb19c135cad871a0a8a251723088132ce801/ruff-0.12.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e7731c3eec50af71597243bace7ec6104616ca56dda2b99c89935fe926bdcd48", size = 10661366, upload-time = "2025-06-17T15:18:53.29Z" }, - { url = "https://files.pythonhosted.org/packages/e9/ba/aa393b972a782b4bc9ea121e0e358a18981980856190d7d2b6187f63e03a/ruff-0.12.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:952d0630eae628250ab1c70a7fffb641b03e6b4a2d3f3ec6c1d19b4ab6c6c807", size = 10173492, upload-time = "2025-06-17T15:18:55.262Z" }, - { url = "https://files.pythonhosted.org/packages/d7/50/9349ee777614bc3062fc6b038503a59b2034d09dd259daf8192f56c06720/ruff-0.12.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c021f04ea06966b02614d442e94071781c424ab8e02ec7af2f037b4c1e01cc82", size = 11761739, upload-time = "2025-06-17T15:18:58.906Z" }, - { url = "https://files.pythonhosted.org/packages/04/8f/ad459de67c70ec112e2ba7206841c8f4eb340a03ee6a5cabc159fe558b8e/ruff-0.12.0-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:7d235618283718ee2fe14db07f954f9b2423700919dc688eacf3f8797a11315c", size = 12537098, upload-time = "2025-06-17T15:19:01.316Z" }, - { url = "https://files.pythonhosted.org/packages/ed/50/15ad9c80ebd3c4819f5bd8883e57329f538704ed57bac680d95cb6627527/ruff-0.12.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0c0758038f81beec8cc52ca22de9685b8ae7f7cc18c013ec2050012862cc9165", size = 12154122, upload-time = "2025-06-17T15:19:03.727Z" }, - { url = "https://files.pythonhosted.org/packages/76/e6/79b91e41bc8cc3e78ee95c87093c6cacfa275c786e53c9b11b9358026b3d/ruff-0.12.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:139b3d28027987b78fc8d6cfb61165447bdf3740e650b7c480744873688808c2", size = 11363374, upload-time = "2025-06-17T15:19:05.875Z" }, - { url = "https://files.pythonhosted.org/packages/db/c3/82b292ff8a561850934549aa9dc39e2c4e783ab3c21debe55a495ddf7827/ruff-0.12.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68853e8517b17bba004152aebd9dd77d5213e503a5f2789395b25f26acac0da4", size = 11587647, upload-time = "2025-06-17T15:19:08.246Z" }, - { url = "https://files.pythonhosted.org/packages/2b/42/d5760d742669f285909de1bbf50289baccb647b53e99b8a3b4f7ce1b2001/ruff-0.12.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:3a9512af224b9ac4757f7010843771da6b2b0935a9e5e76bb407caa901a1a514", size = 10527284, upload-time = "2025-06-17T15:19:10.37Z" }, - { url = "https://files.pythonhosted.org/packages/19/f6/fcee9935f25a8a8bba4adbae62495c39ef281256693962c2159e8b284c5f/ruff-0.12.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b08df3d96db798e5beb488d4df03011874aff919a97dcc2dd8539bb2be5d6a88", size = 10158609, upload-time = "2025-06-17T15:19:12.286Z" }, - { url = "https://files.pythonhosted.org/packages/37/fb/057febf0eea07b9384787bfe197e8b3384aa05faa0d6bd844b94ceb29945/ruff-0.12.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:6a315992297a7435a66259073681bb0d8647a826b7a6de45c6934b2ca3a9ed51", size = 11141462, upload-time = "2025-06-17T15:19:15.195Z" }, - { url = "https://files.pythonhosted.org/packages/10/7c/1be8571011585914b9d23c95b15d07eec2d2303e94a03df58294bc9274d4/ruff-0.12.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1e55e44e770e061f55a7dbc6e9aed47feea07731d809a3710feda2262d2d4d8a", size = 11641616, upload-time = "2025-06-17T15:19:17.6Z" }, - { url = "https://files.pythonhosted.org/packages/6a/ef/b960ab4818f90ff59e571d03c3f992828d4683561095e80f9ef31f3d58b7/ruff-0.12.0-py3-none-win32.whl", hash = "sha256:7162a4c816f8d1555eb195c46ae0bd819834d2a3f18f98cc63819a7b46f474fb", size = 10525289, upload-time = "2025-06-17T15:19:19.688Z" }, - { url = "https://files.pythonhosted.org/packages/34/93/8b16034d493ef958a500f17cda3496c63a537ce9d5a6479feec9558f1695/ruff-0.12.0-py3-none-win_amd64.whl", hash = "sha256:d00b7a157b8fb6d3827b49d3324da34a1e3f93492c1f97b08e222ad7e9b291e0", size = 11598311, upload-time = "2025-06-17T15:19:21.785Z" }, - { url = "https://files.pythonhosted.org/packages/d0/33/4d3e79e4a84533d6cd526bfb42c020a23256ae5e4265d858bd1287831f7d/ruff-0.12.0-py3-none-win_arm64.whl", hash = "sha256:8cd24580405ad8c1cc64d61725bca091d6b6da7eb3d36f72cc605467069d7e8b", size = 10724946, upload-time = "2025-06-17T15:19:23.952Z" }, +version = "0.12.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/30/cd/01015eb5034605fd98d829c5839ec2c6b4582b479707f7c1c2af861e8258/ruff-0.12.5.tar.gz", hash = "sha256:b209db6102b66f13625940b7f8c7d0f18e20039bb7f6101fbdac935c9612057e", size = 5170722, upload-time = "2025-07-24T13:26:37.456Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/de/ad2f68f0798ff15dd8c0bcc2889558970d9a685b3249565a937cd820ad34/ruff-0.12.5-py3-none-linux_armv6l.whl", hash = "sha256:1de2c887e9dec6cb31fcb9948299de5b2db38144e66403b9660c9548a67abd92", size = 11819133, upload-time = "2025-07-24T13:25:56.369Z" }, + { url = "https://files.pythonhosted.org/packages/f8/fc/c6b65cd0e7fbe60f17e7ad619dca796aa49fbca34bb9bea5f8faf1ec2643/ruff-0.12.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d1ab65e7d8152f519e7dea4de892317c9da7a108da1c56b6a3c1d5e7cf4c5e9a", size = 12501114, upload-time = "2025-07-24T13:25:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/c5/de/c6bec1dce5ead9f9e6a946ea15e8d698c35f19edc508289d70a577921b30/ruff-0.12.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:962775ed5b27c7aa3fdc0d8f4d4433deae7659ef99ea20f783d666e77338b8cf", size = 11716873, upload-time = "2025-07-24T13:26:01.496Z" }, + { url = "https://files.pythonhosted.org/packages/a1/16/cf372d2ebe91e4eb5b82a2275c3acfa879e0566a7ac94d331ea37b765ac8/ruff-0.12.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:73b4cae449597e7195a49eb1cdca89fd9fbb16140c7579899e87f4c85bf82f73", size = 11958829, upload-time = "2025-07-24T13:26:03.721Z" }, + { url = "https://files.pythonhosted.org/packages/25/bf/cd07e8f6a3a6ec746c62556b4c4b79eeb9b0328b362bb8431b7b8afd3856/ruff-0.12.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8b13489c3dc50de5e2d40110c0cce371e00186b880842e245186ca862bf9a1ac", size = 11626619, upload-time = "2025-07-24T13:26:06.118Z" }, + { url = "https://files.pythonhosted.org/packages/d8/c9/c2ccb3b8cbb5661ffda6925f81a13edbb786e623876141b04919d1128370/ruff-0.12.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f1504fea81461cf4841778b3ef0a078757602a3b3ea4b008feb1308cb3f23e08", size = 13221894, upload-time = "2025-07-24T13:26:08.292Z" }, + { url = "https://files.pythonhosted.org/packages/6b/58/68a5be2c8e5590ecdad922b2bcd5583af19ba648f7648f95c51c3c1eca81/ruff-0.12.5-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c7da4129016ae26c32dfcbd5b671fe652b5ab7fc40095d80dcff78175e7eddd4", size = 14163909, upload-time = "2025-07-24T13:26:10.474Z" }, + { url = "https://files.pythonhosted.org/packages/bd/d1/ef6b19622009ba8386fdb792c0743f709cf917b0b2f1400589cbe4739a33/ruff-0.12.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ca972c80f7ebcfd8af75a0f18b17c42d9f1ef203d163669150453f50ca98ab7b", size = 13583652, upload-time = "2025-07-24T13:26:13.381Z" }, + { url = "https://files.pythonhosted.org/packages/62/e3/1c98c566fe6809a0c83751d825a03727f242cdbe0d142c9e292725585521/ruff-0.12.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8dbbf9f25dfb501f4237ae7501d6364b76a01341c6f1b2cd6764fe449124bb2a", size = 12700451, upload-time = "2025-07-24T13:26:15.488Z" }, + { url = "https://files.pythonhosted.org/packages/24/ff/96058f6506aac0fbc0d0fc0d60b0d0bd746240a0594657a2d94ad28033ba/ruff-0.12.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c47dea6ae39421851685141ba9734767f960113d51e83fd7bb9958d5be8763a", size = 12937465, upload-time = "2025-07-24T13:26:17.808Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d3/68bc5e7ab96c94b3589d1789f2dd6dd4b27b263310019529ac9be1e8f31b/ruff-0.12.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c5076aa0e61e30f848846f0265c873c249d4b558105b221be1828f9f79903dc5", size = 11771136, upload-time = "2025-07-24T13:26:20.422Z" }, + { url = "https://files.pythonhosted.org/packages/52/75/7356af30a14584981cabfefcf6106dea98cec9a7af4acb5daaf4b114845f/ruff-0.12.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a5a4c7830dadd3d8c39b1cc85386e2c1e62344f20766be6f173c22fb5f72f293", size = 11601644, upload-time = "2025-07-24T13:26:22.928Z" }, + { url = "https://files.pythonhosted.org/packages/c2/67/91c71d27205871737cae11025ee2b098f512104e26ffd8656fd93d0ada0a/ruff-0.12.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:46699f73c2b5b137b9dc0fc1a190b43e35b008b398c6066ea1350cce6326adcb", size = 12478068, upload-time = "2025-07-24T13:26:26.134Z" }, + { url = "https://files.pythonhosted.org/packages/34/04/b6b00383cf2f48e8e78e14eb258942fdf2a9bf0287fbf5cdd398b749193a/ruff-0.12.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5a655a0a0d396f0f072faafc18ebd59adde8ca85fb848dc1b0d9f024b9c4d3bb", size = 12991537, upload-time = "2025-07-24T13:26:28.533Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b9/053d6445dc7544fb6594785056d8ece61daae7214859ada4a152ad56b6e0/ruff-0.12.5-py3-none-win32.whl", hash = "sha256:dfeb2627c459b0b78ca2bbdc38dd11cc9a0a88bf91db982058b26ce41714ffa9", size = 11751575, upload-time = "2025-07-24T13:26:30.835Z" }, + { url = "https://files.pythonhosted.org/packages/bc/0f/ab16e8259493137598b9149734fec2e06fdeda9837e6f634f5c4e35916da/ruff-0.12.5-py3-none-win_amd64.whl", hash = "sha256:ae0d90cf5f49466c954991b9d8b953bd093c32c27608e409ae3564c63c5306a5", size = 12882273, upload-time = "2025-07-24T13:26:32.929Z" }, + { url = "https://files.pythonhosted.org/packages/00/db/c376b0661c24cf770cb8815268190668ec1330eba8374a126ceef8c72d55/ruff-0.12.5-py3-none-win_arm64.whl", hash = "sha256:48cdbfc633de2c5c37d9f090ba3b352d1576b0015bfc3bc98eaf230275b7e805", size = 11951564, upload-time = "2025-07-24T13:26:34.994Z" }, ] [[package]] @@ -1554,16 +1586,43 @@ wheels = [ name = "sphinx-prompt" version = "1.8.0" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.10.*'", + "python_full_version < '3.10'", +] dependencies = [ - { name = "docutils" }, - { name = "pygments" }, - { name = "sphinx" }, + { name = "docutils", marker = "python_full_version < '3.11'" }, + { name = "pygments", marker = "python_full_version < '3.11'" }, + { name = "sphinx", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e7/fb/7a07b8df1ca2418147a6b13e3f6b445071f2565198b45efa631d0d6ef0cd/sphinx_prompt-1.8.0.tar.gz", hash = "sha256:47482f86fcec29662fdfd23e7c04ef03582714195d01f5d565403320084372ed", size = 5121, upload-time = "2023-09-14T12:46:13.449Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/39/49/f890a2668b7cbf375f5528b549c8d36dd2e801b0fbb7b2b5ef65663ecb6c/sphinx_prompt-1.8.0-py3-none-any.whl", hash = "sha256:369ecc633f0711886f9b3a078c83264245be1adf46abeeb9b88b5519e4b51007", size = 7298, upload-time = "2023-09-14T12:46:12.373Z" }, ] +[[package]] +name = "sphinx-prompt" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.13'", + "python_full_version >= '3.11' and python_full_version < '3.13'", +] +dependencies = [ + { name = "certifi", marker = "python_full_version >= '3.11'" }, + { name = "docutils", marker = "python_full_version >= '3.11'" }, + { name = "idna", marker = "python_full_version >= '3.11'" }, + { name = "jinja2", marker = "python_full_version >= '3.11'" }, + { name = "pygments", marker = "python_full_version >= '3.11'" }, + { name = "requests", marker = "python_full_version >= '3.11'" }, + { name = "sphinx", marker = "python_full_version >= '3.11'" }, + { name = "urllib3", marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/2b/8f3a87784e6313e48b4d91dfb4aae1e5af3fa0c94ef9e875eb2e471e1418/sphinx_prompt-1.10.0.tar.gz", hash = "sha256:23dca4c07ade840c9e87089d79d3499040fa524b3c422941427454e215fdd111", size = 5181, upload-time = "2025-06-24T08:32:18.684Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/5e/f359e06019dbf0d7f8e23f46c535085c7dc367190a7e19456a09a0153a70/sphinx_prompt-1.10.0-py3-none-any.whl", hash = "sha256:d62f7a1aa346225d30222a271dc78997031204a5f199ce5006c14ece0d94b217", size = 5308, upload-time = "2025-06-24T08:32:17.768Z" }, +] + [[package]] name = "sphinx-rtd-theme" version = "2.0.0" @@ -1610,7 +1669,8 @@ dependencies = [ { name = "sphinx" }, { name = "sphinx-autodoc-typehints" }, { name = "sphinx-jinja2-compat" }, - { name = "sphinx-prompt" }, + { name = "sphinx-prompt", version = "1.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx-prompt", version = "1.10.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "sphinx-tabs" }, { name = "tabulate" }, { name = "typing-extensions" }, @@ -1754,11 +1814,11 @@ wheels = [ [[package]] name = "typing-extensions" -version = "4.14.0" +version = "4.14.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d1/bc/51647cd02527e87d05cb083ccc402f93e441606ff1f01739a62c8ad09ba5/typing_extensions-4.14.0.tar.gz", hash = "sha256:8676b788e32f02ab42d9e7c61324048ae4c6d844a399eebace3d4979d75ceef4", size = 107423, upload-time = "2025-06-02T14:52:11.399Z" } +sdist = { url = "https://files.pythonhosted.org/packages/98/5a/da40306b885cc8c09109dc2e1abd358d5684b1425678151cdaed4731c822/typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36", size = 107673, upload-time = "2025-07-04T13:28:34.16Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/69/e0/552843e0d356fbb5256d21449fa957fa4eff3bbc135a74a691ee70c7c5da/typing_extensions-4.14.0-py3-none-any.whl", hash = "sha256:a1514509136dd0b477638fc68d6a91497af5076466ad0fa6c338e44e359944af", size = 43839, upload-time = "2025-06-02T14:52:10.026Z" }, + { url = "https://files.pythonhosted.org/packages/b5/00/d631e67a838026495268c2f6884f3711a15a9a2a96cd244fdaea53b823fb/typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76", size = 43906, upload-time = "2025-07-04T13:28:32.743Z" }, ] [[package]] @@ -1772,16 +1832,16 @@ wheels = [ [[package]] name = "virtualenv" -version = "20.31.2" +version = "20.32.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, { name = "filelock" }, { name = "platformdirs" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/56/2c/444f465fb2c65f40c3a104fd0c495184c4f2336d65baf398e3c75d72ea94/virtualenv-20.31.2.tar.gz", hash = "sha256:e10c0a9d02835e592521be48b332b6caee6887f332c111aa79a09b9e79efc2af", size = 6076316, upload-time = "2025-05-08T17:58:23.811Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/96/0834f30fa08dca3738614e6a9d42752b6420ee94e58971d702118f7cfd30/virtualenv-20.32.0.tar.gz", hash = "sha256:886bf75cadfdc964674e6e33eb74d787dff31ca314ceace03ca5810620f4ecf0", size = 6076970, upload-time = "2025-07-21T04:09:50.985Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f3/40/b1c265d4b2b62b58576588510fc4d1fe60a86319c8de99fd8e9fec617d2c/virtualenv-20.31.2-py3-none-any.whl", hash = "sha256:36efd0d9650ee985f0cad72065001e66d49a6f24eb44d98980f630686243cf11", size = 6057982, upload-time = "2025-05-08T17:58:21.15Z" }, + { url = "https://files.pythonhosted.org/packages/5c/c6/f8f28009920a736d0df434b52e9feebfb4d702ba942f15338cb4a83eafc1/virtualenv-20.32.0-py3-none-any.whl", hash = "sha256:2c310aecb62e5aa1b06103ed7c2977b81e042695de2697d01017ff0f1034af56", size = 6057761, upload-time = "2025-07-21T04:09:48.059Z" }, ] [[package]] From ee981975d9cd40e3a8e9a13ccf742f3dbbbe8a1b Mon Sep 17 00:00:00 2001 From: Jared Casey Date: Tue, 29 Jul 2025 10:48:26 -0600 Subject: [PATCH 15/18] PYCO-68: Remove HTTPTransport from blocking API --- .../protocol/_core/client_adapter.py | 2 +- .../protocol/_core/client_adapter.py | 6 +- .../protocol/_core/http_transport.py | 246 ------------------ 3 files changed, 2 insertions(+), 252 deletions(-) delete mode 100644 couchbase_analytics/protocol/_core/http_transport.py diff --git a/acouchbase_analytics/protocol/_core/client_adapter.py b/acouchbase_analytics/protocol/_core/client_adapter.py index 27a8ab8..ad9727e 100644 --- a/acouchbase_analytics/protocol/_core/client_adapter.py +++ b/acouchbase_analytics/protocol/_core/client_adapter.py @@ -48,7 +48,7 @@ def __init__( self._opts_builder = OptionsBuilder() kwargs['logger_name'] = self.logger_name self._conn_details = _ConnectionDetails.create(self._opts_builder, http_endpoint, credential, options, **kwargs) - # TODO: do we want to support custom HTTP transports for the async client? + # PYCO-67: Do we want to allow supporting custom HTTP transports? self._http_transport_cls = None @property diff --git a/couchbase_analytics/protocol/_core/client_adapter.py b/couchbase_analytics/protocol/_core/client_adapter.py index 8386f36..09f8bcd 100644 --- a/couchbase_analytics/protocol/_core/client_adapter.py +++ b/couchbase_analytics/protocol/_core/client_adapter.py @@ -27,8 +27,6 @@ from couchbase_analytics.protocol.connection import _ConnectionDetails from couchbase_analytics.protocol.options import OptionsBuilder -# from couchbase_analytics.protocol.core._http_transport import AnalyticsHTTPTransport - if TYPE_CHECKING: from couchbase_analytics.protocol._core.request import QueryRequest @@ -48,9 +46,7 @@ def __init__( self._prefix = '' self._cluster_id = cast(str, kwargs.pop('cluster_id', '')) self._opts_builder = OptionsBuilder() - # TODO: We should limit the allowed transports to the ones we support - # Question is how do we want to limit the transports? Should users even need to override? - # self._http_transport_cls = kwargs.pop('http_transport_cls', AnalyticsHTTPTransport) + # PYCO-67: Do we want to allow supporting custom HTTP transports? self._http_transport_cls = None kwargs['logger_name'] = self.logger_name self._conn_details = _ConnectionDetails.create(self._opts_builder, http_endpoint, credential, options, **kwargs) diff --git a/couchbase_analytics/protocol/_core/http_transport.py b/couchbase_analytics/protocol/_core/http_transport.py deleted file mode 100644 index d9d347e..0000000 --- a/couchbase_analytics/protocol/_core/http_transport.py +++ /dev/null @@ -1,246 +0,0 @@ -import ssl -import time -from types import TracebackType -from typing import Iterable, Optional, TypeVar, Union - -from httpcore import ( - ConnectionInterface, - ConnectionPool, - HTTP2Connection, - HTTP11Connection, - HTTPConnection, - Origin, - Request, -) -from httpcore import Response as CoreResponse -from httpcore._exceptions import ConnectionNotAvailable, UnsupportedProtocol -from httpcore._sync.connection_pool import PoolByteStream, PoolRequest -from httpx import URL, BaseTransport, HTTPTransport, Limits, Proxy, Response, SyncByteStream, create_ssl_context -from httpx._transports.default import SOCKET_OPTION, ResponseStream, map_httpcore_exceptions -from httpx._types import CertTypes, ProxyTypes - -# httpx._transports.default.py -T = TypeVar('T', bound='HTTPTransport') -DEFAULT_LIMITS = Limits(max_connections=100, max_keepalive_connections=20) - -# ProxyTypes = Union["URL", str, "Proxy"] -# CertTypes = Union[str, Tuple[str, str], Tuple[str, str, str]] - - -class AnalyticsHTTPConnection(HTTPConnection): - def __init__(self, *args, **kwargs) -> None: # type: ignore - super().__init__(*args, **kwargs) - - # The logic is the exact same as httpcore's Connection.handle_request, with the following additions: - # - We update the request's read timeout to remove the time taken to establish a connection - # 2025-06-05: https://github.com/encode/httpcore/blob/98209758cc14e1a5f966fe1dfdc1064b94055d8c/httpcore/_sync/connection.py#L69 - def handle_request(self, request: Request) -> CoreResponse: - if not self.can_handle_request(request.url.origin): - raise RuntimeError(f'Attempted to send request to {request.url.origin} on connection to {self._origin}') - - # PYCBAC Addition: track the query deadline - timeouts = request.extensions.get('timeout', {}) - timeout = timeouts.get('read', None) - deadline = time.monotonic() + timeout - try: - with self._request_lock: - if self._connection is None: - stream = self._connect(request) - - ssl_object = stream.get_extra_info('ssl_object') - http2_negotiated = ssl_object is not None and ssl_object.selected_alpn_protocol() == 'h2' - if http2_negotiated or (self._http2 and not self._http1): - self._connection = HTTP2Connection( - origin=self._origin, - stream=stream, - keepalive_expiry=self._keepalive_expiry, - ) - else: - self._connection = HTTP11Connection( - origin=self._origin, - stream=stream, - keepalive_expiry=self._keepalive_expiry, - ) - except BaseException as exc: - self._connect_failed = True - raise exc - - # PYCBAC Addition: We _always_ set the request timeouts, so no need to validate keys - query_timeout = round(deadline - time.monotonic(), 6) # round to microseconds - request.extensions['timeout']['read'] = query_timeout - - return self._connection.handle_request(request) - - -class AnalyticsConnectionPool(ConnectionPool): - def __init__(self, *args, **kwargs) -> None: # type: ignore - super().__init__(*args, **kwargs) - - # The logic is the exact same as httpcore's ConnectionPool.handle_request, with the following additions: - # - We update the request's connect timeout to remove the time taken to obtain a connection from the pool - # - For any retries in obtaining a connection from the pool, we update the timeout for subsequent attempts - # 2025.05.30: https://github.com/encode/httpcore/blob/98209758cc14e1a5f966fe1dfdc1064b94055d8c/httpcore/_sync/connection_pool.py#L199 - def handle_request(self, request: Request) -> CoreResponse: - """ - Send an HTTP request, and return an HTTP response. - - This is the core implementation that is called into by `.request()` or `.stream()`. - """ - scheme = request.url.scheme.decode() - if scheme == '': - raise UnsupportedProtocol("Request URL is missing an 'http://' or 'https://' protocol.") - if scheme not in ('http', 'https', 'ws', 'wss'): - raise UnsupportedProtocol(f"Request URL has an unsupported protocol '{scheme}://'.") - - timeouts = request.extensions.get('timeout', {}) - timeout = timeouts.get('pool', None) - - with self._optional_thread_lock: - # Add the incoming request to our request queue. - pool_request = PoolRequest(request) - self._requests.append(pool_request) - - # PYCBAC Addition: track the deadline - deadline = time.monotonic() + timeout - try: - while True: - with self._optional_thread_lock: - # Assign incoming requests to available connections, - # closing or creating new connections as required. - closing = self._assign_requests_to_connections() - self._close_connections(closing) - - # Wait until this request has an assigned connection. - connection = pool_request.wait_for_connection(timeout=timeout) - # PYCBAC Addition: We _always_ set the request timeouts, so no need to validate keys - connect_timeout = round(deadline - time.monotonic(), 6) # round to microseconds - pool_request.request.extensions['timeout']['connect'] = connect_timeout - - try: - # Send the request on the assigned connection. - response = connection.handle_request(pool_request.request) - except ConnectionNotAvailable: - # In some cases a connection may initially be available to - # handle a request, but then become unavailable. - # - # In this case we clear the connection and try again. - pool_request.clear_connection() - # PYCBAC Addition: We update the timeout for the next attempt - timeout = round(deadline - time.monotonic(), 6) # round to microseconds - else: - break # pragma: nocover - - except BaseException as exc: - with self._optional_thread_lock: - # For any exception or cancellation we remove the request from - # the queue, and then re-assign requests to connections. - self._requests.remove(pool_request) - closing = self._assign_requests_to_connections() - - self._close_connections(closing) - raise exc from None - - # Return the response. Note that in this case we still have to manage - # the point at which the response is closed. - assert isinstance(response.stream, Iterable) # nosec B101 - return CoreResponse( - status=response.status, - headers=response.headers, - content=PoolByteStream(stream=response.stream, pool_request=pool_request, pool=self), - extensions=response.extensions, - ) - - # Override httpcore's ConnectionPool.create_connection to only return our own AnalyticsHTTPConnection. - # 2025-06-05: https://github.com/encode/httpcore/blob/98209758cc14e1a5f966fe1dfdc1064b94055d8c/httpcore/_sync/connection_pool.py#L128 - def create_connection(self, origin: Origin) -> ConnectionInterface: - return AnalyticsHTTPConnection( - origin=origin, - ssl_context=self._ssl_context, - keepalive_expiry=self._keepalive_expiry, - http1=self._http1, - http2=self._http2, - retries=self._retries, - local_address=self._local_address, - uds=self._uds, - network_backend=self._network_backend, - socket_options=self._socket_options, - ) - - -class AnalyticsHTTPTransport(BaseTransport): - def __init__( - self, - verify: Union[ssl.SSLContext, str, bool] = True, - cert: Optional[CertTypes] = None, - trust_env: bool = True, - http1: bool = True, - http2: bool = False, - limits: Limits = DEFAULT_LIMITS, - proxy: Optional[ProxyTypes] = None, - uds: Optional[str] = None, - local_address: Optional[str] = None, - retries: int = 0, - socket_options: Optional[Iterable[SOCKET_OPTION]] = None, - ) -> None: - proxy = Proxy(url=proxy) if isinstance(proxy, (str, URL)) else proxy - ssl_context = create_ssl_context(verify=verify, cert=cert, trust_env=trust_env) - - self._pool = AnalyticsConnectionPool( - ssl_context=ssl_context, - max_connections=limits.max_connections, - max_keepalive_connections=limits.max_keepalive_connections, - keepalive_expiry=limits.keepalive_expiry, - http1=http1, - http2=http2, - uds=uds, - local_address=local_address, - retries=retries, - socket_options=socket_options, - ) - - def __enter__(self: T) -> T: # type: ignore - self._pool.__enter__() - return self - - def __exit__( - self, - exc_type: Optional[type[BaseException]] = None, - exc_value: Optional[BaseException] = None, - traceback: Optional[TracebackType] = None, - ) -> None: - with map_httpcore_exceptions(): - self._pool.__exit__(exc_type, exc_value, traceback) - - def handle_request( - self, - request: Request, # type: ignore - ) -> Response: - assert isinstance(request.stream, SyncByteStream) # nosec B101 - import httpcore - - req = httpcore.Request( - method=request.method, - url=httpcore.URL( - scheme=request.url.raw_scheme, # type: ignore - host=request.url.raw_host, # type: ignore - port=request.url.port, - target=request.url.raw_path, # type: ignore - ), - headers=request.headers.raw, # type: ignore - content=request.stream, - extensions=request.extensions, - ) - with map_httpcore_exceptions(): - resp = self._pool.handle_request(req) - - assert isinstance(resp.stream, Iterable) # nosec B101 - - return Response( - status_code=resp.status, - headers=resp.headers, - stream=ResponseStream(resp.stream), - extensions=resp.extensions, - ) - - def close(self) -> None: - self._pool.close() From 96add1a1da7af9526ef5e32825178dbc6a15f4a4 Mon Sep 17 00:00:00 2001 From: Jared Casey Date: Tue, 29 Jul 2025 11:37:39 -0600 Subject: [PATCH 16/18] PYCO-69: Update error handling within retry logic Changes ======= * Handle wider range of httpx errors * Allow BaseException to be raised if not a timeout or cancel scenario --- .../protocol/_core/retries.py | 24 ++++++++++++----- couchbase_analytics/protocol/_core/retries.py | 26 +++++++++++++------ 2 files changed, 36 insertions(+), 14 deletions(-) diff --git a/acouchbase_analytics/protocol/_core/retries.py b/acouchbase_analytics/protocol/_core/retries.py index e480db5..a23330a 100644 --- a/acouchbase_analytics/protocol/_core/retries.py +++ b/acouchbase_analytics/protocol/_core/retries.py @@ -19,7 +19,7 @@ from functools import wraps from typing import TYPE_CHECKING, Any, Callable, Coroutine, Optional, Union -from httpx import ConnectError, ConnectTimeout, ReadTimeout, WriteError, WriteTimeout +from httpx import ConnectError, ConnectTimeout, CookieConflict, HTTPError, InvalidURL, ReadTimeout, StreamError from acouchbase_analytics.protocol._core.anyio_utils import sleep from couchbase_analytics.common.errors import AnalyticsError, InternalSDKError, TimeoutError @@ -39,7 +39,7 @@ class AsyncRetryHandler: @staticmethod async def handle_httpx_retry( - ex: Union[ConnectError, ConnectTimeout, WriteError, WriteTimeout], ctx: AsyncRequestContext + ex: Union[ConnectError, ConnectTimeout], ctx: AsyncRequestContext ) -> Optional[Exception]: err_str = str(ex) if 'SSL:' in err_str: @@ -107,7 +107,7 @@ async def wrapped_fn(self: AsyncHttpStreamingResponse) -> None: # noqa: C901 continue await self._request_context.shutdown(type(ex), ex, ex.__traceback__) raise err from None - except (ConnectError, ConnectTimeout, WriteError, WriteTimeout) as ex: + except (ConnectError, ConnectTimeout) as ex: err = await AsyncRetryHandler.handle_httpx_retry(ex, self._request_context) if err is None: continue @@ -120,6 +120,12 @@ async def wrapped_fn(self: AsyncHttpStreamingResponse) -> None: # noqa: C901 raise TimeoutError( message='Request timed out.', context=str(self._request_context.error_context) ) from None + except (CookieConflict, HTTPError, StreamError, InvalidURL) as ex: + # these are not retriable errors, so we just shutdown the request context and raise the error + await self._request_context.shutdown(type(ex), ex, ex.__traceback__) + raise AnalyticsError( + cause=ex, message=str(ex), context=str(self._request_context.error_context) + ) from None except AnalyticsError: # if an AnalyticsError is raised, we have already shut down the request context raise @@ -142,9 +148,15 @@ async def wrapped_fn(self: AsyncHttpStreamingResponse) -> None: # noqa: C901 raise CancelledError('Request was cancelled.') from None if self._request_context.request_error is not None: raise self._request_context.request_error from None - raise InternalSDKError( - cause=ex, message=str(ex), context=str(self._request_context.error_context) - ) from None + if isinstance(ex, Exception): + # If the exception is an Exception, we raise it as an InternalSDKError as this is + # an unexpected error in the SDK + raise InternalSDKError( + cause=ex, message=str(ex), context=str(self._request_context.error_context) + ) from None + # we should have handled CancelledError and TimeoutError above, so if we get here, + # raise the BaseException as is (most likely a KeyboardInterrupt) + raise ex finally: if not RequestState.is_okay(self._request_context.request_state): await self.close() diff --git a/couchbase_analytics/protocol/_core/retries.py b/couchbase_analytics/protocol/_core/retries.py index 5aeb837..b135e80 100644 --- a/couchbase_analytics/protocol/_core/retries.py +++ b/couchbase_analytics/protocol/_core/retries.py @@ -20,7 +20,7 @@ from time import sleep from typing import TYPE_CHECKING, Callable, Optional, Union -from httpx import ConnectError, ConnectTimeout, ReadTimeout, WriteError, WriteTimeout +from httpx import ConnectError, ConnectTimeout, CookieConflict, HTTPError, InvalidURL, ReadTimeout, StreamError from couchbase_analytics.common.errors import AnalyticsError, InternalSDKError, TimeoutError from couchbase_analytics.common.logging import LogLevel @@ -38,9 +38,7 @@ class RetryHandler: """ @staticmethod - def handle_httpx_retry( - ex: Union[ConnectError, ConnectTimeout, WriteError, WriteTimeout], ctx: RequestContext - ) -> Optional[Exception]: + def handle_httpx_retry(ex: Union[ConnectError, ConnectTimeout], ctx: RequestContext) -> Optional[Exception]: err_str = str(ex) if 'SSL:' in err_str: message = 'TLS connection error occurred.' @@ -105,7 +103,7 @@ def wrapped_fn(self: HttpStreamingResponse) -> None: # noqa: C901 continue self._request_context.shutdown(ex) raise err from None - except (ConnectError, ConnectTimeout, WriteError, WriteTimeout) as ex: + except (ConnectError, ConnectTimeout) as ex: err = RetryHandler.handle_httpx_retry(ex, self._request_context) if err is None: continue @@ -118,6 +116,12 @@ def wrapped_fn(self: HttpStreamingResponse) -> None: # noqa: C901 raise TimeoutError( message='Request timed out.', context=str(self._request_context.error_context) ) from None + except (CookieConflict, HTTPError, StreamError, InvalidURL) as ex: + # these are not retriable errors, so we just shutdown the request context and raise the error + self._request_context.shutdown(ex) + raise AnalyticsError( + cause=ex, message=str(ex), context=str(self._request_context.error_context) + ) from None except AnalyticsError: # if an AnalyticsError is raised, we have already shut down the request context raise @@ -138,9 +142,15 @@ def wrapped_fn(self: HttpStreamingResponse) -> None: # noqa: C901 ) from None if self._request_context.cancelled: raise CancelledError('Request was cancelled.') from None - raise InternalSDKError( - cause=ex, message=str(ex), context=str(self._request_context.error_context) - ) from None + if isinstance(ex, Exception): + # If the exception is an Exception, we raise it as an InternalSDKError as this is + # an unexpected error in the SDK + raise InternalSDKError( + cause=ex, message=str(ex), context=str(self._request_context.error_context) + ) from None + # we should have handled CancelledError and TimeoutError above, so if we get here, + # raise the BaseException as is (most likely a KeyboardInterrupt) + raise ex finally: if not RequestState.is_okay(self._request_context.request_state): self.close() From 9e3d6653079174a9b4b3ded19ed435746b13aa14 Mon Sep 17 00:00:00 2001 From: Jared Casey Date: Wed, 30 Jul 2025 17:17:37 -0600 Subject: [PATCH 17/18] PYCO-70: Update README (#2) --- README.md | 131 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 131 insertions(+) diff --git a/README.md b/README.md index 7a07614..0d04e21 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,133 @@ # Couchbase Python Analytics Client Python client for [Couchbase](https://couchbase.com) Analytics. + +Currently Python 3.9 - Python 3.13 is supported. + +The Analytics SDK supports static typing. Currently only [mypy](https://github.com/python/mypy) is supported. You mileage may vary (YMMV) with the use of other static type checkers (e.g. [pyright](https://github.com/microsoft/pyright)). + +# Installing the SDK + +Until a version is available on PyPI, the SDK can be installed via pip with the following command (note the `dev` branch in the url). + +Install the SDK via `pip`: +```console +python3 -m pip install git+https://github.com/couchbaselabs/analytics-python-client@dev +``` + +# Using the SDK + +Some more examples are provided in the [examples directory](https://github.com/couchbaselabs/analytics-python-client/tree/dev/examples). + +**Connecting and executing a query** +```python +from couchbase_analytics.cluster import Cluster +from couchbase_analytics.credential import Credential +from couchbase_analytics.options import QueryOptions + + +def main() -> None: + # Update this to your cluster + # IMPORTANT: The appropriate port needs to be specified. The SDK's default ports are 80 (http) and 443 (https). + # If attempting to connect to Capella, the correct ports are most likely to be 8095 (http) and 18095 (https). + # Capella example: https://cb.2xg3vwszqgqcrsix.cloud.couchbase.com:18095 + endpoint = 'https://--your-instance--' + username = 'username' + pw = 'password' + # User Input ends here. + + cred = Credential.from_username_and_password(username, pw) + cluster = Cluster.create_instance(endpoint, cred) + + # Execute a query and buffer all result rows in client memory. + statement = 'SELECT * FROM `travel-sample`.inventory.airline LIMIT 10;' + res = cluster.execute_query(statement) + all_rows = res.get_all_rows() + for row in all_rows: + print(f'Found row: {row}') + print(f'metadata={res.metadata()}') + + # Execute a query and process rows as they arrive from server. + statement = 'SELECT * FROM `travel-sample`.inventory.airline WHERE country="United States" LIMIT 10;' + res = cluster.execute_query(statement) + for row in res.rows(): + print(f'Found row: {row}') + print(f'metadata={res.metadata()}') + + # Execute a streaming query with positional arguments. + statement = 'SELECT * FROM `travel-sample`.inventory.airline WHERE country=$1 LIMIT $2;' + res = cluster.execute_query(statement, QueryOptions(positional_parameters=['United States', 10])) + for row in res: + print(f'Found row: {row}') + print(f'metadata={res.metadata()}') + + # Execute a streaming query with named arguments. + statement = 'SELECT * FROM `travel-sample`.inventory.airline WHERE country=$country LIMIT $limit;' + res = cluster.execute_query(statement, QueryOptions(named_parameters={'country': 'United States', + 'limit': 10})) + for row in res.rows(): + print(f'Found row: {row}') + print(f'metadata={res.metadata()}') + + +if __name__ == '__main__': + main() + +``` + +## Using the async API +```python +import asyncio + +from acouchbase_analytics.cluster import AsyncCluster +from acouchbase_analytics.credential import Credential +from acouchbase_analytics.options import QueryOptions + + +async def main() -> None: + # Update this to your cluster + # IMPORTANT: The appropriate port needs to be specified. The SDK's default ports are 80 (http) and 443 (https). + # If attempting to connect to Capella, the correct ports are most likely to be 8095 (http) and 18095 (https). + # Capella example: https://cb.2xg3vwszqgqcrsix.cloud.couchbase.com:18095 + endpoint = 'https://--your-instance--' + username = 'username' + pw = 'password' + # User Input ends here. + + cred = Credential.from_username_and_password(username, pw) + cluster = AsyncCluster.create_instance(endpoint, cred) + + # Execute a query and buffer all result rows in client memory. + statement = 'SELECT * FROM `travel-sample`.inventory.airline LIMIT 10;' + res = await cluster.execute_query(statement) + all_rows = await res.get_all_rows() + # NOTE: all_rows is a list, _do not_ use `async for` + for row in all_rows: + print(f'Found row: {row}') + print(f'metadata={res.metadata()}') + + # Execute a query and process rows as they arrive from server. + statement = 'SELECT * FROM `travel-sample`.inventory.airline WHERE country="United States" LIMIT 10;' + res = await cluster.execute_query(statement) + async for row in res.rows(): + print(f'Found row: {row}') + print(f'metadata={res.metadata()}') + + # Execute a streaming query with positional arguments. + statement = 'SELECT * FROM `travel-sample`.inventory.airline WHERE country=$1 LIMIT $2;' + res = await cluster.execute_query(statement, QueryOptions(positional_parameters=['United States', 10])) + async for row in res: + print(f'Found row: {row}') + print(f'metadata={res.metadata()}') + + # Execute a streaming query with named arguments. + statement = 'SELECT * FROM `travel-sample`.inventory.airline WHERE country=$country LIMIT $limit;' + res = await cluster.execute_query(statement, QueryOptions(named_parameters={'country': 'United States', + 'limit': 10})) + async for row in res.rows(): + print(f'Found row: {row}') + print(f'metadata={res.metadata()}') + +if __name__ == '__main__': + asyncio.run(main()) + +``` From 5a96463e2aaa42383ec4f15d16eccc508bc66de7 Mon Sep 17 00:00:00 2001 From: Jared Casey Date: Thu, 31 Jul 2025 11:36:55 -0600 Subject: [PATCH 18/18] PYCO-65: Add cbdino job for integration tests (#1) --- .github/workflows/tests.yml | 98 ++++++++++++++++++- .../tests/connect_integration_t.py | 3 +- .../tests/connect_integration_t.py | 3 +- 3 files changed, 100 insertions(+), 4 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ea2b503..cfddc67 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -227,8 +227,8 @@ jobs: cat pycbac_test/requirements-test.txt echo "pycbac_test/pytest.ini contents:" cat pycbac_test/pytest.ini - echo "pycbac_test/test_config.ini contents:" - cat pycbac_test/test_config.ini + echo "pycbac_test/tests/test_config.ini contents:" + cat pycbac_test/tests/test_config.ini - name: Upload test setup uses: actions/upload-artifact@v4 with: @@ -562,3 +562,97 @@ jobs: if ( $TEST_COUCHBASE_API = "true" ) { python -m pytest -m "pycbac_couchbase and pycbac_unit" -rA -vv } + + cbdino-integration-tests: + needs: [setup, test-setup, sdist-wheel] + if: >- + ${{ fromJson(needs.setup.outputs.stage_matrices).test_integration.has_linux_cbdino + && !fromJson(needs.setup.outputs.stage_matrices).test_integration.skip_cbdino }} + name: Run integration tests w/ cbdino; Python ${{ matrix.python-version }} - ${{ matrix.os }} (${{ matrix.arch }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.setup.outputs.stage_matrices).test_integration.linux_cbdino }} + steps: + - name: Install cbdinocluster + run: | + mkdir -p "$HOME/bin" + CB_DINO_VERSION=${{ env.CBCI_CBDINO_VERSION }} + CB_DINO_TYPE="cbdinocluster-${{ matrix.arch == 'x86_64' && 'linux-amd64' || 'linux-arm64' }}" + wget -nv -O $HOME/bin/cbdinocluster https://github.com/couchbaselabs/cbdinocluster/releases/download/$CB_DINO_VERSION/$CB_DINO_TYPE + chmod +x $HOME/bin/cbdinocluster + echo "$HOME/bin" >> $GITHUB_PATH + - name: Install s3mock + run: | + docker pull adobe/s3mock + docker pull nginx + - name: Initialize cbdinocluster + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + cbdinocluster -v init --auto + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Confirm Python version + run: python -c "import sys; print(sys.version)" + - name: Download CI scripts + uses: actions/download-artifact@v4 + with: + name: ci_scripts + path: ci_scripts + - name: Enable CI Scripts + run: | + chmod +x ci_scripts/gha.sh + - name: Download wheel + uses: actions/download-artifact@v4 + with: + name: pycbac-artifact-wheel + path: pycbac + - name: Download test setup + uses: actions/download-artifact@v4 + with: + name: pycbac-test-setup + path: pycbac + - name: Start couchbase cluster + run: | + cd pycbac + cat cluster_def.yaml + CBDC_ID=$(cbdinocluster -v alloc --def-file=cluster_def.yaml) + CBDC_CONNSTR=$(cbdinocluster -v connstr --analytics $CBDC_ID) + echo "CBDC_ID=$CBDC_ID" >> "$GITHUB_ENV" + echo "CBDC_CONNSTR=$CBDC_CONNSTR" >> "$GITHUB_ENV" + echo "CBDC_CONNSTR=$CBDC_CONNSTR" + cbdinocluster buckets load-sample $CBDC_ID travel-sample + - name: Update test_config.ini + env: + PYCBAC_USERNAME: 'Administrator' + PYCBAC_PASSWORD: 'password' + PYCBAC_FQDN: 'travel-sample.inventory.airline' + CBCONNSTR: ${{ env.CBDC_CONNSTR }} + run: | + ./ci_scripts/gha.sh build_test_config_ini pycbac/tests + - name: Run tests + timeout-minutes: 30 + run: | + python -m pip install --upgrade pip setuptools wheel + cd pycbac + ls -alh + cat tests/test_config.ini + python -m pip install -r requirements-test.txt + WHEEL_NAME=${{ needs.sdist-wheel.outputs.wheel_name }} + echo "WHEEL_NAME=$WHEEL_NAME" + python -m pip install ${WHEEL_NAME} + python -m pip list + TEST_ACOUCHBASE_API=${{ fromJson(needs.setup.outputs.stage_matrices).test_integration.test_acouchbase_api }} + if [ "$TEST_ACOUCHBASE_API" = "true" ]; then + python -m pytest -m "pycbac_acouchbase and pycbac_integration" -rA -vv + fi + TEST_COUCHBASE_API=${{ fromJson(needs.setup.outputs.stage_matrices).test_integration.test_couchbase_api }} + if [ "$TEST_COUCHBASE_API" = "true" ]; then + python -m pytest -m "pycbac_couchbase and pycbac_integration" -rA -vv + fi + - name: Cleanup cbdino cluster + run: | + cbdinocluster rm ${{ env.CBDC_ID }} diff --git a/acouchbase_analytics/tests/connect_integration_t.py b/acouchbase_analytics/tests/connect_integration_t.py index 48180a3..a9c15be 100644 --- a/acouchbase_analytics/tests/connect_integration_t.py +++ b/acouchbase_analytics/tests/connect_integration_t.py @@ -63,7 +63,8 @@ async def test_connect_timeout_query_timeout(self, test_env: AsyncTestEnvironmen connstr = test_env.config.get_connection_string(ignore_port=True) cluster = AsyncCluster.create_instance(connstr, cred) - q_opts = QueryOptions(timeout=timedelta(seconds=3)) + # increase the max retries to ensure that the timeout is hit + q_opts = QueryOptions(max_retries=20, timeout=timedelta(seconds=3)) with pytest.raises(TimeoutError) as ex: await cluster.execute_query(statement, q_opts) diff --git a/couchbase_analytics/tests/connect_integration_t.py b/couchbase_analytics/tests/connect_integration_t.py index 4b83859..76923bd 100644 --- a/couchbase_analytics/tests/connect_integration_t.py +++ b/couchbase_analytics/tests/connect_integration_t.py @@ -63,7 +63,8 @@ def test_connect_timeout_query_timeout(self, test_env: BlockingTestEnvironment) connstr = test_env.config.get_connection_string(ignore_port=True) cluster = Cluster.create_instance(connstr, cred) - q_opts = QueryOptions(timeout=timedelta(seconds=3)) + # increase the max retries to ensure that the timeout is hit + q_opts = QueryOptions(max_retries=20, timeout=timedelta(seconds=3)) with pytest.raises(TimeoutError) as ex: cluster.execute_query(statement, q_opts)