|
19 | 19 | from datetime import datetime, timedelta |
20 | 20 | from importlib import reload |
21 | 21 | import json |
| 22 | +import aiohttp |
| 23 | +from aiohttp import web as aiohttp_web |
22 | 24 | import requests |
23 | 25 | from unittest import mock |
24 | 26 | from google.protobuf import duration_pb2 |
@@ -662,6 +664,38 @@ def predict_async_client_predict_mock(): |
662 | 664 | yield predict_mock |
663 | 665 |
|
664 | 666 |
|
| 667 | +@pytest.fixture |
| 668 | +def predict_endpoint_aiohttp_mock(): |
| 669 | + """Mocks aiohttp.ClientSession.post for dedicated-endpoint async predict. |
| 670 | +
|
| 671 | + ClientSession.post returns a _RequestContextManager (async ctx mgr), so the |
| 672 | + mock must implement __aenter__/__aexit__. Also patches Credentials.refresh |
| 673 | + to a no-op so we don't hit real auth during tests. |
| 674 | + """ |
| 675 | + payload = { |
| 676 | + "predictions": _TEST_PREDICTION, |
| 677 | + "metadata": _TEST_METADATA, |
| 678 | + "deployedModelId": _TEST_DEPLOYED_MODELS[0].id, |
| 679 | + "model": _TEST_MODEL_NAME, |
| 680 | + "modelVersionId": "1", |
| 681 | + } |
| 682 | + response = mock.AsyncMock() |
| 683 | + response.status = 200 |
| 684 | + response.json = mock.AsyncMock(return_value=payload) |
| 685 | + response.text = mock.AsyncMock(return_value=json.dumps(payload)) |
| 686 | + |
| 687 | + post_cm = mock.MagicMock() |
| 688 | + post_cm.__aenter__ = mock.AsyncMock(return_value=response) |
| 689 | + post_cm.__aexit__ = mock.AsyncMock(return_value=None) |
| 690 | + |
| 691 | + with mock.patch.object( |
| 692 | + aiohttp.ClientSession, "post", return_value=post_cm |
| 693 | + ) as post_mock, mock.patch.object( |
| 694 | + auth_credentials.AnonymousCredentials, "refresh" |
| 695 | + ): |
| 696 | + yield post_mock |
| 697 | + |
| 698 | + |
665 | 699 | @pytest.fixture |
666 | 700 | def predict_client_direct_predict_mock(): |
667 | 701 | with mock.patch.object( |
@@ -3687,6 +3721,175 @@ async def test_predict_async(self, predict_async_client_predict_mock): |
3687 | 3721 | timeout=None, |
3688 | 3722 | ) |
3689 | 3723 |
|
| 3724 | + @pytest.mark.asyncio |
| 3725 | + @pytest.mark.usefixtures("get_dedicated_endpoint_mock") |
| 3726 | + async def test_predict_async_dedicated_endpoint( |
| 3727 | + self, predict_endpoint_aiohttp_mock |
| 3728 | + ): |
| 3729 | + """Async predict on a dedicated endpoint routes via aiohttp REST.""" |
| 3730 | + test_endpoint = models.Endpoint(_TEST_ENDPOINT_NAME) |
| 3731 | + |
| 3732 | + test_prediction = await test_endpoint.predict_async( |
| 3733 | + instances=_TEST_INSTANCES, parameters={"param": 3.0} |
| 3734 | + ) |
| 3735 | + |
| 3736 | + true_prediction = models.Prediction( |
| 3737 | + predictions=_TEST_PREDICTION, |
| 3738 | + deployed_model_id=_TEST_ID, |
| 3739 | + metadata=_TEST_METADATA, |
| 3740 | + model_version_id=_TEST_VERSION_ID, |
| 3741 | + model_resource_name=_TEST_MODEL_NAME, |
| 3742 | + ) |
| 3743 | + assert true_prediction == test_prediction |
| 3744 | + |
| 3745 | + predict_endpoint_aiohttp_mock.assert_called_once() |
| 3746 | + call_kwargs = predict_endpoint_aiohttp_mock.call_args.kwargs |
| 3747 | + assert call_kwargs["url"] == ( |
| 3748 | + f"https://{_TEST_DEDICATED_ENDPOINT_DNS}/v1/projects/" |
| 3749 | + f"{_TEST_PROJECT}/locations/{_TEST_LOCATION}/endpoints/{_TEST_ID}:predict" |
| 3750 | + ) |
| 3751 | + assert call_kwargs["data"] == ( |
| 3752 | + '{"instances": [[1.0, 2.0, 3.0], [1.0, 3.0, 4.0]],' |
| 3753 | + ' "parameters": {"param": 3.0}}' |
| 3754 | + ) |
| 3755 | + assert call_kwargs["headers"]["Content-Type"] == "application/json" |
| 3756 | + assert call_kwargs["headers"]["Authorization"].startswith("Bearer ") |
| 3757 | + assert call_kwargs["timeout"] is None |
| 3758 | + |
| 3759 | + @pytest.mark.asyncio |
| 3760 | + @pytest.mark.usefixtures("get_dedicated_endpoint_no_dns_mock") |
| 3761 | + async def test_predict_async_dedicated_endpoint_without_dns( |
| 3762 | + self, predict_endpoint_aiohttp_mock |
| 3763 | + ): |
| 3764 | + """Async predict on a dedicated endpoint with no DNS raises ValueError.""" |
| 3765 | + test_endpoint = models.Endpoint(_TEST_ENDPOINT_NAME) |
| 3766 | + |
| 3767 | + with pytest.raises(ValueError) as err: |
| 3768 | + await test_endpoint.predict_async( |
| 3769 | + instances=_TEST_INSTANCES, parameters={"param": 3.0} |
| 3770 | + ) |
| 3771 | + assert err.match( |
| 3772 | + regexp=r"Dedicated endpoint DNS is empty. Please make sure endpoint" |
| 3773 | + "and model are ready before making a prediction." |
| 3774 | + ) |
| 3775 | + |
| 3776 | + @pytest.mark.asyncio |
| 3777 | + @pytest.mark.usefixtures("get_dedicated_endpoint_mock") |
| 3778 | + async def test_predict_async_dedicated_endpoint_with_timeout( |
| 3779 | + self, predict_endpoint_aiohttp_mock |
| 3780 | + ): |
| 3781 | + """A non-None timeout is forwarded as an aiohttp.ClientTimeout.""" |
| 3782 | + test_endpoint = models.Endpoint(_TEST_ENDPOINT_NAME) |
| 3783 | + |
| 3784 | + await test_endpoint.predict_async( |
| 3785 | + instances=_TEST_INSTANCES, |
| 3786 | + parameters={"param": 3.0}, |
| 3787 | + timeout=_TEST_PREDICT_TIMEOUT, |
| 3788 | + ) |
| 3789 | + |
| 3790 | + predict_endpoint_aiohttp_mock.assert_called_once() |
| 3791 | + call_timeout = predict_endpoint_aiohttp_mock.call_args.kwargs["timeout"] |
| 3792 | + assert isinstance(call_timeout, aiohttp.ClientTimeout) |
| 3793 | + assert call_timeout.total == _TEST_PREDICT_TIMEOUT |
| 3794 | + |
| 3795 | + @pytest.mark.asyncio |
| 3796 | + @pytest.mark.usefixtures("get_dedicated_endpoint_mock") |
| 3797 | + async def test_predict_async_dedicated_endpoint_integration(self): |
| 3798 | + """Hermetic integration test: real aiohttp request against a local server. |
| 3799 | +
|
| 3800 | + Unlike the mock-based tests above, this exercises the real |
| 3801 | + aiohttp.ClientSession code path (connection, request encoding, |
| 3802 | + response decoding, JSON parsing, async context manager teardown) by |
| 3803 | + pointing the dedicated DNS at an in-process aiohttp.web server. |
| 3804 | +
|
| 3805 | + The server validates the URL path, headers, and body shape the SDK |
| 3806 | + sends, and returns a Vertex-shaped JSON response. The SDK builds the |
| 3807 | + URL as ``https://{dns}/...``; we wrap the session to downgrade the |
| 3808 | + scheme to ``http://`` so the local server can serve plain HTTP without |
| 3809 | + needing self-signed TLS plumbing in the test. |
| 3810 | + """ |
| 3811 | + captured = {} |
| 3812 | + |
| 3813 | + async def predict_handler(request: aiohttp_web.Request): |
| 3814 | + captured["path"] = request.path |
| 3815 | + captured["headers"] = dict(request.headers) |
| 3816 | + captured["body"] = await request.json() |
| 3817 | + return aiohttp_web.json_response( |
| 3818 | + { |
| 3819 | + "predictions": _TEST_PREDICTION, |
| 3820 | + "metadata": _TEST_METADATA, |
| 3821 | + "deployedModelId": _TEST_DEPLOYED_MODELS[0].id, |
| 3822 | + "model": _TEST_MODEL_NAME, |
| 3823 | + "modelVersionId": "1", |
| 3824 | + } |
| 3825 | + ) |
| 3826 | + |
| 3827 | + app = aiohttp_web.Application() |
| 3828 | + app.router.add_post( |
| 3829 | + f"/v1/{_TEST_ENDPOINT_NAME}:predict", predict_handler |
| 3830 | + ) |
| 3831 | + runner = aiohttp_web.AppRunner(app) |
| 3832 | + await runner.setup() |
| 3833 | + site = aiohttp_web.TCPSite(runner, "127.0.0.1", 0) |
| 3834 | + await site.start() |
| 3835 | + port = site._server.sockets[0].getsockname()[1] |
| 3836 | + |
| 3837 | + try: |
| 3838 | + test_endpoint = models.Endpoint(_TEST_ENDPOINT_NAME) |
| 3839 | + |
| 3840 | + # Force the dedicated DNS to point at the local server. |
| 3841 | + with mock.patch.object( |
| 3842 | + models.Endpoint, |
| 3843 | + "dedicated_endpoint_dns", |
| 3844 | + new_callable=mock.PropertyMock, |
| 3845 | + return_value=f"127.0.0.1:{port}", |
| 3846 | + ), mock.patch.object( |
| 3847 | + auth_credentials.AnonymousCredentials, "refresh" |
| 3848 | + ): |
| 3849 | + # Inject a session that downgrades https:// -> http:// so the |
| 3850 | + # local test server can serve plain HTTP. Real aiohttp does |
| 3851 | + # everything else (real socket, real request/response cycle). |
| 3852 | + real_session = aiohttp.ClientSession() |
| 3853 | + original_post = real_session.post |
| 3854 | + |
| 3855 | + def http_post(url, **kwargs): |
| 3856 | + return original_post( |
| 3857 | + url.replace("https://", "http://", 1), **kwargs |
| 3858 | + ) |
| 3859 | + |
| 3860 | + real_session.post = http_post |
| 3861 | + test_endpoint._authorized_session_async = real_session |
| 3862 | + # Give the mocked credentials a fake token. |
| 3863 | + test_endpoint.credentials.token = "test-token" |
| 3864 | + |
| 3865 | + try: |
| 3866 | + prediction = await test_endpoint.predict_async( |
| 3867 | + instances=_TEST_INSTANCES, |
| 3868 | + parameters={"param": 3.0}, |
| 3869 | + ) |
| 3870 | + finally: |
| 3871 | + await real_session.close() |
| 3872 | + |
| 3873 | + # Verify the SDK sent the right wire request. |
| 3874 | + assert captured["path"] == f"/v1/{_TEST_ENDPOINT_NAME}:predict" |
| 3875 | + assert captured["headers"]["Content-Type"] == "application/json" |
| 3876 | + assert captured["headers"]["Authorization"] == "Bearer test-token" |
| 3877 | + assert captured["body"] == { |
| 3878 | + "instances": _TEST_INSTANCES, |
| 3879 | + "parameters": {"param": 3.0}, |
| 3880 | + } |
| 3881 | + |
| 3882 | + # Verify the response was parsed correctly into a Prediction. |
| 3883 | + assert prediction == models.Prediction( |
| 3884 | + predictions=_TEST_PREDICTION, |
| 3885 | + deployed_model_id=_TEST_ID, |
| 3886 | + metadata=_TEST_METADATA, |
| 3887 | + model_version_id=_TEST_VERSION_ID, |
| 3888 | + model_resource_name=_TEST_MODEL_NAME, |
| 3889 | + ) |
| 3890 | + finally: |
| 3891 | + await runner.cleanup() |
| 3892 | + |
3690 | 3893 | @pytest.mark.usefixtures("get_endpoint_mock") |
3691 | 3894 | def test_explain(self, predict_client_explain_mock): |
3692 | 3895 | test_endpoint = models.Endpoint(_TEST_ID) |
|
0 commit comments