gremlinpython 4.0.0b2__py3-none-any.whl → 4.0.0.dev1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -18,4 +18,4 @@
18
18
  #
19
19
 
20
20
  __author__ = 'Marko A. Rodriguez (http://markorodriguez.com)'
21
- __version__ = '4.0.0b2'
21
+ __version__ = '4.0.0.dev1'
@@ -16,27 +16,163 @@
16
16
  # specific language governing permissions and limitations
17
17
  # under the License.
18
18
  #
19
- import json
20
-
21
19
  import aiohttp
22
20
  import asyncio
21
+ import socket
23
22
  import sys
24
23
 
24
+ from gremlin_python.driver.exceptions import ReadTimeoutError
25
+
25
26
  if sys.version_info >= (3, 11):
26
27
  import asyncio as async_timeout
27
28
  else:
28
29
  import async_timeout
29
- from aiohttp import ClientPayloadError
30
- from gremlin_python.driver.protocol import GremlinServerError
31
- from gremlin_python.driver.transport import AbstractBaseTransport
32
30
 
33
31
  __author__ = 'Lyndon Bauto (lyndonb@bitquilltech.com)'
34
32
 
33
+ # Default connection option values (canonical TinkerPop 4.x GLV defaults). The millisecond-suffixed
34
+ # options are the primary form (mirroring the other GLVs); aiohttp itself works in seconds, so the
35
+ # values are converted internally.
36
+ DEFAULT_CONNECT_TIMEOUT_MILLIS = 5000
37
+ DEFAULT_IDLE_TIMEOUT_MILLIS = 180000
38
+ DEFAULT_KEEP_ALIVE_TIME_MILLIS = 30000
39
+ # Seconds equivalents retained for internal use / backwards reference.
40
+ DEFAULT_CONNECT_TIMEOUT = DEFAULT_CONNECT_TIMEOUT_MILLIS / 1000
41
+ DEFAULT_IDLE_TIMEOUT = DEFAULT_IDLE_TIMEOUT_MILLIS / 1000
42
+ DEFAULT_KEEP_ALIVE_TIME = DEFAULT_KEEP_ALIVE_TIME_MILLIS / 1000
43
+ DEFAULT_COMPRESSION = 'deflate'
44
+
45
+ def _resolve_timeout_seconds(millis, seconds, default_millis):
46
+ """Resolve a timeout to seconds (aiohttp's unit) from the ``*_millis`` number or the idiomatic
47
+ unsuffixed seconds number (``None`` means not supplied for either). Supplying both raises
48
+ ``ValueError``; if neither is given, ``default_millis`` is used (``None`` leaves it unset).
49
+ """
50
+ if millis is not None and seconds is not None:
51
+ raise ValueError("provide only one of the milliseconds option or the seconds option, not both")
52
+ if seconds is not None:
53
+ return seconds
54
+ if millis is not None:
55
+ return millis / 1000
56
+ if default_millis is None:
57
+ return None
58
+ return default_millis / 1000
59
+
60
+
61
+ def _normalize_compression(compression):
62
+ """Normalize the compression option to a canonical string ('none' or 'deflate').
63
+
64
+ Accepts the string forms 'none'/'deflate'.
65
+ """
66
+ if compression is None:
67
+ return DEFAULT_COMPRESSION
68
+ if isinstance(compression, str):
69
+ normalized = compression.lower()
70
+ if normalized in ('none', 'deflate'):
71
+ return normalized
72
+ raise ValueError("compression must be one of 'none', 'deflate', got '%s'" % compression)
73
+ raise TypeError("compression must be a str ('none'|'deflate'), got %s" % type(compression).__name__)
74
+
75
+
76
+ def _run_read(loop, read_timeout, coro):
77
+ """Run a response-read coroutine on ``loop``, normalizing aiohttp's read timeout
78
+ (SocketTimeoutError / ServerTimeoutError) into a ``ReadTimeoutError`` so a read
79
+ timeout always surfaces as one deterministic, transport-agnostic type. It subclasses
80
+ the builtin ``TimeoutError`` so callers can still ``except TimeoutError``."""
81
+ try:
82
+ return loop.run_until_complete(coro)
83
+ except aiohttp.ServerTimeoutError as e:
84
+ raise ReadTimeoutError(
85
+ f"Read timed out after {read_timeout}s waiting for response data.") from e
86
+
87
+
88
+ def _keep_alive_socket_options(keep_alive_time):
89
+ """Build the list of socket options that enable TCP keep-alive with the
90
+ given idle time before probes begin. TCP_KEEPIDLE is platform dependent
91
+ (Linux); macOS exposes the equivalent as TCP_KEEPALIVE. Both are guarded so
92
+ platforms lacking the option (e.g. Windows) simply enable SO_KEEPALIVE."""
93
+ options = [(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)]
94
+ idle_opt = getattr(socket, 'TCP_KEEPIDLE', None)
95
+ if idle_opt is None:
96
+ # macOS names the idle-before-probe option TCP_KEEPALIVE
97
+ idle_opt = getattr(socket, 'TCP_KEEPALIVE', None)
98
+ if idle_opt is not None:
99
+ options.append((socket.IPPROTO_TCP, idle_opt, int(keep_alive_time)))
100
+ return options
101
+
102
+
103
+ def _keep_alive_socket_factory(keep_alive_time):
104
+ """Return a socket_factory (aiohttp >= 3.11) that applies the keep-alive
105
+ socket options when each connection socket is created."""
106
+ options = _keep_alive_socket_options(keep_alive_time)
107
+
108
+ def factory(addr_info):
109
+ family, type_, proto, _, _ = addr_info
110
+ sock = socket.socket(family=family, type=type_, proto=proto)
111
+ for level, optname, value in options:
112
+ try:
113
+ sock.setsockopt(level, optname, value)
114
+ except (OSError, AttributeError):
115
+ pass
116
+ return sock
117
+
118
+ return factory
119
+
120
+
121
+ class AiohttpSyncStream:
122
+ """Wraps aiohttp's async StreamReader as a synchronous file-like object.
123
+ read(n) blocks until exactly n bytes are available from the HTTP response.
124
+
125
+ Maintains an internal byte buffer that is refilled one HTTP chunk at a time
126
+ so the deserializer's many small read(n) calls don't each pay the cost of a
127
+ full asyncio event-loop turn."""
128
+
129
+ # Max bytes pulled from the response per underlying read. Matches
130
+ # aiohttp.StreamReader's default 64 KB limit, which is the per-connection
131
+ # high-water mark, asking for more in one read() never returns more.
132
+ _FILL_SIZE = 64 * 1024
133
+
134
+ def __init__(self, response, loop, read_timeout):
135
+ self._response = response
136
+ self._loop = loop
137
+ self._read_timeout = read_timeout
138
+ self._buf = bytearray()
139
+ self._pos = 0
140
+
141
+ def read(self, n):
142
+ if n <= 0:
143
+ return b''
144
+ while len(self._buf) - self._pos < n:
145
+ data = self._read_chunk()
146
+ if not data:
147
+ partial = bytes(self._buf[self._pos:])
148
+ self._buf.clear()
149
+ self._pos = 0
150
+ raise asyncio.IncompleteReadError(partial=partial, expected=n)
151
+ self._buf.extend(data)
152
+ end = self._pos + n
153
+ out = bytes(self._buf[self._pos:end])
154
+ self._pos = end
155
+ # Reclaim memory once the buffer is fully drained
156
+ if self._pos == len(self._buf):
157
+ self._buf.clear()
158
+ self._pos = 0
159
+ return out
160
+
161
+ def _read_chunk(self):
162
+ return _run_read(self._loop, self._read_timeout,
163
+ self._response.content.read(self._FILL_SIZE))
164
+
35
165
 
