connector-py 4.180.0__py3-none-any.whl → 4.181.0__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.
- connector/__about__.py +1 -1
- connector/client.py +6 -1
- connector/oai/base_clients.py +101 -6
- {connector_py-4.180.0.dist-info → connector_py-4.181.0.dist-info}/METADATA +1 -1
- {connector_py-4.180.0.dist-info → connector_py-4.181.0.dist-info}/RECORD +10 -10
- tests/oai/test_base_clients.py +366 -1
- {connector_py-4.180.0.dist-info → connector_py-4.181.0.dist-info}/WHEEL +0 -0
- {connector_py-4.180.0.dist-info → connector_py-4.181.0.dist-info}/entry_points.txt +0 -0
- {connector_py-4.180.0.dist-info → connector_py-4.181.0.dist-info}/licenses/LICENSE.txt +0 -0
- {connector_py-4.180.0.dist-info → connector_py-4.181.0.dist-info}/top_level.txt +0 -0
connector/__about__.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "4.
|
|
1
|
+
__version__ = "4.181.0"
|
connector/client.py
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
from .httpx_rewrite import AsyncClient, GqlHTTPXAsyncTransport, HTTPXAsyncTransport
|
|
2
|
-
from .oai.base_clients import
|
|
2
|
+
from .oai.base_clients import (
|
|
3
|
+
BaseGraphQLSession,
|
|
4
|
+
BaseIntegrationClient,
|
|
5
|
+
RateLimitedHTTPXAsyncTransport,
|
|
6
|
+
)
|
|
3
7
|
from .oai.capability import (
|
|
4
8
|
get_basic_auth,
|
|
5
9
|
get_jwt_auth,
|
|
@@ -20,6 +24,7 @@ from .utils.sync_to_async import sync_to_async
|
|
|
20
24
|
__all__ = [
|
|
21
25
|
"GqlHTTPXAsyncTransport",
|
|
22
26
|
"HTTPXAsyncTransport",
|
|
27
|
+
"RateLimitedHTTPXAsyncTransport",
|
|
23
28
|
"BaseGraphQLSession",
|
|
24
29
|
"BaseIntegrationClient",
|
|
25
30
|
"get_basic_auth",
|
connector/oai/base_clients.py
CHANGED
|
@@ -10,11 +10,11 @@ from connector_sdk_types.generated import ErrorCode
|
|
|
10
10
|
from gql import Client
|
|
11
11
|
from gql.client import AsyncClientSession
|
|
12
12
|
from gql.dsl import DSLSchema
|
|
13
|
-
from graphql import GraphQLSchema, build_client_schema, build_schema
|
|
13
|
+
from graphql import DocumentNode, GraphQLSchema, build_client_schema, build_schema
|
|
14
14
|
from httpx import Response
|
|
15
15
|
from typing_extensions import Self
|
|
16
16
|
|
|
17
|
-
from connector.httpx_rewrite import AsyncClient
|
|
17
|
+
from connector.httpx_rewrite import AsyncClient, HTTPXAsyncTransport
|
|
18
18
|
from connector.oai.capability import Request
|
|
19
19
|
from connector.oai.errors import ConnectorError
|
|
20
20
|
from connector.utils.rate_limiting import RateLimitConfig, RateLimiter
|
|
@@ -178,6 +178,75 @@ class RateLimitedClient(AsyncClient):
|
|
|
178
178
|
await self.base_client.__aexit__(exc_type, exc_val, exc_tb)
|
|
179
179
|
|
|
180
180
|
|
|
181
|
+
class RateLimitedHTTPXAsyncTransport(HTTPXAsyncTransport):
|
|
182
|
+
"""A wrapper around HTTPXAsyncTransport that applies rate limiting to GraphQL requests."""
|
|
183
|
+
|
|
184
|
+
def __init__(self, base_transport: HTTPXAsyncTransport, rate_limit_config: RateLimitConfig):
|
|
185
|
+
# Copy all attributes from base transport, but exclude 'execute' to avoid shadowing our method
|
|
186
|
+
base_dict = {k: v for k, v in base_transport.__dict__.items() if k != "execute"}
|
|
187
|
+
self.__dict__.update(base_dict)
|
|
188
|
+
self.base_transport = base_transport
|
|
189
|
+
self.rate_limiter = RateLimiter[Callable[[], Any], Any](rate_limit_config)
|
|
190
|
+
|
|
191
|
+
async def connect(self):
|
|
192
|
+
"""Connect the underlying transport and replace its client with a rate-limited one."""
|
|
193
|
+
await self.base_transport.connect()
|
|
194
|
+
|
|
195
|
+
# Replace the base transport's client with a rate-limited version
|
|
196
|
+
if hasattr(self.base_transport, "client") and self.base_transport.client:
|
|
197
|
+
# The transport's client should be our AsyncClient type, but we need to handle the type
|
|
198
|
+
if isinstance(self.base_transport.client, AsyncClient):
|
|
199
|
+
self.base_transport.client = RateLimitedClient(
|
|
200
|
+
self.base_transport.client, self.rate_limiter.config
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
# Copy the client reference
|
|
204
|
+
self.client = self.base_transport.client
|
|
205
|
+
|
|
206
|
+
async def execute(
|
|
207
|
+
self,
|
|
208
|
+
document: DocumentNode,
|
|
209
|
+
variable_values: dict[str, Any] | None = None,
|
|
210
|
+
operation_name: str | None = None,
|
|
211
|
+
extra_args: dict[str, Any] | None = None,
|
|
212
|
+
upload_files: bool = False,
|
|
213
|
+
):
|
|
214
|
+
"""Execute a GraphQL request with rate limiting."""
|
|
215
|
+
|
|
216
|
+
async def request_func():
|
|
217
|
+
result = await self.base_transport.execute(
|
|
218
|
+
document=document,
|
|
219
|
+
variable_values=variable_values,
|
|
220
|
+
operation_name=operation_name,
|
|
221
|
+
extra_args=extra_args,
|
|
222
|
+
upload_files=upload_files,
|
|
223
|
+
)
|
|
224
|
+
return result
|
|
225
|
+
|
|
226
|
+
# Use the rate limiter to execute the request
|
|
227
|
+
responses = await self.rate_limiter.execute_requests([request_func], lambda x: x())
|
|
228
|
+
if responses:
|
|
229
|
+
return responses[0]
|
|
230
|
+
|
|
231
|
+
raise ConnectorError(
|
|
232
|
+
message="No response from GraphQL API",
|
|
233
|
+
error_code=ErrorCode.API_ERROR,
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
def get_state(self) -> tuple[RateLimitConfig, float]:
|
|
237
|
+
"""Get the current rate limit state."""
|
|
238
|
+
return self.rate_limiter.config, self.rate_limiter.current_delay
|
|
239
|
+
|
|
240
|
+
async def close(self):
|
|
241
|
+
"""Close the underlying transport."""
|
|
242
|
+
if hasattr(self.base_transport, "close"):
|
|
243
|
+
await self.base_transport.close()
|
|
244
|
+
|
|
245
|
+
def __getattr__(self, name):
|
|
246
|
+
"""Delegate attribute access to the underlying base_transport."""
|
|
247
|
+
return getattr(self.base_transport, name)
|
|
248
|
+
|
|
249
|
+
|
|
181
250
|
class BaseIntegrationClient:
|
|
182
251
|
_http_client: AsyncClient | RateLimitedClient
|
|
183
252
|
_rate_limit_config: RateLimitConfig | None = None
|
|
@@ -248,8 +317,11 @@ class BaseIntegrationClient:
|
|
|
248
317
|
|
|
249
318
|
|
|
250
319
|
class BaseGraphQLSession(AsyncClientSession):
|
|
251
|
-
|
|
252
|
-
|
|
320
|
+
_rate_limit_config: RateLimitConfig | None = None
|
|
321
|
+
|
|
322
|
+
def __init__(self, args: Request, rate_limit_config: RateLimitConfig | None = None):
|
|
323
|
+
client = self.build_client(args, rate_limit_config)
|
|
324
|
+
super().__init__(client=client)
|
|
253
325
|
|
|
254
326
|
async def __aenter__(self) -> Self:
|
|
255
327
|
await self.client.__aenter__()
|
|
@@ -267,8 +339,31 @@ class BaseGraphQLSession(AsyncClientSession):
|
|
|
267
339
|
pass
|
|
268
340
|
|
|
269
341
|
@classmethod
|
|
270
|
-
def build_client(
|
|
271
|
-
|
|
342
|
+
def build_client(
|
|
343
|
+
cls, args: Request, rate_limit_config: RateLimitConfig | None = None
|
|
344
|
+
) -> Client:
|
|
345
|
+
client_args = cls.prepare_client_args(args)
|
|
346
|
+
|
|
347
|
+
# Apply rate limiting if configured
|
|
348
|
+
rate_limiting = rate_limit_config or cls._rate_limit_config
|
|
349
|
+
if rate_limiting is not None and "transport" in client_args:
|
|
350
|
+
transport = client_args["transport"]
|
|
351
|
+
if isinstance(transport, HTTPXAsyncTransport):
|
|
352
|
+
client_args["transport"] = RateLimitedHTTPXAsyncTransport(transport, rate_limiting)
|
|
353
|
+
|
|
354
|
+
return Client(**client_args)
|
|
355
|
+
|
|
356
|
+
def get_current_rate_limits(self) -> tuple[RateLimitConfig | None, float]:
|
|
357
|
+
"""
|
|
358
|
+
Get the current rate limit state.
|
|
359
|
+
|
|
360
|
+
Returns a tuple of the rate limit config and the current delay. (or None if the client is not rate limited)
|
|
361
|
+
"""
|
|
362
|
+
if hasattr(self.client, "transport") and isinstance(
|
|
363
|
+
self.client.transport, RateLimitedHTTPXAsyncTransport
|
|
364
|
+
):
|
|
365
|
+
return self.client.transport.get_state()
|
|
366
|
+
return None, 0
|
|
272
367
|
|
|
273
368
|
@classmethod
|
|
274
369
|
def load_schema(cls, schema_file_path: str | Path) -> GraphQLSchema:
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
connector/__about__.py,sha256=
|
|
1
|
+
connector/__about__.py,sha256=xLzagTPz6HRhUidqvSVmMPy8ATGLe1gDR7H3AaIl0JY,24
|
|
2
2
|
connector/__init__.py,sha256=QUNbfyRmjFwfO296tG-H_d8FFCqcNc3kAu1QfYGtP38,84
|
|
3
3
|
connector/auth_helper.py,sha256=0HBFYwd0ixOL46blGvb1ot0a8ZOmNutU13Q95JGN04Q,637
|
|
4
4
|
connector/ca_certs.py,sha256=h7l8FvUsGXAvQC73BA2x5HJccxC5ooIOOww0mTSEerw,603
|
|
5
5
|
connector/cli.py,sha256=sjqqrEbdj15Pfjy3ZKLyMpw2QIZdLKIZGUhVw6BzOpQ,13626
|
|
6
|
-
connector/client.py,sha256=
|
|
6
|
+
connector/client.py,sha256=ZdYDkidl13gCkFa_F86n26Piew6-4wKDrEyam-JH_Fs,1274
|
|
7
7
|
connector/compile.py,sha256=Yg_j0GPsKlvbyvJTVoBFsg2tQOLJq8PiPI8Yrfk6N28,10071
|
|
8
8
|
connector/config.py,sha256=rI0YAzMbzhW8x_KbTGUoVz-xUuPlfaJkqdvra8tfyec,3607
|
|
9
9
|
connector/error.py,sha256=Sz7v36jxBbvnA8njFW-EKGXC4xm7fwRPm1yTsm_s8SQ,314
|
|
@@ -200,7 +200,7 @@ connector/generated/models/validated_credentials.py,sha256=2Mmr2P8YmjCKxMoBmeV_-
|
|
|
200
200
|
connector/generated/models/vendor.py,sha256=JOyV0pbjcKJYMHeXjtL9ttQ5UGZCm4W0WMCh5pp-rp4,316
|
|
201
201
|
connector/handlers/lumos_log_handler.py,sha256=KHy7KyZV0PisZqvOaX3V1bVDqNfaJi0yvTQdzdHUQTE,3190
|
|
202
202
|
connector/oai/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
203
|
-
connector/oai/base_clients.py,sha256
|
|
203
|
+
connector/oai/base_clients.py,sha256=Wj15CPtYcWwtFe2tW_-apSvokgXGWqNXNnL7VVHTnPg,15039
|
|
204
204
|
connector/oai/capability.py,sha256=01itvXellF2GyxEdSkdlXP6JOEhQsTF5TBiWORfEG94,33253
|
|
205
205
|
connector/oai/errors.py,sha256=gZFUcUzTYkQ_KE3LxQHcRHm2vv-0VmkMQTxOu0R2O3w,9295
|
|
206
206
|
connector/oai/fingerprint.py,sha256=AmHpybwoVrhrrNosLxVfRl_cFiKhCuSG-8poqRfVs5c,103
|
|
@@ -278,7 +278,7 @@ connector/utils/sync_to_async.py,sha256=sL7p_gJT5fQXbwXL1OIe93rlxWlnvmdK_espSNbQ
|
|
|
278
278
|
connector/utils/test.py,sha256=nvGSsVCCnellamh8nwZK6jLDp1n_irw9NaDPTNpJ9E4,656
|
|
279
279
|
connector/utils/test_case_insensitive_dict.py,sha256=VIUnWVKOh1hrXHk0kllMtaqh-9h6apahPHt9U7oBGp0,356
|
|
280
280
|
connector/utils/validation_utils.py,sha256=ih-EFW-ei-fKil0U2n0y4GePuA-Jz76XryExFBppHLk,1352
|
|
281
|
-
connector_py-4.
|
|
281
|
+
connector_py-4.181.0.dist-info/licenses/LICENSE.txt,sha256=5GN09YoDG6soQSs8dUL08aXcF-QA17TtDOoixKVPzsc,11346
|
|
282
282
|
tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
283
283
|
tests/test_cli.py,sha256=6sk1fmfXDTFbdVAKDCKRSo2hslrg_bywjo3rbYJFjPs,8937
|
|
284
284
|
tests/test_compile.py,sha256=NkPUC7GGvO3dGAgIIz5o0z5Rr5qDeQLtH_IJBlE1wHE,5689
|
|
@@ -291,7 +291,7 @@ tests/oai/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
|
291
291
|
tests/oai/schema_linter_checks.py,sha256=88Vmd6h4eD_bUfWOAiKoZo5NYO-XlRsBfe3ITKsEYho,7331
|
|
292
292
|
tests/oai/shared_types.py,sha256=IcI2qzg532F3tj9u23JX2RfiTCIpSM84MBdbGzQAlyM,137
|
|
293
293
|
tests/oai/test_appinfo_all_connectors.py,sha256=vsqtHAMTYnmr9NApRJThaLsxaPwW-TmKuh2PUxGLW88,5195
|
|
294
|
-
tests/oai/test_base_clients.py,sha256=
|
|
294
|
+
tests/oai/test_base_clients.py,sha256=7KNj8v_lKXQ6ePQa_AvLEydSZS5oacoLwI288zhzCb0,39667
|
|
295
295
|
tests/oai/test_capability.py,sha256=p6rdIlmu-bfYB_XXVk8OE4xTDOJOvd132LNjvvpeFoM,3910
|
|
296
296
|
tests/oai/test_credentials_module.py,sha256=HMzoxRz001a6HA8Ols0NsbW4zJxazZRr7H6uXt_kAKA,27335
|
|
297
297
|
tests/oai/test_dispatch_cases.py,sha256=nigQa1BUgVGSn3pzNbhonRiark0mnqb3elSQeERyCZc,16261
|
|
@@ -325,8 +325,8 @@ tests/utils/test_pagination.py,sha256=_VCeSTmbcqzMSESNuM9ta3-zxDempIJGR_CkcKRjCO
|
|
|
325
325
|
tests/utils/test_pagination_decode_cases.py,sha256=Ogtprql_vwTWkHV_JceemNtJQMKNekDWAa1yj09uPnw,221
|
|
326
326
|
tests/utils/test_pagination_duality_cases.py,sha256=Z_mI5PNbeKyTzdxdabfVDvOrCcbCgH8EuulVmb4Dcco,928
|
|
327
327
|
tests/utils/test_pagination_encode_cases.py,sha256=XsJ5PvlHT4stu3x6YF3-WADMk8ftfbDGOmbcof2_tiE,635
|
|
328
|
-
connector_py-4.
|
|
329
|
-
connector_py-4.
|
|
330
|
-
connector_py-4.
|
|
331
|
-
connector_py-4.
|
|
332
|
-
connector_py-4.
|
|
328
|
+
connector_py-4.181.0.dist-info/METADATA,sha256=UNsIyc1vo-fk9z3cD2bqqNPxso2fLbcvbRQLsYxBi8s,18641
|
|
329
|
+
connector_py-4.181.0.dist-info/WHEEL,sha256=qELbo2s1Yzl39ZmrAibXA2jjPLUYfnVhUNTlyF1rq0Y,92
|
|
330
|
+
connector_py-4.181.0.dist-info/entry_points.txt,sha256=D7nFwDmXoeHYpRVov7TrlbOtZqe9q1dOdkUY0GK0AEg,50
|
|
331
|
+
connector_py-4.181.0.dist-info/top_level.txt,sha256=a1lmLxeM8B6LFAD0kkCE1ZAK3rJP9Uv0Jd1fHSskX-k,16
|
|
332
|
+
connector_py-4.181.0.dist-info/RECORD,,
|
tests/oai/test_base_clients.py
CHANGED
|
@@ -11,15 +11,18 @@ from connector.generated import (
|
|
|
11
11
|
TokenCredential,
|
|
12
12
|
)
|
|
13
13
|
from connector.oai.base_clients import (
|
|
14
|
+
BaseGraphQLSession,
|
|
14
15
|
BaseIntegrationClient,
|
|
15
16
|
BatchRequest,
|
|
16
17
|
BatchRequests,
|
|
17
18
|
RateLimitedClient,
|
|
19
|
+
RateLimitedHTTPXAsyncTransport,
|
|
18
20
|
)
|
|
19
21
|
from connector.oai.capability import Request, get_token_auth
|
|
20
22
|
from connector.oai.errors import ConnectorError
|
|
21
23
|
from connector.utils.httpx_auth import BearerAuth
|
|
22
|
-
from connector.utils.rate_limiting import RateLimitConfig, RateLimitStrategy
|
|
24
|
+
from connector.utils.rate_limiting import RateLimitConfig, RateLimiter, RateLimitStrategy
|
|
25
|
+
from connector_sdk_types.generated import ErrorCode
|
|
23
26
|
|
|
24
27
|
|
|
25
28
|
@pytest.fixture(autouse=True)
|
|
@@ -579,3 +582,365 @@ class TestIntegrationScenarios:
|
|
|
579
582
|
assert len(responses) == 2
|
|
580
583
|
assert responses[0].status_code == 200
|
|
581
584
|
assert responses[1].status_code == 404
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
class TestRateLimitedHTTPXAsyncTransport:
|
|
588
|
+
"""Test cases for RateLimitedHTTPXAsyncTransport."""
|
|
589
|
+
|
|
590
|
+
@pytest.fixture
|
|
591
|
+
def mock_base_transport(self):
|
|
592
|
+
"""Create a mock HTTPXAsyncTransport for testing."""
|
|
593
|
+
from types import SimpleNamespace
|
|
594
|
+
|
|
595
|
+
transport = SimpleNamespace()
|
|
596
|
+
transport.some_attr = "value"
|
|
597
|
+
return transport
|
|
598
|
+
|
|
599
|
+
async def test_rate_limited_graphql_execution(self, mock_base_transport, rate_limit_config):
|
|
600
|
+
"""Test that GraphQL requests are executed through rate limiter."""
|
|
601
|
+
mock_result = {"data": {"test": "result"}}
|
|
602
|
+
mock_base_transport.execute = AsyncMock(return_value=mock_result)
|
|
603
|
+
|
|
604
|
+
transport = RateLimitedHTTPXAsyncTransport(mock_base_transport, rate_limit_config)
|
|
605
|
+
|
|
606
|
+
async def mock_execute_requests(self, requests, handler):
|
|
607
|
+
results = []
|
|
608
|
+
for req in requests:
|
|
609
|
+
result = await handler(req)
|
|
610
|
+
results.append(result)
|
|
611
|
+
return results
|
|
612
|
+
|
|
613
|
+
with patch.object(RateLimiter, "execute_requests", mock_execute_requests):
|
|
614
|
+
result = await transport.execute("query { test }", variable_values={})
|
|
615
|
+
|
|
616
|
+
assert result == mock_result
|
|
617
|
+
mock_base_transport.execute.assert_called_once()
|
|
618
|
+
call_args = mock_base_transport.execute.call_args
|
|
619
|
+
assert call_args.kwargs.get("document") == "query { test }"
|
|
620
|
+
assert call_args.kwargs.get("variable_values") == {}
|
|
621
|
+
|
|
622
|
+
async def test_connect_wraps_async_client(self, rate_limit_config):
|
|
623
|
+
"""Test that connect wraps AsyncClient with RateLimitedClient."""
|
|
624
|
+
from connector.httpx_rewrite import AsyncClient
|
|
625
|
+
|
|
626
|
+
class MockTransport:
|
|
627
|
+
def __init__(self):
|
|
628
|
+
self.client = None
|
|
629
|
+
self.connect_called = False
|
|
630
|
+
|
|
631
|
+
async def connect(self):
|
|
632
|
+
self.connect_called = True
|
|
633
|
+
|
|
634
|
+
real_client = AsyncClient(base_url="https://example.com")
|
|
635
|
+
base_transport = MockTransport()
|
|
636
|
+
base_transport.client = real_client
|
|
637
|
+
|
|
638
|
+
transport = RateLimitedHTTPXAsyncTransport(base_transport, rate_limit_config)
|
|
639
|
+
await transport.connect()
|
|
640
|
+
|
|
641
|
+
assert base_transport.connect_called
|
|
642
|
+
assert isinstance(base_transport.client, RateLimitedClient)
|
|
643
|
+
assert base_transport.client.base_client is real_client
|
|
644
|
+
assert transport.client is base_transport.client
|
|
645
|
+
|
|
646
|
+
async def test_connect_with_non_async_client(self, mock_base_transport, rate_limit_config):
|
|
647
|
+
"""Test that connect does not wrap non-AsyncClient instances."""
|
|
648
|
+
mock_client = MagicMock()
|
|
649
|
+
mock_base_transport.client = mock_client
|
|
650
|
+
mock_base_transport.connect = AsyncMock()
|
|
651
|
+
|
|
652
|
+
transport = RateLimitedHTTPXAsyncTransport(mock_base_transport, rate_limit_config)
|
|
653
|
+
await transport.connect()
|
|
654
|
+
|
|
655
|
+
mock_base_transport.connect.assert_called_once()
|
|
656
|
+
assert mock_base_transport.client is mock_client
|
|
657
|
+
assert not isinstance(mock_base_transport.client, RateLimitedClient)
|
|
658
|
+
assert transport.client is mock_client
|
|
659
|
+
|
|
660
|
+
async def test_connect_without_client(self, mock_base_transport, rate_limit_config):
|
|
661
|
+
"""Test that connect handles case when base transport has no client."""
|
|
662
|
+
mock_base_transport.connect = AsyncMock()
|
|
663
|
+
mock_base_transport.client = None
|
|
664
|
+
|
|
665
|
+
transport = RateLimitedHTTPXAsyncTransport(mock_base_transport, rate_limit_config)
|
|
666
|
+
await transport.connect()
|
|
667
|
+
|
|
668
|
+
mock_base_transport.connect.assert_called_once()
|
|
669
|
+
assert transport.client is None
|
|
670
|
+
|
|
671
|
+
async def test_connect_without_client_attribute(self, rate_limit_config):
|
|
672
|
+
"""Test that connect handles case when base transport doesn't have client attribute.
|
|
673
|
+
|
|
674
|
+
This tests the branch on line 195 where hasattr(self.base_transport, "client") returns False.
|
|
675
|
+
We use a mock that raises AttributeError when accessing the client attribute to test this branch.
|
|
676
|
+
"""
|
|
677
|
+
|
|
678
|
+
class MockTransportWithoutClient:
|
|
679
|
+
def __init__(self):
|
|
680
|
+
self.connect_called = False
|
|
681
|
+
|
|
682
|
+
async def connect(self):
|
|
683
|
+
self.connect_called = True
|
|
684
|
+
|
|
685
|
+
def __getattribute__(self, name):
|
|
686
|
+
if name == "client":
|
|
687
|
+
raise AttributeError(
|
|
688
|
+
f"'{type(self).__name__}' object has no attribute 'client'"
|
|
689
|
+
)
|
|
690
|
+
return super().__getattribute__(name)
|
|
691
|
+
|
|
692
|
+
mock_base_transport = MockTransportWithoutClient()
|
|
693
|
+
|
|
694
|
+
transport = RateLimitedHTTPXAsyncTransport(mock_base_transport, rate_limit_config)
|
|
695
|
+
|
|
696
|
+
assert not hasattr(transport.base_transport, "client")
|
|
697
|
+
|
|
698
|
+
with pytest.raises(AttributeError):
|
|
699
|
+
await transport.connect()
|
|
700
|
+
|
|
701
|
+
async def test_connect_isinstance_async_client_true(self, rate_limit_config):
|
|
702
|
+
"""Test that connect wraps AsyncClient when isinstance check is True, covering line 197 positive branch."""
|
|
703
|
+
from connector.httpx_rewrite import AsyncClient
|
|
704
|
+
|
|
705
|
+
class MockTransport:
|
|
706
|
+
def __init__(self):
|
|
707
|
+
self.client = None
|
|
708
|
+
self.connect_called = False
|
|
709
|
+
|
|
710
|
+
async def connect(self):
|
|
711
|
+
self.connect_called = True
|
|
712
|
+
|
|
713
|
+
real_client = AsyncClient(base_url="https://example.com")
|
|
714
|
+
base_transport = MockTransport()
|
|
715
|
+
base_transport.client = real_client
|
|
716
|
+
|
|
717
|
+
transport = RateLimitedHTTPXAsyncTransport(base_transport, rate_limit_config)
|
|
718
|
+
await transport.connect()
|
|
719
|
+
|
|
720
|
+
assert base_transport.connect_called
|
|
721
|
+
assert isinstance(base_transport.client, RateLimitedClient)
|
|
722
|
+
assert base_transport.client.base_client is real_client
|
|
723
|
+
assert transport.client is base_transport.client
|
|
724
|
+
|
|
725
|
+
async def test_connect_isinstance_async_client_false(self, rate_limit_config):
|
|
726
|
+
"""Test that connect does not wrap when isinstance check is False, covering line 197 negative branch."""
|
|
727
|
+
from connector.httpx_rewrite import AsyncClient
|
|
728
|
+
|
|
729
|
+
class MockTransport:
|
|
730
|
+
def __init__(self):
|
|
731
|
+
self.client = None
|
|
732
|
+
self.connect_called = False
|
|
733
|
+
|
|
734
|
+
async def connect(self):
|
|
735
|
+
self.connect_called = True
|
|
736
|
+
|
|
737
|
+
mock_client = MagicMock()
|
|
738
|
+
mock_client.some_attr = "value"
|
|
739
|
+
base_transport = MockTransport()
|
|
740
|
+
base_transport.client = mock_client
|
|
741
|
+
|
|
742
|
+
transport = RateLimitedHTTPXAsyncTransport(base_transport, rate_limit_config)
|
|
743
|
+
await transport.connect()
|
|
744
|
+
|
|
745
|
+
assert base_transport.connect_called
|
|
746
|
+
assert base_transport.client is mock_client
|
|
747
|
+
assert not isinstance(base_transport.client, RateLimitedClient)
|
|
748
|
+
assert not isinstance(base_transport.client, AsyncClient)
|
|
749
|
+
assert transport.client is mock_client
|
|
750
|
+
|
|
751
|
+
async def test_execute_with_all_parameters(self, mock_base_transport, rate_limit_config):
|
|
752
|
+
"""Test execute method with all GraphQL parameters."""
|
|
753
|
+
mock_result = {"data": {"test": "result"}}
|
|
754
|
+
mock_base_transport.execute = AsyncMock(return_value=mock_result)
|
|
755
|
+
|
|
756
|
+
transport = RateLimitedHTTPXAsyncTransport(mock_base_transport, rate_limit_config)
|
|
757
|
+
|
|
758
|
+
async def mock_execute_requests(self, requests, handler):
|
|
759
|
+
results = []
|
|
760
|
+
for req in requests:
|
|
761
|
+
result = await handler(req)
|
|
762
|
+
results.append(result)
|
|
763
|
+
return results
|
|
764
|
+
|
|
765
|
+
with patch.object(RateLimiter, "execute_requests", mock_execute_requests):
|
|
766
|
+
result = await transport.execute(
|
|
767
|
+
"query { test }", variable_values={"var": "value"}, operation_name="TestQuery"
|
|
768
|
+
)
|
|
769
|
+
|
|
770
|
+
assert result == mock_result
|
|
771
|
+
mock_base_transport.execute.assert_called_once()
|
|
772
|
+
call_args = mock_base_transport.execute.call_args
|
|
773
|
+
assert call_args.kwargs.get("document") == "query { test }"
|
|
774
|
+
assert call_args.kwargs.get("variable_values") == {"var": "value"}
|
|
775
|
+
assert call_args.kwargs.get("operation_name") == "TestQuery"
|
|
776
|
+
|
|
777
|
+
async def test_execute_calls_request_func_through_handler(
|
|
778
|
+
self, mock_base_transport, rate_limit_config
|
|
779
|
+
):
|
|
780
|
+
"""Test that execute calls request_func() through the handler lambda (lines 209-214).
|
|
781
|
+
|
|
782
|
+
This verifies that:
|
|
783
|
+
1. A request_func closure is created (line 208-211) that calls base_transport.execute
|
|
784
|
+
2. The request_func is passed to rate_limiter.execute_requests (line 214)
|
|
785
|
+
3. The handler lambda (lambda x: x()) is used to call request_func (line 214)
|
|
786
|
+
4. request_func() executes and calls base_transport.execute
|
|
787
|
+
"""
|
|
788
|
+
mock_result = {"data": {"test": "result"}}
|
|
789
|
+
mock_base_transport.execute = AsyncMock(return_value=mock_result)
|
|
790
|
+
|
|
791
|
+
transport = RateLimitedHTTPXAsyncTransport(mock_base_transport, rate_limit_config)
|
|
792
|
+
|
|
793
|
+
async def mock_execute_requests(self, requests, handler):
|
|
794
|
+
results = []
|
|
795
|
+
for req in requests:
|
|
796
|
+
result = await handler(req)
|
|
797
|
+
results.append(result)
|
|
798
|
+
return results
|
|
799
|
+
|
|
800
|
+
with patch.object(RateLimiter, "execute_requests", mock_execute_requests):
|
|
801
|
+
result = await transport.execute("query { test }", variable_values={"var": "value"})
|
|
802
|
+
|
|
803
|
+
assert result == mock_result
|
|
804
|
+
mock_base_transport.execute.assert_called_once()
|
|
805
|
+
|
|
806
|
+
call_args = mock_base_transport.execute.call_args
|
|
807
|
+
assert call_args.kwargs.get("document") == "query { test }"
|
|
808
|
+
assert call_args.kwargs.get("variable_values") == {"var": "value"}
|
|
809
|
+
|
|
810
|
+
async def test_execute_raises_on_no_response(self, mock_base_transport, rate_limit_config):
|
|
811
|
+
"""Test that execute raises ConnectorError when rate limiter returns no response.
|
|
812
|
+
|
|
813
|
+
This tests the negative branch of line 215 where responses is falsy (empty list).
|
|
814
|
+
"""
|
|
815
|
+
transport = RateLimitedHTTPXAsyncTransport(mock_base_transport, rate_limit_config)
|
|
816
|
+
|
|
817
|
+
async def empty_execute_requests(self, requests, handler):
|
|
818
|
+
return []
|
|
819
|
+
|
|
820
|
+
with patch.object(RateLimiter, "execute_requests", empty_execute_requests):
|
|
821
|
+
with pytest.raises(ConnectorError) as exc_info:
|
|
822
|
+
await transport.execute("query { test }")
|
|
823
|
+
|
|
824
|
+
assert exc_info.value.message == "No response from GraphQL API"
|
|
825
|
+
assert exc_info.value.error_code == ErrorCode.API_ERROR
|
|
826
|
+
|
|
827
|
+
async def test_execute_with_successful_response(self, mock_base_transport, rate_limit_config):
|
|
828
|
+
"""Test that execute returns response when rate limiter returns a response.
|
|
829
|
+
|
|
830
|
+
This tests the positive branch of line 215 where responses is truthy (non-empty list).
|
|
831
|
+
We patch at the class level to ensure the mock is actually used.
|
|
832
|
+
"""
|
|
833
|
+
mock_result = {"data": {"test": "result"}}
|
|
834
|
+
mock_base_transport.execute = AsyncMock(return_value=mock_result)
|
|
835
|
+
|
|
836
|
+
transport = RateLimitedHTTPXAsyncTransport(mock_base_transport, rate_limit_config)
|
|
837
|
+
|
|
838
|
+
async def mock_execute_requests(self, requests, handler):
|
|
839
|
+
results = []
|
|
840
|
+
for req in requests:
|
|
841
|
+
result = await handler(req)
|
|
842
|
+
results.append(result)
|
|
843
|
+
return results
|
|
844
|
+
|
|
845
|
+
with patch.object(RateLimiter, "execute_requests", mock_execute_requests):
|
|
846
|
+
result = await transport.execute("query { test }")
|
|
847
|
+
|
|
848
|
+
assert result == mock_result
|
|
849
|
+
mock_base_transport.execute.assert_called_once()
|
|
850
|
+
|
|
851
|
+
def test_delegation_and_state(self, mock_base_transport, rate_limit_config):
|
|
852
|
+
"""Test attribute delegation and state retrieval."""
|
|
853
|
+
mock_base_transport.delegated_method = MagicMock(return_value="delegated_value")
|
|
854
|
+
transport = RateLimitedHTTPXAsyncTransport(mock_base_transport, rate_limit_config)
|
|
855
|
+
|
|
856
|
+
assert transport.some_attr == "value"
|
|
857
|
+
assert transport.delegated_method() == "delegated_value"
|
|
858
|
+
|
|
859
|
+
config, delay = transport.get_state()
|
|
860
|
+
assert config is rate_limit_config
|
|
861
|
+
assert isinstance(delay, float)
|
|
862
|
+
|
|
863
|
+
|
|
864
|
+
class TestBaseGraphQLSession:
|
|
865
|
+
"""Test cases for BaseGraphQLSession."""
|
|
866
|
+
|
|
867
|
+
class ConcreteTestGraphQLSession(BaseGraphQLSession):
|
|
868
|
+
"""Concrete implementation for testing."""
|
|
869
|
+
|
|
870
|
+
@classmethod
|
|
871
|
+
def prepare_client_args(cls, args: Request) -> dict[str, Any]:
|
|
872
|
+
from connector.httpx_rewrite import HTTPXAsyncTransport
|
|
873
|
+
|
|
874
|
+
return {
|
|
875
|
+
"transport": HTTPXAsyncTransport(
|
|
876
|
+
url="https://example.com/graphql",
|
|
877
|
+
auth=BearerAuth(
|
|
878
|
+
token=get_token_auth(args).token,
|
|
879
|
+
token_prefix="",
|
|
880
|
+
auth_header="Authorization",
|
|
881
|
+
),
|
|
882
|
+
),
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
def test_build_client_applies_rate_limiting(self, sample_request, rate_limit_config):
|
|
886
|
+
"""Test that build_client wraps transport with rate limiter when config provided."""
|
|
887
|
+
client = self.ConcreteTestGraphQLSession.build_client(sample_request, rate_limit_config)
|
|
888
|
+
|
|
889
|
+
assert client is not None
|
|
890
|
+
assert isinstance(client.transport, RateLimitedHTTPXAsyncTransport)
|
|
891
|
+
assert client.transport.rate_limiter.config is rate_limit_config
|
|
892
|
+
|
|
893
|
+
client_no_rate_limit = self.ConcreteTestGraphQLSession.build_client(sample_request)
|
|
894
|
+
assert client_no_rate_limit is not None
|
|
895
|
+
assert not isinstance(client_no_rate_limit.transport, RateLimitedHTTPXAsyncTransport)
|
|
896
|
+
|
|
897
|
+
class TestSessionWithClassConfig(self.ConcreteTestGraphQLSession):
|
|
898
|
+
_rate_limit_config = RateLimitConfig(
|
|
899
|
+
app_id="class-config",
|
|
900
|
+
requests_per_window=5,
|
|
901
|
+
window_seconds=30,
|
|
902
|
+
)
|
|
903
|
+
|
|
904
|
+
client_with_class_config = TestSessionWithClassConfig.build_client(sample_request)
|
|
905
|
+
assert isinstance(client_with_class_config.transport, RateLimitedHTTPXAsyncTransport)
|
|
906
|
+
|
|
907
|
+
def test_get_current_rate_limits(self, sample_request, rate_limit_config):
|
|
908
|
+
"""Test rate limit state retrieval."""
|
|
909
|
+
client_with_rate_limit = self.ConcreteTestGraphQLSession.build_client(
|
|
910
|
+
sample_request, rate_limit_config
|
|
911
|
+
)
|
|
912
|
+
session_with_rate_limit = self.ConcreteTestGraphQLSession(sample_request, rate_limit_config)
|
|
913
|
+
session_with_rate_limit.client = client_with_rate_limit
|
|
914
|
+
|
|
915
|
+
config, delay = session_with_rate_limit.get_current_rate_limits()
|
|
916
|
+
assert config is rate_limit_config
|
|
917
|
+
assert isinstance(delay, float)
|
|
918
|
+
|
|
919
|
+
client_no_rate_limit = self.ConcreteTestGraphQLSession.build_client(sample_request)
|
|
920
|
+
session_no_rate_limit = self.ConcreteTestGraphQLSession(sample_request)
|
|
921
|
+
session_no_rate_limit.client = client_no_rate_limit
|
|
922
|
+
|
|
923
|
+
config, delay = session_no_rate_limit.get_current_rate_limits()
|
|
924
|
+
assert config is None
|
|
925
|
+
assert delay == 0
|
|
926
|
+
|
|
927
|
+
async def test_context_manager(self, sample_request):
|
|
928
|
+
"""Test async context manager functionality."""
|
|
929
|
+
with patch("connector.oai.base_clients.Client") as mock_client_class:
|
|
930
|
+
mock_client = MagicMock()
|
|
931
|
+
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
932
|
+
mock_client.__aexit__ = AsyncMock()
|
|
933
|
+
mock_client_class.return_value = mock_client
|
|
934
|
+
|
|
935
|
+
session = self.ConcreteTestGraphQLSession(sample_request)
|
|
936
|
+
|
|
937
|
+
async with session as ctx:
|
|
938
|
+
assert ctx is session
|
|
939
|
+
|
|
940
|
+
mock_client.__aenter__.assert_called_once()
|
|
941
|
+
mock_client.__aexit__.assert_called_once()
|
|
942
|
+
|
|
943
|
+
test_exception = ValueError("Test error")
|
|
944
|
+
with pytest.raises(ValueError, match="Test error"):
|
|
945
|
+
async with session:
|
|
946
|
+
raise test_exception
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|