Skip to content

Commit 38038e0

Browse files
committed
define recording decorators that can record openai traffic within test_base
1 parent be3f29f commit 38038e0

File tree

3 files changed

+223
-82
lines changed

3 files changed

+223
-82
lines changed

eng/tools/azure-sdk-tools/devtools_testutils/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@
5252
set_session_recording_options,
5353
)
5454
from .cert import create_combined_bundle
55-
from .helpers import ResponseCallback, RetryCounter, is_live_and_not_recording, trim_kwargs_from_test_function
55+
from .helpers import ResponseCallback, RetryCounter, is_live_and_not_recording, is_live as is_live_internal, trim_kwargs_from_test_function
5656
from .fake_credentials import FakeTokenCredential
5757

5858
PowerShellPreparer = EnvironmentVariableLoader # Backward compat
@@ -117,4 +117,5 @@
117117
"FakeTokenCredential",
118118
"create_combined_bundle",
119119
"is_live_and_not_recording",
120+
"is_live_internal"
120121
]

sdk/ai/azure-ai-projects/tests/agents/test_responses.py

Lines changed: 3 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -7,94 +7,16 @@
77

88
# import os
99
import pytest
10-
from test_base import TestBase, servicePreparer
11-
from devtools_testutils import is_live_and_not_recording # , get_proxy_netloc
12-
13-
# from ci_tools.variables import PROXY_URL
14-
import urllib.parse as url_parse
15-
16-
# import httpx
17-
# from urllib.parse import urlparse, urlunparse
18-
19-
# # option one: use custom transport to route through the proxy
20-
# # need to figure out how to plug this into the client used in the tests
21-
# class ProxyTransport(httpx.HTTPTransport):
22-
# def __init__(self, *args, **kwargs):
23-
# super().__init__(*args, **kwargs)
24-
# self.proxy_url = os.getenv("PROXY_URL")
25-
26-
# def handle_request(self, request: httpx.Request) -> httpx.Response:
27-
# # Save original upstream base for the proxy to use during playback
28-
# parsed_target_url = urlparse(str(request.url))
29-
# if "x-recording-upstream-base-uri" not in request.headers:
30-
# request.headers["x-recording-upstream-base-uri"] = f"{parsed_target_url.scheme}://{parsed_target_url.netloc}"
31-
32-
# # Set recording headers
33-
# request.headers["x-recording-id"] = "<RECORDING_ID>"
34-
# request.headers["x-recording-mode"] = "record" # or "playback"
35-
# # rewrite destination to proxy
36-
# updated_target = parsed_target_url._replace(**get_proxy_netloc()).geturl()
37-
# request.url = httpx.URL(updated_target)
38-
39-
# # Delegate to parent (real) transport and capture response
40-
# response = super().handle_request(request)
41-
42-
# # Restore response.request.url to the original upstream target so callers see the original URL
43-
# try:
44-
# parsed_resp = urlparse(str(response.request.url))
45-
# upstream_uri = urlparse(response.request.headers.get("x-recording-upstream-base-uri", ""))
46-
# if upstream_uri.netloc:
47-
# original_target = parsed_resp._replace(scheme=upstream_uri.scheme or parsed_resp.scheme, netloc=upstream_uri.netloc).geturl()
48-
# response.request.url = httpx.URL(original_target)
49-
# except Exception:
50-
# # best-effort restore; do not fail the call if something goes wrong here
51-
# pass
52-
53-
# return response
54-
55-
56-
# class AsyncProxyTransport(httpx.AsyncHTTPTransport):
57-
# def __init__(self, *args, **kwargs):
58-
# super().__init__(*args, **kwargs)
59-
# self.proxy_url = os.getenv("PROXY_URL")
60-
61-
# async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
62-
# # Save original upstream base for the proxy to use during playback
63-
# parsed_target_url = urlparse(str(request.url))
64-
# if "x-recording-upstream-base-uri" not in request.headers:
65-
# request.headers["x-recording-upstream-base-uri"] = f"{parsed_target_url.scheme}://{parsed_target_url.netloc}"
66-
67-
# # Set recording headers
68-
# request.headers["x-recording-id"] = "<RECORDING_ID>"
69-
# request.headers["x-recording-mode"] = "record" # or "playback"
70-
71-
# # rewrite to proxy
72-
# updated_target = parsed_target_url._replace(**get_proxy_netloc()).geturl()
73-
# request.url = httpx.URL(updated_target)
74-
75-
# # Delegate to underlying async transport and capture response
76-
# response = await super().handle_async_request(request)
77-
78-
# # Restore response.request.url to the original upstream target so callers see the original URL
79-
# try:
80-
# parsed_resp = urlparse(str(response.request.url))
81-
# upstream_uri = urlparse(response.request.headers.get("x-recording-upstream-base-uri", ""))
82-
# if upstream_uri.netloc:
83-
# original_target = parsed_resp._replace(scheme=upstream_uri.scheme or parsed_resp.scheme, netloc=upstream_uri.netloc).geturl()
84-
# response.request.url = httpx.URL(original_target)
85-
# except Exception:
86-
# pass
87-
88-
# return response
89-
10+
from test_base import TestBase, servicePreparer, recorded_by_proxy_httpx
11+
from devtools_testutils import is_live_and_not_recording
9012

