diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f88c4fe28..98974d6cf 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 fe87cd917..cc51f6f8e 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.43.0" + ".": "0.44.0" } \ No newline at end of file diff --git a/.stats.yml b/.stats.yml index 2595a1785..f27792d6d 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 91 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/runloop-ai%2Frunloop-32aaecf1da425c37d534ed04df36003ab9d766a7755cd18f96541929a2a3ea59.yml -openapi_spec_hash: e326c47b99943cbbab473fde3b257221 -config_hash: 421e8d0e71c7ef71fdfebede08ea7271 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/runloop-ai%2Frunloop-ecb3d41adaf06e76fd95f11d6da77c7aa0119387a3f372e736edd1579ec2aa03.yml +openapi_spec_hash: 2671664b7d6b0107a6402746033a65ac +config_hash: c4d0f5cf7262a18f9254da07d289f3ec diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cf3b9669..7f0586117 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,33 @@ # Changelog +## 0.44.0 (2025-06-21) + +Full Changelog: [v0.43.0...v0.44.0](https://github.com/runloopai/api-client-python/compare/v0.43.0...v0.44.0) + +### Features + +* **api:** api update ([8bd0a39](https://github.com/runloopai/api-client-python/commit/8bd0a3937ff3d5b3e03a34ab2fc291377bbc4203)) +* **api:** api update ([8ebd055](https://github.com/runloopai/api-client-python/commit/8ebd055bb3760c7a01193df890ca7e56d7ff9c01)) +* **client:** add support for aiohttp ([4237321](https://github.com/runloopai/api-client-python/commit/4237321688934fcc89a17588800ce33fb47d9633)) + + +### Bug Fixes + +* **tests:** fix: tests which call HTTP endpoints directly with the example parameters ([038fe71](https://github.com/runloopai/api-client-python/commit/038fe71f5d0bee93bdcad12af69a8f5b09b6664f)) + + +### Chores + +* **ci:** enable for pull requests ([f64b8a2](https://github.com/runloopai/api-client-python/commit/f64b8a2a52a28a86f25d8b8ad1998e9ce4dda542)) +* **internal:** update conftest.py ([d840c86](https://github.com/runloopai/api-client-python/commit/d840c863eb3b080202874080e44b8ab0baa379c4)) +* **readme:** update badges ([4b5af3f](https://github.com/runloopai/api-client-python/commit/4b5af3faf2f3cfbcee8474b864335eb0a4c0a78b)) +* **tests:** add tests for httpx client instantiation & proxies ([938b9aa](https://github.com/runloopai/api-client-python/commit/938b9aa33ef7ae52809ca6e9111fc3265d70263d)) + + +### Documentation + +* **client:** fix httpx.Timeout documentation reference ([b8df915](https://github.com/runloopai/api-client-python/commit/b8df91557df34f77ce5a6f85769305628d9f2a1d)) + ## 0.43.0 (2025-06-14) Full Changelog: [v0.42.0...v0.43.0](https://github.com/runloopai/api-client-python/compare/v0.42.0...v0.43.0) diff --git a/README.md b/README.md index b59a62b8d..eb5672f4d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Runloop Python API library -[![PyPI version](https://img.shields.io/pypi/v/runloop_api_client.svg)](https://pypi.org/project/runloop_api_client/) +[![PyPI version]()](https://pypi.org/project/runloop_api_client/) The Runloop Python library provides convenient access to the Runloop REST API from any Python 3.8+ application. The library includes type definitions for all request params and response fields, @@ -64,6 +64,38 @@ asyncio.run(main()) Functionality between the synchronous and asynchronous clients is otherwise identical. +### With aiohttp + +By default, the async client uses `httpx` for HTTP requests. However, for improved concurrency performance you may also use `aiohttp` as the HTTP backend. + +You can enable this by installing `aiohttp`: + +```sh +# install from PyPI +pip install runloop_api_client[aiohttp] +``` + +Then you can enable it by instantiating the client with `http_client=DefaultAioHttpClient()`: + +```python +import os +import asyncio +from runloop_api_client import DefaultAioHttpClient +from runloop_api_client import AsyncRunloop + + +async def main() -> None: + async with AsyncRunloop( + bearer_token=os.environ.get("RUNLOOP_API_KEY"), # This is the default and can be omitted + http_client=DefaultAioHttpClient(), + ) as client: + devbox_view = await client.devboxes.create() + print(devbox_view.id) + + +asyncio.run(main()) +``` + ## Using types Nested request parameters are [TypedDicts](https://docs.python.org/3/library/typing.html#typing.TypedDict). Responses are [Pydantic models](https://docs.pydantic.dev) which also provide helper methods for things like: @@ -235,7 +267,7 @@ client.with_options(max_retries=5).devboxes.create() ### Timeouts By default requests time out after 1 minute. 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 runloop_api_client import Runloop diff --git a/api.md b/api.md index da4195d42..20cfc67ff 100644 --- a/api.md +++ b/api.md @@ -268,6 +268,7 @@ from runloop_api_client.types import ( ScenarioEnvironment, ScenarioRunListView, ScenarioRunView, + ScenarioUpdateParameters, ScenarioView, ScoringContract, ScoringContractResultView, diff --git a/pyproject.toml b/pyproject.toml index 1f083a49a..541db7250 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "runloop_api_client" -version = "0.43.0" +version = "0.44.0" description = "The official Python library for the runloop API" dynamic = ["readme"] license = "MIT" @@ -37,6 +37,8 @@ classifiers = [ Homepage = "https://github.com/runloopai/api-client-python" Repository = "https://github.com/runloopai/api-client-python" +[project.optional-dependencies] +aiohttp = ["aiohttp", "httpx_aiohttp>=0.1.6"] [tool.rye] managed = true diff --git a/requirements-dev.lock b/requirements-dev.lock index 3063be477..56d0598e4 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -10,6 +10,13 @@ # universal: false -e file:. +aiohappyeyeballs==2.6.1 + # via aiohttp +aiohttp==3.12.8 + # via httpx-aiohttp + # via runloop-api-client +aiosignal==1.3.2 + # via aiohttp annotated-types==0.6.0 # via pydantic anyio==4.4.0 @@ -17,6 +24,10 @@ anyio==4.4.0 # via runloop-api-client argcomplete==3.1.2 # via nox +async-timeout==5.0.1 + # via aiohttp +attrs==25.3.0 + # via aiohttp certifi==2023.7.22 # via httpcore # via httpx @@ -34,16 +45,23 @@ execnet==2.1.1 # via pytest-xdist filelock==3.12.4 # via virtualenv +frozenlist==1.6.2 + # via aiohttp + # via aiosignal h11==0.14.0 # via httpcore httpcore==1.0.2 # via httpx httpx==0.28.1 + # via httpx-aiohttp # via respx # via runloop-api-client +httpx-aiohttp==0.1.6 + # via runloop-api-client idna==3.4 # via anyio # via httpx + # via yarl importlib-metadata==7.0.0 iniconfig==2.0.0 # via pytest @@ -51,6 +69,9 @@ markdown-it-py==3.0.0 # via rich mdurl==0.1.2 # via markdown-it-py +multidict==6.4.4 + # via aiohttp + # via yarl mypy==1.14.1 mypy-extensions==1.0.0 # via mypy @@ -65,6 +86,9 @@ platformdirs==3.11.0 # via virtualenv pluggy==1.5.0 # via pytest +propcache==0.3.1 + # via aiohttp + # via yarl pydantic==2.10.3 # via runloop-api-client pydantic-core==2.27.1 @@ -97,6 +121,7 @@ tomli==2.0.2 # via pytest typing-extensions==4.12.2 # via anyio + # via multidict # via mypy # via pydantic # via pydantic-core @@ -104,5 +129,7 @@ typing-extensions==4.12.2 # via runloop-api-client virtualenv==20.24.5 # via nox +yarl==1.20.0 + # via aiohttp zipp==3.17.0 # via importlib-metadata diff --git a/requirements.lock b/requirements.lock index bcd4ab118..24dc0c2dd 100644 --- a/requirements.lock +++ b/requirements.lock @@ -10,11 +10,22 @@ # universal: false -e file:. +aiohappyeyeballs==2.6.1 + # via aiohttp +aiohttp==3.12.8 + # via httpx-aiohttp + # via runloop-api-client +aiosignal==1.3.2 + # via aiohttp annotated-types==0.6.0 # via pydantic anyio==4.4.0 # via httpx # via runloop-api-client +async-timeout==5.0.1 + # via aiohttp +attrs==25.3.0 + # via aiohttp certifi==2023.7.22 # via httpcore # via httpx @@ -22,15 +33,28 @@ distro==1.8.0 # via runloop-api-client exceptiongroup==1.2.2 # via anyio +frozenlist==1.6.2 + # via aiohttp + # via aiosignal h11==0.14.0 # via httpcore httpcore==1.0.2 # via httpx httpx==0.28.1 + # via httpx-aiohttp + # via runloop-api-client +httpx-aiohttp==0.1.6 # via runloop-api-client idna==3.4 # via anyio # via httpx + # via yarl +multidict==6.4.4 + # via aiohttp + # via yarl +propcache==0.3.1 + # via aiohttp + # via yarl pydantic==2.10.3 # via runloop-api-client pydantic-core==2.27.1 @@ -40,6 +64,9 @@ sniffio==1.3.0 # via runloop-api-client typing-extensions==4.12.2 # via anyio + # via multidict # via pydantic # via pydantic-core # via runloop-api-client +yarl==1.20.0 + # via aiohttp diff --git a/src/runloop_api_client/__init__.py b/src/runloop_api_client/__init__.py index fd4bc5d8d..2c679e1f1 100644 --- a/src/runloop_api_client/__init__.py +++ b/src/runloop_api_client/__init__.py @@ -26,7 +26,7 @@ UnprocessableEntityError, APIResponseValidationError, ) -from ._base_client import DefaultHttpxClient, DefaultAsyncHttpxClient +from ._base_client import DefaultHttpxClient, DefaultAioHttpClient, DefaultAsyncHttpxClient from ._utils._logs import setup_logging as _setup_logging __all__ = [ @@ -68,6 +68,7 @@ "DEFAULT_CONNECTION_LIMITS", "DefaultHttpxClient", "DefaultAsyncHttpxClient", + "DefaultAioHttpClient", ] if not _t.TYPE_CHECKING: diff --git a/src/runloop_api_client/_base_client.py b/src/runloop_api_client/_base_client.py index 74864535d..d2758d8cc 100644 --- a/src/runloop_api_client/_base_client.py +++ b/src/runloop_api_client/_base_client.py @@ -1289,6 +1289,24 @@ def __init__(self, **kwargs: Any) -> None: super().__init__(**kwargs) +try: + import httpx_aiohttp +except ImportError: + + class _DefaultAioHttpClient(httpx.AsyncClient): + def __init__(self, **_kwargs: Any) -> None: + raise RuntimeError("To use the aiohttp client you must have installed the package with the `aiohttp` extra") +else: + + class _DefaultAioHttpClient(httpx_aiohttp.HttpxAiohttpClient): # type: ignore + def __init__(self, **kwargs: Any) -> None: + kwargs.setdefault("timeout", DEFAULT_TIMEOUT) + kwargs.setdefault("limits", DEFAULT_CONNECTION_LIMITS) + kwargs.setdefault("follow_redirects", True) + + super().__init__(**kwargs) + + if TYPE_CHECKING: DefaultAsyncHttpxClient = httpx.AsyncClient """An alias to `httpx.AsyncClient` that provides the same defaults that this SDK @@ -1297,8 +1315,12 @@ def __init__(self, **kwargs: Any) -> None: This is useful because overriding the `http_client` with your own instance of `httpx.AsyncClient` will result in httpx's defaults being used, not ours. """ + + DefaultAioHttpClient = httpx.AsyncClient + """An alias to `httpx.AsyncClient` that changes the default HTTP transport to `aiohttp`.""" else: DefaultAsyncHttpxClient = _DefaultAsyncHttpxClient + DefaultAioHttpClient = _DefaultAioHttpClient class AsyncHttpxClientWrapper(DefaultAsyncHttpxClient): diff --git a/src/runloop_api_client/_version.py b/src/runloop_api_client/_version.py index 5c05630c7..9382b6e18 100644 --- a/src/runloop_api_client/_version.py +++ b/src/runloop_api_client/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "runloop_api_client" -__version__ = "0.43.0" # x-release-please-version +__version__ = "0.44.0" # x-release-please-version diff --git a/src/runloop_api_client/resources/scenarios/scenarios.py b/src/runloop_api_client/resources/scenarios/scenarios.py index f86bc4873..07511e4aa 100644 --- a/src/runloop_api_client/resources/scenarios/scenarios.py +++ b/src/runloop_api_client/resources/scenarios/scenarios.py @@ -185,12 +185,12 @@ def update( self, id: str, *, - input_context: InputContextParam, - name: str, - scoring_contract: ScoringContractParam, environment_parameters: Optional[ScenarioEnvironmentParam] | NotGiven = NOT_GIVEN, + input_context: Optional[InputContextParam] | NotGiven = NOT_GIVEN, metadata: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, + name: Optional[str] | NotGiven = NOT_GIVEN, reference_output: Optional[str] | NotGiven = NOT_GIVEN, + scoring_contract: Optional[ScoringContractParam] | NotGiven = NOT_GIVEN, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, @@ -201,23 +201,24 @@ def update( ) -> ScenarioView: """ Update a Scenario, a repeatable AI coding evaluation test that defines the - starting environment as well as evaluation success criteria. + starting environment as well as evaluation success criteria. Only provided + fields will be updated. Args: - input_context: The input context for the Scenario. - - name: Name of the scenario. - - scoring_contract: The scoring contract for the Scenario. - environment_parameters: The Environment in which the Scenario will run. + input_context: The input context for the Scenario. + metadata: User defined metadata to attach to the scenario for organization. + name: Name of the scenario. + reference_output: A string representation of the reference output to solve the scenario. Commonly can be the result of a git diff or a sequence of command actions to apply to the environment. + scoring_contract: The scoring contract for the Scenario. + extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -234,12 +235,12 @@ def update( f"/v1/scenarios/{id}", body=maybe_transform( { - "input_context": input_context, - "name": name, - "scoring_contract": scoring_contract, "environment_parameters": environment_parameters, + "input_context": input_context, "metadata": metadata, + "name": name, "reference_output": reference_output, + "scoring_contract": scoring_contract, }, scenario_update_params.ScenarioUpdateParams, ), @@ -611,12 +612,12 @@ async def update( self, id: str, *, - input_context: InputContextParam, - name: str, - scoring_contract: ScoringContractParam, environment_parameters: Optional[ScenarioEnvironmentParam] | NotGiven = NOT_GIVEN, + input_context: Optional[InputContextParam] | NotGiven = NOT_GIVEN, metadata: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, + name: Optional[str] | NotGiven = NOT_GIVEN, reference_output: Optional[str] | NotGiven = NOT_GIVEN, + scoring_contract: Optional[ScoringContractParam] | NotGiven = NOT_GIVEN, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, @@ -627,23 +628,24 @@ async def update( ) -> ScenarioView: """ Update a Scenario, a repeatable AI coding evaluation test that defines the - starting environment as well as evaluation success criteria. + starting environment as well as evaluation success criteria. Only provided + fields will be updated. Args: - input_context: The input context for the Scenario. - - name: Name of the scenario. - - scoring_contract: The scoring contract for the Scenario. - environment_parameters: The Environment in which the Scenario will run. + input_context: The input context for the Scenario. + metadata: User defined metadata to attach to the scenario for organization. + name: Name of the scenario. + reference_output: A string representation of the reference output to solve the scenario. Commonly can be the result of a git diff or a sequence of command actions to apply to the environment. + scoring_contract: The scoring contract for the Scenario. + extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -660,12 +662,12 @@ async def update( f"/v1/scenarios/{id}", body=await async_maybe_transform( { - "input_context": input_context, - "name": name, - "scoring_contract": scoring_contract, "environment_parameters": environment_parameters, + "input_context": input_context, "metadata": metadata, + "name": name, "reference_output": reference_output, + "scoring_contract": scoring_contract, }, scenario_update_params.ScenarioUpdateParams, ), diff --git a/src/runloop_api_client/types/scenario_update_params.py b/src/runloop_api_client/types/scenario_update_params.py index 1004e7bc8..e2be5fac1 100644 --- a/src/runloop_api_client/types/scenario_update_params.py +++ b/src/runloop_api_client/types/scenario_update_params.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Dict, Optional -from typing_extensions import Required, TypedDict +from typing_extensions import TypedDict from .input_context_param import InputContextParam from .scoring_contract_param import ScoringContractParam @@ -13,24 +13,24 @@ class ScenarioUpdateParams(TypedDict, total=False): - input_context: Required[InputContextParam] - """The input context for the Scenario.""" - - name: Required[str] - """Name of the scenario.""" - - scoring_contract: Required[ScoringContractParam] - """The scoring contract for the Scenario.""" - environment_parameters: Optional[ScenarioEnvironmentParam] """The Environment in which the Scenario will run.""" + input_context: Optional[InputContextParam] + """The input context for the Scenario.""" + metadata: Optional[Dict[str, str]] """User defined metadata to attach to the scenario for organization.""" + name: Optional[str] + """Name of the scenario.""" + reference_output: Optional[str] """A string representation of the reference output to solve the scenario. Commonly can be the result of a git diff or a sequence of command actions to apply to the environment. """ + + scoring_contract: Optional[ScoringContractParam] + """The scoring contract for the Scenario.""" diff --git a/tests/api_resources/benchmarks/test_runs.py b/tests/api_resources/benchmarks/test_runs.py index 03db357df..a95855518 100644 --- a/tests/api_resources/benchmarks/test_runs.py +++ b/tests/api_resources/benchmarks/test_runs.py @@ -216,7 +216,9 @@ def test_path_params_list_scenario_runs(self, client: Runloop) -> None: class TestAsyncRuns: - parametrize = pytest.mark.parametrize("async_client", [False, True], indirect=True, ids=["loose", "strict"]) + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) @parametrize async def test_method_retrieve(self, async_client: AsyncRunloop) -> None: diff --git a/tests/api_resources/devboxes/test_browsers.py b/tests/api_resources/devboxes/test_browsers.py index 5fa04afc0..1e72e079e 100644 --- a/tests/api_resources/devboxes/test_browsers.py +++ b/tests/api_resources/devboxes/test_browsers.py @@ -89,7 +89,9 @@ def test_path_params_retrieve(self, client: Runloop) -> None: class TestAsyncBrowsers: - parametrize = pytest.mark.parametrize("async_client", [False, True], indirect=True, ids=["loose", "strict"]) + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) @parametrize async def test_method_create(self, async_client: AsyncRunloop) -> None: diff --git a/tests/api_resources/devboxes/test_computers.py b/tests/api_resources/devboxes/test_computers.py index 8671cf96c..a7cbf4e52 100644 --- a/tests/api_resources/devboxes/test_computers.py +++ b/tests/api_resources/devboxes/test_computers.py @@ -245,7 +245,9 @@ def test_path_params_screen_interaction(self, client: Runloop) -> None: class TestAsyncComputers: - parametrize = pytest.mark.parametrize("async_client", [False, True], indirect=True, ids=["loose", "strict"]) + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) @parametrize async def test_method_create(self, async_client: AsyncRunloop) -> None: diff --git a/tests/api_resources/devboxes/test_disk_snapshots.py b/tests/api_resources/devboxes/test_disk_snapshots.py index aff385ef0..3ef9f7b94 100644 --- a/tests/api_resources/devboxes/test_disk_snapshots.py +++ b/tests/api_resources/devboxes/test_disk_snapshots.py @@ -182,7 +182,9 @@ def test_path_params_query_status(self, client: Runloop) -> None: class TestAsyncDiskSnapshots: - parametrize = pytest.mark.parametrize("async_client", [False, True], indirect=True, ids=["loose", "strict"]) + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) @parametrize async def test_method_update(self, async_client: AsyncRunloop) -> None: diff --git a/tests/api_resources/devboxes/test_executions.py b/tests/api_resources/devboxes/test_executions.py index a1a7b8b58..32da6909f 100755 --- a/tests/api_resources/devboxes/test_executions.py +++ b/tests/api_resources/devboxes/test_executions.py @@ -226,7 +226,9 @@ def test_path_params_kill(self, client: Runloop) -> None: class TestAsyncExecutions: - parametrize = pytest.mark.parametrize("async_client", [False, True], indirect=True, ids=["loose", "strict"]) + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) @parametrize async def test_method_retrieve(self, async_client: AsyncRunloop) -> None: diff --git a/tests/api_resources/devboxes/test_logs.py b/tests/api_resources/devboxes/test_logs.py index 3546c378a..80f10afca 100644 --- a/tests/api_resources/devboxes/test_logs.py +++ b/tests/api_resources/devboxes/test_logs.py @@ -66,7 +66,9 @@ def test_path_params_list(self, client: Runloop) -> None: class TestAsyncLogs: - parametrize = pytest.mark.parametrize("async_client", [False, True], indirect=True, ids=["loose", "strict"]) + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) @parametrize async def test_method_list(self, async_client: AsyncRunloop) -> None: diff --git a/tests/api_resources/devboxes/test_lsp.py b/tests/api_resources/devboxes/test_lsp.py index c23afde06..fe9a479b1 100644 --- a/tests/api_resources/devboxes/test_lsp.py +++ b/tests/api_resources/devboxes/test_lsp.py @@ -822,7 +822,9 @@ def test_path_params_set_watch_directory(self, client: Runloop) -> None: class TestAsyncLsp: - parametrize = pytest.mark.parametrize("async_client", [False, True], indirect=True, ids=["loose", "strict"]) + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) @parametrize async def test_method_apply_code_action(self, async_client: AsyncRunloop) -> None: diff --git a/tests/api_resources/scenarios/test_runs.py b/tests/api_resources/scenarios/test_runs.py index de0adca5f..57eacb2b5 100644 --- a/tests/api_resources/scenarios/test_runs.py +++ b/tests/api_resources/scenarios/test_runs.py @@ -206,7 +206,9 @@ def test_path_params_score(self, client: Runloop) -> None: class TestAsyncRuns: - parametrize = pytest.mark.parametrize("async_client", [False, True], indirect=True, ids=["loose", "strict"]) + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) @parametrize async def test_method_retrieve(self, async_client: AsyncRunloop) -> None: diff --git a/tests/api_resources/scenarios/test_scorers.py b/tests/api_resources/scenarios/test_scorers.py index db1ae2c56..5388ad044 100644 --- a/tests/api_resources/scenarios/test_scorers.py +++ b/tests/api_resources/scenarios/test_scorers.py @@ -250,7 +250,9 @@ def test_path_params_validate(self, client: Runloop) -> None: class TestAsyncScorers: - parametrize = pytest.mark.parametrize("async_client", [False, True], indirect=True, ids=["loose", "strict"]) + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) @parametrize async def test_method_create(self, async_client: AsyncRunloop) -> None: diff --git a/tests/api_resources/test_benchmarks.py b/tests/api_resources/test_benchmarks.py index c453baf0a..615ff95da 100644 --- a/tests/api_resources/test_benchmarks.py +++ b/tests/api_resources/test_benchmarks.py @@ -307,7 +307,9 @@ def test_streaming_response_start_run(self, client: Runloop) -> None: class TestAsyncBenchmarks: - parametrize = pytest.mark.parametrize("async_client", [False, True], indirect=True, ids=["loose", "strict"]) + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) @parametrize async def test_method_create(self, async_client: AsyncRunloop) -> None: diff --git a/tests/api_resources/test_blueprints.py b/tests/api_resources/test_blueprints.py index 64cd30459..f2f3f7c67 100644 --- a/tests/api_resources/test_blueprints.py +++ b/tests/api_resources/test_blueprints.py @@ -304,7 +304,9 @@ def test_streaming_response_preview(self, client: Runloop) -> None: class TestAsyncBlueprints: - parametrize = pytest.mark.parametrize("async_client", [False, True], indirect=True, ids=["loose", "strict"]) + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) @parametrize async def test_method_create(self, async_client: AsyncRunloop) -> None: diff --git a/tests/api_resources/test_devboxes.py b/tests/api_resources/test_devboxes.py index f9402f955..9c9c78e49 100644 --- a/tests/api_resources/test_devboxes.py +++ b/tests/api_resources/test_devboxes.py @@ -967,7 +967,9 @@ def test_path_params_write_file_contents(self, client: Runloop) -> None: class TestAsyncDevboxes: - parametrize = pytest.mark.parametrize("async_client", [False, True], indirect=True, ids=["loose", "strict"]) + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) @parametrize async def test_method_create(self, async_client: AsyncRunloop) -> None: diff --git a/tests/api_resources/test_repositories.py b/tests/api_resources/test_repositories.py index c74290fbe..da723e080 100644 --- a/tests/api_resources/test_repositories.py +++ b/tests/api_resources/test_repositories.py @@ -263,7 +263,9 @@ def test_path_params_refresh(self, client: Runloop) -> None: class TestAsyncRepositories: - parametrize = pytest.mark.parametrize("async_client", [False, True], indirect=True, ids=["loose", "strict"]) + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) @parametrize async def test_method_create(self, async_client: AsyncRunloop) -> None: diff --git a/tests/api_resources/test_scenarios.py b/tests/api_resources/test_scenarios.py index a7fa7a293..c4243a16a 100644 --- a/tests/api_resources/test_scenarios.py +++ b/tests/api_resources/test_scenarios.py @@ -186,21 +186,6 @@ def test_path_params_retrieve(self, client: Runloop) -> None: def test_method_update(self, client: Runloop) -> None: scenario = client.scenarios.update( id="id", - input_context={"problem_statement": "problem_statement"}, - name="name", - scoring_contract={ - "scoring_function_parameters": [ - { - "name": "name", - "scorer": { - "pattern": "pattern", - "search_directory": "search_directory", - "type": "ast_grep_scorer", - }, - "weight": 0, - } - ] - }, ) assert_matches_type(ScenarioView, scenario, path=["response"]) @@ -208,25 +193,6 @@ def test_method_update(self, client: Runloop) -> None: def test_method_update_with_all_params(self, client: Runloop) -> None: scenario = client.scenarios.update( id="id", - input_context={ - "problem_statement": "problem_statement", - "additional_context": {}, - }, - name="name", - scoring_contract={ - "scoring_function_parameters": [ - { - "name": "name", - "scorer": { - "pattern": "pattern", - "search_directory": "search_directory", - "type": "ast_grep_scorer", - "lang": "lang", - }, - "weight": 0, - } - ] - }, environment_parameters={ "blueprint_id": "blueprint_id", "launch_parameters": { @@ -250,17 +216,13 @@ def test_method_update_with_all_params(self, client: Runloop) -> None: "snapshot_id": "snapshot_id", "working_directory": "working_directory", }, + input_context={ + "problem_statement": "problem_statement", + "additional_context": {}, + }, metadata={"foo": "string"}, - reference_output="reference_output", - ) - assert_matches_type(ScenarioView, scenario, path=["response"]) - - @parametrize - def test_raw_response_update(self, client: Runloop) -> None: - response = client.scenarios.with_raw_response.update( - id="id", - input_context={"problem_statement": "problem_statement"}, name="name", + reference_output="reference_output", scoring_contract={ "scoring_function_parameters": [ { @@ -269,12 +231,20 @@ def test_raw_response_update(self, client: Runloop) -> None: "pattern": "pattern", "search_directory": "search_directory", "type": "ast_grep_scorer", + "lang": "lang", }, "weight": 0, } ] }, ) + assert_matches_type(ScenarioView, scenario, path=["response"]) + + @parametrize + def test_raw_response_update(self, client: Runloop) -> None: + response = client.scenarios.with_raw_response.update( + id="id", + ) assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" @@ -285,21 +255,6 @@ def test_raw_response_update(self, client: Runloop) -> None: def test_streaming_response_update(self, client: Runloop) -> None: with client.scenarios.with_streaming_response.update( id="id", - input_context={"problem_statement": "problem_statement"}, - name="name", - scoring_contract={ - "scoring_function_parameters": [ - { - "name": "name", - "scorer": { - "pattern": "pattern", - "search_directory": "search_directory", - "type": "ast_grep_scorer", - }, - "weight": 0, - } - ] - }, ) as response: assert not response.is_closed assert response.http_request.headers.get("X-Stainless-Lang") == "python" @@ -314,21 +269,6 @@ def test_path_params_update(self, client: Runloop) -> None: with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): client.scenarios.with_raw_response.update( id="", - input_context={"problem_statement": "problem_statement"}, - name="name", - scoring_contract={ - "scoring_function_parameters": [ - { - "name": "name", - "scorer": { - "pattern": "pattern", - "search_directory": "search_directory", - "type": "ast_grep_scorer", - }, - "weight": 0, - } - ] - }, ) @parametrize @@ -443,7 +383,9 @@ def test_streaming_response_start_run(self, client: Runloop) -> None: class TestAsyncScenarios: - parametrize = pytest.mark.parametrize("async_client", [False, True], indirect=True, ids=["loose", "strict"]) + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) @parametrize async def test_method_create(self, async_client: AsyncRunloop) -> None: @@ -610,21 +552,6 @@ async def test_path_params_retrieve(self, async_client: AsyncRunloop) -> None: async def test_method_update(self, async_client: AsyncRunloop) -> None: scenario = await async_client.scenarios.update( id="id", - input_context={"problem_statement": "problem_statement"}, - name="name", - scoring_contract={ - "scoring_function_parameters": [ - { - "name": "name", - "scorer": { - "pattern": "pattern", - "search_directory": "search_directory", - "type": "ast_grep_scorer", - }, - "weight": 0, - } - ] - }, ) assert_matches_type(ScenarioView, scenario, path=["response"]) @@ -632,25 +559,6 @@ async def test_method_update(self, async_client: AsyncRunloop) -> None: async def test_method_update_with_all_params(self, async_client: AsyncRunloop) -> None: scenario = await async_client.scenarios.update( id="id", - input_context={ - "problem_statement": "problem_statement", - "additional_context": {}, - }, - name="name", - scoring_contract={ - "scoring_function_parameters": [ - { - "name": "name", - "scorer": { - "pattern": "pattern", - "search_directory": "search_directory", - "type": "ast_grep_scorer", - "lang": "lang", - }, - "weight": 0, - } - ] - }, environment_parameters={ "blueprint_id": "blueprint_id", "launch_parameters": { @@ -674,17 +582,13 @@ async def test_method_update_with_all_params(self, async_client: AsyncRunloop) - "snapshot_id": "snapshot_id", "working_directory": "working_directory", }, + input_context={ + "problem_statement": "problem_statement", + "additional_context": {}, + }, metadata={"foo": "string"}, - reference_output="reference_output", - ) - assert_matches_type(ScenarioView, scenario, path=["response"]) - - @parametrize - async def test_raw_response_update(self, async_client: AsyncRunloop) -> None: - response = await async_client.scenarios.with_raw_response.update( - id="id", - input_context={"problem_statement": "problem_statement"}, name="name", + reference_output="reference_output", scoring_contract={ "scoring_function_parameters": [ { @@ -693,12 +597,20 @@ async def test_raw_response_update(self, async_client: AsyncRunloop) -> None: "pattern": "pattern", "search_directory": "search_directory", "type": "ast_grep_scorer", + "lang": "lang", }, "weight": 0, } ] }, ) + assert_matches_type(ScenarioView, scenario, path=["response"]) + + @parametrize + async def test_raw_response_update(self, async_client: AsyncRunloop) -> None: + response = await async_client.scenarios.with_raw_response.update( + id="id", + ) assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" @@ -709,21 +621,6 @@ async def test_raw_response_update(self, async_client: AsyncRunloop) -> None: async def test_streaming_response_update(self, async_client: AsyncRunloop) -> None: async with async_client.scenarios.with_streaming_response.update( id="id", - input_context={"problem_statement": "problem_statement"}, - name="name", - scoring_contract={ - "scoring_function_parameters": [ - { - "name": "name", - "scorer": { - "pattern": "pattern", - "search_directory": "search_directory", - "type": "ast_grep_scorer", - }, - "weight": 0, - } - ] - }, ) as response: assert not response.is_closed assert response.http_request.headers.get("X-Stainless-Lang") == "python" @@ -738,21 +635,6 @@ async def test_path_params_update(self, async_client: AsyncRunloop) -> None: with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): await async_client.scenarios.with_raw_response.update( id="", - input_context={"problem_statement": "problem_statement"}, - name="name", - scoring_contract={ - "scoring_function_parameters": [ - { - "name": "name", - "scorer": { - "pattern": "pattern", - "search_directory": "search_directory", - "type": "ast_grep_scorer", - }, - "weight": 0, - } - ] - }, ) @parametrize diff --git a/tests/api_resources/test_secrets.py b/tests/api_resources/test_secrets.py index 730447d77..09a27ab44 100644 --- a/tests/api_resources/test_secrets.py +++ b/tests/api_resources/test_secrets.py @@ -158,7 +158,9 @@ def test_path_params_delete(self, client: Runloop) -> None: class TestAsyncSecrets: - parametrize = pytest.mark.parametrize("async_client", [False, True], indirect=True, ids=["loose", "strict"]) + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) @parametrize async def test_method_create(self, async_client: AsyncRunloop) -> None: diff --git a/tests/conftest.py b/tests/conftest.py index fe6961d03..b68f5d7ab 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,13 +1,17 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + from __future__ import annotations import os import logging from typing import TYPE_CHECKING, Iterator, AsyncIterator +import httpx import pytest from pytest_asyncio import is_async_test -from runloop_api_client import Runloop, AsyncRunloop +from runloop_api_client import Runloop, AsyncRunloop, DefaultAioHttpClient +from runloop_api_client._utils import is_dict if TYPE_CHECKING: from _pytest.fixtures import FixtureRequest # pyright: ignore[reportPrivateImportUsage] @@ -25,6 +29,19 @@ def pytest_collection_modifyitems(items: list[pytest.Function]) -> None: for async_test in pytest_asyncio_tests: async_test.add_marker(session_scope_marker, append=False) + # We skip tests that use both the aiohttp client and respx_mock as respx_mock + # doesn't support custom transports. + for item in items: + if "async_client" not in item.fixturenames or "respx_mock" not in item.fixturenames: + continue + + if not hasattr(item, "callspec"): + continue + + async_client_param = item.callspec.params.get("async_client") + if is_dict(async_client_param) and async_client_param.get("http_client") == "aiohttp": + item.add_marker(pytest.mark.skip(reason="aiohttp client is not compatible with respx_mock")) + base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") @@ -43,9 +60,25 @@ def client(request: FixtureRequest) -> Iterator[Runloop]: @pytest.fixture(scope="session") async def async_client(request: FixtureRequest) -> AsyncIterator[AsyncRunloop]: - strict = getattr(request, "param", True) - if not isinstance(strict, bool): - raise TypeError(f"Unexpected fixture parameter type {type(strict)}, expected {bool}") - - async with AsyncRunloop(base_url=base_url, bearer_token=bearer_token, _strict_response_validation=strict) as client: + param = getattr(request, "param", True) + + # defaults + strict = True + http_client: None | httpx.AsyncClient = None + + if isinstance(param, bool): + strict = param + elif is_dict(param): + strict = param.get("strict", True) + assert isinstance(strict, bool) + + http_client_type = param.get("http_client", "httpx") + if http_client_type == "aiohttp": + http_client = DefaultAioHttpClient() + else: + raise TypeError(f"Unexpected fixture parameter type {type(param)}, expected bool or dict") + + async with AsyncRunloop( + base_url=base_url, bearer_token=bearer_token, _strict_response_validation=strict, http_client=http_client + ) as client: yield client diff --git a/tests/test_client.py b/tests/test_client.py index e3faa0506..60cefa491 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -23,17 +23,16 @@ from runloop_api_client import Runloop, AsyncRunloop, APIResponseValidationError from runloop_api_client._types import Omit -from runloop_api_client._utils import maybe_transform from runloop_api_client._models import BaseModel, FinalRequestOptions -from runloop_api_client._constants import RAW_RESPONSE_HEADER from runloop_api_client._exceptions import RunloopError, APIStatusError, APITimeoutError, APIResponseValidationError from runloop_api_client._base_client import ( DEFAULT_TIMEOUT, HTTPX_DEFAULT_TIMEOUT, BaseClient, + DefaultHttpxClient, + DefaultAsyncHttpxClient, make_request_options, ) -from runloop_api_client.types.devbox_create_params import DevboxCreateParams from .utils import update_env @@ -768,32 +767,21 @@ def test_parse_retry_after_header(self, remaining_retries: int, retry_after: str @mock.patch("runloop_api_client._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: Runloop) -> None: respx_mock.post("/v1/devboxes").mock(side_effect=httpx.TimeoutException("Test timeout error")) with pytest.raises(APITimeoutError): - self.client.post( - "/v1/devboxes", - body=cast(object, maybe_transform({}, DevboxCreateParams)), - cast_to=httpx.Response, - options={"headers": {RAW_RESPONSE_HEADER: "stream"}}, - ) + client.devboxes.with_streaming_response.create().__enter__() assert _get_open_connections(self.client) == 0 @mock.patch("runloop_api_client._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: Runloop) -> None: respx_mock.post("/v1/devboxes").mock(return_value=httpx.Response(500)) with pytest.raises(APIStatusError): - self.client.post( - "/v1/devboxes", - body=cast(object, maybe_transform({}, DevboxCreateParams)), - cast_to=httpx.Response, - options={"headers": {RAW_RESPONSE_HEADER: "stream"}}, - ) - + client.devboxes.with_streaming_response.create().__enter__() assert _get_open_connections(self.client) == 0 @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) @@ -873,6 +861,28 @@ 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 @@ -1616,32 +1626,23 @@ async def test_parse_retry_after_header(self, remaining_retries: int, retry_afte @mock.patch("runloop_api_client._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: AsyncRunloop + ) -> None: respx_mock.post("/v1/devboxes").mock(side_effect=httpx.TimeoutException("Test timeout error")) with pytest.raises(APITimeoutError): - await self.client.post( - "/v1/devboxes", - body=cast(object, maybe_transform({}, DevboxCreateParams)), - cast_to=httpx.Response, - options={"headers": {RAW_RESPONSE_HEADER: "stream"}}, - ) + await async_client.devboxes.with_streaming_response.create().__aenter__() assert _get_open_connections(self.client) == 0 @mock.patch("runloop_api_client._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: AsyncRunloop) -> None: respx_mock.post("/v1/devboxes").mock(return_value=httpx.Response(500)) with pytest.raises(APIStatusError): - await self.client.post( - "/v1/devboxes", - body=cast(object, maybe_transform({}, DevboxCreateParams)), - cast_to=httpx.Response, - options={"headers": {RAW_RESPONSE_HEADER: "stream"}}, - ) - + await async_client.devboxes.with_streaming_response.create().__aenter__() assert _get_open_connections(self.client) == 0 @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) @@ -1769,6 +1770,28 @@ async def test_main() -> None: 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