|
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,197 @@ 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( |
| 3797 | + "get_dedicated_endpoint_mock", "predict_endpoint_aiohttp_mock" |
| 3798 | + ) |
| 3799 | + async def test_predict_async_dedicated_endpoint_closes_session(self): |
| 3800 | + """The per-call ClientSession is closed, so no session is leaked.""" |
| 3801 | + test_endpoint = models.Endpoint(_TEST_ENDPOINT_NAME) |
| 3802 | + |
| 3803 | + with mock.patch.object( |
| 3804 | + aiohttp.ClientSession, "close", new_callable=mock.AsyncMock |
| 3805 | + ) as close_mock: |
| 3806 | + await test_endpoint.predict_async( |
| 3807 | + instances=_TEST_INSTANCES, parameters={"param": 3.0} |
| 3808 | + ) |
| 3809 | + |
| 3810 | + close_mock.assert_awaited_once() |
| 3811 | + |
| 3812 | + @pytest.mark.asyncio |
| 3813 | + @pytest.mark.usefixtures("get_dedicated_endpoint_mock") |
| 3814 | + async def test_predict_async_dedicated_endpoint_integration(self): |
| 3815 | + """Hermetic integration test: real aiohttp request against a local server. |
| 3816 | +
|
| 3817 | + Unlike the mock-based tests above, this exercises the real |
| 3818 | + aiohttp.ClientSession code path (connection, request encoding, |
| 3819 | + response decoding, JSON parsing, async context manager teardown) by |
| 3820 | + pointing the dedicated DNS at an in-process aiohttp.web server. |
| 3821 | +
|
| 3822 | + The server validates the URL path, headers, and body shape the SDK |
| 3823 | + sends, and returns a Vertex-shaped JSON response. ``predict_async`` |
| 3824 | + creates its own ClientSession per call, so we patch the constructor to |
| 3825 | + return a session whose ``post`` downgrades the scheme from ``https://`` |
| 3826 | + to ``http://`` so the local server can serve plain HTTP without needing |
| 3827 | + self-signed TLS plumbing in the test. |
| 3828 | + """ |
| 3829 | + captured = {} |
| 3830 | + |
| 3831 | + async def predict_handler(request: aiohttp_web.Request): |
| 3832 | + captured["path"] = request.path |
| 3833 | + captured["headers"] = dict(request.headers) |
| 3834 | + captured["body"] = await request.json() |
| 3835 | + return aiohttp_web.json_response( |
| 3836 | + { |
| 3837 | + "predictions": _TEST_PREDICTION, |
| 3838 | + "metadata": _TEST_METADATA, |
| 3839 | + "deployedModelId": _TEST_DEPLOYED_MODELS[0].id, |
| 3840 | + "model": _TEST_MODEL_NAME, |
| 3841 | + "modelVersionId": "1", |
| 3842 | + } |
| 3843 | + ) |
| 3844 | + |
| 3845 | + app = aiohttp_web.Application() |
| 3846 | + app.router.add_post( |
| 3847 | + f"/v1/{_TEST_ENDPOINT_NAME}:predict", predict_handler |
| 3848 | + ) |
| 3849 | + runner = aiohttp_web.AppRunner(app) |
| 3850 | + await runner.setup() |
| 3851 | + site = aiohttp_web.TCPSite(runner, "127.0.0.1", 0) |
| 3852 | + await site.start() |
| 3853 | + port = site._server.sockets[0].getsockname()[1] |
| 3854 | + |
| 3855 | + # Build a real session whose post() downgrades https:// -> http:// so |
| 3856 | + # the local test server can serve plain HTTP. Real aiohttp does |
| 3857 | + # everything else (real socket, real request/response cycle). Capture |
| 3858 | + # the real constructor here so the patched one below doesn't recurse. |
| 3859 | + real_client_session_cls = aiohttp.ClientSession |
| 3860 | + |
| 3861 | + def make_http_session(*args, **kwargs): |
| 3862 | + session = real_client_session_cls(*args, **kwargs) |
| 3863 | + original_post = session.post |
| 3864 | + |
| 3865 | + def http_post(url, **post_kwargs): |
| 3866 | + return original_post( |
| 3867 | + url.replace("https://", "http://", 1), **post_kwargs |
| 3868 | + ) |
| 3869 | + |
| 3870 | + session.post = http_post |
| 3871 | + return session |
| 3872 | + |
| 3873 | + try: |
| 3874 | + test_endpoint = models.Endpoint(_TEST_ENDPOINT_NAME) |
| 3875 | + |
| 3876 | + # Force the dedicated DNS to point at the local server. |
| 3877 | + with mock.patch.object( |
| 3878 | + models.Endpoint, |
| 3879 | + "dedicated_endpoint_dns", |
| 3880 | + new_callable=mock.PropertyMock, |
| 3881 | + return_value=f"127.0.0.1:{port}", |
| 3882 | + ), mock.patch.object( |
| 3883 | + auth_credentials.AnonymousCredentials, "refresh" |
| 3884 | + ), mock.patch.object( |
| 3885 | + aiohttp, "ClientSession", side_effect=make_http_session |
| 3886 | + ): |
| 3887 | + # Give the mocked credentials a fake token. |
| 3888 | + test_endpoint.credentials.token = "test-token" |
| 3889 | + |
| 3890 | + prediction = await test_endpoint.predict_async( |
| 3891 | + instances=_TEST_INSTANCES, |
| 3892 | + parameters={"param": 3.0}, |
| 3893 | + ) |
| 3894 | + |
| 3895 | + # Verify the SDK sent the right wire request. |
| 3896 | + assert captured["path"] == f"/v1/{_TEST_ENDPOINT_NAME}:predict" |
| 3897 | + assert captured["headers"]["Content-Type"] == "application/json" |
| 3898 | + assert captured["headers"]["Authorization"] == "Bearer test-token" |
| 3899 | + assert captured["body"] == { |
| 3900 | + "instances": _TEST_INSTANCES, |
| 3901 | + "parameters": {"param": 3.0}, |
| 3902 | + } |
| 3903 | + |
| 3904 | + # Verify the response was parsed correctly into a Prediction. |
| 3905 | + assert prediction == models.Prediction( |
| 3906 | + predictions=_TEST_PREDICTION, |
| 3907 | + deployed_model_id=_TEST_ID, |
| 3908 | + metadata=_TEST_METADATA, |
| 3909 | + model_version_id=_TEST_VERSION_ID, |
| 3910 | + model_resource_name=_TEST_MODEL_NAME, |
| 3911 | + ) |
| 3912 | + finally: |
| 3913 | + await runner.cleanup() |
| 3914 | + |
3690 | 3915 | @pytest.mark.usefixtures("get_endpoint_mock") |
3691 | 3916 | def test_explain(self, predict_client_explain_mock): |
3692 | 3917 | test_endpoint = models.Endpoint(_TEST_ID) |
|
0 commit comments