Skip to content

Commit d198f88

Browse files
committed
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
1 parent d096358 commit d198f88

67 files changed

Lines changed: 3472 additions & 420 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

MANIFEST.in

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
include *.txt LICENSE CONTRIBUTING.md pyproject.toml couchbase_analytics_version.py
2+
include couchbase-sdk-analytics-python-black-duck-manifest.yaml
3+
include couchbase_analytics/common/core/_nonprod_certificates/*.pem
4+
include couchbase_analytics/common/core/_capella_certificates/*.pem
5+
recursive-include couchbase_analytics *.py
6+
recursive-include acouchbase_analytics *.py
7+
global-exclude *.py[cod] *.DS_Store
8+
exclude .git .gitignore .gitmodules gocaves* *.jar .clang* .cmake* .pre* .flake* MANIFEST.in

acouchbase_analytics/options.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
# See the License for the specific language governing permissions and
1414
# limitations under the License.
1515

16-
from couchbase_analytics.common.enums import IpProtocol as IpProtocol # noqa: F401
1716
from couchbase_analytics.common.options import ClusterOptions as ClusterOptions # noqa: F401
1817
from couchbase_analytics.common.options import ClusterOptionsKwargs as ClusterOptionsKwargs # noqa: F401
1918
from couchbase_analytics.common.options import QueryOptions as QueryOptions # noqa: F401

acouchbase_analytics/protocol/cluster.pyi

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ if sys.version_info < (3, 11):
2121
else:
2222
from typing import Unpack
2323

24-
from acouchbase_analytics.protocol.core.client_adapter import _ClientAdapter
24+
from acouchbase_analytics.protocol.core.client_adapter import _AsyncClientAdapter
2525
from acouchbase_analytics.protocol.database import AsyncDatabase
2626
from couchbase_analytics.common.credential import Credential
2727
from couchbase_analytics.common.result import AsyncQueryResult
@@ -54,7 +54,7 @@ class AsyncCluster:
5454
**kwargs: Unpack[ClusterOptionsKwargs]) -> None: ...
5555

5656
@property
57-
def client_adapter(self) -> _ClientAdapter: ...
57+
def client_adapter(self) -> _AsyncClientAdapter: ...
5858

5959
@property
6060
def connected(self) -> bool: ...

acouchbase_analytics/protocol/core/_request_context.py

Lines changed: 40 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from typing import (Any,
66
Awaitable,
77
Callable,
8+
Coroutine,
89
Dict,
910
List,
1011
Optional,
@@ -20,7 +21,7 @@
2021
get_time)
2122
from couchbase_analytics.common.core.net_utils import get_request_ip_async
2223
from couchbase_analytics.common.deserializer import Deserializer
23-
from couchbase_analytics.common.errors import AnalyticsError
24+
from couchbase_analytics.common.errors import AnalyticsError, InvalidCredentialError
2425
from couchbase_analytics.common.streaming import StreamingState
2526
from couchbase_analytics.protocol.connection import DEFAULT_TIMEOUTS
2627
from couchbase_analytics.protocol.errors import ErrorMapper
@@ -41,7 +42,7 @@ def __init__(self,
4142
self._client_adapter = client_adapter
4243
self._request = request
4344
self._backend = backend or current_async_library()
44-
self._response_task: Optional[Task] = None
45+
# self._response_task: Optional[Task] = None
4546
self._request_state = StreamingState.NotStarted
4647
self._stage_completed: Optional[anyio.Event] = None
4748
self._request_error: Optional[Exception] = None
@@ -80,9 +81,9 @@ def request_state(self, state: StreamingState) -> None:
8081
raise TypeError('request_state must be an instance of StreamingState')
8182
self._request_state = state
8283

83-
@property
84-
def stage_completed(self) -> anyio.Event:
85-
return self._stage_completed
84+
# @property
85+
# def stage_completed(self) -> Optional[anyio.Event]:
86+
# return self._stage_completed
8687

8788
@property
8889
def timed_out(self) -> bool:
@@ -94,9 +95,10 @@ def cancelled(self) -> bool:
9495

9596
async def _execute(self, fn: Callable[..., Awaitable[Any]], *args: object) -> None:
9697
await fn(*args)
97-
self._stage_completed.set()
98+
if self._stage_completed is not None:
99+
self._stage_completed.set()
98100

99-
async def _trace_handler(self, event_name, _) -> None:
101+
async def _trace_handler(self, event_name: str, _: str) -> None:
100102
if event_name == 'connection.connect_tcp.complete':
101103
# after connection is established, we need to update the cancel_scope deadline to match the query_timeout
102104
self._update_cancel_scope_deadline(self._request_deadline, is_absolute=True)
@@ -115,24 +117,31 @@ async def initialize(self) -> None:
115117
self._request_state = StreamingState.Started
116118
# we set the request timeout once the context is initialized in order to create the deadline
117119
# closer to when the upstream logic will begin to use the request context
118-
timeouts = self._request.get_request_timeouts()
119-
self._request_deadline = get_time() + timeouts.get('read', DEFAULT_TIMEOUTS['query_timeout'])
120+
timeouts = self._request.get_request_timeouts() or {}
121+
self._request_deadline = get_time() + (timeouts.get('read', None) or DEFAULT_TIMEOUTS['query_timeout'])
120122
self._update_cancel_scope_deadline(self._connect_timeout)
121123

122124
async def send_request(self, enable_trace_handling: Optional[bool]=False) -> HttpCoreResponse:
123125
ip = await get_request_ip_async(self._request.host, self._request.port, self._request.previous_ips)
124126
if ip is None:
125127
attempted_ips = ', '.join(self._request.previous_ips or [])
126-
raise AnalyticsError(f'Connect failure. Attempted to connect to resolved IPs: {attempted_ips}.')
128+
raise AnalyticsError(message=f'Connect failure. Attempted to connect to resolved IPs: {attempted_ips}.')
127129

128130
if enable_trace_handling is True:
129131
(self._request.update_url(ip, self._client_adapter.analytics_path)
130-
.update_extensions({'trace': self._trace_handler})
132+
.add_trace_to_extensions(self._trace_handler)
131133
.update_previous_ips(ip))
132134
else:
133135
self._request.update_url(ip, self._client_adapter.analytics_path).update_previous_ips(ip)
134136
response = await self._client_adapter.send_request(self._request)
135137
self._request.set_client_server_addrs(response)
138+
if response.status_code == 401:
139+
context = {
140+
'client_addr': self._request.client_addr,
141+
'server_addr': self._request.server_addr,
142+
'http_status': response.status_code,
143+
}
144+
raise InvalidCredentialError(str(context))
136145
return response
137146

138147
async def shutdown(self,
@@ -149,14 +158,16 @@ async def shutdown(self,
149158
if StreamingState.is_okay(self._request_state):
150159
self._request_state = StreamingState.Completed
151160

152-
def create_response_task(self, fn: Callable[..., Awaitable[Any]], *args: object) -> Task:
161+
def create_response_task(self, fn: Callable[..., Coroutine[Any, Any, Any]], *args: object) -> Task[Any]:
153162
if self._backend is None or self._backend.backend_lib != 'asyncio':
154163
raise RuntimeError('Must use the asyncio backend to create a response task.')
164+
if self._backend.loop is None:
165+
raise RuntimeError('Async backend loop is not initialized.')
155166
task_name = f'{self._id}-response-task'
156167
print(f'Creating response task: {task_name}')
157-
task = self._backend.loop.create_task(fn(*args), name=task_name)
168+
task: Task[Any] = self._backend.loop.create_task(fn(*args), name=task_name)
158169
# TODO: I don't think this callback is necessary...need to add more tests to confirm
159-
def task_done(t: Task) -> None:
170+
def task_done(t: Task[Any]) -> None:
160171
print(f'Task ({t.get_name()}) done: {t.done()}, cancelled: {t.cancelled()}')
161172

162173
task.add_done_callback(task_done)
@@ -170,15 +181,23 @@ def start_next_stage(self,
170181
fn: Callable[..., Awaitable[Any]],
171182
*args: object,
172183
reset_previous_stage: Optional[bool]=False) -> None:
173-
if reset_previous_stage is True:
174-
if self._stage_completed is not None:
184+
# if reset_previous_stage is True:
185+
# if self._stage_completed is not None:
186+
# self._stage_completed = None
187+
if self._stage_completed is not None:
188+
if reset_previous_stage is True:
175189
self._stage_completed = None
176-
elif self._stage_completed is not None:
177-
raise RuntimeError('Task already running in this context.')
190+
else:
191+
raise RuntimeError('Task already running in this context.')
178192

179193
self._stage_completed = anyio.Event()
180194
self._taskgroup.start_soon(self._execute, fn, *args)
181195

196+
async def wait_for_stage_to_complete(self) -> None:
197+
if self._stage_completed is None:
198+
return
199+
await self._stage_completed.wait()
200+
182201
async def process_error(self, json_data: List[Dict[str, Any]]) -> None:
183202
self._request_state = StreamingState.Error
184203
if not isinstance(json_data, list):
@@ -209,4 +228,6 @@ async def __aexit__(self,
209228
self._request_state = StreamingState.Cancelled
210229
elif exc_val is not None:
211230
self._request_state = StreamingState.Error
212-
del self._taskgroup
231+
del self._taskgroup
232+
# TODO: should we suppress here (e.g., return True)
233+
return None

acouchbase_analytics/protocol/core/client_adapter.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,8 @@ async def create_client(self) -> None:
115115
"""
116116
if not hasattr(self, '_client'):
117117
if self._conn_details.is_secure():
118+
if self._conn_details.ssl_context is None:
119+
raise ValueError('SSL context is required for secure connections.')
118120
transport = None
119121
if self._http_transport_cls is not None:
120122
transport = self._http_transport_cls(verify=self._conn_details.ssl_context)
@@ -137,6 +139,9 @@ async def send_request(self, request: QueryRequest) -> Response:
137139
if not hasattr(self, '_client'):
138140
raise RuntimeError('Client not created yet')
139141

142+
if request.url is None:
143+
raise ValueError('Request URL cannot be None')
144+
140145
req = self._client.build_request(request.method,
141146
request.url,
142147
json=request.body,

acouchbase_analytics/protocol/scope.pyi

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ if sys.version_info < (3, 11):
2121
else:
2222
from typing import Unpack
2323

24-
from acouchbase_analytics.protocol.core.client_adapter import _ClientAdapter
24+
from acouchbase_analytics.protocol.core.client_adapter import _AsyncClientAdapter
2525
from acouchbase_analytics.protocol.database import AsyncDatabase as AsyncDatabase
2626
from couchbase_analytics.options import QueryOptions, QueryOptionsKwargs
2727
from couchbase_analytics.result import AsyncQueryResult
@@ -30,7 +30,7 @@ class AsyncScope:
3030
def __init__(self, database: AsyncDatabase, scope_name: str) -> None: ...
3131

3232
@property
33-
def client_adapter(self) -> _ClientAdapter: ...
33+
def client_adapter(self) -> _AsyncClientAdapter: ...
3434

3535
@property
3636
def name(self) -> str: ...

acouchbase_analytics/protocol/streaming.py

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,20 @@
1616
from __future__ import annotations
1717

1818
import json
19+
import sys
20+
1921
from asyncio import CancelledError
2022
from functools import wraps
2123
from typing import (Any,
2224
Callable,
2325
Coroutine,
2426
Optional)
2527

28+
if sys.version_info < (3, 10):
29+
from typing_extensions import TypeAlias
30+
else:
31+
from typing import TypeAlias
32+
2633
from httpx import Response as HttpCoreResponse
2734

2835
# TODO: errors?
@@ -39,20 +46,20 @@
3946
from couchbase_analytics.common.streaming import StreamingState
4047

4148

49+
50+
4251
class RequestWrapper:
4352
"""
4453
**INTERNAL**
4554
"""
4655

4756
@classmethod
48-
def handle_retries(cls, # noqa: C901
49-
) -> Callable[[Callable[[], None]], Callable[[AsyncHttpStreamingResponse], Coroutine[Any, Any, None]]]:
57+
def handle_retries(cls) -> Callable[[SendRequestFunc], WrappedSendRequestFunc]: # noqa: C901
5058
"""
5159
**INTERNAL**
5260
"""
5361

54-
def decorator(fn: Callable[[], None] # noqa: C901
55-
) -> Callable[[AsyncHttpStreamingResponse], Coroutine[Any, Any, None]]:
62+
def decorator(fn: SendRequestFunc) -> WrappedSendRequestFunc: # noqa: C901
5663
@wraps(fn)
5764
async def wrapped_fn(self: AsyncHttpStreamingResponse) -> None:
5865
try:
@@ -93,11 +100,11 @@ def __init__(self,
93100

94101
async def _finish_processing_stream(self) -> None:
95102
if not self._request_context.has_stage_completed:
96-
await self._request_context.stage_completed.wait()
103+
await self._request_context.wait_for_stage_to_complete()
97104

98105
while not self._json_stream.token_stream_exhausted:
99106
self._request_context.start_next_stage(self._json_stream.continue_parsing, reset_previous_stage=True)
100-
await self._request_context.stage_completed.wait()
107+
await self._request_context.wait_for_stage_to_complete()
101108

102109
def _maybe_continue_to_process_stream(self) -> None:
103110
if not self._request_context.has_stage_completed:
@@ -112,10 +119,11 @@ async def _process_response(self, raw_response: Optional[ParsedResult]=None) ->
112119
if raw_response is None:
113120
raw_response = await self._json_stream.get_result()
114121
if raw_response is None:
115-
# TODO: logging??
116-
# TODO: exception??
117-
raise RuntimeError('No result from JsonStream')
118-
122+
raise AnalyticsError(message='Received unexpected empty result from JsonStream.')
123+
124+
if raw_response.value is None:
125+
raise AnalyticsError(message='Received unexpected empty result from JsonStream.')
126+
119127
json_response = json.loads(raw_response.value)
120128
if 'errors' in json_response:
121129
await self._request_context.process_error(json_response['errors'])
@@ -173,6 +181,8 @@ async def get_next_row(self) -> Any:
173181
self._maybe_continue_to_process_stream()
174182
raw_response = await self._json_stream.get_result()
175183
if raw_response.result_type == ParsedResultType.ROW:
184+
if raw_response.value is None:
185+
raise AnalyticsError(message='Unexpected empty row response while streaming.')
176186
return self._request_context.deserializer.deserialize(raw_response.value)
177187
elif raw_response.result_type in [ParsedResultType.ERROR, ParsedResultType.UNKNOWN]:
178188
await self._process_response(raw_response=raw_response)
@@ -200,3 +210,8 @@ async def send_request(self) -> None:
200210
else:
201211
await self._finish_processing_stream()
202212
await self._process_response()
213+
214+
SendRequestFunc: TypeAlias = Callable[[AsyncHttpStreamingResponse], Coroutine[Any, Any, None]]
215+
# Although, SendRequestFunc is the same type as WrappedSendRequestFunc, keep separate for clarity and indicate
216+
# WrappedSendRequestFunc is a decorator
217+
WrappedSendRequestFunc: TypeAlias = Callable[[AsyncHttpStreamingResponse], Coroutine[Any, Any, None]]

0 commit comments

Comments
 (0)