36
- class AiohttpHTTPTransport(AbstractBaseTransport):
166
+ class AiohttpHTTPTransport:
37
167
  nest_asyncio_applied = False
38
168
 
39
- def __init__(self, call_from_event_loop=None, read_timeout=None, write_timeout=None, **kwargs):
169
+ def __init__(self, call_from_event_loop=None, write_timeout=None,
170
+ connect_timeout_millis=None, connect_timeout=None,
171
+ idle_timeout_millis=None, idle_timeout=None,
172
+ read_timeout_millis=None, read_timeout=None,
173
+ keep_alive_time_millis=None, keep_alive_time=None,
174
+ compression=DEFAULT_COMPRESSION, proxy=None, trust_env=False,
175
+ max_connections=None, **kwargs):
40
176
  if call_from_event_loop is not None and call_from_event_loop and not AiohttpHTTPTransport.nest_asyncio_applied:
41
177
  """
42
178
  The AiohttpTransport implementation uses the asyncio event loop. Because of this, it cannot be called
@@ -53,18 +189,36 @@ class AiohttpHTTPTransport(AbstractBaseTransport):
53
189
  self._client_session = None
54
190
  self._http_req_resp = None
55
191
  self._enable_ssl = False
192
+ self._ssl_context = None
56
193
  self._url = None
57
194
 
58
195
  # Set all inner variables to parameters passed in.
59
196
  self._aiohttp_kwargs = kwargs
60
197
  self._write_timeout = write_timeout
61
- self._read_timeout = read_timeout
62
- if "max_content_length" in self._aiohttp_kwargs:
63
- self._max_content_len = self._aiohttp_kwargs.pop("max_content_length")
64
- else:
65
- self._max_content_len = 10 * 1024 * 1024
66
- if "ssl_options" in self._aiohttp_kwargs:
67
- self._ssl_context = self._aiohttp_kwargs.pop("ssl_options")
198
+
199
+ # Timeouts accept a millisecond number (*_millis, primary form) or the idiomatic seconds number
200
+ # (unsuffixed). read_timeout defaults off; the others use the canonical millisecond defaults.
201
+ self._read_timeout = _resolve_timeout_seconds(read_timeout_millis, read_timeout, None)
202
+
203
+ # Connection-level pooling / lifecycle options.
204
+ self._connect_timeout = _resolve_timeout_seconds(connect_timeout_millis, connect_timeout, DEFAULT_CONNECT_TIMEOUT_MILLIS)
205
+ self._idle_timeout = _resolve_timeout_seconds(idle_timeout_millis, idle_timeout, DEFAULT_IDLE_TIMEOUT_MILLIS)
206
+ self._keep_alive_time = _resolve_timeout_seconds(keep_alive_time_millis, keep_alive_time, DEFAULT_KEEP_ALIVE_TIME_MILLIS)
207
+ # Caps the aiohttp connector's simultaneous connections per Connection
208
+ # (the Client also sizes its Connection pool by this value).
209
+ self._max_connections = max_connections
210
+
211
+ # Compression negotiation. Default 'deflate' (on); advertises
212
+ # Accept-Encoding: deflate. Set 'none' to opt out.
213
+ self._compression = _normalize_compression(compression)
214
+
215
+ # HTTP proxy support routed through the ClientSession.
216
+ self._proxy = proxy
217
+ self._trust_env = trust_env
218
+
219
+ # ssl: canonical name accepting an ssl.SSLContext.
220
+ if "ssl" in self._aiohttp_kwargs:
221
+ self._ssl_context = self._aiohttp_kwargs.pop("ssl")
68
222
  self._enable_ssl = True
69
223
 
70
224
  def __del__(self):
@@ -76,90 +230,108 @@ class AiohttpHTTPTransport(AbstractBaseTransport):
76
230
  self._url = url
77
231
  # Inner function to perform async connect.
78
232
  async def async_connect():
79
- # Start client session and use it to send all HTTP requests. Headers can be set here.
233
+ # Build the TCP connector with the standardized pooling / lifecycle
234
+ # options. keepalive_timeout maps to the idle connection timeout;
235
+ # the socket factory enables TCP keep-alive probes after
236
+ # keep_alive_time idle.
237
+ connector_kwargs = {}
80
238
  if self._enable_ssl:
81
- # ssl context is established through tcp connector
82
- tcp_conn = aiohttp.TCPConnector(ssl_context=self._ssl_context)
83
- self._client_session = aiohttp.ClientSession(connector=tcp_conn,
84
- headers=headers, loop=self._loop)
85
- else:
86
- self._client_session = aiohttp.ClientSession(headers=headers, loop=self._loop)
239
+ # ssl context is established through the tcp connector
240
+ connector_kwargs['ssl_context'] = self._ssl_context
241
+ if self._idle_timeout is not None:
242
+ connector_kwargs['keepalive_timeout'] = self._idle_timeout
243
+ if self._keep_alive_time is not None:
244
+ self._apply_keep_alive(connector_kwargs)
245
+ # Reflect max_connections at the aiohttp layer so the connector's
246
+ # simultaneous-connection limit matches the driver option.
247
+ if self._max_connections is not None:
248
+ connector_kwargs['limit'] = self._max_connections
249
+
250
+ session_kwargs = {'headers': headers, 'loop': self._loop,
251
+ 'trust_env': self._trust_env}
252
+ # Use the per-socket timeouts (sock_connect/sock_read) rather than a
253
+ # whole-request total, which would abort long but legitimate streaming
254
+ # responses. sock_read bounds idle time between chunks so a stalled
255
+ # server cannot hang forever.
256
+ timeout_kwargs = {}
257
+ if self._connect_timeout is not None:
258
+ timeout_kwargs['sock_connect'] = self._connect_timeout
259
+ if self._read_timeout is not None:
260
+ timeout_kwargs['sock_read'] = self._read_timeout
261
+ if timeout_kwargs:
262
+ session_kwargs['timeout'] = aiohttp.ClientTimeout(**timeout_kwargs)
263
+ if connector_kwargs:
264
+ session_kwargs['connector'] = aiohttp.TCPConnector(**connector_kwargs)
265
+
266
+ self._client_session = aiohttp.ClientSession(**session_kwargs)
87
267
 
88
268
  # Execute the async connect synchronously.
89
269
  self._loop.run_until_complete(async_connect())
90
270
 
271
+ def _apply_keep_alive(self, connector_kwargs):
272
+ """Wire TCP keep-alive into the connector via the aiohttp socket_factory.
273
+ The factory sets SO_KEEPALIVE plus the per-socket idle time; unsupported
274
+ platforms degrade gracefully inside the factory."""
275
+ connector_kwargs['socket_factory'] = _keep_alive_socket_factory(self._keep_alive_time)
276
+
91
277
  def write(self, message):
278
+ # Negotiate compression unless the caller already set Accept-Encoding:
279
+ # deflate advertises Accept-Encoding: deflate; none suppresses aiohttp's
280
+ # auto-injected Accept-Encoding so compression is not silently negotiated.
281
+ headers = message['headers']
282
+ has_accept_encoding = any(k.lower() == 'accept-encoding' for k in headers)
283
+ skip_auto_headers = None
284
+ if self._compression == 'deflate':
285
+ if not has_accept_encoding:
286
+ headers['accept-encoding'] = 'deflate'
287
+ elif not has_accept_encoding:
288
+ skip_auto_headers = ['Accept-Encoding']
289
+
92
290
  # Inner function to perform async write.
93
291
  async def async_write():
94
- # To pass url into message for request authentication processing
95
- message.update({'url': self._url})
96
- if message['auth']:
97
- message['auth'](message)
98
-
292
+ post_kwargs = dict(self._aiohttp_kwargs)
293
+ if self._proxy is not None:
294
+ post_kwargs['proxy'] = self._proxy
295
+ if skip_auto_headers is not None:
296
+ post_kwargs['skip_auto_headers'] = skip_auto_headers
99
297
  async with async_timeout.timeout(self._write_timeout):
100
298
  self._http_req_resp = await self._client_session.post(url=self._url,
101
299
  data=message['payload'],
102
- headers=message['headers'],
103
- **self._aiohttp_kwargs)
300
+ headers=headers,
301
+ **post_kwargs)
104
302
 
105
303
  # Execute the async write synchronously.
106
304
  self._loop.run_until_complete(async_write())
107
305
 
108
- def read(self, stream_chunk=None):
109
- if not stream_chunk:
110
- '''
111
- GraphSON does not support streaming deserialization, we are aggregating data and bypassing streamed
112
- deserialization while GraphSON is enabled for testing. Remove after GraphSON is removed.
113
- '''
114
- async def async_read():
115
- async with async_timeout.timeout(self._read_timeout):
116
- data_buffer = b""
117
- async for data, end_of_http_chunk in self._http_req_resp.content.iter_chunks():
118
- try:
119
- data_buffer += data
120
- except ClientPayloadError:
121
- # server disconnect during streaming will cause ClientPayLoadError from aiohttp
122
- raise GremlinServerError({'code': 500,
123
- 'message': 'Server disconnected - please try to reconnect',
124
- 'exception': ClientPayloadError})
125
- if self._max_content_len and len(
126
- data_buffer) > self._max_content_len:
127
- raise Exception(f'Response size {len(data_buffer)} exceeds limit {self._max_content_len} bytes')
128
- if self._http_req_resp.headers.get('content-type') == 'application/json':
129
- message = json.loads(data_buffer.decode('utf-8'))
130
- err = message.get('message')
131
- raise Exception(f'Server disconnected with error message: "{err}" - please try to reconnect')
132
- return data_buffer
133
- return self._loop.run_until_complete(async_read())
134
- # raise Exception('missing handling of streamed responses to protocol')
135
-
136
- # Inner function to perform async read.
137
- async def async_read():
138
- # TODO: potentially refactor to just use streaming and remove transport/protocol
139
- async with async_timeout.timeout(self._read_timeout):
140
- read_completed = False
141
- # aiohttp streaming may not iterate through one whole chunk if it's too large, need to buffer it
142
- data_buffer = b""
143
- async for data, end_of_http_chunk in self._http_req_resp.content.iter_chunks():
144
- try:
145
- data_buffer += data
146
- if end_of_http_chunk:
147
- if self._max_content_len and len(
148
- data_buffer) > self._max_content_len:
149
- raise Exception( # TODO: do we need proper exception class for this?
150
- f'Response size {len(data_buffer)} exceeds limit {self._max_content_len} bytes')
151
- stream_chunk(data_buffer, read_completed, self._http_req_resp.ok)
152
- data_buffer = b""
153
- except ClientPayloadError:
154
- # server disconnect during streaming will cause ClientPayLoadError from aiohttp
155
- # TODO: double check during refactoring
156
- raise GremlinServerError({'code': 500,
157
- 'message': 'Server disconnected - please try to reconnect',
158
- 'exception': ClientPayloadError})
159
- read_completed = True
160
- stream_chunk(data_buffer, read_completed, self._http_req_resp.ok)
161
-
162
- return self._loop.run_until_complete(async_read())
306
+ def get_stream(self):
307
+ """Returns a synchronous file-like object for the HTTP response body."""
308
+ return AiohttpSyncStream(self._http_req_resp, self._loop, self._read_timeout)
309
+
310
+ @property
311
+ def content_type(self):
312
+ """Returns the Content-Type header of the HTTP response."""
313
+ if self._http_req_resp is not None:
314
+ return self._http_req_resp.headers.get('content-type', '')
315
+ return ''
316
+
317
+ @property
318
+ def status_code(self):
319
+ """Returns the HTTP status code of the response."""
320
+ if self._http_req_resp is not None:
321
+ return self._http_req_resp.status
322
+ return None
323
+
324
+ def read_body(self):
325
+ """Read the entire HTTP response body as bytes."""
326
+ return _run_read(self._loop, self._read_timeout, self._http_req_resp.read())
327
+
328
+ def evict_response(self):
329
+ """Close the current HTTP response and its underlying connection, evicting it from
330
+ aiohttp's connection pool. Used after a transport failure so a dead/half-closed
331
+ connection is discarded rather than returned to the pool for reuse."""
332
+ if self._http_req_resp is not None:
333
+ self._http_req_resp.close()
334
+ self._http_req_resp = None
163
335
 
164
336
  def close(self):
165
337
  # Inner function to perform async close.
@@ -178,5 +350,5 @@ class AiohttpHTTPTransport(AbstractBaseTransport):
178
350
 
179
351
  @property
180
352
  def closed(self):
181
- # Connection is closed when client session is closed.
182
- return self._client_session.closed
353
+ # Connection is closed when client session is closed (or not yet created).
354
+ return self._client_session is None or self._client_session.closed
@@ -16,40 +16,65 @@
16
16
  # specific language governing permissions and limitations
17
17
  # under the License.
18
18
  #
19
+ import base64
19
20
 
20
21
 
21
22
  def basic(username, password):
22
- from aiohttp import BasicAuth as aiohttpBasicAuth
23
+ """Returns an interceptor that adds Basic auth to the request."""
24
+ def interceptor(request):
25
+ credentials = base64.b64encode(f"{username}:{password}".encode("utf-8")).decode("utf-8")
26
+ request.headers['authorization'] = f"Basic {credentials}"
23
27
 
24
- def apply(request):
25
- return request['headers'].update({'authorization': aiohttpBasicAuth(username, password).encode()})
28
+ return interceptor
26
29
 
27
- return apply
28
30
 
31
+ def sigv4(region, service, credentials=None):
32
+ """Returns an interceptor that signs the request with AWS SigV4.
29
33
 
