From dec07b5258bc057375c8412c76f0a7f29f12b723 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sat, 30 Aug 2025 03:17:05 +0000 Subject: [PATCH 1/6] chore(internal): add Sequence related utils --- src/runloop_api_client/_types.py | 36 ++++++++++++++++++++++- src/runloop_api_client/_utils/__init__.py | 1 + src/runloop_api_client/_utils/_typing.py | 5 ++++ tests/utils.py | 10 ++++++- 4 files changed, 50 insertions(+), 2 deletions(-) diff --git a/src/runloop_api_client/_types.py b/src/runloop_api_client/_types.py index 40ccb19ed..46042bd52 100644 --- a/src/runloop_api_client/_types.py +++ b/src/runloop_api_client/_types.py @@ -13,10 +13,21 @@ Mapping, TypeVar, Callable, + Iterator, Optional, Sequence, ) -from typing_extensions import Set, Literal, Protocol, TypeAlias, TypedDict, override, runtime_checkable +from typing_extensions import ( + Set, + Literal, + Protocol, + TypeAlias, + TypedDict, + SupportsIndex, + overload, + override, + runtime_checkable, +) import httpx import pydantic @@ -217,3 +228,26 @@ class _GenericAlias(Protocol): class HttpxSendArgs(TypedDict, total=False): auth: httpx.Auth follow_redirects: bool + + +_T_co = TypeVar("_T_co", covariant=True) + + +if TYPE_CHECKING: + # This works because str.__contains__ does not accept object (either in typeshed or at runtime) + # https://github.com/hauntsaninja/useful_types/blob/5e9710f3875107d068e7679fd7fec9cfab0eff3b/useful_types/__init__.py#L285 + class SequenceNotStr(Protocol[_T_co]): + @overload + def __getitem__(self, index: SupportsIndex, /) -> _T_co: ... + @overload + def __getitem__(self, index: slice, /) -> Sequence[_T_co]: ... + def __contains__(self, value: object, /) -> bool: ... + def __len__(self) -> int: ... + def __iter__(self) -> Iterator[_T_co]: ... + def index(self, value: Any, start: int = 0, stop: int = ..., /) -> int: ... + def count(self, value: Any, /) -> int: ... + def __reversed__(self) -> Iterator[_T_co]: ... +else: + # just point this to a normal `Sequence` at runtime to avoid having to special case + # deserializing our custom sequence type + SequenceNotStr = Sequence diff --git a/src/runloop_api_client/_utils/__init__.py b/src/runloop_api_client/_utils/__init__.py index d4fda26f3..ca547ce52 100644 --- a/src/runloop_api_client/_utils/__init__.py +++ b/src/runloop_api_client/_utils/__init__.py @@ -38,6 +38,7 @@ extract_type_arg as extract_type_arg, is_iterable_type as is_iterable_type, is_required_type as is_required_type, + is_sequence_type as is_sequence_type, is_annotated_type as is_annotated_type, is_type_alias_type as is_type_alias_type, strip_annotated_type as strip_annotated_type, diff --git a/src/runloop_api_client/_utils/_typing.py b/src/runloop_api_client/_utils/_typing.py index 1bac9542e..845cd6b28 100644 --- a/src/runloop_api_client/_utils/_typing.py +++ b/src/runloop_api_client/_utils/_typing.py @@ -26,6 +26,11 @@ def is_list_type(typ: type) -> bool: return (get_origin(typ) or typ) == list +def is_sequence_type(typ: type) -> bool: + origin = get_origin(typ) or typ + return origin == typing_extensions.Sequence or origin == typing.Sequence or origin == _c_abc.Sequence + + def is_iterable_type(typ: type) -> bool: """If the given type is `typing.Iterable[T]`""" origin = get_origin(typ) or typ diff --git a/tests/utils.py b/tests/utils.py index 9ae192db0..44d940e25 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -4,7 +4,7 @@ import inspect import traceback import contextlib -from typing import Any, TypeVar, Iterator, cast +from typing import Any, TypeVar, Iterator, Sequence, cast from datetime import date, datetime from typing_extensions import Literal, get_args, get_origin, assert_type @@ -15,6 +15,7 @@ is_list_type, is_union_type, extract_type_arg, + is_sequence_type, is_annotated_type, is_type_alias_type, ) @@ -71,6 +72,13 @@ def assert_matches_type( if is_list_type(type_): return _assert_list_type(type_, value) + if is_sequence_type(type_): + assert isinstance(value, Sequence) + inner_type = get_args(type_)[0] + for entry in value: # type: ignore + assert_type(inner_type, entry) # type: ignore + return + if origin == str: assert isinstance(value, str) elif origin == int: From 9c3bbcdfdaedcf60d9829bc3cad83f89c9add98a Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 3 Sep 2025 02:56:08 +0000 Subject: [PATCH 2/6] feat(types): replace List[str] with SequenceNotStr in params --- src/runloop_api_client/_utils/_transform.py | 6 ++++ .../resources/benchmarks/benchmarks.py | 28 +++++++++---------- .../resources/blueprints.py | 12 ++++---- .../resources/scenarios/scenarios.py | 20 ++++++------- .../types/benchmark_create_params.py | 10 ++++--- .../types/benchmark_update_params.py | 10 ++++--- .../types/blueprint_create_params.py | 7 +++-- .../types/blueprint_preview_params.py | 7 +++-- .../devboxes/code_action_context_param.py | 5 ++-- .../types/scenario_create_params.py | 7 +++-- .../types/scenario_update_params.py | 7 +++-- .../types/shared_params/launch_parameters.py | 7 +++-- 12 files changed, 71 insertions(+), 55 deletions(-) diff --git a/src/runloop_api_client/_utils/_transform.py b/src/runloop_api_client/_utils/_transform.py index b0cc20a73..f0bcefd49 100644 --- a/src/runloop_api_client/_utils/_transform.py +++ b/src/runloop_api_client/_utils/_transform.py @@ -16,6 +16,7 @@ lru_cache, is_mapping, is_iterable, + is_sequence, ) from .._files import is_base64_file_input from ._typing import ( @@ -24,6 +25,7 @@ extract_type_arg, is_iterable_type, is_required_type, + is_sequence_type, is_annotated_type, strip_annotated_type, ) @@ -184,6 +186,8 @@ def _transform_recursive( (is_list_type(stripped_type) and is_list(data)) # Iterable[T] or (is_iterable_type(stripped_type) and is_iterable(data) and not isinstance(data, str)) + # Sequence[T] + or (is_sequence_type(stripped_type) and is_sequence(data) and not isinstance(data, str)) ): # dicts are technically iterable, but it is an iterable on the keys of the dict and is not usually # intended as an iterable, so we don't transform it. @@ -346,6 +350,8 @@ async def _async_transform_recursive( (is_list_type(stripped_type) and is_list(data)) # Iterable[T] or (is_iterable_type(stripped_type) and is_iterable(data) and not isinstance(data, str)) + # Sequence[T] + or (is_sequence_type(stripped_type) and is_sequence(data) and not isinstance(data, str)) ): # dicts are technically iterable, but it is an iterable on the keys of the dict and is not usually # intended as an iterable, so we don't transform it. diff --git a/src/runloop_api_client/resources/benchmarks/benchmarks.py b/src/runloop_api_client/resources/benchmarks/benchmarks.py index 38b84a5fb..12de4d42a 100644 --- a/src/runloop_api_client/resources/benchmarks/benchmarks.py +++ b/src/runloop_api_client/resources/benchmarks/benchmarks.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Dict, List, Optional +from typing import Dict, Optional import httpx @@ -22,7 +22,7 @@ benchmark_definitions_params, benchmark_list_public_params, ) -from ..._types import NOT_GIVEN, Body, Query, Headers, NotGiven +from ..._types import NOT_GIVEN, Body, Query, Headers, NotGiven, SequenceNotStr from ..._utils import maybe_transform, async_maybe_transform from ..._compat import cached_property from ..._resource import SyncAPIResource, AsyncAPIResource @@ -73,9 +73,9 @@ def create( attribution: Optional[str] | NotGiven = NOT_GIVEN, description: Optional[str] | NotGiven = NOT_GIVEN, metadata: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, - required_environment_variables: Optional[List[str]] | NotGiven = NOT_GIVEN, - required_secret_names: List[str] | NotGiven = NOT_GIVEN, - scenario_ids: Optional[List[str]] | NotGiven = NOT_GIVEN, + required_environment_variables: Optional[SequenceNotStr[str]] | NotGiven = NOT_GIVEN, + required_secret_names: SequenceNotStr[str] | NotGiven = NOT_GIVEN, + scenario_ids: Optional[SequenceNotStr[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, @@ -180,9 +180,9 @@ def update( attribution: Optional[str] | NotGiven = NOT_GIVEN, description: Optional[str] | NotGiven = NOT_GIVEN, metadata: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, - required_environment_variables: Optional[List[str]] | NotGiven = NOT_GIVEN, - required_secret_names: List[str] | NotGiven = NOT_GIVEN, - scenario_ids: Optional[List[str]] | NotGiven = NOT_GIVEN, + required_environment_variables: Optional[SequenceNotStr[str]] | NotGiven = NOT_GIVEN, + required_secret_names: SequenceNotStr[str] | NotGiven = NOT_GIVEN, + scenario_ids: Optional[SequenceNotStr[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, @@ -481,9 +481,9 @@ async def create( attribution: Optional[str] | NotGiven = NOT_GIVEN, description: Optional[str] | NotGiven = NOT_GIVEN, metadata: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, - required_environment_variables: Optional[List[str]] | NotGiven = NOT_GIVEN, - required_secret_names: List[str] | NotGiven = NOT_GIVEN, - scenario_ids: Optional[List[str]] | NotGiven = NOT_GIVEN, + required_environment_variables: Optional[SequenceNotStr[str]] | NotGiven = NOT_GIVEN, + required_secret_names: SequenceNotStr[str] | NotGiven = NOT_GIVEN, + scenario_ids: Optional[SequenceNotStr[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, @@ -588,9 +588,9 @@ async def update( attribution: Optional[str] | NotGiven = NOT_GIVEN, description: Optional[str] | NotGiven = NOT_GIVEN, metadata: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, - required_environment_variables: Optional[List[str]] | NotGiven = NOT_GIVEN, - required_secret_names: List[str] | NotGiven = NOT_GIVEN, - scenario_ids: Optional[List[str]] | NotGiven = NOT_GIVEN, + required_environment_variables: Optional[SequenceNotStr[str]] | NotGiven = NOT_GIVEN, + required_secret_names: SequenceNotStr[str] | NotGiven = NOT_GIVEN, + scenario_ids: Optional[SequenceNotStr[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, diff --git a/src/runloop_api_client/resources/blueprints.py b/src/runloop_api_client/resources/blueprints.py index 1785d512a..b81e685c0 100644 --- a/src/runloop_api_client/resources/blueprints.py +++ b/src/runloop_api_client/resources/blueprints.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Dict, List, Iterable, Optional, TypedDict +from typing import Dict, Iterable, Optional, TypedDict import httpx @@ -12,7 +12,7 @@ blueprint_preview_params, blueprint_list_public_params, ) -from .._types import NOT_GIVEN, Body, Query, Headers, NotGiven +from .._types import NOT_GIVEN, Body, Query, Headers, NotGiven, SequenceNotStr from .._utils import maybe_transform, async_maybe_transform from .._compat import cached_property from .._resource import SyncAPIResource, AsyncAPIResource @@ -79,7 +79,7 @@ def create( launch_parameters: Optional[LaunchParameters] | NotGiven = NOT_GIVEN, metadata: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, services: Optional[Iterable[blueprint_create_params.Service]] | NotGiven = NOT_GIVEN, - system_setup_commands: Optional[List[str]] | NotGiven = NOT_GIVEN, + system_setup_commands: Optional[SequenceNotStr[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, @@ -478,7 +478,7 @@ def preview( launch_parameters: Optional[LaunchParameters] | NotGiven = NOT_GIVEN, metadata: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, services: Optional[Iterable[blueprint_preview_params.Service]] | NotGiven = NOT_GIVEN, - system_setup_commands: Optional[List[str]] | NotGiven = NOT_GIVEN, + system_setup_commands: Optional[SequenceNotStr[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, @@ -582,7 +582,7 @@ async def create( launch_parameters: Optional[LaunchParameters] | NotGiven = NOT_GIVEN, metadata: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, services: Optional[Iterable[blueprint_create_params.Service]] | NotGiven = NOT_GIVEN, - system_setup_commands: Optional[List[str]] | NotGiven = NOT_GIVEN, + system_setup_commands: Optional[SequenceNotStr[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, @@ -981,7 +981,7 @@ async def preview( launch_parameters: Optional[LaunchParameters] | NotGiven = NOT_GIVEN, metadata: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, services: Optional[Iterable[blueprint_preview_params.Service]] | NotGiven = NOT_GIVEN, - system_setup_commands: Optional[List[str]] | NotGiven = NOT_GIVEN, + system_setup_commands: Optional[SequenceNotStr[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, diff --git a/src/runloop_api_client/resources/scenarios/scenarios.py b/src/runloop_api_client/resources/scenarios/scenarios.py index 65232bc46..0f7cfa79c 100644 --- a/src/runloop_api_client/resources/scenarios/scenarios.py +++ b/src/runloop_api_client/resources/scenarios/scenarios.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Dict, List, Optional +from typing import Dict, Optional from typing_extensions import Literal import httpx @@ -30,7 +30,7 @@ ScorersResourceWithStreamingResponse, AsyncScorersResourceWithStreamingResponse, ) -from ..._types import NOT_GIVEN, Body, Query, Headers, NotGiven +from ..._types import NOT_GIVEN, Body, Query, Headers, NotGiven, SequenceNotStr from ..._utils import maybe_transform, async_maybe_transform from ..._compat import cached_property from ..._resource import SyncAPIResource, AsyncAPIResource @@ -92,8 +92,8 @@ def create( environment_parameters: Optional[ScenarioEnvironmentParam] | NotGiven = NOT_GIVEN, metadata: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, reference_output: Optional[str] | NotGiven = NOT_GIVEN, - required_environment_variables: Optional[List[str]] | NotGiven = NOT_GIVEN, - required_secret_names: Optional[List[str]] | NotGiven = NOT_GIVEN, + required_environment_variables: Optional[SequenceNotStr[str]] | NotGiven = NOT_GIVEN, + required_secret_names: Optional[SequenceNotStr[str]] | NotGiven = NOT_GIVEN, validation_type: Optional[Literal["FORWARD", "REVERSE", "EVALUATION"]] | 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. @@ -209,8 +209,8 @@ def update( metadata: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, name: Optional[str] | NotGiven = NOT_GIVEN, reference_output: Optional[str] | NotGiven = NOT_GIVEN, - required_environment_variables: Optional[List[str]] | NotGiven = NOT_GIVEN, - required_secret_names: Optional[List[str]] | NotGiven = NOT_GIVEN, + required_environment_variables: Optional[SequenceNotStr[str]] | NotGiven = NOT_GIVEN, + required_secret_names: Optional[SequenceNotStr[str]] | NotGiven = NOT_GIVEN, scoring_contract: Optional[ScoringContractUpdateParam] | NotGiven = NOT_GIVEN, validation_type: Optional[Literal["FORWARD", "REVERSE", "EVALUATION"]] | NotGiven = NOT_GIVEN, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. @@ -549,8 +549,8 @@ async def create( environment_parameters: Optional[ScenarioEnvironmentParam] | NotGiven = NOT_GIVEN, metadata: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, reference_output: Optional[str] | NotGiven = NOT_GIVEN, - required_environment_variables: Optional[List[str]] | NotGiven = NOT_GIVEN, - required_secret_names: Optional[List[str]] | NotGiven = NOT_GIVEN, + required_environment_variables: Optional[SequenceNotStr[str]] | NotGiven = NOT_GIVEN, + required_secret_names: Optional[SequenceNotStr[str]] | NotGiven = NOT_GIVEN, validation_type: Optional[Literal["FORWARD", "REVERSE", "EVALUATION"]] | 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. @@ -666,8 +666,8 @@ async def update( metadata: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, name: Optional[str] | NotGiven = NOT_GIVEN, reference_output: Optional[str] | NotGiven = NOT_GIVEN, - required_environment_variables: Optional[List[str]] | NotGiven = NOT_GIVEN, - required_secret_names: Optional[List[str]] | NotGiven = NOT_GIVEN, + required_environment_variables: Optional[SequenceNotStr[str]] | NotGiven = NOT_GIVEN, + required_secret_names: Optional[SequenceNotStr[str]] | NotGiven = NOT_GIVEN, scoring_contract: Optional[ScoringContractUpdateParam] | NotGiven = NOT_GIVEN, validation_type: Optional[Literal["FORWARD", "REVERSE", "EVALUATION"]] | 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/runloop_api_client/types/benchmark_create_params.py b/src/runloop_api_client/types/benchmark_create_params.py index c43d5a98c..1aec35f5f 100644 --- a/src/runloop_api_client/types/benchmark_create_params.py +++ b/src/runloop_api_client/types/benchmark_create_params.py @@ -2,9 +2,11 @@ from __future__ import annotations -from typing import Dict, List, Optional +from typing import Dict, Optional from typing_extensions import Required, TypedDict +from .._types import SequenceNotStr + __all__ = ["BenchmarkCreateParams"] @@ -21,18 +23,18 @@ class BenchmarkCreateParams(TypedDict, total=False): metadata: Optional[Dict[str, str]] """User defined metadata to attach to the benchmark for organization.""" - required_environment_variables: Optional[List[str]] + required_environment_variables: Optional[SequenceNotStr[str]] """Environment variables required to run the benchmark. If any required variables are not supplied, the benchmark will fail to start """ - required_secret_names: List[str] + required_secret_names: SequenceNotStr[str] """ Secrets required to run the benchmark with (environment variable name will be mapped to the your user secret by name). If any of these secrets are not provided or the mapping is incorrect, the benchmark will fail to start. """ - scenario_ids: Optional[List[str]] + scenario_ids: Optional[SequenceNotStr[str]] """The Scenario IDs that make up the Benchmark.""" diff --git a/src/runloop_api_client/types/benchmark_update_params.py b/src/runloop_api_client/types/benchmark_update_params.py index ef26cd42f..1291e3e38 100644 --- a/src/runloop_api_client/types/benchmark_update_params.py +++ b/src/runloop_api_client/types/benchmark_update_params.py @@ -2,9 +2,11 @@ from __future__ import annotations -from typing import Dict, List, Optional +from typing import Dict, Optional from typing_extensions import Required, TypedDict +from .._types import SequenceNotStr + __all__ = ["BenchmarkUpdateParams"] @@ -21,18 +23,18 @@ class BenchmarkUpdateParams(TypedDict, total=False): metadata: Optional[Dict[str, str]] """User defined metadata to attach to the benchmark for organization.""" - required_environment_variables: Optional[List[str]] + required_environment_variables: Optional[SequenceNotStr[str]] """Environment variables required to run the benchmark. If any required variables are not supplied, the benchmark will fail to start """ - required_secret_names: List[str] + required_secret_names: SequenceNotStr[str] """ Secrets required to run the benchmark with (environment variable name will be mapped to the your user secret by name). If any of these secrets are not provided or the mapping is incorrect, the benchmark will fail to start. """ - scenario_ids: Optional[List[str]] + scenario_ids: Optional[SequenceNotStr[str]] """The Scenario IDs that make up the Benchmark.""" diff --git a/src/runloop_api_client/types/blueprint_create_params.py b/src/runloop_api_client/types/blueprint_create_params.py index 5931a8ce4..fb3e2ee49 100644 --- a/src/runloop_api_client/types/blueprint_create_params.py +++ b/src/runloop_api_client/types/blueprint_create_params.py @@ -2,9 +2,10 @@ from __future__ import annotations -from typing import Dict, List, Iterable, Optional +from typing import Dict, Iterable, Optional from typing_extensions import Required, TypedDict +from .._types import SequenceNotStr from .shared_params.launch_parameters import LaunchParameters from .shared_params.code_mount_parameters import CodeMountParameters @@ -43,7 +44,7 @@ class BlueprintCreateParams(TypedDict, total=False): performance. """ - system_setup_commands: Optional[List[str]] + system_setup_commands: Optional[SequenceNotStr[str]] """A list of commands to run to set up your system.""" @@ -71,7 +72,7 @@ class Service(TypedDict, total=False): options: Optional[str] """Additional Docker container create options.""" - port_mappings: Optional[List[str]] + port_mappings: Optional[SequenceNotStr[str]] """The port mappings of the container service. Port mappings are in the format of :. diff --git a/src/runloop_api_client/types/blueprint_preview_params.py b/src/runloop_api_client/types/blueprint_preview_params.py index 41c0d9e63..eb3ba757b 100644 --- a/src/runloop_api_client/types/blueprint_preview_params.py +++ b/src/runloop_api_client/types/blueprint_preview_params.py @@ -2,9 +2,10 @@ from __future__ import annotations -from typing import Dict, List, Iterable, Optional +from typing import Dict, Iterable, Optional from typing_extensions import Required, TypedDict +from .._types import SequenceNotStr from .shared_params.launch_parameters import LaunchParameters from .shared_params.code_mount_parameters import CodeMountParameters @@ -43,7 +44,7 @@ class BlueprintPreviewParams(TypedDict, total=False): performance. """ - system_setup_commands: Optional[List[str]] + system_setup_commands: Optional[SequenceNotStr[str]] """A list of commands to run to set up your system.""" @@ -71,7 +72,7 @@ class Service(TypedDict, total=False): options: Optional[str] """Additional Docker container create options.""" - port_mappings: Optional[List[str]] + port_mappings: Optional[SequenceNotStr[str]] """The port mappings of the container service. Port mappings are in the format of :. diff --git a/src/runloop_api_client/types/devboxes/code_action_context_param.py b/src/runloop_api_client/types/devboxes/code_action_context_param.py index 118601394..94238f551 100644 --- a/src/runloop_api_client/types/devboxes/code_action_context_param.py +++ b/src/runloop_api_client/types/devboxes/code_action_context_param.py @@ -2,9 +2,10 @@ from __future__ import annotations -from typing import Dict, List, Union, Iterable +from typing import Dict, Union, Iterable from typing_extensions import Required, Annotated, TypeAlias, TypedDict +from ..._types import SequenceNotStr from ..._utils import PropertyInfo from .code_action_kind import CodeActionKind from .diagnostic_param import DiagnosticParam @@ -23,7 +24,7 @@ class CodeActionContextParamTyped(TypedDict, total=False): resource. The primary parameter to compute code actions is the provided range. """ - only: List[CodeActionKind] + only: SequenceNotStr[CodeActionKind] """Requested kind of actions to return. Actions not of this kind are filtered out by the client before being shown. So diff --git a/src/runloop_api_client/types/scenario_create_params.py b/src/runloop_api_client/types/scenario_create_params.py index 294c1645d..b8f6910e0 100644 --- a/src/runloop_api_client/types/scenario_create_params.py +++ b/src/runloop_api_client/types/scenario_create_params.py @@ -2,9 +2,10 @@ from __future__ import annotations -from typing import Dict, List, Optional +from typing import Dict, Optional from typing_extensions import Literal, Required, TypedDict +from .._types import SequenceNotStr from .input_context_param import InputContextParam from .scoring_contract_param import ScoringContractParam from .scenario_environment_param import ScenarioEnvironmentParam @@ -35,13 +36,13 @@ class ScenarioCreateParams(TypedDict, total=False): apply to the environment. """ - required_environment_variables: Optional[List[str]] + required_environment_variables: Optional[SequenceNotStr[str]] """Environment variables required to run the scenario. If these variables are not provided, the scenario will fail to start. """ - required_secret_names: Optional[List[str]] + required_secret_names: Optional[SequenceNotStr[str]] """ Secrets required to run the scenario (user secret name to scenario required secret name). If these secrets are not provided or the mapping is incorrect, the diff --git a/src/runloop_api_client/types/scenario_update_params.py b/src/runloop_api_client/types/scenario_update_params.py index d92641215..4dcb6ee5c 100644 --- a/src/runloop_api_client/types/scenario_update_params.py +++ b/src/runloop_api_client/types/scenario_update_params.py @@ -2,9 +2,10 @@ from __future__ import annotations -from typing import Dict, List, Optional +from typing import Dict, Optional from typing_extensions import Literal, TypedDict +from .._types import SequenceNotStr from .input_context_update_param import InputContextUpdateParam from .scenario_environment_param import ScenarioEnvironmentParam from .scoring_contract_update_param import ScoringContractUpdateParam @@ -32,10 +33,10 @@ class ScenarioUpdateParams(TypedDict, total=False): apply to the environment. """ - required_environment_variables: Optional[List[str]] + required_environment_variables: Optional[SequenceNotStr[str]] """Environment variables required to run the scenario.""" - required_secret_names: Optional[List[str]] + required_secret_names: Optional[SequenceNotStr[str]] """Secrets required to run the scenario.""" scoring_contract: Optional[ScoringContractUpdateParam] diff --git a/src/runloop_api_client/types/shared_params/launch_parameters.py b/src/runloop_api_client/types/shared_params/launch_parameters.py index 000d52c11..4fd9ef5a6 100644 --- a/src/runloop_api_client/types/shared_params/launch_parameters.py +++ b/src/runloop_api_client/types/shared_params/launch_parameters.py @@ -2,9 +2,10 @@ from __future__ import annotations -from typing import List, Iterable, Optional +from typing import Iterable, Optional from typing_extensions import Literal, Required, TypedDict +from ..._types import SequenceNotStr from .after_idle import AfterIdle __all__ = ["LaunchParameters", "UserParameters"] @@ -59,10 +60,10 @@ class LaunchParameters(TypedDict, total=False): Default is 1 hour. """ - launch_commands: Optional[List[str]] + launch_commands: Optional[SequenceNotStr[str]] """Set of commands to be run at launch time, before the entrypoint process is run.""" - required_services: Optional[List[str]] + required_services: Optional[SequenceNotStr[str]] """A list of ContainerizedService names to be started when a Devbox is created. A valid ContainerizedService must be specified in Blueprint to be started. From 3ea66e12556b91517cbd5feaac69d6577e1eb627 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 4 Sep 2025 02:00:35 +0000 Subject: [PATCH 3/6] codegen metadata --- .stats.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.stats.yml b/.stats.yml index 6225bdc74..3f9351b29 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 100 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/runloop-ai%2Frunloop-2027ff4b25ad2b5574b2ce497f5d0d02ad8804816ffd6b4e36511e2fa955d0cf.yml openapi_spec_hash: 54a5a26e9c0b179d2f2a532268d3d711 -config_hash: b97411af91b8ec0b8b066358c29091b4 +config_hash: ca2bcf0cc299c9d5cba1b16fb49a0d25 From adb97652aea509e832ab50f25d7e04e80d8fd09e Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 4 Sep 2025 02:59:24 +0000 Subject: [PATCH 4/6] feat: improve future compat with pydantic v3 --- src/runloop_api_client/_base_client.py | 6 +- src/runloop_api_client/_compat.py | 96 ++++++------- src/runloop_api_client/_models.py | 80 +++++------ src/runloop_api_client/_utils/__init__.py | 10 +- src/runloop_api_client/_utils/_compat.py | 45 ++++++ .../_utils/_datetime_parse.py | 136 ++++++++++++++++++ src/runloop_api_client/_utils/_transform.py | 6 +- src/runloop_api_client/_utils/_typing.py | 2 +- src/runloop_api_client/_utils/_utils.py | 1 - src/runloop_api_client/types/__init__.py | 8 +- tests/test_models.py | 48 +++---- tests/test_transform.py | 16 +-- tests/test_utils/test_datetime_parse.py | 110 ++++++++++++++ tests/utils.py | 8 +- 14 files changed, 436 insertions(+), 136 deletions(-) create mode 100644 src/runloop_api_client/_utils/_compat.py create mode 100644 src/runloop_api_client/_utils/_datetime_parse.py create mode 100644 tests/test_utils/test_datetime_parse.py diff --git a/src/runloop_api_client/_base_client.py b/src/runloop_api_client/_base_client.py index 4090e2164..5233ec37d 100644 --- a/src/runloop_api_client/_base_client.py +++ b/src/runloop_api_client/_base_client.py @@ -59,7 +59,7 @@ ModelBuilderProtocol, ) from ._utils import is_dict, is_list, asyncify, is_given, lru_cache, is_mapping -from ._compat import PYDANTIC_V2, model_copy, model_dump +from ._compat import PYDANTIC_V1, model_copy, model_dump from ._models import GenericModel, FinalRequestOptions, validate_type, construct_type from ._response import ( APIResponse, @@ -232,7 +232,7 @@ def _set_private_attributes( model: Type[_T], options: FinalRequestOptions, ) -> None: - if PYDANTIC_V2 and getattr(self, "__pydantic_private__", None) is None: + if (not PYDANTIC_V1) and getattr(self, "__pydantic_private__", None) is None: self.__pydantic_private__ = {} self._model = model @@ -320,7 +320,7 @@ def _set_private_attributes( client: AsyncAPIClient, options: FinalRequestOptions, ) -> None: - if PYDANTIC_V2 and getattr(self, "__pydantic_private__", None) is None: + if (not PYDANTIC_V1) and getattr(self, "__pydantic_private__", None) is None: self.__pydantic_private__ = {} self._model = model diff --git a/src/runloop_api_client/_compat.py b/src/runloop_api_client/_compat.py index 92d9ee61e..bdef67f04 100644 --- a/src/runloop_api_client/_compat.py +++ b/src/runloop_api_client/_compat.py @@ -12,14 +12,13 @@ _T = TypeVar("_T") _ModelT = TypeVar("_ModelT", bound=pydantic.BaseModel) -# --------------- Pydantic v2 compatibility --------------- +# --------------- Pydantic v2, v3 compatibility --------------- # Pyright incorrectly reports some of our functions as overriding a method when they don't # pyright: reportIncompatibleMethodOverride=false -PYDANTIC_V2 = pydantic.VERSION.startswith("2.") +PYDANTIC_V1 = pydantic.VERSION.startswith("1.") -# v1 re-exports if TYPE_CHECKING: def parse_date(value: date | StrBytesIntFloat) -> date: # noqa: ARG001 @@ -44,90 +43,92 @@ def is_typeddict(type_: type[Any]) -> bool: # noqa: ARG001 ... else: - if PYDANTIC_V2: - from pydantic.v1.typing import ( + # v1 re-exports + if PYDANTIC_V1: + from pydantic.typing import ( get_args as get_args, is_union as is_union, get_origin as get_origin, is_typeddict as is_typeddict, is_literal_type as is_literal_type, ) - from pydantic.v1.datetime_parse import parse_date as parse_date, parse_datetime as parse_datetime + from pydantic.datetime_parse import parse_date as parse_date, parse_datetime as parse_datetime else: - from pydantic.typing import ( + from ._utils import ( get_args as get_args, is_union as is_union, get_origin as get_origin, + parse_date as parse_date, is_typeddict as is_typeddict, + parse_datetime as parse_datetime, is_literal_type as is_literal_type, ) - from pydantic.datetime_parse import parse_date as parse_date, parse_datetime as parse_datetime # refactored config if TYPE_CHECKING: from pydantic import ConfigDict as ConfigDict else: - if PYDANTIC_V2: - from pydantic import ConfigDict - else: + if PYDANTIC_V1: # TODO: provide an error message here? ConfigDict = None + else: + from pydantic import ConfigDict as ConfigDict # renamed methods / properties def parse_obj(model: type[_ModelT], value: object) -> _ModelT: - if PYDANTIC_V2: - return model.model_validate(value) - else: + if PYDANTIC_V1: return cast(_ModelT, model.parse_obj(value)) # pyright: ignore[reportDeprecated, reportUnnecessaryCast] + else: + return model.model_validate(value) def field_is_required(field: FieldInfo) -> bool: - if PYDANTIC_V2: - return field.is_required() - return field.required # type: ignore + if PYDANTIC_V1: + return field.required # type: ignore + return field.is_required() def field_get_default(field: FieldInfo) -> Any: value = field.get_default() - if PYDANTIC_V2: - from pydantic_core import PydanticUndefined - - if value == PydanticUndefined: - return None + if PYDANTIC_V1: return value + from pydantic_core import PydanticUndefined + + if value == PydanticUndefined: + return None return value def field_outer_type(field: FieldInfo) -> Any: - if PYDANTIC_V2: - return field.annotation - return field.outer_type_ # type: ignore + if PYDANTIC_V1: + return field.outer_type_ # type: ignore + return field.annotation def get_model_config(model: type[pydantic.BaseModel]) -> Any: - if PYDANTIC_V2: - return model.model_config - return model.__config__ # type: ignore + if PYDANTIC_V1: + return model.__config__ # type: ignore + return model.model_config def get_model_fields(model: type[pydantic.BaseModel]) -> dict[str, FieldInfo]: - if PYDANTIC_V2: - return model.model_fields - return model.__fields__ # type: ignore + if PYDANTIC_V1: + return model.__fields__ # type: ignore + return model.model_fields def model_copy(model: _ModelT, *, deep: bool = False) -> _ModelT: - if PYDANTIC_V2: - return model.model_copy(deep=deep) - return model.copy(deep=deep) # type: ignore + if PYDANTIC_V1: + return model.copy(deep=deep) # type: ignore + return model.model_copy(deep=deep) def model_json(model: pydantic.BaseModel, *, indent: int | None = None) -> str: - if PYDANTIC_V2: - return model.model_dump_json(indent=indent) - return model.json(indent=indent) # type: ignore + if PYDANTIC_V1: + return model.json(indent=indent) # type: ignore + return model.model_dump_json(indent=indent) def model_dump( @@ -139,14 +140,14 @@ def model_dump( warnings: bool = True, mode: Literal["json", "python"] = "python", ) -> dict[str, Any]: - if PYDANTIC_V2 or hasattr(model, "model_dump"): + if (not PYDANTIC_V1) or hasattr(model, "model_dump"): return model.model_dump( mode=mode, exclude=exclude, exclude_unset=exclude_unset, exclude_defaults=exclude_defaults, # warnings are not supported in Pydantic v1 - warnings=warnings if PYDANTIC_V2 else True, + warnings=True if PYDANTIC_V1 else warnings, ) return cast( "dict[str, Any]", @@ -159,9 +160,9 @@ def model_dump( def model_parse(model: type[_ModelT], data: Any) -> _ModelT: - if PYDANTIC_V2: - return model.model_validate(data) - return model.parse_obj(data) # pyright: ignore[reportDeprecated] + if PYDANTIC_V1: + return model.parse_obj(data) # pyright: ignore[reportDeprecated] + return model.model_validate(data) # generic models @@ -170,17 +171,16 @@ def model_parse(model: type[_ModelT], data: Any) -> _ModelT: class GenericModel(pydantic.BaseModel): ... else: - if PYDANTIC_V2: + if PYDANTIC_V1: + import pydantic.generics + + class GenericModel(pydantic.generics.GenericModel, pydantic.BaseModel): ... + else: # there no longer needs to be a distinction in v2 but # we still have to create our own subclass to avoid # inconsistent MRO ordering errors class GenericModel(pydantic.BaseModel): ... - else: - import pydantic.generics - - class GenericModel(pydantic.generics.GenericModel, pydantic.BaseModel): ... - # cached properties if TYPE_CHECKING: diff --git a/src/runloop_api_client/_models.py b/src/runloop_api_client/_models.py index 92f7c10bc..3a6017ef2 100644 --- a/src/runloop_api_client/_models.py +++ b/src/runloop_api_client/_models.py @@ -50,7 +50,7 @@ strip_annotated_type, ) from ._compat import ( - PYDANTIC_V2, + PYDANTIC_V1, ConfigDict, GenericModel as BaseGenericModel, get_args, @@ -81,11 +81,7 @@ class _ConfigProtocol(Protocol): class BaseModel(pydantic.BaseModel): - if PYDANTIC_V2: - model_config: ClassVar[ConfigDict] = ConfigDict( - extra="allow", defer_build=coerce_boolean(os.environ.get("DEFER_PYDANTIC_BUILD", "true")) - ) - else: + if PYDANTIC_V1: @property @override @@ -95,6 +91,10 @@ def model_fields_set(self) -> set[str]: class Config(pydantic.BaseConfig): # pyright: ignore[reportDeprecated] extra: Any = pydantic.Extra.allow # type: ignore + else: + model_config: ClassVar[ConfigDict] = ConfigDict( + extra="allow", defer_build=coerce_boolean(os.environ.get("DEFER_PYDANTIC_BUILD", "true")) + ) def to_dict( self, @@ -215,25 +215,25 @@ def construct( # pyright: ignore[reportIncompatibleMethodOverride] if key not in model_fields: parsed = construct_type(value=value, type_=extra_field_type) if extra_field_type is not None else value - if PYDANTIC_V2: - _extra[key] = parsed - else: + if PYDANTIC_V1: _fields_set.add(key) fields_values[key] = parsed + else: + _extra[key] = parsed object.__setattr__(m, "__dict__", fields_values) - if PYDANTIC_V2: - # these properties are copied from Pydantic's `model_construct()` method - object.__setattr__(m, "__pydantic_private__", None) - object.__setattr__(m, "__pydantic_extra__", _extra) - object.__setattr__(m, "__pydantic_fields_set__", _fields_set) - else: + if PYDANTIC_V1: # init_private_attributes() does not exist in v2 m._init_private_attributes() # type: ignore # copied from Pydantic v1's `construct()` method object.__setattr__(m, "__fields_set__", _fields_set) + else: + # these properties are copied from Pydantic's `model_construct()` method + object.__setattr__(m, "__pydantic_private__", None) + object.__setattr__(m, "__pydantic_extra__", _extra) + object.__setattr__(m, "__pydantic_fields_set__", _fields_set) return m @@ -243,7 +243,7 @@ def construct( # pyright: ignore[reportIncompatibleMethodOverride] # although not in practice model_construct = construct - if not PYDANTIC_V2: + if PYDANTIC_V1: # we define aliases for some of the new pydantic v2 methods so # that we can just document these methods without having to specify # a specific pydantic version as some users may not know which @@ -363,10 +363,10 @@ def _construct_field(value: object, field: FieldInfo, key: str) -> object: if value is None: return field_get_default(field) - if PYDANTIC_V2: - type_ = field.annotation - else: + if PYDANTIC_V1: type_ = cast(type, field.outer_type_) # type: ignore + else: + type_ = field.annotation # type: ignore if type_ is None: raise RuntimeError(f"Unexpected field type is None for {key}") @@ -375,7 +375,7 @@ def _construct_field(value: object, field: FieldInfo, key: str) -> object: def _get_extra_fields_type(cls: type[pydantic.BaseModel]) -> type | None: - if not PYDANTIC_V2: + if PYDANTIC_V1: # TODO return None @@ -628,30 +628,30 @@ def _build_discriminated_union_meta(*, union: type, meta_annotations: tuple[Any, for variant in get_args(union): variant = strip_annotated_type(variant) if is_basemodel_type(variant): - if PYDANTIC_V2: - field = _extract_field_schema_pv2(variant, discriminator_field_name) - if not field: + if PYDANTIC_V1: + field_info = cast("dict[str, FieldInfo]", variant.__fields__).get(discriminator_field_name) # pyright: ignore[reportDeprecated, reportUnnecessaryCast] + if not field_info: continue # Note: if one variant defines an alias then they all should - discriminator_alias = field.get("serialization_alias") - - field_schema = field["schema"] + discriminator_alias = field_info.alias - if field_schema["type"] == "literal": - for entry in cast("LiteralSchema", field_schema)["expected"]: + if (annotation := getattr(field_info, "annotation", None)) and is_literal_type(annotation): + for entry in get_args(annotation): if isinstance(entry, str): mapping[entry] = variant else: - field_info = cast("dict[str, FieldInfo]", variant.__fields__).get(discriminator_field_name) # pyright: ignore[reportDeprecated, reportUnnecessaryCast] - if not field_info: + field = _extract_field_schema_pv2(variant, discriminator_field_name) + if not field: continue # Note: if one variant defines an alias then they all should - discriminator_alias = field_info.alias + discriminator_alias = field.get("serialization_alias") - if (annotation := getattr(field_info, "annotation", None)) and is_literal_type(annotation): - for entry in get_args(annotation): + field_schema = field["schema"] + + if field_schema["type"] == "literal": + for entry in cast("LiteralSchema", field_schema)["expected"]: if isinstance(entry, str): mapping[entry] = variant @@ -714,7 +714,7 @@ class GenericModel(BaseGenericModel, BaseModel): pass -if PYDANTIC_V2: +if not PYDANTIC_V1: from pydantic import TypeAdapter as _TypeAdapter _CachedTypeAdapter = cast("TypeAdapter[object]", lru_cache(maxsize=None)(_TypeAdapter)) @@ -782,12 +782,12 @@ class FinalRequestOptions(pydantic.BaseModel): json_data: Union[Body, None] = None extra_json: Union[AnyMapping, None] = None - if PYDANTIC_V2: - model_config: ClassVar[ConfigDict] = ConfigDict(arbitrary_types_allowed=True) - else: + if PYDANTIC_V1: class Config(pydantic.BaseConfig): # pyright: ignore[reportDeprecated] arbitrary_types_allowed: bool = True + else: + model_config: ClassVar[ConfigDict] = ConfigDict(arbitrary_types_allowed=True) def get_max_retries(self, max_retries: int) -> int: if isinstance(self.max_retries, NotGiven): @@ -820,9 +820,9 @@ def construct( # type: ignore key: strip_not_given(value) for key, value in values.items() } - if PYDANTIC_V2: - return super().model_construct(_fields_set, **kwargs) - return cast(FinalRequestOptions, super().construct(_fields_set, **kwargs)) # pyright: ignore[reportDeprecated] + if PYDANTIC_V1: + return cast(FinalRequestOptions, super().construct(_fields_set, **kwargs)) # pyright: ignore[reportDeprecated] + return super().model_construct(_fields_set, **kwargs) if not TYPE_CHECKING: # type checkers incorrectly complain about this assignment diff --git a/src/runloop_api_client/_utils/__init__.py b/src/runloop_api_client/_utils/__init__.py index ca547ce52..dc64e29a1 100644 --- a/src/runloop_api_client/_utils/__init__.py +++ b/src/runloop_api_client/_utils/__init__.py @@ -10,7 +10,6 @@ lru_cache as lru_cache, is_mapping as is_mapping, is_tuple_t as is_tuple_t, - parse_date as parse_date, is_iterable as is_iterable, is_sequence as is_sequence, coerce_float as coerce_float, @@ -23,7 +22,6 @@ coerce_boolean as coerce_boolean, coerce_integer as coerce_integer, file_from_path as file_from_path, - parse_datetime as parse_datetime, strip_not_given as strip_not_given, deepcopy_minimal as deepcopy_minimal, get_async_library as get_async_library, @@ -32,6 +30,13 @@ maybe_coerce_boolean as maybe_coerce_boolean, maybe_coerce_integer as maybe_coerce_integer, ) +from ._compat import ( + get_args as get_args, + is_union as is_union, + get_origin as get_origin, + is_typeddict as is_typeddict, + is_literal_type as is_literal_type, +) from ._typing import ( is_list_type as is_list_type, is_union_type as is_union_type, @@ -56,3 +61,4 @@ function_has_argument as function_has_argument, assert_signatures_in_sync as assert_signatures_in_sync, ) +from ._datetime_parse import parse_date as parse_date, parse_datetime as parse_datetime diff --git a/src/runloop_api_client/_utils/_compat.py b/src/runloop_api_client/_utils/_compat.py new file mode 100644 index 000000000..dd703233c --- /dev/null +++ b/src/runloop_api_client/_utils/_compat.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import sys +import typing_extensions +from typing import Any, Type, Union, Literal, Optional +from datetime import date, datetime +from typing_extensions import get_args as _get_args, get_origin as _get_origin + +from .._types import StrBytesIntFloat +from ._datetime_parse import parse_date as _parse_date, parse_datetime as _parse_datetime + +_LITERAL_TYPES = {Literal, typing_extensions.Literal} + + +def get_args(tp: type[Any]) -> tuple[Any, ...]: + return _get_args(tp) + + +def get_origin(tp: type[Any]) -> type[Any] | None: + return _get_origin(tp) + + +def is_union(tp: Optional[Type[Any]]) -> bool: + if sys.version_info < (3, 10): + return tp is Union # type: ignore[comparison-overlap] + else: + import types + + return tp is Union or tp is types.UnionType + + +def is_typeddict(tp: Type[Any]) -> bool: + return typing_extensions.is_typeddict(tp) + + +def is_literal_type(tp: Type[Any]) -> bool: + return get_origin(tp) in _LITERAL_TYPES + + +def parse_date(value: Union[date, StrBytesIntFloat]) -> date: + return _parse_date(value) + + +def parse_datetime(value: Union[datetime, StrBytesIntFloat]) -> datetime: + return _parse_datetime(value) diff --git a/src/runloop_api_client/_utils/_datetime_parse.py b/src/runloop_api_client/_utils/_datetime_parse.py new file mode 100644 index 000000000..7cb9d9e66 --- /dev/null +++ b/src/runloop_api_client/_utils/_datetime_parse.py @@ -0,0 +1,136 @@ +""" +This file contains code from https://github.com/pydantic/pydantic/blob/main/pydantic/v1/datetime_parse.py +without the Pydantic v1 specific errors. +""" + +from __future__ import annotations + +import re +from typing import Dict, Union, Optional +from datetime import date, datetime, timezone, timedelta + +from .._types import StrBytesIntFloat + +date_expr = r"(?P\d{4})-(?P\d{1,2})-(?P\d{1,2})" +time_expr = ( + r"(?P\d{1,2}):(?P\d{1,2})" + r"(?::(?P\d{1,2})(?:\.(?P\d{1,6})\d{0,6})?)?" + r"(?PZ|[+-]\d{2}(?::?\d{2})?)?$" +) + +date_re = re.compile(f"{date_expr}$") +datetime_re = re.compile(f"{date_expr}[T ]{time_expr}") + + +EPOCH = datetime(1970, 1, 1) +# if greater than this, the number is in ms, if less than or equal it's in seconds +# (in seconds this is 11th October 2603, in ms it's 20th August 1970) +MS_WATERSHED = int(2e10) +# slightly more than datetime.max in ns - (datetime.max - EPOCH).total_seconds() * 1e9 +MAX_NUMBER = int(3e20) + + +def _get_numeric(value: StrBytesIntFloat, native_expected_type: str) -> Union[None, int, float]: + if isinstance(value, (int, float)): + return value + try: + return float(value) + except ValueError: + return None + except TypeError: + raise TypeError(f"invalid type; expected {native_expected_type}, string, bytes, int or float") from None + + +def _from_unix_seconds(seconds: Union[int, float]) -> datetime: + if seconds > MAX_NUMBER: + return datetime.max + elif seconds < -MAX_NUMBER: + return datetime.min + + while abs(seconds) > MS_WATERSHED: + seconds /= 1000 + dt = EPOCH + timedelta(seconds=seconds) + return dt.replace(tzinfo=timezone.utc) + + +def _parse_timezone(value: Optional[str]) -> Union[None, int, timezone]: + if value == "Z": + return timezone.utc + elif value is not None: + offset_mins = int(value[-2:]) if len(value) > 3 else 0 + offset = 60 * int(value[1:3]) + offset_mins + if value[0] == "-": + offset = -offset + return timezone(timedelta(minutes=offset)) + else: + return None + + +def parse_datetime(value: Union[datetime, StrBytesIntFloat]) -> datetime: + """ + Parse a datetime/int/float/string and return a datetime.datetime. + + This function supports time zone offsets. When the input contains one, + the output uses a timezone with a fixed offset from UTC. + + Raise ValueError if the input is well formatted but not a valid datetime. + Raise ValueError if the input isn't well formatted. + """ + if isinstance(value, datetime): + return value + + number = _get_numeric(value, "datetime") + if number is not None: + return _from_unix_seconds(number) + + if isinstance(value, bytes): + value = value.decode() + + assert not isinstance(value, (float, int)) + + match = datetime_re.match(value) + if match is None: + raise ValueError("invalid datetime format") + + kw = match.groupdict() + if kw["microsecond"]: + kw["microsecond"] = kw["microsecond"].ljust(6, "0") + + tzinfo = _parse_timezone(kw.pop("tzinfo")) + kw_: Dict[str, Union[None, int, timezone]] = {k: int(v) for k, v in kw.items() if v is not None} + kw_["tzinfo"] = tzinfo + + return datetime(**kw_) # type: ignore + + +def parse_date(value: Union[date, StrBytesIntFloat]) -> date: + """ + Parse a date/int/float/string and return a datetime.date. + + Raise ValueError if the input is well formatted but not a valid date. + Raise ValueError if the input isn't well formatted. + """ + if isinstance(value, date): + if isinstance(value, datetime): + return value.date() + else: + return value + + number = _get_numeric(value, "date") + if number is not None: + return _from_unix_seconds(number).date() + + if isinstance(value, bytes): + value = value.decode() + + assert not isinstance(value, (float, int)) + match = date_re.match(value) + if match is None: + raise ValueError("invalid date format") + + kw = {k: int(v) for k, v in match.groupdict().items()} + + try: + return date(**kw) + except ValueError: + raise ValueError("invalid date format") from None diff --git a/src/runloop_api_client/_utils/_transform.py b/src/runloop_api_client/_utils/_transform.py index f0bcefd49..c19124f0d 100644 --- a/src/runloop_api_client/_utils/_transform.py +++ b/src/runloop_api_client/_utils/_transform.py @@ -19,6 +19,7 @@ is_sequence, ) from .._files import is_base64_file_input +from ._compat import get_origin, is_typeddict from ._typing import ( is_list_type, is_union_type, @@ -29,7 +30,6 @@ is_annotated_type, strip_annotated_type, ) -from .._compat import get_origin, model_dump, is_typeddict _T = TypeVar("_T") @@ -169,6 +169,8 @@ def _transform_recursive( Defaults to the same value as the `annotation` argument. """ + from .._compat import model_dump + if inner_type is None: inner_type = annotation @@ -333,6 +335,8 @@ async def _async_transform_recursive( Defaults to the same value as the `annotation` argument. """ + from .._compat import model_dump + if inner_type is None: inner_type = annotation diff --git a/src/runloop_api_client/_utils/_typing.py b/src/runloop_api_client/_utils/_typing.py index 845cd6b28..193109f3a 100644 --- a/src/runloop_api_client/_utils/_typing.py +++ b/src/runloop_api_client/_utils/_typing.py @@ -15,7 +15,7 @@ from ._utils import lru_cache from .._types import InheritsGeneric -from .._compat import is_union as _is_union +from ._compat import is_union as _is_union def is_annotated_type(typ: type) -> bool: diff --git a/src/runloop_api_client/_utils/_utils.py b/src/runloop_api_client/_utils/_utils.py index ea3cf3f2c..f0818595d 100644 --- a/src/runloop_api_client/_utils/_utils.py +++ b/src/runloop_api_client/_utils/_utils.py @@ -22,7 +22,6 @@ import sniffio from .._types import NotGiven, FileTypes, NotGivenOr, HeadersLike -from .._compat import parse_date as parse_date, parse_datetime as parse_datetime _T = TypeVar("_T") _TupleT = TypeVar("_TupleT", bound=Tuple[object, ...]) diff --git a/src/runloop_api_client/types/__init__.py b/src/runloop_api_client/types/__init__.py index 31f7c8ca5..e57d0b026 100644 --- a/src/runloop_api_client/types/__init__.py +++ b/src/runloop_api_client/types/__init__.py @@ -98,9 +98,9 @@ # This ensures that, when building the deferred (due to cyclical references) model schema, # Pydantic can resolve the necessary references. # See: https://github.com/pydantic/pydantic/issues/11250 for more context. -if _compat.PYDANTIC_V2: - devboxes.code_segment_info_response.CodeSegmentInfoResponse.model_rebuild(_parent_namespace_depth=0) - devboxes.document_symbol.DocumentSymbol.model_rebuild(_parent_namespace_depth=0) -else: +if _compat.PYDANTIC_V1: devboxes.code_segment_info_response.CodeSegmentInfoResponse.update_forward_refs() # type: ignore devboxes.document_symbol.DocumentSymbol.update_forward_refs() # type: ignore +else: + devboxes.code_segment_info_response.CodeSegmentInfoResponse.model_rebuild(_parent_namespace_depth=0) + devboxes.document_symbol.DocumentSymbol.model_rebuild(_parent_namespace_depth=0) diff --git a/tests/test_models.py b/tests/test_models.py index b11d9b63d..87683d2ad 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -8,7 +8,7 @@ from pydantic import Field from runloop_api_client._utils import PropertyInfo -from runloop_api_client._compat import PYDANTIC_V2, parse_obj, model_dump, model_json +from runloop_api_client._compat import PYDANTIC_V1, parse_obj, model_dump, model_json from runloop_api_client._models import BaseModel, construct_type @@ -294,12 +294,12 @@ class Model(BaseModel): assert cast(bool, m.foo) is True m = Model.construct(foo={"name": 3}) - if PYDANTIC_V2: - assert isinstance(m.foo, Submodel1) - assert m.foo.name == 3 # type: ignore - else: + if PYDANTIC_V1: assert isinstance(m.foo, Submodel2) assert m.foo.name == "3" + else: + assert isinstance(m.foo, Submodel1) + assert m.foo.name == 3 # type: ignore def test_list_of_unions() -> None: @@ -426,10 +426,10 @@ class Model(BaseModel): expected = datetime(2019, 12, 27, 18, 11, 19, 117000, tzinfo=timezone.utc) - if PYDANTIC_V2: - expected_json = '{"created_at":"2019-12-27T18:11:19.117000Z"}' - else: + if PYDANTIC_V1: expected_json = '{"created_at": "2019-12-27T18:11:19.117000+00:00"}' + else: + expected_json = '{"created_at":"2019-12-27T18:11:19.117000Z"}' model = Model.construct(created_at="2019-12-27T18:11:19.117Z") assert model.created_at == expected @@ -531,7 +531,7 @@ class Model2(BaseModel): assert m4.to_dict(mode="python") == {"created_at": datetime.fromisoformat(time_str)} assert m4.to_dict(mode="json") == {"created_at": time_str} - if not PYDANTIC_V2: + if PYDANTIC_V1: with pytest.raises(ValueError, match="warnings is only supported in Pydantic v2"): m.to_dict(warnings=False) @@ -556,7 +556,7 @@ class Model(BaseModel): assert m3.model_dump() == {"foo": None} assert m3.model_dump(exclude_none=True) == {} - if not PYDANTIC_V2: + if PYDANTIC_V1: with pytest.raises(ValueError, match="round_trip is only supported in Pydantic v2"): m.model_dump(round_trip=True) @@ -580,10 +580,10 @@ class Model(BaseModel): assert json.loads(m.to_json()) == {"FOO": "hello"} assert json.loads(m.to_json(use_api_names=False)) == {"foo": "hello"} - if PYDANTIC_V2: - assert m.to_json(indent=None) == '{"FOO":"hello"}' - else: + if PYDANTIC_V1: assert m.to_json(indent=None) == '{"FOO": "hello"}' + else: + assert m.to_json(indent=None) == '{"FOO":"hello"}' m2 = Model() assert json.loads(m2.to_json()) == {} @@ -595,7 +595,7 @@ class Model(BaseModel): assert json.loads(m3.to_json()) == {"FOO": None} assert json.loads(m3.to_json(exclude_none=True)) == {} - if not PYDANTIC_V2: + if PYDANTIC_V1: with pytest.raises(ValueError, match="warnings is only supported in Pydantic v2"): m.to_json(warnings=False) @@ -622,7 +622,7 @@ class Model(BaseModel): assert json.loads(m3.model_dump_json()) == {"foo": None} assert json.loads(m3.model_dump_json(exclude_none=True)) == {} - if not PYDANTIC_V2: + if PYDANTIC_V1: with pytest.raises(ValueError, match="round_trip is only supported in Pydantic v2"): m.model_dump_json(round_trip=True) @@ -679,12 +679,12 @@ class B(BaseModel): ) assert isinstance(m, A) assert m.type == "a" - if PYDANTIC_V2: - assert m.data == 100 # type: ignore[comparison-overlap] - else: + if PYDANTIC_V1: # pydantic v1 automatically converts inputs to strings # if the expected type is a str assert m.data == "100" + else: + assert m.data == 100 # type: ignore[comparison-overlap] def test_discriminated_unions_unknown_variant() -> None: @@ -768,12 +768,12 @@ class B(BaseModel): ) assert isinstance(m, A) assert m.foo_type == "a" - if PYDANTIC_V2: - assert m.data == 100 # type: ignore[comparison-overlap] - else: + if PYDANTIC_V1: # pydantic v1 automatically converts inputs to strings # if the expected type is a str assert m.data == "100" + else: + assert m.data == 100 # type: ignore[comparison-overlap] def test_discriminated_unions_overlapping_discriminators_invalid_data() -> None: @@ -833,7 +833,7 @@ class B(BaseModel): assert UnionType.__discriminator__ is discriminator -@pytest.mark.skipif(not PYDANTIC_V2, reason="TypeAliasType is not supported in Pydantic v1") +@pytest.mark.skipif(PYDANTIC_V1, reason="TypeAliasType is not supported in Pydantic v1") def test_type_alias_type() -> None: Alias = TypeAliasType("Alias", str) # pyright: ignore @@ -849,7 +849,7 @@ class Model(BaseModel): assert m.union == "bar" -@pytest.mark.skipif(not PYDANTIC_V2, reason="TypeAliasType is not supported in Pydantic v1") +@pytest.mark.skipif(PYDANTIC_V1, reason="TypeAliasType is not supported in Pydantic v1") def test_field_named_cls() -> None: class Model(BaseModel): cls: str @@ -936,7 +936,7 @@ class Type2(BaseModel): assert isinstance(model.value, InnerType2) -@pytest.mark.skipif(not PYDANTIC_V2, reason="this is only supported in pydantic v2 for now") +@pytest.mark.skipif(PYDANTIC_V1, reason="this is only supported in pydantic v2 for now") def test_extra_properties() -> None: class Item(BaseModel): prop: int diff --git a/tests/test_transform.py b/tests/test_transform.py index bc6003b5f..ad9fa8b24 100644 --- a/tests/test_transform.py +++ b/tests/test_transform.py @@ -15,7 +15,7 @@ parse_datetime, async_transform as _async_transform, ) -from runloop_api_client._compat import PYDANTIC_V2 +from runloop_api_client._compat import PYDANTIC_V1 from runloop_api_client._models import BaseModel _T = TypeVar("_T") @@ -189,7 +189,7 @@ class DateModel(BaseModel): @pytest.mark.asyncio async def test_iso8601_format(use_async: bool) -> None: dt = datetime.fromisoformat("2023-02-23T14:16:36.337692+00:00") - tz = "Z" if PYDANTIC_V2 else "+00:00" + tz = "+00:00" if PYDANTIC_V1 else "Z" assert await transform({"foo": dt}, DatetimeDict, use_async) == {"foo": "2023-02-23T14:16:36.337692+00:00"} # type: ignore[comparison-overlap] assert await transform(DatetimeModel(foo=dt), Any, use_async) == {"foo": "2023-02-23T14:16:36.337692" + tz} # type: ignore[comparison-overlap] @@ -297,11 +297,11 @@ async def test_pydantic_unknown_field(use_async: bool) -> None: @pytest.mark.asyncio async def test_pydantic_mismatched_types(use_async: bool) -> None: model = MyModel.construct(foo=True) - if PYDANTIC_V2: + if PYDANTIC_V1: + params = await transform(model, Any, use_async) + else: with pytest.warns(UserWarning): params = await transform(model, Any, use_async) - else: - params = await transform(model, Any, use_async) assert cast(Any, params) == {"foo": True} @@ -309,11 +309,11 @@ async def test_pydantic_mismatched_types(use_async: bool) -> None: @pytest.mark.asyncio async def test_pydantic_mismatched_object_type(use_async: bool) -> None: model = MyModel.construct(foo=MyModel.construct(hello="world")) - if PYDANTIC_V2: + if PYDANTIC_V1: + params = await transform(model, Any, use_async) + else: with pytest.warns(UserWarning): params = await transform(model, Any, use_async) - else: - params = await transform(model, Any, use_async) assert cast(Any, params) == {"foo": {"hello": "world"}} diff --git a/tests/test_utils/test_datetime_parse.py b/tests/test_utils/test_datetime_parse.py new file mode 100644 index 000000000..f8eb5c9fe --- /dev/null +++ b/tests/test_utils/test_datetime_parse.py @@ -0,0 +1,110 @@ +""" +Copied from https://github.com/pydantic/pydantic/blob/v1.10.22/tests/test_datetime_parse.py +with modifications so it works without pydantic v1 imports. +""" + +from typing import Type, Union +from datetime import date, datetime, timezone, timedelta + +import pytest + +from runloop_api_client._utils import parse_date, parse_datetime + + +def create_tz(minutes: int) -> timezone: + return timezone(timedelta(minutes=minutes)) + + +@pytest.mark.parametrize( + "value,result", + [ + # Valid inputs + ("1494012444.883309", date(2017, 5, 5)), + (b"1494012444.883309", date(2017, 5, 5)), + (1_494_012_444.883_309, date(2017, 5, 5)), + ("1494012444", date(2017, 5, 5)), + (1_494_012_444, date(2017, 5, 5)), + (0, date(1970, 1, 1)), + ("2012-04-23", date(2012, 4, 23)), + (b"2012-04-23", date(2012, 4, 23)), + ("2012-4-9", date(2012, 4, 9)), + (date(2012, 4, 9), date(2012, 4, 9)), + (datetime(2012, 4, 9, 12, 15), date(2012, 4, 9)), + # Invalid inputs + ("x20120423", ValueError), + ("2012-04-56", ValueError), + (19_999_999_999, date(2603, 10, 11)), # just before watershed + (20_000_000_001, date(1970, 8, 20)), # just after watershed + (1_549_316_052, date(2019, 2, 4)), # nowish in s + (1_549_316_052_104, date(2019, 2, 4)), # nowish in ms + (1_549_316_052_104_324, date(2019, 2, 4)), # nowish in μs + (1_549_316_052_104_324_096, date(2019, 2, 4)), # nowish in ns + ("infinity", date(9999, 12, 31)), + ("inf", date(9999, 12, 31)), + (float("inf"), date(9999, 12, 31)), + ("infinity ", date(9999, 12, 31)), + (int("1" + "0" * 100), date(9999, 12, 31)), + (1e1000, date(9999, 12, 31)), + ("-infinity", date(1, 1, 1)), + ("-inf", date(1, 1, 1)), + ("nan", ValueError), + ], +) +def test_date_parsing(value: Union[str, bytes, int, float], result: Union[date, Type[Exception]]) -> None: + if type(result) == type and issubclass(result, Exception): # pyright: ignore[reportUnnecessaryIsInstance] + with pytest.raises(result): + parse_date(value) + else: + assert parse_date(value) == result + + +@pytest.mark.parametrize( + "value,result", + [ + # Valid inputs + # values in seconds + ("1494012444.883309", datetime(2017, 5, 5, 19, 27, 24, 883_309, tzinfo=timezone.utc)), + (1_494_012_444.883_309, datetime(2017, 5, 5, 19, 27, 24, 883_309, tzinfo=timezone.utc)), + ("1494012444", datetime(2017, 5, 5, 19, 27, 24, tzinfo=timezone.utc)), + (b"1494012444", datetime(2017, 5, 5, 19, 27, 24, tzinfo=timezone.utc)), + (1_494_012_444, datetime(2017, 5, 5, 19, 27, 24, tzinfo=timezone.utc)), + # values in ms + ("1494012444000.883309", datetime(2017, 5, 5, 19, 27, 24, 883, tzinfo=timezone.utc)), + ("-1494012444000.883309", datetime(1922, 8, 29, 4, 32, 35, 999117, tzinfo=timezone.utc)), + (1_494_012_444_000, datetime(2017, 5, 5, 19, 27, 24, tzinfo=timezone.utc)), + ("2012-04-23T09:15:00", datetime(2012, 4, 23, 9, 15)), + ("2012-4-9 4:8:16", datetime(2012, 4, 9, 4, 8, 16)), + ("2012-04-23T09:15:00Z", datetime(2012, 4, 23, 9, 15, 0, 0, timezone.utc)), + ("2012-4-9 4:8:16-0320", datetime(2012, 4, 9, 4, 8, 16, 0, create_tz(-200))), + ("2012-04-23T10:20:30.400+02:30", datetime(2012, 4, 23, 10, 20, 30, 400_000, create_tz(150))), + ("2012-04-23T10:20:30.400+02", datetime(2012, 4, 23, 10, 20, 30, 400_000, create_tz(120))), + ("2012-04-23T10:20:30.400-02", datetime(2012, 4, 23, 10, 20, 30, 400_000, create_tz(-120))), + (b"2012-04-23T10:20:30.400-02", datetime(2012, 4, 23, 10, 20, 30, 400_000, create_tz(-120))), + (datetime(2017, 5, 5), datetime(2017, 5, 5)), + (0, datetime(1970, 1, 1, 0, 0, 0, tzinfo=timezone.utc)), + # Invalid inputs + ("x20120423091500", ValueError), + ("2012-04-56T09:15:90", ValueError), + ("2012-04-23T11:05:00-25:00", ValueError), + (19_999_999_999, datetime(2603, 10, 11, 11, 33, 19, tzinfo=timezone.utc)), # just before watershed + (20_000_000_001, datetime(1970, 8, 20, 11, 33, 20, 1000, tzinfo=timezone.utc)), # just after watershed + (1_549_316_052, datetime(2019, 2, 4, 21, 34, 12, 0, tzinfo=timezone.utc)), # nowish in s + (1_549_316_052_104, datetime(2019, 2, 4, 21, 34, 12, 104_000, tzinfo=timezone.utc)), # nowish in ms + (1_549_316_052_104_324, datetime(2019, 2, 4, 21, 34, 12, 104_324, tzinfo=timezone.utc)), # nowish in μs + (1_549_316_052_104_324_096, datetime(2019, 2, 4, 21, 34, 12, 104_324, tzinfo=timezone.utc)), # nowish in ns + ("infinity", datetime(9999, 12, 31, 23, 59, 59, 999999)), + ("inf", datetime(9999, 12, 31, 23, 59, 59, 999999)), + ("inf ", datetime(9999, 12, 31, 23, 59, 59, 999999)), + (1e50, datetime(9999, 12, 31, 23, 59, 59, 999999)), + (float("inf"), datetime(9999, 12, 31, 23, 59, 59, 999999)), + ("-infinity", datetime(1, 1, 1, 0, 0)), + ("-inf", datetime(1, 1, 1, 0, 0)), + ("nan", ValueError), + ], +) +def test_datetime_parsing(value: Union[str, bytes, int, float], result: Union[datetime, Type[Exception]]) -> None: + if type(result) == type and issubclass(result, Exception): # pyright: ignore[reportUnnecessaryIsInstance] + with pytest.raises(result): + parse_datetime(value) + else: + assert parse_datetime(value) == result diff --git a/tests/utils.py b/tests/utils.py index 44d940e25..606067e05 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -19,7 +19,7 @@ is_annotated_type, is_type_alias_type, ) -from runloop_api_client._compat import PYDANTIC_V2, field_outer_type, get_model_fields +from runloop_api_client._compat import PYDANTIC_V1, field_outer_type, get_model_fields from runloop_api_client._models import BaseModel BaseModelT = TypeVar("BaseModelT", bound=BaseModel) @@ -28,12 +28,12 @@ def assert_matches_model(model: type[BaseModelT], value: BaseModelT, *, path: list[str]) -> bool: for name, field in get_model_fields(model).items(): field_value = getattr(value, name) - if PYDANTIC_V2: - allow_none = False - else: + if PYDANTIC_V1: # in v1 nullability was structured differently # https://docs.pydantic.dev/2.0/migration/#required-optional-and-nullable-fields allow_none = getattr(field, "allow_none", False) + else: + allow_none = False assert_matches_type( field_outer_type(field), From 59e649db1632c06c7c6b7cdabaf7e42e003daa09 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 4 Sep 2025 19:24:34 +0000 Subject: [PATCH 5/6] feat(api): api update --- .stats.yml | 8 +- api.md | 9 +- .../resources/blueprints.py | 28 ++++ .../resources/devboxes/executions.py | 124 ++++++++++++++++- .../types/blueprint_build_parameters.py | 8 ++ .../types/blueprint_create_params.py | 8 ++ .../types/blueprint_preview_params.py | 8 ++ .../types/blueprint_view.py | 5 + src/runloop_api_client/types/devbox_view.py | 2 +- .../types/devboxes/__init__.py | 3 + .../types/devboxes/execution_kill_params.py | 14 ++ .../execution_stream_updates_params.py | 14 ++ .../types/devboxes/execution_update_chunk.py | 40 ++++++ .../api_resources/devboxes/test_executions.py | 131 +++++++++++++++++- tests/api_resources/test_blueprints.py | 4 + 15 files changed, 396 insertions(+), 10 deletions(-) create mode 100644 src/runloop_api_client/types/devboxes/execution_kill_params.py create mode 100644 src/runloop_api_client/types/devboxes/execution_stream_updates_params.py create mode 100644 src/runloop_api_client/types/devboxes/execution_update_chunk.py diff --git a/.stats.yml b/.stats.yml index 3f9351b29..900ce23a3 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ -configured_endpoints: 100 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/runloop-ai%2Frunloop-2027ff4b25ad2b5574b2ce497f5d0d02ad8804816ffd6b4e36511e2fa955d0cf.yml -openapi_spec_hash: 54a5a26e9c0b179d2f2a532268d3d711 -config_hash: ca2bcf0cc299c9d5cba1b16fb49a0d25 +configured_endpoints: 101 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/runloop-ai%2Frunloop-6b4c63a026f224ec02ccd715e063e07107b545bb859218afaac2b3df84cd227a.yml +openapi_spec_hash: 76072cd766a9c45cff8890bb2bb8b1d5 +config_hash: a8ac5e38099129b07ae4decb0774719d diff --git a/api.md b/api.md index d9c0b1032..0de2d614f 100644 --- a/api.md +++ b/api.md @@ -254,12 +254,19 @@ Methods: ## Executions +Types: + +```python +from runloop_api_client.types.devboxes import ExecutionUpdateChunk +``` + Methods: - client.devboxes.executions.retrieve(execution_id, \*, devbox_id, \*\*params) -> DevboxAsyncExecutionDetailView - client.devboxes.executions.execute_async(id, \*\*params) -> DevboxAsyncExecutionDetailView - client.devboxes.executions.execute_sync(id, \*\*params) -> DevboxExecutionDetailView -- client.devboxes.executions.kill(execution_id, \*, devbox_id) -> DevboxAsyncExecutionDetailView +- client.devboxes.executions.kill(execution_id, \*, devbox_id, \*\*params) -> DevboxAsyncExecutionDetailView +- client.devboxes.executions.stream_updates(execution_id, \*, devbox_id, \*\*params) -> DevboxAsyncExecutionDetailView # Scenarios diff --git a/src/runloop_api_client/resources/blueprints.py b/src/runloop_api_client/resources/blueprints.py index b81e685c0..443e71df8 100644 --- a/src/runloop_api_client/resources/blueprints.py +++ b/src/runloop_api_client/resources/blueprints.py @@ -73,6 +73,7 @@ def create( *, name: str, base_blueprint_id: Optional[str] | NotGiven = NOT_GIVEN, + base_blueprint_name: Optional[str] | NotGiven = NOT_GIVEN, code_mounts: Optional[Iterable[CodeMountParameters]] | NotGiven = NOT_GIVEN, dockerfile: Optional[str] | NotGiven = NOT_GIVEN, file_mounts: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, @@ -101,6 +102,11 @@ def create( base_blueprint_id: (Optional) ID of previously built blueprint to use as a base blueprint for this build. + base_blueprint_name: (Optional) Name of previously built blueprint to use as a base blueprint for + this build. When set, this will load the latest successfully built Blueprint + with the given name. Only one of (base_blueprint_id, base_blueprint_name) should + be specified. + code_mounts: A list of code mounts to be included in the Blueprint. dockerfile: Dockerfile contents to be used to build the Blueprint. @@ -133,6 +139,7 @@ def create( { "name": name, "base_blueprint_id": base_blueprint_id, + "base_blueprint_name": base_blueprint_name, "code_mounts": code_mounts, "dockerfile": dockerfile, "file_mounts": file_mounts, @@ -472,6 +479,7 @@ def preview( *, name: str, base_blueprint_id: Optional[str] | NotGiven = NOT_GIVEN, + base_blueprint_name: Optional[str] | NotGiven = NOT_GIVEN, code_mounts: Optional[Iterable[CodeMountParameters]] | NotGiven = NOT_GIVEN, dockerfile: Optional[str] | NotGiven = NOT_GIVEN, file_mounts: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, @@ -498,6 +506,11 @@ def preview( base_blueprint_id: (Optional) ID of previously built blueprint to use as a base blueprint for this build. + base_blueprint_name: (Optional) Name of previously built blueprint to use as a base blueprint for + this build. When set, this will load the latest successfully built Blueprint + with the given name. Only one of (base_blueprint_id, base_blueprint_name) should + be specified. + code_mounts: A list of code mounts to be included in the Blueprint. dockerfile: Dockerfile contents to be used to build the Blueprint. @@ -530,6 +543,7 @@ def preview( { "name": name, "base_blueprint_id": base_blueprint_id, + "base_blueprint_name": base_blueprint_name, "code_mounts": code_mounts, "dockerfile": dockerfile, "file_mounts": file_mounts, @@ -576,6 +590,7 @@ async def create( *, name: str, base_blueprint_id: Optional[str] | NotGiven = NOT_GIVEN, + base_blueprint_name: Optional[str] | NotGiven = NOT_GIVEN, code_mounts: Optional[Iterable[CodeMountParameters]] | NotGiven = NOT_GIVEN, dockerfile: Optional[str] | NotGiven = NOT_GIVEN, file_mounts: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, @@ -604,6 +619,11 @@ async def create( base_blueprint_id: (Optional) ID of previously built blueprint to use as a base blueprint for this build. + base_blueprint_name: (Optional) Name of previously built blueprint to use as a base blueprint for + this build. When set, this will load the latest successfully built Blueprint + with the given name. Only one of (base_blueprint_id, base_blueprint_name) should + be specified. + code_mounts: A list of code mounts to be included in the Blueprint. dockerfile: Dockerfile contents to be used to build the Blueprint. @@ -636,6 +656,7 @@ async def create( { "name": name, "base_blueprint_id": base_blueprint_id, + "base_blueprint_name": base_blueprint_name, "code_mounts": code_mounts, "dockerfile": dockerfile, "file_mounts": file_mounts, @@ -975,6 +996,7 @@ async def preview( *, name: str, base_blueprint_id: Optional[str] | NotGiven = NOT_GIVEN, + base_blueprint_name: Optional[str] | NotGiven = NOT_GIVEN, code_mounts: Optional[Iterable[CodeMountParameters]] | NotGiven = NOT_GIVEN, dockerfile: Optional[str] | NotGiven = NOT_GIVEN, file_mounts: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, @@ -1001,6 +1023,11 @@ async def preview( base_blueprint_id: (Optional) ID of previously built blueprint to use as a base blueprint for this build. + base_blueprint_name: (Optional) Name of previously built blueprint to use as a base blueprint for + this build. When set, this will load the latest successfully built Blueprint + with the given name. Only one of (base_blueprint_id, base_blueprint_name) should + be specified. + code_mounts: A list of code mounts to be included in the Blueprint. dockerfile: Dockerfile contents to be used to build the Blueprint. @@ -1033,6 +1060,7 @@ async def preview( { "name": name, "base_blueprint_id": base_blueprint_id, + "base_blueprint_name": base_blueprint_name, "code_mounts": code_mounts, "dockerfile": dockerfile, "file_mounts": file_mounts, diff --git a/src/runloop_api_client/resources/devboxes/executions.py b/src/runloop_api_client/resources/devboxes/executions.py index 99df1fd85..2968aa13d 100755 --- a/src/runloop_api_client/resources/devboxes/executions.py +++ b/src/runloop_api_client/resources/devboxes/executions.py @@ -19,10 +19,18 @@ from ..._constants import DEFAULT_TIMEOUT from ..._exceptions import APIStatusError, APITimeoutError from ...lib.polling import PollingConfig, poll_until +from ..._streaming import Stream, AsyncStream from ..._base_client import make_request_options -from ...types.devboxes import execution_retrieve_params, execution_execute_sync_params, execution_execute_async_params +from ...types.devboxes import ( + execution_kill_params, + execution_retrieve_params, + execution_execute_sync_params, + execution_execute_async_params, + execution_stream_updates_params, +) from ...lib.polling_async import async_poll_until from ...types.devbox_execution_detail_view import DevboxExecutionDetailView +from ...types.devboxes.execution_update_chunk import ExecutionUpdateChunk from ...types.devbox_async_execution_detail_view import DevboxAsyncExecutionDetailView __all__ = ["ExecutionsResource", "AsyncExecutionsResource"] @@ -277,6 +285,7 @@ def kill( execution_id: str, *, devbox_id: str, + kill_process_group: Optional[bool] | 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, @@ -287,7 +296,7 @@ def kill( ) -> DevboxAsyncExecutionDetailView: """ Kill a previously launched asynchronous execution if it is still running by - killing the launched process. + killing the launched process. Optionally kill the entire process group. Args: extra_headers: Send extra headers @@ -306,6 +315,7 @@ def kill( raise ValueError(f"Expected a non-empty value for `execution_id` but received {execution_id!r}") return self._post( f"/v1/devboxes/{devbox_id}/executions/{execution_id}/kill", + body=maybe_transform({"kill_process_group": kill_process_group}, execution_kill_params.ExecutionKillParams), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, @@ -316,6 +326,51 @@ def kill( cast_to=DevboxAsyncExecutionDetailView, ) + def stream_updates( + self, + execution_id: str, + *, + devbox_id: str, + offset: 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, + ) -> Stream[ExecutionUpdateChunk]: + """ + Tails the logs for the given execution with SSE streaming + + Args: + offset: The byte offset to start the stream from + + 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 devbox_id: + raise ValueError(f"Expected a non-empty value for `devbox_id` but received {devbox_id!r}") + if not execution_id: + raise ValueError(f"Expected a non-empty value for `execution_id` but received {execution_id!r}") + return self._get( + f"/v1/devboxes/{devbox_id}/executions/{execution_id}/stream_updates", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform({"offset": offset}, execution_stream_updates_params.ExecutionStreamUpdatesParams), + ), + cast_to=DevboxAsyncExecutionDetailView, + stream=True, + stream_cls=Stream[ExecutionUpdateChunk], + ) + class AsyncExecutionsResource(AsyncAPIResource): @cached_property @@ -554,6 +609,7 @@ async def kill( execution_id: str, *, devbox_id: str, + kill_process_group: Optional[bool] | 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, @@ -564,7 +620,7 @@ async def kill( ) -> DevboxAsyncExecutionDetailView: """ Kill a previously launched asynchronous execution if it is still running by - killing the launched process. + killing the launched process. Optionally kill the entire process group. Args: extra_headers: Send extra headers @@ -583,6 +639,9 @@ async def kill( raise ValueError(f"Expected a non-empty value for `execution_id` but received {execution_id!r}") return await self._post( f"/v1/devboxes/{devbox_id}/executions/{execution_id}/kill", + body=await async_maybe_transform( + {"kill_process_group": kill_process_group}, execution_kill_params.ExecutionKillParams + ), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, @@ -593,6 +652,53 @@ async def kill( cast_to=DevboxAsyncExecutionDetailView, ) + async def stream_updates( + self, + execution_id: str, + *, + devbox_id: str, + offset: 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, + ) -> AsyncStream[ExecutionUpdateChunk]: + """ + Tails the logs for the given execution with SSE streaming + + Args: + offset: The byte offset to start the stream from + + 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 devbox_id: + raise ValueError(f"Expected a non-empty value for `devbox_id` but received {devbox_id!r}") + if not execution_id: + raise ValueError(f"Expected a non-empty value for `execution_id` but received {execution_id!r}") + return await self._get( + f"/v1/devboxes/{devbox_id}/executions/{execution_id}/stream_updates", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform( + {"offset": offset}, execution_stream_updates_params.ExecutionStreamUpdatesParams + ), + ), + cast_to=DevboxAsyncExecutionDetailView, + stream=True, + stream_cls=AsyncStream[ExecutionUpdateChunk], + ) + class ExecutionsResourceWithRawResponse: def __init__(self, executions: ExecutionsResource) -> None: @@ -610,6 +716,9 @@ def __init__(self, executions: ExecutionsResource) -> None: self.kill = to_raw_response_wrapper( executions.kill, ) + self.stream_updates = to_raw_response_wrapper( + executions.stream_updates, + ) class AsyncExecutionsResourceWithRawResponse: @@ -628,6 +737,9 @@ def __init__(self, executions: AsyncExecutionsResource) -> None: self.kill = async_to_raw_response_wrapper( executions.kill, ) + self.stream_updates = async_to_raw_response_wrapper( + executions.stream_updates, + ) class ExecutionsResourceWithStreamingResponse: @@ -646,6 +758,9 @@ def __init__(self, executions: ExecutionsResource) -> None: self.kill = to_streamed_response_wrapper( executions.kill, ) + self.stream_updates = to_streamed_response_wrapper( + executions.stream_updates, + ) class AsyncExecutionsResourceWithStreamingResponse: @@ -664,3 +779,6 @@ def __init__(self, executions: AsyncExecutionsResource) -> None: self.kill = async_to_streamed_response_wrapper( executions.kill, ) + self.stream_updates = async_to_streamed_response_wrapper( + executions.stream_updates, + ) diff --git a/src/runloop_api_client/types/blueprint_build_parameters.py b/src/runloop_api_client/types/blueprint_build_parameters.py index 9d6b25504..a0a6b69d1 100644 --- a/src/runloop_api_client/types/blueprint_build_parameters.py +++ b/src/runloop_api_client/types/blueprint_build_parameters.py @@ -50,6 +50,14 @@ class BlueprintBuildParameters(BaseModel): build. """ + base_blueprint_name: Optional[str] = None + """ + (Optional) Name of previously built blueprint to use as a base blueprint for + this build. When set, this will load the latest successfully built Blueprint + with the given name. Only one of (base_blueprint_id, base_blueprint_name) should + be specified. + """ + code_mounts: Optional[List[CodeMountParameters]] = None """A list of code mounts to be included in the Blueprint.""" diff --git a/src/runloop_api_client/types/blueprint_create_params.py b/src/runloop_api_client/types/blueprint_create_params.py index fb3e2ee49..214e3ef67 100644 --- a/src/runloop_api_client/types/blueprint_create_params.py +++ b/src/runloop_api_client/types/blueprint_create_params.py @@ -22,6 +22,14 @@ class BlueprintCreateParams(TypedDict, total=False): build. """ + base_blueprint_name: Optional[str] + """ + (Optional) Name of previously built blueprint to use as a base blueprint for + this build. When set, this will load the latest successfully built Blueprint + with the given name. Only one of (base_blueprint_id, base_blueprint_name) should + be specified. + """ + code_mounts: Optional[Iterable[CodeMountParameters]] """A list of code mounts to be included in the Blueprint.""" diff --git a/src/runloop_api_client/types/blueprint_preview_params.py b/src/runloop_api_client/types/blueprint_preview_params.py index eb3ba757b..9efca9c68 100644 --- a/src/runloop_api_client/types/blueprint_preview_params.py +++ b/src/runloop_api_client/types/blueprint_preview_params.py @@ -22,6 +22,14 @@ class BlueprintPreviewParams(TypedDict, total=False): build. """ + base_blueprint_name: Optional[str] + """ + (Optional) Name of previously built blueprint to use as a base blueprint for + this build. When set, this will load the latest successfully built Blueprint + with the given name. Only one of (base_blueprint_id, base_blueprint_name) should + be specified. + """ + code_mounts: Optional[Iterable[CodeMountParameters]] """A list of code mounts to be included in the Blueprint.""" diff --git a/src/runloop_api_client/types/blueprint_view.py b/src/runloop_api_client/types/blueprint_view.py index 7c6b9ee77..2dce4446c 100644 --- a/src/runloop_api_client/types/blueprint_view.py +++ b/src/runloop_api_client/types/blueprint_view.py @@ -68,6 +68,11 @@ class BlueprintView(BaseModel): Services can be explicitly started when creating a Devbox. """ + devbox_capabilities: Optional[ + List[Literal["unknown", "computer_usage", "browser_usage", "language_server", "docker_in_docker"]] + ] = None + """Capabilities that will be available on Devbox.""" + failure_reason: Optional[Literal["out_of_memory", "out_of_disk", "build_failed"]] = None """The failure reason if the Blueprint build failed, if any.""" diff --git a/src/runloop_api_client/types/devbox_view.py b/src/runloop_api_client/types/devbox_view.py index dc4904eae..5724e118f 100644 --- a/src/runloop_api_client/types/devbox_view.py +++ b/src/runloop_api_client/types/devbox_view.py @@ -33,7 +33,7 @@ class DevboxView(BaseModel): id: str """The ID of the Devbox.""" - capabilities: List[Literal["unknown", "computer_usage", "browser_usage", "language_server"]] + capabilities: List[Literal["unknown", "computer_usage", "browser_usage", "language_server", "docker_in_docker"]] """A list of capability groups this devbox has access to. This allows devboxes to be compatible with certain tools sets like computer diff --git a/src/runloop_api_client/types/devboxes/__init__.py b/src/runloop_api_client/types/devboxes/__init__.py index 2d5c61a93..43e642744 100644 --- a/src/runloop_api_client/types/devboxes/__init__.py +++ b/src/runloop_api_client/types/devboxes/__init__.py @@ -46,11 +46,13 @@ from .browser_create_params import BrowserCreateParams as BrowserCreateParams from .code_actions_response import CodeActionsResponse as CodeActionsResponse from .devbox_logs_list_view import DevboxLogsListView as DevboxLogsListView +from .execution_kill_params import ExecutionKillParams as ExecutionKillParams from .lsp_formatting_params import LspFormattingParams as LspFormattingParams from .lsp_references_params import LspReferencesParams as LspReferencesParams from .watched_file_response import WatchedFileResponse as WatchedFileResponse from .code_description_param import CodeDescriptionParam as CodeDescriptionParam from .computer_create_params import ComputerCreateParams as ComputerCreateParams +from .execution_update_chunk import ExecutionUpdateChunk as ExecutionUpdateChunk from .file_contents_response import FileContentsResponse as FileContentsResponse from .health_status_response import HealthStatusResponse as HealthStatusResponse from .lsp_diagnostics_params import LspDiagnosticsParams as LspDiagnosticsParams @@ -75,6 +77,7 @@ from .code_action_application_result import CodeActionApplicationResult as CodeActionApplicationResult from .execution_execute_async_params import ExecutionExecuteAsyncParams as ExecutionExecuteAsyncParams from .lsp_set_watch_directory_params import LspSetWatchDirectoryParams as LspSetWatchDirectoryParams +from .execution_stream_updates_params import ExecutionStreamUpdatesParams as ExecutionStreamUpdatesParams from .lsp_get_code_segment_info_params import LspGetCodeSegmentInfoParams as LspGetCodeSegmentInfoParams from .lsp_set_watch_directory_response import LspSetWatchDirectoryResponse as LspSetWatchDirectoryResponse from .computer_mouse_interaction_params import ComputerMouseInteractionParams as ComputerMouseInteractionParams diff --git a/src/runloop_api_client/types/devboxes/execution_kill_params.py b/src/runloop_api_client/types/devboxes/execution_kill_params.py new file mode 100644 index 000000000..384da945c --- /dev/null +++ b/src/runloop_api_client/types/devboxes/execution_kill_params.py @@ -0,0 +1,14 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Optional +from typing_extensions import Required, TypedDict + +__all__ = ["ExecutionKillParams"] + + +class ExecutionKillParams(TypedDict, total=False): + devbox_id: Required[str] + + kill_process_group: Optional[bool] diff --git a/src/runloop_api_client/types/devboxes/execution_stream_updates_params.py b/src/runloop_api_client/types/devboxes/execution_stream_updates_params.py new file mode 100644 index 000000000..3473cbc2c --- /dev/null +++ b/src/runloop_api_client/types/devboxes/execution_stream_updates_params.py @@ -0,0 +1,14 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, TypedDict + +__all__ = ["ExecutionStreamUpdatesParams"] + + +class ExecutionStreamUpdatesParams(TypedDict, total=False): + devbox_id: Required[str] + + offset: str + """The byte offset to start the stream from""" diff --git a/src/runloop_api_client/types/devboxes/execution_update_chunk.py b/src/runloop_api_client/types/devboxes/execution_update_chunk.py new file mode 100644 index 000000000..f5366d0cb --- /dev/null +++ b/src/runloop_api_client/types/devboxes/execution_update_chunk.py @@ -0,0 +1,40 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional +from typing_extensions import Literal + +from ..._models import BaseModel + +__all__ = ["ExecutionUpdateChunk"] + + +class ExecutionUpdateChunk(BaseModel): + devbox_id: str + """Devbox id where command was executed.""" + + execution_id: str + """Ephemeral id of the execution in progress.""" + + status: Literal["queued", "running", "completed"] + """Current status of the execution.""" + + exit_status: Optional[int] = None + """Exit code of command execution. + + This field will remain unset until the execution has completed. + """ + + shell_name: Optional[str] = None + """Shell name.""" + + stderr: Optional[str] = None + """Standard error generated by command. + + This field will remain unset until the execution has completed. + """ + + stdout: Optional[str] = None + """Standard out generated by command. + + This field will remain unset until the execution has completed. + """ diff --git a/tests/api_resources/devboxes/test_executions.py b/tests/api_resources/devboxes/test_executions.py index 5818a94d7..e4597808d 100755 --- a/tests/api_resources/devboxes/test_executions.py +++ b/tests/api_resources/devboxes/test_executions.py @@ -187,6 +187,15 @@ def test_method_kill(self, client: Runloop) -> None: ) assert_matches_type(DevboxAsyncExecutionDetailView, execution, path=["response"]) + @parametrize + def test_method_kill_with_all_params(self, client: Runloop) -> None: + execution = client.devboxes.executions.kill( + execution_id="execution_id", + devbox_id="devbox_id", + kill_process_group=True, + ) + assert_matches_type(DevboxAsyncExecutionDetailView, execution, path=["response"]) + @parametrize def test_raw_response_kill(self, client: Runloop) -> None: response = client.devboxes.executions.with_raw_response.kill( @@ -227,6 +236,62 @@ def test_path_params_kill(self, client: Runloop) -> None: devbox_id="devbox_id", ) + @parametrize + def test_method_stream_updates(self, client: Runloop) -> None: + execution_stream = client.devboxes.executions.stream_updates( + execution_id="execution_id", + devbox_id="devbox_id", + ) + execution_stream.response.close() + + @parametrize + def test_method_stream_updates_with_all_params(self, client: Runloop) -> None: + execution_stream = client.devboxes.executions.stream_updates( + execution_id="execution_id", + devbox_id="devbox_id", + offset="offset", + ) + execution_stream.response.close() + + @parametrize + def test_raw_response_stream_updates(self, client: Runloop) -> None: + response = client.devboxes.executions.with_raw_response.stream_updates( + execution_id="execution_id", + devbox_id="devbox_id", + ) + + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + stream = response.parse() + stream.close() + + @parametrize + def test_streaming_response_stream_updates(self, client: Runloop) -> None: + with client.devboxes.executions.with_streaming_response.stream_updates( + execution_id="execution_id", + devbox_id="devbox_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + stream = response.parse() + stream.close() + + assert cast(Any, response.is_closed) is True + + @parametrize + def test_path_params_stream_updates(self, client: Runloop) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `devbox_id` but received ''"): + client.devboxes.executions.with_raw_response.stream_updates( + execution_id="execution_id", + devbox_id="", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `execution_id` but received ''"): + client.devboxes.executions.with_raw_response.stream_updates( + execution_id="", + devbox_id="devbox_id", + ) + # Polling method tests @parametrize def test_method_await_completed_success(self, client: Runloop) -> None: @@ -393,7 +458,6 @@ def test_method_await_completed_various_statuses(self, client: Runloop) -> None: assert result.status == "completed" assert mock_post.call_count == 2 - class TestAsyncExecutions: parametrize = pytest.mark.parametrize( "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] @@ -566,6 +630,15 @@ async def test_method_kill(self, async_client: AsyncRunloop) -> None: ) assert_matches_type(DevboxAsyncExecutionDetailView, execution, path=["response"]) + @parametrize + async def test_method_kill_with_all_params(self, async_client: AsyncRunloop) -> None: + execution = await async_client.devboxes.executions.kill( + execution_id="execution_id", + devbox_id="devbox_id", + kill_process_group=True, + ) + assert_matches_type(DevboxAsyncExecutionDetailView, execution, path=["response"]) + @parametrize async def test_raw_response_kill(self, async_client: AsyncRunloop) -> None: response = await async_client.devboxes.executions.with_raw_response.kill( @@ -606,6 +679,62 @@ async def test_path_params_kill(self, async_client: AsyncRunloop) -> None: devbox_id="devbox_id", ) + @parametrize + async def test_method_stream_updates(self, async_client: AsyncRunloop) -> None: + execution_stream = await async_client.devboxes.executions.stream_updates( + execution_id="execution_id", + devbox_id="devbox_id", + ) + await execution_stream.response.aclose() + + @parametrize + async def test_method_stream_updates_with_all_params(self, async_client: AsyncRunloop) -> None: + execution_stream = await async_client.devboxes.executions.stream_updates( + execution_id="execution_id", + devbox_id="devbox_id", + offset="offset", + ) + await execution_stream.response.aclose() + + @parametrize + async def test_raw_response_stream_updates(self, async_client: AsyncRunloop) -> None: + response = await async_client.devboxes.executions.with_raw_response.stream_updates( + execution_id="execution_id", + devbox_id="devbox_id", + ) + + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + stream = await response.parse() + await stream.close() + + @parametrize + async def test_streaming_response_stream_updates(self, async_client: AsyncRunloop) -> None: + async with async_client.devboxes.executions.with_streaming_response.stream_updates( + execution_id="execution_id", + devbox_id="devbox_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + stream = await response.parse() + await stream.close() + + assert cast(Any, response.is_closed) is True + + @parametrize + async def test_path_params_stream_updates(self, async_client: AsyncRunloop) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `devbox_id` but received ''"): + await async_client.devboxes.executions.with_raw_response.stream_updates( + execution_id="execution_id", + devbox_id="", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `execution_id` but received ''"): + await async_client.devboxes.executions.with_raw_response.stream_updates( + execution_id="", + devbox_id="devbox_id", + ) + # Async polling method tests @parametrize async def test_method_await_completed_success(self, async_client: AsyncRunloop) -> None: diff --git a/tests/api_resources/test_blueprints.py b/tests/api_resources/test_blueprints.py index 0ac239ecb..5f33036ce 100644 --- a/tests/api_resources/test_blueprints.py +++ b/tests/api_resources/test_blueprints.py @@ -34,6 +34,7 @@ def test_method_create_with_all_params(self, client: Runloop) -> None: blueprint = client.blueprints.create( name="name", base_blueprint_id="base_blueprint_id", + base_blueprint_name="base_blueprint_name", code_mounts=[ { "repo_name": "repo_name", @@ -299,6 +300,7 @@ def test_method_preview_with_all_params(self, client: Runloop) -> None: blueprint = client.blueprints.preview( name="name", base_blueprint_id="base_blueprint_id", + base_blueprint_name="base_blueprint_name", code_mounts=[ { "repo_name": "repo_name", @@ -388,6 +390,7 @@ async def test_method_create_with_all_params(self, async_client: AsyncRunloop) - blueprint = await async_client.blueprints.create( name="name", base_blueprint_id="base_blueprint_id", + base_blueprint_name="base_blueprint_name", code_mounts=[ { "repo_name": "repo_name", @@ -653,6 +656,7 @@ async def test_method_preview_with_all_params(self, async_client: AsyncRunloop) blueprint = await async_client.blueprints.preview( name="name", base_blueprint_id="base_blueprint_id", + base_blueprint_name="base_blueprint_name", code_mounts=[ { "repo_name": "repo_name", From 6d0d0f88afc08d041a860f063b76db3a9644606c Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 4 Sep 2025 20:17:05 +0000 Subject: [PATCH 6/6] release: 0.58.0 --- .release-please-manifest.json | 2 +- CHANGELOG.md | 15 +++++++++++++++ pyproject.toml | 2 +- src/runloop_api_client/_version.py | 2 +- 4 files changed, 18 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 2afb750c5..2fbefb942 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.57.0" + ".": "0.58.0" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 710321319..5095bbba2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## 0.58.0 (2025-09-04) + +Full Changelog: [v0.57.0...v0.58.0](https://github.com/runloopai/api-client-python/compare/v0.57.0...v0.58.0) + +### Features + +* **api:** api update ([59e649d](https://github.com/runloopai/api-client-python/commit/59e649db1632c06c7c6b7cdabaf7e42e003daa09)) +* improve future compat with pydantic v3 ([adb9765](https://github.com/runloopai/api-client-python/commit/adb97652aea509e832ab50f25d7e04e80d8fd09e)) +* **types:** replace List[str] with SequenceNotStr in params ([9c3bbcd](https://github.com/runloopai/api-client-python/commit/9c3bbcdfdaedcf60d9829bc3cad83f89c9add98a)) + + +### Chores + +* **internal:** add Sequence related utils ([dec07b5](https://github.com/runloopai/api-client-python/commit/dec07b5258bc057375c8412c76f0a7f29f12b723)) + ## 0.57.0 (2025-08-27) Full Changelog: [v0.56.2...v0.57.0](https://github.com/runloopai/api-client-python/compare/v0.56.2...v0.57.0) diff --git a/pyproject.toml b/pyproject.toml index a3c6e3692..52e917e86 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "runloop_api_client" -version = "0.57.0" +version = "0.58.0" description = "The official Python library for the runloop API" dynamic = ["readme"] license = "MIT" diff --git a/src/runloop_api_client/_version.py b/src/runloop_api_client/_version.py index 23715c2d3..d2b85015b 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.57.0" # x-release-please-version +__version__ = "0.58.0" # x-release-please-version