diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3d428e5a..059570f6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,10 @@ on: - 'integrated/**' - 'stl-preview-head/**' - 'stl-preview-base/**' + pull_request: + branches-ignore: + - 'stl-preview-head/**' + - 'stl-preview-base/**' jobs: lint: diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 21f60560..e6c39adb 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "2.10.0" + ".": "2.11.0" } \ No newline at end of file diff --git a/.stats.yml b/.stats.yml index 8ff9c0ad..ca9c65db 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 66 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/julep-ai-inc-dash%2Fjulep-8a1ecc9e35c22008057ceaddf31012f66d2894afab0df2ef269e077cbd0f81ee.yml openapi_spec_hash: bc7c9c317519c92f3f2d4198be8ab5c2 -config_hash: cbbcfe531bf3096e61f5c3e4e398440e +config_hash: 593253e3de9c2ae6ed207577854150a1 diff --git a/CHANGELOG.md b/CHANGELOG.md index d6fa2394..e939fef2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,35 @@ # Changelog +## 2.11.0 (2025-06-19) + +Full Changelog: [v2.10.0...v2.11.0](https://github.com/julep-ai/python-sdk/compare/v2.10.0...v2.11.0) + +### Features + +* **client:** add follow_redirects request option ([2ee1435](https://github.com/julep-ai/python-sdk/commit/2ee14355f25b30a4a80931401c48f1c51e3b09a0)) + + +### Bug Fixes + +* **client:** correctly parse binary response | stream ([bb822dd](https://github.com/julep-ai/python-sdk/commit/bb822dd04059161a6db37f13712268f4001f6088)) +* **tests:** fix: tests which call HTTP endpoints directly with the example parameters ([9f17127](https://github.com/julep-ai/python-sdk/commit/9f17127aaca470f58d7a6f720dc91fa9c4380b41)) + + +### Chores + +* **ci:** enable for pull requests ([4461d5e](https://github.com/julep-ai/python-sdk/commit/4461d5edcc8b175c896aad80f427388b452d1859)) +* **docs:** remove reference to rye shell ([acbfb68](https://github.com/julep-ai/python-sdk/commit/acbfb68a81a0f1c197b33b4bb7959511545f5176)) +* **docs:** remove unnecessary param examples ([66e291e](https://github.com/julep-ai/python-sdk/commit/66e291e0cea554edb5da15a4e105eb46de76d047)) +* **internal:** update conftest.py ([1a93922](https://github.com/julep-ai/python-sdk/commit/1a93922b9d71549b44bdc41beb94fa0c12f03f82)) +* **readme:** update badges ([afd2518](https://github.com/julep-ai/python-sdk/commit/afd2518be4dc7a881b3502b9c2fcb5ea449136e9)) +* **tests:** add tests for httpx client instantiation & proxies ([82132b5](https://github.com/julep-ai/python-sdk/commit/82132b536095da8441b5747431383967c836c044)) +* **tests:** run tests in parallel ([131c597](https://github.com/julep-ai/python-sdk/commit/131c59790439ec3bce60e943b8dfa7caf3edc26c)) + + +### Documentation + +* **client:** fix httpx.Timeout documentation reference ([0ec016a](https://github.com/julep-ai/python-sdk/commit/0ec016a46a16345bc22e21da216afbefb4898432)) + ## 2.10.0 (2025-05-30) Full Changelog: [v2.9.0...v2.10.0](https://github.com/julep-ai/python-sdk/compare/v2.9.0...v2.10.0) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c883862c..44d3e4a5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,8 +17,7 @@ $ rye sync --all-features You can then run scripts using `rye run python script.py` or by activating the virtual environment: ```sh -$ rye shell -# or manually activate - https://docs.python.org/3/library/venv.html#how-venvs-work +# Activate the virtual environment - https://docs.python.org/3/library/venv.html#how-venvs-work $ source .venv/bin/activate # now you can omit the `rye run` prefix diff --git a/README.md b/README.md index 53ecbe96..c1c6ad46 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Julep Python API library -[![PyPI version](https://img.shields.io/pypi/v/julep.svg)](https://pypi.org/project/julep/) +[![PyPI version]()](https://pypi.org/project/julep/) The Julep Python library provides convenient access to the Julep REST API from any Python 3.8+ application. The library includes type definitions for all request params and response fields, @@ -174,18 +174,6 @@ tool = client.agents.tools.create( api_call={ "method": "GET", "url": "https://example.com", - "content": "content", - "cookies": {"foo": "string"}, - "data": {}, - "files": {}, - "follow_redirects": True, - "headers": {"foo": "string"}, - "include_response_content": True, - "json": {}, - "params": "string", - "schema": {}, - "secrets": {"foo": {"name": "name"}}, - "timeout": 0, }, ) print(tool.api_call) @@ -266,7 +254,7 @@ client.with_options(max_retries=5).agents.create_or_update( ### Timeouts By default requests time out after 2 minutes. You can configure this with a `timeout` option, -which accepts a float or an [`httpx.Timeout`](https://www.python-httpx.org/advanced/#fine-tuning-the-configuration) object: +which accepts a float or an [`httpx.Timeout`](https://www.python-httpx.org/advanced/timeouts/#fine-tuning-the-configuration) object: ```python from julep import Julep diff --git a/pyproject.toml b/pyproject.toml index 393e1e74..9d4d759c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "julep" -version = "2.10.0" +version = "2.11.0" description = "The official Python library for the julep API" dynamic = ["readme"] license = "Apache-2.0" @@ -59,6 +59,7 @@ dev-dependencies = [ "importlib-metadata>=6.7.0", "rich>=13.7.1", "nest_asyncio==1.6.0", + "pytest-xdist>=3.6.1", ] [tool.rye.scripts] @@ -130,7 +131,7 @@ replacement = '[\1](https://github.com/julep-ai/python-sdk/tree/main/\g<2>)' [tool.pytest.ini_options] testpaths = ["tests"] -addopts = "--tb=short" +addopts = "--tb=short -n auto" xfail_strict = true asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "session" diff --git a/requirements-dev.lock b/requirements-dev.lock index d73e75ce..ccd7380a 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -30,6 +30,8 @@ distro==1.8.0 exceptiongroup==1.2.2 # via anyio # via pytest +execnet==2.1.1 + # via pytest-xdist filelock==3.12.4 # via virtualenv h11==0.14.0 @@ -72,7 +74,9 @@ pygments==2.18.0 pyright==1.1.399 pytest==8.3.3 # via pytest-asyncio + # via pytest-xdist pytest-asyncio==0.24.0 +pytest-xdist==3.7.0 python-dateutil==2.8.2 # via time-machine python-dotenv==1.0.1 diff --git a/src/julep/_base_client.py b/src/julep/_base_client.py index 7cf65785..42270863 100644 --- a/src/julep/_base_client.py +++ b/src/julep/_base_client.py @@ -960,6 +960,9 @@ def request( if self.custom_auth is not None: kwargs["auth"] = self.custom_auth + if options.follow_redirects is not None: + kwargs["follow_redirects"] = options.follow_redirects + log.debug("Sending HTTP Request: %s %s", request.method, request.url) response = None @@ -1068,7 +1071,14 @@ def _process_response( ) -> ResponseT: origin = get_origin(cast_to) or cast_to - if inspect.isclass(origin) and issubclass(origin, BaseAPIResponse): + if ( + inspect.isclass(origin) + and issubclass(origin, BaseAPIResponse) + # we only want to actually return the custom BaseAPIResponse class if we're + # returning the raw response, or if we're not streaming SSE, as if we're streaming + # SSE then `cast_to` doesn't actively reflect the type we need to parse into + and (not stream or bool(response.request.headers.get(RAW_RESPONSE_HEADER))) + ): if not issubclass(origin, APIResponse): raise TypeError(f"API Response types must subclass {APIResponse}; Received {origin}") @@ -1460,6 +1470,9 @@ async def request( if self.custom_auth is not None: kwargs["auth"] = self.custom_auth + if options.follow_redirects is not None: + kwargs["follow_redirects"] = options.follow_redirects + log.debug("Sending HTTP Request: %s %s", request.method, request.url) response = None @@ -1568,7 +1581,14 @@ async def _process_response( ) -> ResponseT: origin = get_origin(cast_to) or cast_to - if inspect.isclass(origin) and issubclass(origin, BaseAPIResponse): + if ( + inspect.isclass(origin) + and issubclass(origin, BaseAPIResponse) + # we only want to actually return the custom BaseAPIResponse class if we're + # returning the raw response, or if we're not streaming SSE, as if we're streaming + # SSE then `cast_to` doesn't actively reflect the type we need to parse into + and (not stream or bool(response.request.headers.get(RAW_RESPONSE_HEADER))) + ): if not issubclass(origin, AsyncAPIResponse): raise TypeError(f"API Response types must subclass {AsyncAPIResponse}; Received {origin}") diff --git a/src/julep/_models.py b/src/julep/_models.py index 798956f1..4f214980 100644 --- a/src/julep/_models.py +++ b/src/julep/_models.py @@ -737,6 +737,7 @@ class FinalRequestOptionsInput(TypedDict, total=False): idempotency_key: str json_data: Body extra_json: AnyMapping + follow_redirects: bool @final @@ -750,6 +751,7 @@ class FinalRequestOptions(pydantic.BaseModel): files: Union[HttpxRequestFiles, None] = None idempotency_key: Union[str, None] = None post_parser: Union[Callable[[Any], Any], NotGiven] = NotGiven() + follow_redirects: Union[bool, None] = None # It should be noted that we cannot use `json` here as that would override # a BaseModel method in an incompatible fashion. diff --git a/src/julep/_types.py b/src/julep/_types.py index cb1f2fff..653ec7f7 100644 --- a/src/julep/_types.py +++ b/src/julep/_types.py @@ -100,6 +100,7 @@ class RequestOptions(TypedDict, total=False): params: Query extra_json: AnyMapping idempotency_key: str + follow_redirects: bool # Sentinel class used until PEP 0661 is accepted @@ -215,3 +216,4 @@ class _GenericAlias(Protocol): class HttpxSendArgs(TypedDict, total=False): auth: httpx.Auth + follow_redirects: bool diff --git a/src/julep/_version.py b/src/julep/_version.py index cedabc8c..8a4c7664 100644 --- a/src/julep/_version.py +++ b/src/julep/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "julep" -__version__ = "2.10.0" # x-release-please-version +__version__ = "2.11.0" # x-release-please-version diff --git a/tests/conftest.py b/tests/conftest.py index aa9daa75..8832cc8c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,5 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + from __future__ import annotations import os diff --git a/tests/test_client.py b/tests/test_client.py index dd159c45..908ced4d 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -23,12 +23,16 @@ from julep import Julep, AsyncJulep, APIResponseValidationError from julep._types import Omit -from julep._utils import maybe_transform from julep._models import BaseModel, FinalRequestOptions -from julep._constants import RAW_RESPONSE_HEADER from julep._exceptions import JulepError, APIStatusError, APITimeoutError, APIResponseValidationError -from julep._base_client import DEFAULT_TIMEOUT, HTTPX_DEFAULT_TIMEOUT, BaseClient, make_request_options -from julep.types.agent_create_or_update_params import AgentCreateOrUpdateParams +from julep._base_client import ( + DEFAULT_TIMEOUT, + HTTPX_DEFAULT_TIMEOUT, + BaseClient, + DefaultHttpxClient, + DefaultAsyncHttpxClient, + make_request_options, +) from .utils import update_env @@ -714,46 +718,27 @@ def test_parse_retry_after_header(self, remaining_retries: int, retry_after: str @mock.patch("julep._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @pytest.mark.respx(base_url=base_url) - def test_retrying_timeout_errors_doesnt_leak(self, respx_mock: MockRouter) -> None: + def test_retrying_timeout_errors_doesnt_leak(self, respx_mock: MockRouter, client: Julep) -> None: respx_mock.post("/agents/182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e").mock( side_effect=httpx.TimeoutException("Test timeout error") ) with pytest.raises(APITimeoutError): - self.client.post( - "/agents/182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e", - body=cast( - object, - maybe_transform( - dict(name="R2D2", instructions=["Protect Leia", "Kick butt"], model="o1-preview"), - AgentCreateOrUpdateParams, - ), - ), - cast_to=httpx.Response, - options={"headers": {RAW_RESPONSE_HEADER: "stream"}}, - ) + client.agents.with_streaming_response.create_or_update( + agent_id="182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e", name="x" + ).__enter__() assert _get_open_connections(self.client) == 0 @mock.patch("julep._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @pytest.mark.respx(base_url=base_url) - def test_retrying_status_errors_doesnt_leak(self, respx_mock: MockRouter) -> None: + def test_retrying_status_errors_doesnt_leak(self, respx_mock: MockRouter, client: Julep) -> None: respx_mock.post("/agents/182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e").mock(return_value=httpx.Response(500)) with pytest.raises(APIStatusError): - self.client.post( - "/agents/182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e", - body=cast( - object, - maybe_transform( - dict(name="R2D2", instructions=["Protect Leia", "Kick butt"], model="o1-preview"), - AgentCreateOrUpdateParams, - ), - ), - cast_to=httpx.Response, - options={"headers": {RAW_RESPONSE_HEADER: "stream"}}, - ) - + client.agents.with_streaming_response.create_or_update( + agent_id="182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e", name="x" + ).__enter__() assert _get_open_connections(self.client) == 0 @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) @@ -837,6 +822,55 @@ def retry_handler(_request: httpx.Request) -> httpx.Response: assert response.http_request.headers.get("x-stainless-retry-count") == "42" + def test_proxy_environment_variables(self, monkeypatch: pytest.MonkeyPatch) -> None: + # Test that the proxy environment variables are set correctly + monkeypatch.setenv("HTTPS_PROXY", "https://example.org") + + client = DefaultHttpxClient() + + mounts = tuple(client._mounts.items()) + assert len(mounts) == 1 + assert mounts[0][0].pattern == "https://" + + @pytest.mark.filterwarnings("ignore:.*deprecated.*:DeprecationWarning") + def test_default_client_creation(self) -> None: + # Ensure that the client can be initialized without any exceptions + DefaultHttpxClient( + verify=True, + cert=None, + trust_env=True, + http1=True, + http2=False, + limits=httpx.Limits(max_connections=100, max_keepalive_connections=20), + ) + + @pytest.mark.respx(base_url=base_url) + def test_follow_redirects(self, respx_mock: MockRouter) -> None: + # Test that the default follow_redirects=True allows following redirects + respx_mock.post("/redirect").mock( + return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"}) + ) + respx_mock.get("/redirected").mock(return_value=httpx.Response(200, json={"status": "ok"})) + + response = self.client.post("/redirect", body={"key": "value"}, cast_to=httpx.Response) + assert response.status_code == 200 + assert response.json() == {"status": "ok"} + + @pytest.mark.respx(base_url=base_url) + def test_follow_redirects_disabled(self, respx_mock: MockRouter) -> None: + # Test that follow_redirects=False prevents following redirects + respx_mock.post("/redirect").mock( + return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"}) + ) + + with pytest.raises(APIStatusError) as exc_info: + self.client.post( + "/redirect", body={"key": "value"}, options={"follow_redirects": False}, cast_to=httpx.Response + ) + + assert exc_info.value.response.status_code == 302 + assert exc_info.value.response.headers["Location"] == f"{base_url}/redirected" + class TestAsyncJulep: client = AsyncJulep(base_url=base_url, api_key=api_key, _strict_response_validation=True) @@ -1516,46 +1550,27 @@ async def test_parse_retry_after_header(self, remaining_retries: int, retry_afte @mock.patch("julep._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @pytest.mark.respx(base_url=base_url) - async def test_retrying_timeout_errors_doesnt_leak(self, respx_mock: MockRouter) -> None: + async def test_retrying_timeout_errors_doesnt_leak(self, respx_mock: MockRouter, async_client: AsyncJulep) -> None: respx_mock.post("/agents/182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e").mock( side_effect=httpx.TimeoutException("Test timeout error") ) with pytest.raises(APITimeoutError): - await self.client.post( - "/agents/182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e", - body=cast( - object, - maybe_transform( - dict(name="R2D2", instructions=["Protect Leia", "Kick butt"], model="o1-preview"), - AgentCreateOrUpdateParams, - ), - ), - cast_to=httpx.Response, - options={"headers": {RAW_RESPONSE_HEADER: "stream"}}, - ) + await async_client.agents.with_streaming_response.create_or_update( + agent_id="182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e", name="x" + ).__aenter__() assert _get_open_connections(self.client) == 0 @mock.patch("julep._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @pytest.mark.respx(base_url=base_url) - async def test_retrying_status_errors_doesnt_leak(self, respx_mock: MockRouter) -> None: + async def test_retrying_status_errors_doesnt_leak(self, respx_mock: MockRouter, async_client: AsyncJulep) -> None: respx_mock.post("/agents/182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e").mock(return_value=httpx.Response(500)) with pytest.raises(APIStatusError): - await self.client.post( - "/agents/182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e", - body=cast( - object, - maybe_transform( - dict(name="R2D2", instructions=["Protect Leia", "Kick butt"], model="o1-preview"), - AgentCreateOrUpdateParams, - ), - ), - cast_to=httpx.Response, - options={"headers": {RAW_RESPONSE_HEADER: "stream"}}, - ) - + await async_client.agents.with_streaming_response.create_or_update( + agent_id="182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e", name="x" + ).__aenter__() assert _get_open_connections(self.client) == 0 @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) @@ -1688,3 +1703,52 @@ async def test_main() -> None: raise AssertionError("calling get_platform using asyncify resulted in a hung process") time.sleep(0.1) + + async def test_proxy_environment_variables(self, monkeypatch: pytest.MonkeyPatch) -> None: + # Test that the proxy environment variables are set correctly + monkeypatch.setenv("HTTPS_PROXY", "https://example.org") + + client = DefaultAsyncHttpxClient() + + mounts = tuple(client._mounts.items()) + assert len(mounts) == 1 + assert mounts[0][0].pattern == "https://" + + @pytest.mark.filterwarnings("ignore:.*deprecated.*:DeprecationWarning") + async def test_default_client_creation(self) -> None: + # Ensure that the client can be initialized without any exceptions + DefaultAsyncHttpxClient( + verify=True, + cert=None, + trust_env=True, + http1=True, + http2=False, + limits=httpx.Limits(max_connections=100, max_keepalive_connections=20), + ) + + @pytest.mark.respx(base_url=base_url) + async def test_follow_redirects(self, respx_mock: MockRouter) -> None: + # Test that the default follow_redirects=True allows following redirects + respx_mock.post("/redirect").mock( + return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"}) + ) + respx_mock.get("/redirected").mock(return_value=httpx.Response(200, json={"status": "ok"})) + + response = await self.client.post("/redirect", body={"key": "value"}, cast_to=httpx.Response) + assert response.status_code == 200 + assert response.json() == {"status": "ok"} + + @pytest.mark.respx(base_url=base_url) + async def test_follow_redirects_disabled(self, respx_mock: MockRouter) -> None: + # Test that follow_redirects=False prevents following redirects + respx_mock.post("/redirect").mock( + return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"}) + ) + + with pytest.raises(APIStatusError) as exc_info: + await self.client.post( + "/redirect", body={"key": "value"}, options={"follow_redirects": False}, cast_to=httpx.Response + ) + + assert exc_info.value.response.status_code == 302 + assert exc_info.value.response.headers["Location"] == f"{base_url}/redirected"