30
- def sigv4(region, service):
34
+ By default credentials are sourced from the standard AWS environment
35
+ variables (``AWS_ACCESS_KEY_ID``, ``AWS_SECRET_ACCESS_KEY`` and the optional
36
+ ``AWS_SESSION_TOKEN``). A custom credentials provider may be supplied via
37
+ ``credentials``; it accepts either:
38
+
39
+ * a callable returning a botocore ``Credentials`` object (or any object with
40
+ ``access_key``/``secret_key``/``token`` attributes), evaluated per request, or
41
+ * a botocore ``Credentials`` object (or ``Session``) used directly.
42
+
43
+ When no provider is given, signing falls back to the environment.
44
+ """
31
45
  import os
32
46
  from boto3 import Session
33
47
  from botocore.auth import SigV4Auth
34
48
  from botocore.awsrequest import AWSRequest
35
49
 
36
- def apply(request):
37
- access_key = os.environ.get('AWS_ACCESS_KEY_ID', '')
38
- secret_key = os.environ.get('AWS_SECRET_ACCESS_KEY', '')
39
- session_token = os.environ.get('AWS_SESSION_TOKEN', '')
50
+ def _resolve_credentials():
51
+ if credentials is None:
52
+ access_key = os.environ.get('AWS_ACCESS_KEY_ID', '')
53
+ secret_key = os.environ.get('AWS_SECRET_ACCESS_KEY', '')
54
+ session_token = os.environ.get('AWS_SESSION_TOKEN', '')
55
+ session = Session(
56
+ aws_access_key_id=access_key,
57
+ aws_secret_access_key=secret_key,
58
+ aws_session_token=session_token,
59
+ region_name=region
60
+ )
61
+ return session.get_credentials()
62
+
63
+ provider = credentials() if callable(credentials) else credentials
64
+ # A botocore Session exposes get_credentials(); a Credentials object is
65
+ # already usable as-is.
66
+ if hasattr(provider, 'get_credentials'):
67
+ return provider.get_credentials()
68
+ return provider
40
69
 
41
- session = Session(
42
- aws_access_key_id=access_key,
43
- aws_secret_access_key=secret_key,
44
- aws_session_token=session_token,
45
- region_name=region
46
- )
70
+ def interceptor(request):
71
+ # Ensure body is serialized so we can sign it
72
+ body_bytes = request.serialize_body()
47
73
 
48
- sigv4_request = AWSRequest(method="POST", url=request['url'], data=request['payload'])
49
- SigV4Auth(session.get_credentials(), service, region).add_auth(sigv4_request)
50
- request['headers'].update(sigv4_request.headers)
51
- request['payload'] = sigv4_request.data
52
- return request
74
+ resolved = _resolve_credentials()
53
75
 
54
- return apply
76
+ sigv4_request = AWSRequest(method=request.method, url=request.url, data=body_bytes)
77
+ SigV4Auth(resolved, service, region).add_auth(sigv4_request)
78
+ request.headers.update(dict(sigv4_request.headers))
55
79
 
80
+ return interceptor