From 4818d2d6084684529935c8e6b9b109516a1de373 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 5 Sep 2025 03:38:14 +0000 Subject: [PATCH 1/6] chore(internal): move mypy configurations to `pyproject.toml` file --- mypy.ini | 50 ------------------------------------------------ pyproject.toml | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 50 deletions(-) delete mode 100644 mypy.ini diff --git a/mypy.ini b/mypy.ini deleted file mode 100644 index 0745431c..00000000 --- a/mypy.ini +++ /dev/null @@ -1,50 +0,0 @@ -[mypy] -pretty = True -show_error_codes = True - -# Exclude _files.py because mypy isn't smart enough to apply -# the correct type narrowing and as this is an internal module -# it's fine to just use Pyright. -# -# We also exclude our `tests` as mypy doesn't always infer -# types correctly and Pyright will still catch any type errors. -exclude = ^(src/kernel/_files\.py|_dev/.*\.py|tests/.*)$ - -strict_equality = True -implicit_reexport = True -check_untyped_defs = True -no_implicit_optional = True - -warn_return_any = True -warn_unreachable = True -warn_unused_configs = True - -# Turn these options off as it could cause conflicts -# with the Pyright options. -warn_unused_ignores = False -warn_redundant_casts = False - -disallow_any_generics = True -disallow_untyped_defs = True -disallow_untyped_calls = True -disallow_subclassing_any = True -disallow_incomplete_defs = True -disallow_untyped_decorators = True -cache_fine_grained = True - -# By default, mypy reports an error if you assign a value to the result -# of a function call that doesn't return anything. We do this in our test -# cases: -# ``` -# result = ... -# assert result is None -# ``` -# Changing this codegen to make mypy happy would increase complexity -# and would not be worth it. -disable_error_code = func-returns-value,overload-cannot-match - -# https://github.com/python/mypy/issues/12162 -[mypy.overrides] -module = "black.files.*" -ignore_errors = true -ignore_missing_imports = true diff --git a/pyproject.toml b/pyproject.toml index f731ab32..2acfd24c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -157,6 +157,58 @@ reportOverlappingOverload = false reportImportCycles = false reportPrivateUsage = false +[tool.mypy] +pretty = true +show_error_codes = true + +# Exclude _files.py because mypy isn't smart enough to apply +# the correct type narrowing and as this is an internal module +# it's fine to just use Pyright. +# +# We also exclude our `tests` as mypy doesn't always infer +# types correctly and Pyright will still catch any type errors. +exclude = ['src/kernel/_files.py', '_dev/.*.py', 'tests/.*'] + +strict_equality = true +implicit_reexport = true +check_untyped_defs = true +no_implicit_optional = true + +warn_return_any = true +warn_unreachable = true +warn_unused_configs = true + +# Turn these options off as it could cause conflicts +# with the Pyright options. +warn_unused_ignores = false +warn_redundant_casts = false + +disallow_any_generics = true +disallow_untyped_defs = true +disallow_untyped_calls = true +disallow_subclassing_any = true +disallow_incomplete_defs = true +disallow_untyped_decorators = true +cache_fine_grained = true + +# By default, mypy reports an error if you assign a value to the result +# of a function call that doesn't return anything. We do this in our test +# cases: +# ``` +# result = ... +# assert result is None +# ``` +# Changing this codegen to make mypy happy would increase complexity +# and would not be worth it. +disable_error_code = "func-returns-value,overload-cannot-match" + +# https://github.com/python/mypy/issues/12162 +[[tool.mypy.overrides]] +module = "black.files.*" +ignore_errors = true +ignore_missing_imports = true + + [tool.ruff] line-length = 120 output-format = "grouped" From e5838f51b9af325700b23d55ff2bb11b6ff3306e Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 5 Sep 2025 15:16:51 +0000 Subject: [PATCH 2/6] feat(api): add pagination to the deployments endpoint --- .stats.yml | 6 +- README.md | 73 ++++++++++++++++++ api.md | 2 +- src/kernel/pagination.py | 78 ++++++++++++++++++++ src/kernel/resources/deployments.py | 53 ++++++++++--- src/kernel/types/deployment_list_params.py | 10 ++- src/kernel/types/deployment_list_response.py | 11 +-- tests/api_resources/test_deployments.py | 45 +++++++---- 8 files changed, 239 insertions(+), 39 deletions(-) create mode 100644 src/kernel/pagination.py diff --git a/.stats.yml b/.stats.yml index 6ac19baf..8943c2f3 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 46 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/kernel%2Fkernel-e98d46c55826cdf541a9ee0df04ce92806ac6d4d92957ae79f897270b7d85b23.yml -openapi_spec_hash: 8a1af54fc0a4417165b8a52e6354b685 -config_hash: 043ddc54629c6d8b889123770cb4769f +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/kernel%2Fkernel-33f1feaba7bde46bfa36d2fefb5c3bc9512967945bccf78045ad3f64aafc4eb0.yml +openapi_spec_hash: 52a448889d41216d1ca30e8a57115b14 +config_hash: 1f28d5c3c063f418ebd2799df1e4e781 diff --git a/README.md b/README.md index 884d10c2..c5b5bf7c 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,79 @@ Nested request parameters are [TypedDicts](https://docs.python.org/3/library/typ Typed requests and responses provide autocomplete and documentation within your editor. If you would like to see type errors in VS Code to help catch bugs earlier, set `python.analysis.typeCheckingMode` to `basic`. +## Pagination + +List methods in the Kernel API are paginated. + +This library provides auto-paginating iterators with each list response, so you do not have to request successive pages manually: + +```python +from kernel import Kernel + +client = Kernel() + +all_deployments = [] +# Automatically fetches more pages as needed. +for deployment in client.deployments.list( + app_name="YOUR_APP", + limit=2, +): + # Do something with deployment here + all_deployments.append(deployment) +print(all_deployments) +``` + +Or, asynchronously: + +```python +import asyncio +from kernel import AsyncKernel + +client = AsyncKernel() + + +async def main() -> None: + all_deployments = [] + # Iterate through items across all pages, issuing requests as needed. + async for deployment in client.deployments.list( + app_name="YOUR_APP", + limit=2, + ): + all_deployments.append(deployment) + print(all_deployments) + + +asyncio.run(main()) +``` + +Alternatively, you can use the `.has_next_page()`, `.next_page_info()`, or `.get_next_page()` methods for more granular control working with pages: + +```python +first_page = await client.deployments.list( + app_name="YOUR_APP", + limit=2, +) +if first_page.has_next_page(): + print(f"will fetch next page using these details: {first_page.next_page_info()}") + next_page = await first_page.get_next_page() + print(f"number of items we just fetched: {len(next_page.items)}") + +# Remove `await` for non-async usage. +``` + +Or just work directly with the returned data: + +```python +first_page = await client.deployments.list( + app_name="YOUR_APP", + limit=2, +) +for deployment in first_page.items: + print(deployment.id) + +# Remove `await` for non-async usage. +``` + ## Nested params Nested parameters are dictionaries, typed using `TypedDict`, for example: diff --git a/api.md b/api.md index 78295850..56d852bc 100644 --- a/api.md +++ b/api.md @@ -22,7 +22,7 @@ Methods: - client.deployments.create(\*\*params) -> DeploymentCreateResponse - client.deployments.retrieve(id) -> DeploymentRetrieveResponse -- client.deployments.list(\*\*params) -> DeploymentListResponse +- client.deployments.list(\*\*params) -> SyncOffsetPagination[DeploymentListResponse] - client.deployments.follow(id, \*\*params) -> DeploymentFollowResponse # Apps diff --git a/src/kernel/pagination.py b/src/kernel/pagination.py new file mode 100644 index 00000000..2002d5eb --- /dev/null +++ b/src/kernel/pagination.py @@ -0,0 +1,78 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Any, List, Type, Generic, Mapping, TypeVar, Optional, cast +from typing_extensions import override + +from httpx import Response + +from ._utils import is_mapping +from ._models import BaseModel +from ._base_client import BasePage, PageInfo, BaseSyncPage, BaseAsyncPage + +__all__ = ["SyncOffsetPagination", "AsyncOffsetPagination"] + +_BaseModelT = TypeVar("_BaseModelT", bound=BaseModel) + +_T = TypeVar("_T") + + +class SyncOffsetPagination(BaseSyncPage[_T], BasePage[_T], Generic[_T]): + items: List[_T] + + @override + def _get_page_items(self) -> List[_T]: + items = self.items + if not items: + return [] + return items + + @override + def next_page_info(self) -> Optional[PageInfo]: + offset = self._options.params.get("offset") or 0 + if not isinstance(offset, int): + raise ValueError(f'Expected "offset" param to be an integer but got {offset}') + + length = len(self._get_page_items()) + current_count = offset + length + + return PageInfo(params={"offset": current_count}) + + @classmethod + def build(cls: Type[_BaseModelT], *, response: Response, data: object) -> _BaseModelT: # noqa: ARG003 + return cls.construct( + None, + **{ + **(cast(Mapping[str, Any], data) if is_mapping(data) else {"items": data}), + }, + ) + + +class AsyncOffsetPagination(BaseAsyncPage[_T], BasePage[_T], Generic[_T]): + items: List[_T] + + @override + def _get_page_items(self) -> List[_T]: + items = self.items + if not items: + return [] + return items + + @override + def next_page_info(self) -> Optional[PageInfo]: + offset = self._options.params.get("offset") or 0 + if not isinstance(offset, int): + raise ValueError(f'Expected "offset" param to be an integer but got {offset}') + + length = len(self._get_page_items()) + current_count = offset + length + + return PageInfo(params={"offset": current_count}) + + @classmethod + def build(cls: Type[_BaseModelT], *, response: Response, data: object) -> _BaseModelT: # noqa: ARG003 + return cls.construct( + None, + **{ + **(cast(Mapping[str, Any], data) if is_mapping(data) else {"items": data}), + }, + ) diff --git a/src/kernel/resources/deployments.py b/src/kernel/resources/deployments.py index d54c4ec2..a288798e 100644 --- a/src/kernel/resources/deployments.py +++ b/src/kernel/resources/deployments.py @@ -19,7 +19,8 @@ async_to_streamed_response_wrapper, ) from .._streaming import Stream, AsyncStream -from .._base_client import make_request_options +from ..pagination import SyncOffsetPagination, AsyncOffsetPagination +from .._base_client import AsyncPaginator, make_request_options from ..types.deployment_list_response import DeploymentListResponse from ..types.deployment_create_response import DeploymentCreateResponse from ..types.deployment_follow_response import DeploymentFollowResponse @@ -150,14 +151,16 @@ def retrieve( def list( self, *, - app_name: str | NotGiven = NOT_GIVEN, + app_name: str, + limit: int | NotGiven = NOT_GIVEN, + offset: int | 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, - ) -> DeploymentListResponse: + ) -> SyncOffsetPagination[DeploymentListResponse]: """List deployments. Optionally filter by application name. @@ -165,6 +168,10 @@ def list( Args: app_name: Filter results by application name. + limit: Limit the number of deployments to return. + + offset: Offset the number of deployments to return. + extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -173,16 +180,24 @@ def list( timeout: Override the client-level default timeout for this request, in seconds """ - return self._get( + return self._get_api_list( "/deployments", + page=SyncOffsetPagination[DeploymentListResponse], options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout, - query=maybe_transform({"app_name": app_name}, deployment_list_params.DeploymentListParams), + query=maybe_transform( + { + "app_name": app_name, + "limit": limit, + "offset": offset, + }, + deployment_list_params.DeploymentListParams, + ), ), - cast_to=DeploymentListResponse, + model=DeploymentListResponse, ) def follow( @@ -352,17 +367,19 @@ async def retrieve( cast_to=DeploymentRetrieveResponse, ) - async def list( + def list( self, *, - app_name: str | NotGiven = NOT_GIVEN, + app_name: str, + limit: int | NotGiven = NOT_GIVEN, + offset: int | 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, - ) -> DeploymentListResponse: + ) -> AsyncPaginator[DeploymentListResponse, AsyncOffsetPagination[DeploymentListResponse]]: """List deployments. Optionally filter by application name. @@ -370,6 +387,10 @@ async def list( Args: app_name: Filter results by application name. + limit: Limit the number of deployments to return. + + offset: Offset the number of deployments to return. + extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -378,16 +399,24 @@ async def list( timeout: Override the client-level default timeout for this request, in seconds """ - return await self._get( + return self._get_api_list( "/deployments", + page=AsyncOffsetPagination[DeploymentListResponse], options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout, - query=await async_maybe_transform({"app_name": app_name}, deployment_list_params.DeploymentListParams), + query=maybe_transform( + { + "app_name": app_name, + "limit": limit, + "offset": offset, + }, + deployment_list_params.DeploymentListParams, + ), ), - cast_to=DeploymentListResponse, + model=DeploymentListResponse, ) async def follow( diff --git a/src/kernel/types/deployment_list_params.py b/src/kernel/types/deployment_list_params.py index 05704a19..b39b446a 100644 --- a/src/kernel/types/deployment_list_params.py +++ b/src/kernel/types/deployment_list_params.py @@ -2,11 +2,17 @@ from __future__ import annotations -from typing_extensions import TypedDict +from typing_extensions import Required, TypedDict __all__ = ["DeploymentListParams"] class DeploymentListParams(TypedDict, total=False): - app_name: str + app_name: Required[str] """Filter results by application name.""" + + limit: int + """Limit the number of deployments to return.""" + + offset: int + """Offset the number of deployments to return.""" diff --git a/src/kernel/types/deployment_list_response.py b/src/kernel/types/deployment_list_response.py index ba7759da..d22b0076 100644 --- a/src/kernel/types/deployment_list_response.py +++ b/src/kernel/types/deployment_list_response.py @@ -1,15 +1,15 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. -from typing import Dict, List, Optional +from typing import Dict, Optional from datetime import datetime -from typing_extensions import Literal, TypeAlias +from typing_extensions import Literal from .._models import BaseModel -__all__ = ["DeploymentListResponse", "DeploymentListResponseItem"] +__all__ = ["DeploymentListResponse"] -class DeploymentListResponseItem(BaseModel): +class DeploymentListResponse(BaseModel): id: str """Unique identifier for the deployment""" @@ -33,6 +33,3 @@ class DeploymentListResponseItem(BaseModel): updated_at: Optional[datetime] = None """Timestamp when the deployment was last updated""" - - -DeploymentListResponse: TypeAlias = List[DeploymentListResponseItem] diff --git a/tests/api_resources/test_deployments.py b/tests/api_resources/test_deployments.py index c177978b..97a90a8a 100644 --- a/tests/api_resources/test_deployments.py +++ b/tests/api_resources/test_deployments.py @@ -14,6 +14,7 @@ DeploymentCreateResponse, DeploymentRetrieveResponse, ) +from kernel.pagination import SyncOffsetPagination, AsyncOffsetPagination base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") @@ -116,36 +117,44 @@ def test_path_params_retrieve(self, client: Kernel) -> None: @pytest.mark.skip(reason="Prism tests are disabled") @parametrize def test_method_list(self, client: Kernel) -> None: - deployment = client.deployments.list() - assert_matches_type(DeploymentListResponse, deployment, path=["response"]) + deployment = client.deployments.list( + app_name="app_name", + ) + assert_matches_type(SyncOffsetPagination[DeploymentListResponse], deployment, path=["response"]) @pytest.mark.skip(reason="Prism tests are disabled") @parametrize def test_method_list_with_all_params(self, client: Kernel) -> None: deployment = client.deployments.list( app_name="app_name", + limit=1, + offset=0, ) - assert_matches_type(DeploymentListResponse, deployment, path=["response"]) + assert_matches_type(SyncOffsetPagination[DeploymentListResponse], deployment, path=["response"]) @pytest.mark.skip(reason="Prism tests are disabled") @parametrize def test_raw_response_list(self, client: Kernel) -> None: - response = client.deployments.with_raw_response.list() + response = client.deployments.with_raw_response.list( + app_name="app_name", + ) assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" deployment = response.parse() - assert_matches_type(DeploymentListResponse, deployment, path=["response"]) + assert_matches_type(SyncOffsetPagination[DeploymentListResponse], deployment, path=["response"]) @pytest.mark.skip(reason="Prism tests are disabled") @parametrize def test_streaming_response_list(self, client: Kernel) -> None: - with client.deployments.with_streaming_response.list() as response: + with client.deployments.with_streaming_response.list( + app_name="app_name", + ) as response: assert not response.is_closed assert response.http_request.headers.get("X-Stainless-Lang") == "python" deployment = response.parse() - assert_matches_type(DeploymentListResponse, deployment, path=["response"]) + assert_matches_type(SyncOffsetPagination[DeploymentListResponse], deployment, path=["response"]) assert cast(Any, response.is_closed) is True @@ -300,36 +309,44 @@ async def test_path_params_retrieve(self, async_client: AsyncKernel) -> None: @pytest.mark.skip(reason="Prism tests are disabled") @parametrize async def test_method_list(self, async_client: AsyncKernel) -> None: - deployment = await async_client.deployments.list() - assert_matches_type(DeploymentListResponse, deployment, path=["response"]) + deployment = await async_client.deployments.list( + app_name="app_name", + ) + assert_matches_type(AsyncOffsetPagination[DeploymentListResponse], deployment, path=["response"]) @pytest.mark.skip(reason="Prism tests are disabled") @parametrize async def test_method_list_with_all_params(self, async_client: AsyncKernel) -> None: deployment = await async_client.deployments.list( app_name="app_name", + limit=1, + offset=0, ) - assert_matches_type(DeploymentListResponse, deployment, path=["response"]) + assert_matches_type(AsyncOffsetPagination[DeploymentListResponse], deployment, path=["response"]) @pytest.mark.skip(reason="Prism tests are disabled") @parametrize async def test_raw_response_list(self, async_client: AsyncKernel) -> None: - response = await async_client.deployments.with_raw_response.list() + response = await async_client.deployments.with_raw_response.list( + app_name="app_name", + ) assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" deployment = await response.parse() - assert_matches_type(DeploymentListResponse, deployment, path=["response"]) + assert_matches_type(AsyncOffsetPagination[DeploymentListResponse], deployment, path=["response"]) @pytest.mark.skip(reason="Prism tests are disabled") @parametrize async def test_streaming_response_list(self, async_client: AsyncKernel) -> None: - async with async_client.deployments.with_streaming_response.list() as response: + async with async_client.deployments.with_streaming_response.list( + app_name="app_name", + ) as response: assert not response.is_closed assert response.http_request.headers.get("X-Stainless-Lang") == "python" deployment = await response.parse() - assert_matches_type(DeploymentListResponse, deployment, path=["response"]) + assert_matches_type(AsyncOffsetPagination[DeploymentListResponse], deployment, path=["response"]) assert cast(Any, response.is_closed) is True From f64f55b00b0e0fa19dd2162cd914001381254314 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 5 Sep 2025 15:19:41 +0000 Subject: [PATCH 3/6] feat(api): update API spec with pagination headers --- .stats.yml | 4 ++-- src/kernel/resources/deployments.py | 4 ++-- src/kernel/types/deployment_list_params.py | 4 ++-- tests/api_resources/test_deployments.py | 24 ++++++---------------- 4 files changed, 12 insertions(+), 24 deletions(-) diff --git a/.stats.yml b/.stats.yml index 8943c2f3..9635bb22 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 46 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/kernel%2Fkernel-33f1feaba7bde46bfa36d2fefb5c3bc9512967945bccf78045ad3f64aafc4eb0.yml -openapi_spec_hash: 52a448889d41216d1ca30e8a57115b14 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/kernel%2Fkernel-cb38560915edce03abce2ae3ef5bc745489dbe9b6f80c2b4ff42edf8c2ff276d.yml +openapi_spec_hash: a869194d6c864ba28d79ec0105439c3e config_hash: 1f28d5c3c063f418ebd2799df1e4e781 diff --git a/src/kernel/resources/deployments.py b/src/kernel/resources/deployments.py index a288798e..5c7715de 100644 --- a/src/kernel/resources/deployments.py +++ b/src/kernel/resources/deployments.py @@ -151,7 +151,7 @@ def retrieve( def list( self, *, - app_name: str, + app_name: str | NotGiven = NOT_GIVEN, limit: int | NotGiven = NOT_GIVEN, offset: int | NotGiven = NOT_GIVEN, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. @@ -370,7 +370,7 @@ async def retrieve( def list( self, *, - app_name: str, + app_name: str | NotGiven = NOT_GIVEN, limit: int | NotGiven = NOT_GIVEN, offset: int | NotGiven = NOT_GIVEN, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. diff --git a/src/kernel/types/deployment_list_params.py b/src/kernel/types/deployment_list_params.py index b39b446a..54124da5 100644 --- a/src/kernel/types/deployment_list_params.py +++ b/src/kernel/types/deployment_list_params.py @@ -2,13 +2,13 @@ from __future__ import annotations -from typing_extensions import Required, TypedDict +from typing_extensions import TypedDict __all__ = ["DeploymentListParams"] class DeploymentListParams(TypedDict, total=False): - app_name: Required[str] + app_name: str """Filter results by application name.""" limit: int diff --git a/tests/api_resources/test_deployments.py b/tests/api_resources/test_deployments.py index 97a90a8a..fc5d2991 100644 --- a/tests/api_resources/test_deployments.py +++ b/tests/api_resources/test_deployments.py @@ -117,9 +117,7 @@ def test_path_params_retrieve(self, client: Kernel) -> None: @pytest.mark.skip(reason="Prism tests are disabled") @parametrize def test_method_list(self, client: Kernel) -> None: - deployment = client.deployments.list( - app_name="app_name", - ) + deployment = client.deployments.list() assert_matches_type(SyncOffsetPagination[DeploymentListResponse], deployment, path=["response"]) @pytest.mark.skip(reason="Prism tests are disabled") @@ -135,9 +133,7 @@ def test_method_list_with_all_params(self, client: Kernel) -> None: @pytest.mark.skip(reason="Prism tests are disabled") @parametrize def test_raw_response_list(self, client: Kernel) -> None: - response = client.deployments.with_raw_response.list( - app_name="app_name", - ) + response = client.deployments.with_raw_response.list() assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" @@ -147,9 +143,7 @@ def test_raw_response_list(self, client: Kernel) -> None: @pytest.mark.skip(reason="Prism tests are disabled") @parametrize def test_streaming_response_list(self, client: Kernel) -> None: - with client.deployments.with_streaming_response.list( - app_name="app_name", - ) as response: + with client.deployments.with_streaming_response.list() as response: assert not response.is_closed assert response.http_request.headers.get("X-Stainless-Lang") == "python" @@ -309,9 +303,7 @@ async def test_path_params_retrieve(self, async_client: AsyncKernel) -> None: @pytest.mark.skip(reason="Prism tests are disabled") @parametrize async def test_method_list(self, async_client: AsyncKernel) -> None: - deployment = await async_client.deployments.list( - app_name="app_name", - ) + deployment = await async_client.deployments.list() assert_matches_type(AsyncOffsetPagination[DeploymentListResponse], deployment, path=["response"]) @pytest.mark.skip(reason="Prism tests are disabled") @@ -327,9 +319,7 @@ async def test_method_list_with_all_params(self, async_client: AsyncKernel) -> N @pytest.mark.skip(reason="Prism tests are disabled") @parametrize async def test_raw_response_list(self, async_client: AsyncKernel) -> None: - response = await async_client.deployments.with_raw_response.list( - app_name="app_name", - ) + response = await async_client.deployments.with_raw_response.list() assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" @@ -339,9 +329,7 @@ async def test_raw_response_list(self, async_client: AsyncKernel) -> None: @pytest.mark.skip(reason="Prism tests are disabled") @parametrize async def test_streaming_response_list(self, async_client: AsyncKernel) -> None: - async with async_client.deployments.with_streaming_response.list( - app_name="app_name", - ) as response: + async with async_client.deployments.with_streaming_response.list() as response: assert not response.is_closed assert response.http_request.headers.get("X-Stainless-Lang") == "python" From 5f2329f8712b9d1865cc95dcde06834fe65622ee Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 5 Sep 2025 18:00:37 +0000 Subject: [PATCH 4/6] feat(api): pagination properties added to response (has_more, next_offset) --- .stats.yml | 2 +- README.md | 4 ++++ src/kernel/pagination.py | 42 +++++++++++++++++++++++++++++++--------- 3 files changed, 38 insertions(+), 10 deletions(-) diff --git a/.stats.yml b/.stats.yml index 9635bb22..7fb3d31e 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 46 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/kernel%2Fkernel-cb38560915edce03abce2ae3ef5bc745489dbe9b6f80c2b4ff42edf8c2ff276d.yml openapi_spec_hash: a869194d6c864ba28d79ec0105439c3e -config_hash: 1f28d5c3c063f418ebd2799df1e4e781 +config_hash: ed56f95781ec9b2e73c97e1a66606071 diff --git a/README.md b/README.md index c5b5bf7c..beec3f01 100644 --- a/README.md +++ b/README.md @@ -182,6 +182,10 @@ first_page = await client.deployments.list( app_name="YOUR_APP", limit=2, ) + +print( + f"the current start offset for this page: {first_page.next_offset}" +) # => "the current start offset for this page: 1" for deployment in first_page.items: print(deployment.id) diff --git a/src/kernel/pagination.py b/src/kernel/pagination.py index 2002d5eb..cdf83c2f 100644 --- a/src/kernel/pagination.py +++ b/src/kernel/pagination.py @@ -5,7 +5,7 @@ from httpx import Response -from ._utils import is_mapping +from ._utils import is_mapping, maybe_coerce_boolean, maybe_coerce_integer from ._models import BaseModel from ._base_client import BasePage, PageInfo, BaseSyncPage, BaseAsyncPage @@ -18,6 +18,8 @@ class SyncOffsetPagination(BaseSyncPage[_T], BasePage[_T], Generic[_T]): items: List[_T] + has_more: Optional[bool] = None + next_offset: Optional[int] = None @override def _get_page_items(self) -> List[_T]: @@ -26,14 +28,22 @@ def _get_page_items(self) -> List[_T]: return [] return items + @override + def has_next_page(self) -> bool: + has_more = self.has_more + if has_more is not None and has_more is False: + return False + + return super().has_next_page() + @override def next_page_info(self) -> Optional[PageInfo]: - offset = self._options.params.get("offset") or 0 - if not isinstance(offset, int): - raise ValueError(f'Expected "offset" param to be an integer but got {offset}') + next_offset = self.next_offset + if next_offset is None: + return None # type: ignore[unreachable] length = len(self._get_page_items()) - current_count = offset + length + current_count = next_offset + length return PageInfo(params={"offset": current_count}) @@ -43,12 +53,16 @@ def build(cls: Type[_BaseModelT], *, response: Response, data: object) -> _BaseM None, **{ **(cast(Mapping[str, Any], data) if is_mapping(data) else {"items": data}), + "has_more": maybe_coerce_boolean(response.headers.get("X-Has-More")), + "next_offset": maybe_coerce_integer(response.headers.get("X-Next-Offset")), }, ) class AsyncOffsetPagination(BaseAsyncPage[_T], BasePage[_T], Generic[_T]): items: List[_T] + has_more: Optional[bool] = None + next_offset: Optional[int] = None @override def _get_page_items(self) -> List[_T]: @@ -57,14 +71,22 @@ def _get_page_items(self) -> List[_T]: return [] return items + @override + def has_next_page(self) -> bool: + has_more = self.has_more + if has_more is not None and has_more is False: + return False + + return super().has_next_page() + @override def next_page_info(self) -> Optional[PageInfo]: - offset = self._options.params.get("offset") or 0 - if not isinstance(offset, int): - raise ValueError(f'Expected "offset" param to be an integer but got {offset}') + next_offset = self.next_offset + if next_offset is None: + return None # type: ignore[unreachable] length = len(self._get_page_items()) - current_count = offset + length + current_count = next_offset + length return PageInfo(params={"offset": current_count}) @@ -74,5 +96,7 @@ def build(cls: Type[_BaseModelT], *, response: Response, data: object) -> _BaseM None, **{ **(cast(Mapping[str, Any], data) if is_mapping(data) else {"items": data}), + "has_more": maybe_coerce_boolean(response.headers.get("X-Has-More")), + "next_offset": maybe_coerce_integer(response.headers.get("X-Next-Offset")), }, ) From cd90a498d24b1f4490583bec64e5e670eb725197 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sat, 6 Sep 2025 03:56:02 +0000 Subject: [PATCH 5/6] chore(tests): simplify `get_platform` test `nest_asyncio` is archived and broken on some platforms so it's not worth keeping in our test suite. --- pyproject.toml | 1 - requirements-dev.lock | 1 - tests/test_client.py | 53 +++++-------------------------------------- 3 files changed, 6 insertions(+), 49 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 2acfd24c..0486b41f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,7 +56,6 @@ dev-dependencies = [ "dirty-equals>=0.6.0", "importlib-metadata>=6.7.0", "rich>=13.7.1", - "nest_asyncio==1.6.0", "pytest-xdist>=3.6.1", ] diff --git a/requirements-dev.lock b/requirements-dev.lock index 55681a90..56d0accb 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -75,7 +75,6 @@ multidict==6.4.4 mypy==1.14.1 mypy-extensions==1.0.0 # via mypy -nest-asyncio==1.6.0 nodeenv==1.8.0 # via pyright nox==2023.4.22 diff --git a/tests/test_client.py b/tests/test_client.py index d5a17848..74ffb05e 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -6,13 +6,10 @@ import os import sys import json -import time import asyncio import inspect -import subprocess import tracemalloc from typing import Any, Union, cast -from textwrap import dedent from unittest import mock from typing_extensions import Literal @@ -23,14 +20,17 @@ from kernel import Kernel, AsyncKernel, APIResponseValidationError from kernel._types import Omit +from kernel._utils import asyncify from kernel._models import BaseModel, FinalRequestOptions from kernel._exceptions import KernelError, APIStatusError, APITimeoutError, APIResponseValidationError from kernel._base_client import ( DEFAULT_TIMEOUT, HTTPX_DEFAULT_TIMEOUT, BaseClient, + OtherPlatform, DefaultHttpxClient, DefaultAsyncHttpxClient, + get_platform, make_request_options, ) @@ -1645,50 +1645,9 @@ def retry_handler(_request: httpx.Request) -> httpx.Response: assert response.http_request.headers.get("x-stainless-retry-count") == "42" - def test_get_platform(self) -> None: - # A previous implementation of asyncify could leave threads unterminated when - # used with nest_asyncio. - # - # Since nest_asyncio.apply() is global and cannot be un-applied, this - # test is run in a separate process to avoid affecting other tests. - test_code = dedent(""" - import asyncio - import nest_asyncio - import threading - - from kernel._utils import asyncify - from kernel._base_client import get_platform - - async def test_main() -> None: - result = await asyncify(get_platform)() - print(result) - for thread in threading.enumerate(): - print(thread.name) - - nest_asyncio.apply() - asyncio.run(test_main()) - """) - with subprocess.Popen( - [sys.executable, "-c", test_code], - text=True, - ) as process: - timeout = 10 # seconds - - start_time = time.monotonic() - while True: - return_code = process.poll() - if return_code is not None: - if return_code != 0: - raise AssertionError("calling get_platform using asyncify resulted in a non-zero exit code") - - # success - break - - if time.monotonic() - start_time > timeout: - process.kill() - raise AssertionError("calling get_platform using asyncify resulted in a hung process") - - time.sleep(0.1) + async def test_get_platform(self) -> None: + platform = await asyncify(get_platform)() + assert isinstance(platform, (str, OtherPlatform)) async def test_proxy_environment_variables(self, monkeypatch: pytest.MonkeyPatch) -> None: # Test that the proxy environment variables are set correctly From 0287597eff74b9ba512305a74f00b328d3f61c46 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sat, 6 Sep 2025 03:56:18 +0000 Subject: [PATCH 6/6] release: 0.11.1 --- .release-please-manifest.json | 2 +- CHANGELOG.md | 16 ++++++++++++++++ pyproject.toml | 2 +- src/kernel/_version.py | 2 +- 4 files changed, 19 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index f7014c35..e82003f4 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.11.0" + ".": "0.11.1" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index a6a6cec2..5d1ad48f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## 0.11.1 (2025-09-06) + +Full Changelog: [v0.11.0...v0.11.1](https://github.com/onkernel/kernel-python-sdk/compare/v0.11.0...v0.11.1) + +### Features + +* **api:** add pagination to the deployments endpoint ([e5838f5](https://github.com/onkernel/kernel-python-sdk/commit/e5838f51b9af325700b23d55ff2bb11b6ff3306e)) +* **api:** pagination properties added to response (has_more, next_offset) ([5f2329f](https://github.com/onkernel/kernel-python-sdk/commit/5f2329f8712b9d1865cc95dcde06834fe65622ee)) +* **api:** update API spec with pagination headers ([f64f55b](https://github.com/onkernel/kernel-python-sdk/commit/f64f55b00b0e0fa19dd2162cd914001381254314)) + + +### Chores + +* **internal:** move mypy configurations to `pyproject.toml` file ([4818d2d](https://github.com/onkernel/kernel-python-sdk/commit/4818d2d6084684529935c8e6b9b109516a1de373)) +* **tests:** simplify `get_platform` test ([cd90a49](https://github.com/onkernel/kernel-python-sdk/commit/cd90a498d24b1f4490583bec64e5e670eb725197)) + ## 0.11.0 (2025-09-04) Full Changelog: [v0.10.0...v0.11.0](https://github.com/onkernel/kernel-python-sdk/compare/v0.10.0...v0.11.0) diff --git a/pyproject.toml b/pyproject.toml index 0486b41f..5533264c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "kernel" -version = "0.11.0" +version = "0.11.1" description = "The official Python library for the kernel API" dynamic = ["readme"] license = "Apache-2.0" diff --git a/src/kernel/_version.py b/src/kernel/_version.py index 1caec76c..0e02001c 100644 --- a/src/kernel/_version.py +++ b/src/kernel/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "kernel" -__version__ = "0.11.0" # x-release-please-version +__version__ = "0.11.1" # x-release-please-version