9113
class TestResponses(TestBase):
92-
9314
@servicePreparer()
9415
@pytest.mark.skipif(
9516
condition=(not is_live_and_not_recording()),
9617
reason="Skipped because we cannot record network calls with OpenAI client",
9718
)
19+
# recorded_by_proxy_httpx
9820
def test_responses(self, **kwargs):
9921
"""
10022
Test creating a responses call (no Agents, no Conversation).

sdk/ai/azure-ai-projects/tests/test_base.py

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,26 @@
3333
from azure.ai.projects import AIProjectClient as AIProjectClient
3434
from azure.ai.projects.aio import AIProjectClient as AsyncAIProjectClient
3535

36+
# temporary requirements for recorded_by_proxy_httpx and recorded_by_proxy_async_httpx
37+
import logging
38+
import urllib.parse as url_parse
39+
40+
try:
41+
import httpx
42+
except ImportError:
43+
httpx = None
44+
45+
from azure.core.exceptions import ResourceNotFoundError
46+
from azure.core.pipeline.policies import ContentDecodePolicy
47+
48+
from devtools_testutils import is_live_internal, is_live_and_not_recording, trim_kwargs_from_test_function
49+
from devtools_testutils.proxy_testcase import (
50+
get_test_id,
51+
start_record_or_playback,
52+
stop_record_or_playback,
53+
get_proxy_netloc,
54+
)
55+
3656
# Load secrets from environment variables
3757
servicePreparer = functools.partial(
3858
EnvironmentVariableLoader,
@@ -420,3 +440,201 @@ def _are_json_equal(json_str1: str, json_str2: str) -> bool:
420440
except json.JSONDecodeError as e:
421441
print(f"Invalid JSON: {e}")
422442
return False
443+
444+
# The following two decorators recorded_by_proxy_httpx and recorded_by_proxy_async_httpx will be supplanted as part of #43794.
445+
# These are provided here temporarily to support existing tests that use httpx-based clients until they can be migrated.
446+
def recorded_by_proxy_httpx(test_func):
447+
"""Decorator that redirects httpx network requests to target the azure-sdk-tools test proxy.
448+
449+
Use this decorator for tests that use httpx-based clients (like OpenAI SDK) instead of Azure SDK clients.
450+
It monkeypatches httpx.HTTPTransport.handle_request to route requests through the test proxy.
451+
452+
For more details and usage examples, refer to
453+
https://github.com/Azure/azure-sdk-for-python/blob/main/doc/dev/tests.md#write-or-run-tests
454+
"""
455+
if httpx is None:
456+
raise ImportError("httpx is required to use recorded_by_proxy_httpx. Install it with: pip install httpx")
457+
458+
def record_wrap(*args, **kwargs):
459+
def transform_httpx_request(request: httpx.Request, recording_id: str) -> None:
460+
"""Transform an httpx.Request to route through the test proxy."""
461+
parsed_result = url_parse.urlparse(str(request.url))
462+
463+
# Store original upstream URI
464+
if "x-recording-upstream-base-uri" not in request.headers:
465+
request.headers["x-recording-upstream-base-uri"] = f"{parsed_result.scheme}://{parsed_result.netloc}"
466+
467+
# Set recording headers
468+
request.headers["x-recording-id"] = recording_id
469+
request.headers["x-recording-mode"] = "record" if is_live_internal() else "playback"
470+
471+
# Rewrite URL to proxy
472+
updated_target = parsed_result._replace(**get_proxy_netloc()).geturl()
473+
request.url = httpx.URL(updated_target)
474+
475+
def restore_httpx_response_url(response: httpx.Response) -> httpx.Response:
476+
"""Restore the response's request URL to the original upstream target."""
477+
try:
478+
parsed_resp = url_parse.urlparse(str(response.request.url))
479+
upstream_uri_str = response.request.headers.get("x-recording-upstream-base-uri", "")
480+
if upstream_uri_str:
481+
upstream_uri = url_parse.urlparse(upstream_uri_str)
482+
original_target = parsed_resp._replace(
483+
scheme=upstream_uri.scheme or parsed_resp.scheme,
484+
netloc=upstream_uri.netloc
485+
).geturl()
486+
response.request.url = httpx.URL(original_target)
487+
except Exception:
488+
# Best-effort restore; don't fail the call if something goes wrong
489+
pass
490+
return response
491+
492+
trimmed_kwargs = {k: v for k, v in kwargs.items()}
493+
trim_kwargs_from_test_function(test_func, trimmed_kwargs)
494+
495+
if is_live_and_not_recording():
496+
return test_func(*args, **trimmed_kwargs)
497+
498+
test_id = get_test_id()
499+
recording_id, variables = start_record_or_playback(test_id)
500+
original_transport_func = httpx.HTTPTransport.handle_request
501+
502+
def combined_call(transport_self, request: httpx.Request) -> httpx.Response:
503+
transform_httpx_request(request, recording_id)
504+
result = original_transport_func(transport_self, request)
505+
return restore_httpx_response_url(result)
506+
507+
httpx.HTTPTransport.handle_request = combined_call
508+
509+
# Call the test function
510+
test_variables = None
511+
test_run = False
512+
try:
513+
try:
514+
test_variables = test_func(*args, variables=variables, **trimmed_kwargs)
515+
test_run = True
516+
except TypeError as error:
517+
if "unexpected keyword argument" in str(error) and "variables" in str(error):
518+
logger = logging.getLogger()
519+
logger.info(
520+
"This test can't accept variables as input. The test method should accept `**kwargs` and/or a "
521+
"`variables` parameter to make use of recorded test variables."
522+
)
523+
else:
524+
raise error
525+
# If the test couldn't accept `variables`, run without passing them
526+
if not test_run:
527+
test_variables = test_func(*args, **trimmed_kwargs)
528+
529+
except ResourceNotFoundError as error:
530+
error_body = ContentDecodePolicy.deserialize_from_http_generics(error.response)
531+
message = error_body.get("message") or error_body.get("Message")
532+
error_with_message = ResourceNotFoundError(message=message, response=error.response)
533+
raise error_with_message from error
534+
535+
finally:
536+
httpx.HTTPTransport.handle_request = original_transport_func
537+
stop_record_or_playback(test_id, recording_id, test_variables)
538+
539+
return test_variables
540+
541+
return record_wrap
542+
543+
544+
def recorded_by_proxy_async_httpx(test_func):
545+
"""Decorator that redirects async httpx network requests to target the azure-sdk-tools test proxy.
546+
547+
Use this decorator for async tests that use httpx-based clients (like OpenAI AsyncOpenAI SDK)
548+
instead of Azure SDK clients. It monkeypatches httpx.AsyncHTTPTransport.handle_async_request
549+
to route requests through the test proxy.
550+
551+
For more details and usage examples, refer to
552+
https://github.com/Azure/azure-sdk-for-python/blob/main/doc/dev/tests.md#write-or-run-tests
553+
"""
554+
if httpx is None:
555+
raise ImportError("httpx is required to use recorded_by_proxy_async_httpx. Install it with: pip install httpx")
556+
557+
async def record_wrap(*args, **kwargs):
558+
def transform_httpx_request(request: httpx.Request, recording_id: str) -> None:
559+
"""Transform an httpx.Request to route through the test proxy."""
560+
parsed_result = url_parse.urlparse(str(request.url))
561+
562+
# Store original upstream URI
563+
if "x-recording-upstream-base-uri" not in request.headers:
564+
request.headers["x-recording-upstream-base-uri"] = f"{parsed_result.scheme}://{parsed_result.netloc}"
565+
566+
# Set recording headers
567+
request.headers["x-recording-id"] = recording_id
568+
request.headers["x-recording-mode"] = "record" if is_live_internal() else "playback"
569+
570+
# Rewrite URL to proxy
571+
updated_target = parsed_result._replace(**get_proxy_netloc()).geturl()
572+
request.url = httpx.URL(updated_target)
573+
574+
def restore_httpx_response_url(response: httpx.Response) -> httpx.Response:
575+
"""Restore the response's request URL to the original upstream target."""
576+
try:
577+
parsed_resp = url_parse.urlparse(str(response.request.url))
578+
upstream_uri_str = response.request.headers.get("x-recording-upstream-base-uri", "")
579+
if upstream_uri_str:
580+
upstream_uri = url_parse.urlparse(upstream_uri_str)
581+
original_target = parsed_resp._replace(
582+
scheme=upstream_uri.scheme or parsed_resp.scheme,
583+
netloc=upstream_uri.netloc
584+
).geturl()
585+
response.request.url = httpx.URL(original_target)
586+
except Exception:
587+
# Best-effort restore; don't fail the call if something goes wrong
588+
pass
589+
return response
590+
591+
trimmed_kwargs = {k: v for k, v in kwargs.items()}
592+
trim_kwargs_from_test_function(test_func, trimmed_kwargs)
593+
594+
if is_live_and_not_recording():
595+
return await test_func(*args, **trimmed_kwargs)
596+
597+
test_id = get_test_id()
598+
recording_id, variables = start_record_or_playback(test_id)
599+
original_transport_func = httpx.AsyncHTTPTransport.handle_async_request
600+
601+
async def combined_call(transport_self, request: httpx.Request) -> httpx.Response:
602+
transform_httpx_request(request, recording_id)
603+
result = await original_transport_func(transport_self, request)
604+
return restore_httpx_response_url(result)
605+
606+
httpx.AsyncHTTPTransport.handle_async_request = combined_call
607+
608+
# Call the test function
609+
test_variables = None
610+
test_run = False
611+
try:
612+
try:
613+
test_variables = await test_func(*args, variables=variables, **trimmed_kwargs)
614+
test_run = True
615+
except TypeError as error:
616+
if "unexpected keyword argument" in str(error) and "variables" in str(error):
617+
logger = logging.getLogger()
618+
logger.info(
619+
"This test can't accept variables as input. The test method should accept `**kwargs` and/or a "
620+
"`variables` parameter to make use of recorded test variables."
621+
)
622+
else:
623+
raise error
624+
# If the test couldn't accept `variables`, run without passing them
625+
if not test_run:
626+
test_variables = await test_func(*args, **trimmed_kwargs)
627+
628+
except ResourceNotFoundError as error:
629+
error_body = ContentDecodePolicy.deserialize_from_http_generics(error.response)
630+
message = error_body.get("message") or error_body.get("Message")
631+
error_with_message = ResourceNotFoundError(message=message, response=error.response)
632+
raise error_with_message from error
633+
634+
finally:
635+
httpx.AsyncHTTPTransport.handle_async_request = original_transport_func
636+
stop_record_or_playback(test_id, recording_id, test_variables)
637+
638+
return test_variables
639+
640+
return record_wrap

0 commit comments

Comments
 (0)