diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 157f0355f..51acdaa4e 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.36.0" + ".": "0.37.0" } \ No newline at end of file diff --git a/.stats.yml b/.stats.yml index 5a58d5b1e..cb2817919 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ -configured_endpoints: 85 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/runloop-ai%2Frunloop-3484df665f4c2b7cb17ad044b825223fc69ad67b05d967fbb6dfbb6a6ac9ccac.yml -openapi_spec_hash: 58c0860078f5f26c8b517603956700b5 -config_hash: c03c6a4c057a38e2809a102c48fafe6c +configured_endpoints: 86 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/runloop-ai%2Frunloop-141cac5a78c04271425090df61bb3f19446c1c065ae66a5b7a6329c53628aafa.yml +openapi_spec_hash: 2380917eddc78d715847c7ef6bfa3b4a +config_hash: 5b50498887b4fdca175b8377a9cb675f diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ab9dbf43..e80b2e50a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## 0.37.0 (2025-06-04) + +Full Changelog: [v0.36.0...v0.37.0](https://github.com/runloopai/api-client-python/compare/v0.36.0...v0.37.0) + +### Features + +* **api:** api update ([a167d0b](https://github.com/runloopai/api-client-python/commit/a167d0b2dbab59cb4ffbb029ea6dae22d0d7c2f4)) +* **client:** add follow_redirects request option ([c29d24c](https://github.com/runloopai/api-client-python/commit/c29d24c5483ee2ca7e4d53dfdf8d40278f04f1fe)) + + +### Chores + +* **docs:** remove reference to rye shell ([af51a95](https://github.com/runloopai/api-client-python/commit/af51a958afb08cc728959b154c52d0ca31f4206c)) +* **docs:** remove unnecessary param examples ([a29553d](https://github.com/runloopai/api-client-python/commit/a29553d048fa85207e28e5b3dbc4ad9ea326ad86)) + ## 0.36.0 (2025-06-02) Full Changelog: [v0.35.0...v0.36.0](https://github.com/runloopai/api-client-python/compare/v0.35.0...v0.36.0) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fcf955358..67732aae5 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 05e74c625..b59a62b8d 100644 --- a/README.md +++ b/README.md @@ -146,23 +146,7 @@ from runloop_api_client import Runloop client = Runloop() devbox_view = client.devboxes.create( - launch_parameters={ - "after_idle": { - "idle_time_seconds": 0, - "on_idle": "shutdown", - }, - "architecture": "x86_64", - "available_ports": [0], - "custom_cpu_cores": 0, - "custom_gb_memory": 0, - "keep_alive_time_seconds": 0, - "launch_commands": ["string"], - "resource_size_request": "X_SMALL", - "user_parameters": { - "uid": 0, - "username": "username", - }, - }, + launch_parameters={}, ) print(devbox_view.launch_parameters) ``` diff --git a/api.md b/api.md index 367aa7f0d..7e511f221 100644 --- a/api.md +++ b/api.md @@ -35,6 +35,7 @@ Methods: - client.benchmarks.runs.list(\*\*params) -> SyncBenchmarkRunsCursorIDPage[BenchmarkRunView] - client.benchmarks.runs.cancel(id) -> BenchmarkRunView - client.benchmarks.runs.complete(id) -> BenchmarkRunView +- client.benchmarks.runs.list_scenario_runs(id, \*\*params) -> SyncBenchmarkRunsCursorIDPage[ScenarioRunView] # Blueprints diff --git a/pyproject.toml b/pyproject.toml index 4bfa8e5a8..5ea158891 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "runloop_api_client" -version = "0.36.0" +version = "0.37.0" description = "The official Python library for the runloop API" dynamic = ["readme"] license = "MIT" diff --git a/src/runloop_api_client/_base_client.py b/src/runloop_api_client/_base_client.py index 1b3041060..bba4933f3 100644 --- a/src/runloop_api_client/_base_client.py +++ b/src/runloop_api_client/_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 @@ -1460,6 +1463,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 diff --git a/src/runloop_api_client/_models.py b/src/runloop_api_client/_models.py index 798956f17..4f2149805 100644 --- a/src/runloop_api_client/_models.py +++ b/src/runloop_api_client/_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/runloop_api_client/_types.py b/src/runloop_api_client/_types.py index 114eefd47..40ccb19ed 100644 --- a/src/runloop_api_client/_types.py +++ b/src/runloop_api_client/_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/runloop_api_client/_version.py b/src/runloop_api_client/_version.py index 12ac26a82..5eea31e80 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.36.0" # x-release-please-version +__version__ = "0.37.0" # x-release-please-version diff --git a/src/runloop_api_client/resources/benchmarks/runs.py b/src/runloop_api_client/resources/benchmarks/runs.py index ef6022d19..5735a5aa6 100644 --- a/src/runloop_api_client/resources/benchmarks/runs.py +++ b/src/runloop_api_client/resources/benchmarks/runs.py @@ -16,7 +16,8 @@ ) from ...pagination import SyncBenchmarkRunsCursorIDPage, AsyncBenchmarkRunsCursorIDPage from ..._base_client import AsyncPaginator, make_request_options -from ...types.benchmarks import run_list_params +from ...types.benchmarks import run_list_params, run_list_scenario_runs_params +from ...types.scenario_run_view import ScenarioRunView from ...types.benchmark_run_view import BenchmarkRunView __all__ = ["RunsResource", "AsyncRunsResource"] @@ -206,6 +207,56 @@ def complete( cast_to=BenchmarkRunView, ) + def list_scenario_runs( + self, + id: str, + *, + limit: int | NotGiven = NOT_GIVEN, + starting_after: str | 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, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> SyncBenchmarkRunsCursorIDPage[ScenarioRunView]: + """ + List started scenario runs for a benchmark run. + + Args: + limit: The limit of items to return. Default is 20. + + starting_after: Load the next page of data starting after the item with the given ID. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not id: + raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") + return self._get_api_list( + f"/v1/benchmarks/runs/{id}/scenario_runs", + page=SyncBenchmarkRunsCursorIDPage[ScenarioRunView], + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "limit": limit, + "starting_after": starting_after, + }, + run_list_scenario_runs_params.RunListScenarioRunsParams, + ), + ), + model=ScenarioRunView, + ) + class AsyncRunsResource(AsyncAPIResource): @cached_property @@ -391,6 +442,56 @@ async def complete( cast_to=BenchmarkRunView, ) + def list_scenario_runs( + self, + id: str, + *, + limit: int | NotGiven = NOT_GIVEN, + starting_after: str | 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, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> AsyncPaginator[ScenarioRunView, AsyncBenchmarkRunsCursorIDPage[ScenarioRunView]]: + """ + List started scenario runs for a benchmark run. + + Args: + limit: The limit of items to return. Default is 20. + + starting_after: Load the next page of data starting after the item with the given ID. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not id: + raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") + return self._get_api_list( + f"/v1/benchmarks/runs/{id}/scenario_runs", + page=AsyncBenchmarkRunsCursorIDPage[ScenarioRunView], + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "limit": limit, + "starting_after": starting_after, + }, + run_list_scenario_runs_params.RunListScenarioRunsParams, + ), + ), + model=ScenarioRunView, + ) + class RunsResourceWithRawResponse: def __init__(self, runs: RunsResource) -> None: @@ -408,6 +509,9 @@ def __init__(self, runs: RunsResource) -> None: self.complete = to_raw_response_wrapper( runs.complete, ) + self.list_scenario_runs = to_raw_response_wrapper( + runs.list_scenario_runs, + ) class AsyncRunsResourceWithRawResponse: @@ -426,6 +530,9 @@ def __init__(self, runs: AsyncRunsResource) -> None: self.complete = async_to_raw_response_wrapper( runs.complete, ) + self.list_scenario_runs = async_to_raw_response_wrapper( + runs.list_scenario_runs, + ) class RunsResourceWithStreamingResponse: @@ -444,6 +551,9 @@ def __init__(self, runs: RunsResource) -> None: self.complete = to_streamed_response_wrapper( runs.complete, ) + self.list_scenario_runs = to_streamed_response_wrapper( + runs.list_scenario_runs, + ) class AsyncRunsResourceWithStreamingResponse: @@ -462,3 +572,6 @@ def __init__(self, runs: AsyncRunsResource) -> None: self.complete = async_to_streamed_response_wrapper( runs.complete, ) + self.list_scenario_runs = async_to_streamed_response_wrapper( + runs.list_scenario_runs, + ) diff --git a/src/runloop_api_client/types/benchmarks/__init__.py b/src/runloop_api_client/types/benchmarks/__init__.py index 4bc4e1112..2fb29daa0 100644 --- a/src/runloop_api_client/types/benchmarks/__init__.py +++ b/src/runloop_api_client/types/benchmarks/__init__.py @@ -3,3 +3,4 @@ from __future__ import annotations from .run_list_params import RunListParams as RunListParams +from .run_list_scenario_runs_params import RunListScenarioRunsParams as RunListScenarioRunsParams diff --git a/src/runloop_api_client/types/benchmarks/run_list_scenario_runs_params.py b/src/runloop_api_client/types/benchmarks/run_list_scenario_runs_params.py new file mode 100644 index 000000000..b001bd54e --- /dev/null +++ b/src/runloop_api_client/types/benchmarks/run_list_scenario_runs_params.py @@ -0,0 +1,15 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import TypedDict + +__all__ = ["RunListScenarioRunsParams"] + + +class RunListScenarioRunsParams(TypedDict, total=False): + limit: int + """The limit of items to return. Default is 20.""" + + starting_after: str + """Load the next page of data starting after the item with the given ID.""" diff --git a/tests/api_resources/benchmarks/test_runs.py b/tests/api_resources/benchmarks/test_runs.py index d23aa33b7..a9a54ed76 100644 --- a/tests/api_resources/benchmarks/test_runs.py +++ b/tests/api_resources/benchmarks/test_runs.py @@ -9,7 +9,7 @@ from tests.utils import assert_matches_type from runloop_api_client import Runloop, AsyncRunloop -from runloop_api_client.types import BenchmarkRunView +from runloop_api_client.types import ScenarioRunView, BenchmarkRunView from runloop_api_client.pagination import SyncBenchmarkRunsCursorIDPage, AsyncBenchmarkRunsCursorIDPage base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") @@ -166,6 +166,53 @@ def test_path_params_complete(self, client: Runloop) -> None: "", ) + @parametrize + def test_method_list_scenario_runs(self, client: Runloop) -> None: + run = client.benchmarks.runs.list_scenario_runs( + id="id", + ) + assert_matches_type(SyncBenchmarkRunsCursorIDPage[ScenarioRunView], run, path=["response"]) + + @parametrize + def test_method_list_scenario_runs_with_all_params(self, client: Runloop) -> None: + run = client.benchmarks.runs.list_scenario_runs( + id="id", + limit=0, + starting_after="starting_after", + ) + assert_matches_type(SyncBenchmarkRunsCursorIDPage[ScenarioRunView], run, path=["response"]) + + @parametrize + def test_raw_response_list_scenario_runs(self, client: Runloop) -> None: + response = client.benchmarks.runs.with_raw_response.list_scenario_runs( + id="id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + run = response.parse() + assert_matches_type(SyncBenchmarkRunsCursorIDPage[ScenarioRunView], run, path=["response"]) + + @parametrize + def test_streaming_response_list_scenario_runs(self, client: Runloop) -> None: + with client.benchmarks.runs.with_streaming_response.list_scenario_runs( + id="id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + run = response.parse() + assert_matches_type(SyncBenchmarkRunsCursorIDPage[ScenarioRunView], run, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @parametrize + def test_path_params_list_scenario_runs(self, client: Runloop) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.benchmarks.runs.with_raw_response.list_scenario_runs( + id="", + ) + class TestAsyncRuns: parametrize = pytest.mark.parametrize("async_client", [False, True], indirect=True, ids=["loose", "strict"]) @@ -317,3 +364,50 @@ async def test_path_params_complete(self, async_client: AsyncRunloop) -> None: await async_client.benchmarks.runs.with_raw_response.complete( "", ) + + @parametrize + async def test_method_list_scenario_runs(self, async_client: AsyncRunloop) -> None: + run = await async_client.benchmarks.runs.list_scenario_runs( + id="id", + ) + assert_matches_type(AsyncBenchmarkRunsCursorIDPage[ScenarioRunView], run, path=["response"]) + + @parametrize + async def test_method_list_scenario_runs_with_all_params(self, async_client: AsyncRunloop) -> None: + run = await async_client.benchmarks.runs.list_scenario_runs( + id="id", + limit=0, + starting_after="starting_after", + ) + assert_matches_type(AsyncBenchmarkRunsCursorIDPage[ScenarioRunView], run, path=["response"]) + + @parametrize + async def test_raw_response_list_scenario_runs(self, async_client: AsyncRunloop) -> None: + response = await async_client.benchmarks.runs.with_raw_response.list_scenario_runs( + id="id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + run = await response.parse() + assert_matches_type(AsyncBenchmarkRunsCursorIDPage[ScenarioRunView], run, path=["response"]) + + @parametrize + async def test_streaming_response_list_scenario_runs(self, async_client: AsyncRunloop) -> None: + async with async_client.benchmarks.runs.with_streaming_response.list_scenario_runs( + id="id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + run = await response.parse() + assert_matches_type(AsyncBenchmarkRunsCursorIDPage[ScenarioRunView], run, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @parametrize + async def test_path_params_list_scenario_runs(self, async_client: AsyncRunloop) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.benchmarks.runs.with_raw_response.list_scenario_runs( + id="", + ) diff --git a/tests/test_client.py b/tests/test_client.py index 2453b22ac..e3faa0506 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -873,6 +873,33 @@ def retry_handler(_request: httpx.Request) -> httpx.Response: assert response.http_request.headers.get("x-stainless-retry-count") == "42" + @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 TestAsyncRunloop: client = AsyncRunloop(base_url=base_url, bearer_token=bearer_token, _strict_response_validation=True) @@ -1741,3 +1768,30 @@ async def test_main() -> None: raise AssertionError("calling get_platform using asyncify resulted in a hung process") time.sleep(0.1) + + @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"