diff --git a/.release-please-manifest.json b/.release-please-manifest.json index f7014c3..a713055 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.11.0" + ".": "0.12.0" } \ No newline at end of file diff --git a/.stats.yml b/.stats.yml index 51b5464..fd4484e 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ -configured_endpoints: 14 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/warp-bnavetta%2Fwarp-api-8f9c749573846b07a55a3131b66456f0a592838c6bfc986ab30948df66cd6f11.yml -openapi_spec_hash: 59f1ac98ad6cf13b12c59196bcecffd7 -config_hash: 60052b2c1c0862014416821aba875574 +configured_endpoints: 19 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/warp-bnavetta%2Fwarp-api-caba3084bc4ae52a6847df96dd0f8fe0a3b2bc9801ff4d2935092ae7e2c794f7.yml +openapi_spec_hash: d3dd12a5a9bffb132239a2a206890602 +config_hash: c5fc921cc04f541a85f92299f365eba6 diff --git a/CHANGELOG.md b/CHANGELOG.md index 543cbc1..2827223 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,43 @@ # Changelog +## 0.12.0 (2026-04-17) + +Full Changelog: [v0.11.0...v0.12.0](https://github.com/warpdotdev/oz-sdk-python/compare/v0.11.0...v0.12.0) + +### Features + +* Add parent_run_id filter to List runs endpoint ([33cf04c](https://github.com/warpdotdev/oz-sdk-python/commit/33cf04c0777d53059f8a3c73b925b468276aba2a)) +* Add system prompt to resolve-prompt for harnesses. ([2b7afab](https://github.com/warpdotdev/oz-sdk-python/commit/2b7afabf4a54764ab162ac32f4dbe7fe4274ba72)) +* Add trigger URL to task source. ([9662dbe](https://github.com/warpdotdev/oz-sdk-python/commit/9662dbe10cc5392271b3e56c35cb8ef95ec80eed)) +* Add worker_host to AgentListSource in OpenAPI spec ([ec0982c](https://github.com/warpdotdev/oz-sdk-python/commit/ec0982ce786b2380ab89aa7298b28998c6eade46)) +* **api:** api update ([f94b38b](https://github.com/warpdotdev/oz-sdk-python/commit/f94b38b2ffaf344a198e89ad7ace1d7b8e93c300)) +* **api:** api update ([7f419fa](https://github.com/warpdotdev/oz-sdk-python/commit/7f419faa01908e1b4a613ec2bdd708aed1a05871)) +* **api:** api update ([116f06e](https://github.com/warpdotdev/oz-sdk-python/commit/116f06e5c63e8d015e7dfea47fc22ede53ebb84e)) +* **api:** api update ([66c8521](https://github.com/warpdotdev/oz-sdk-python/commit/66c852194f355cedca2b34b944c398e796f064ab)) +* **api:** api update ([ebd2c55](https://github.com/warpdotdev/oz-sdk-python/commit/ebd2c5520128722d88285aea9dd0caf3242d9a31)) +* **api:** api update ([1ffc53b](https://github.com/warpdotdev/oz-sdk-python/commit/1ffc53b4d69eeb8060faac1903f1ba25f7a4c443)) +* **api:** api update ([cf28804](https://github.com/warpdotdev/oz-sdk-python/commit/cf288045d369ed927db45ac5e5ebd994ccebb547)) +* **api:** api update ([a6d82f1](https://github.com/warpdotdev/oz-sdk-python/commit/a6d82f1a71ad8e0ff5f10c43cc81938babc1444e)) +* **api:** api update ([b9872f2](https://github.com/warpdotdev/oz-sdk-python/commit/b9872f2642191ef6e1d4d5d3542a08f9e2784b9e)) +* **api:** api update ([0ef9c8f](https://github.com/warpdotdev/oz-sdk-python/commit/0ef9c8f483269d203a3edc7b5805f48f4263eb2f)) +* Inject auth secrets via ambient agent config. ([6032a0c](https://github.com/warpdotdev/oz-sdk-python/commit/6032a0ccfe2ce669ce1f815ad853b912d4ca575e)) +* Update public API and graphql to support creating multiple service accounts and API keys ([8d537bc](https://github.com/warpdotdev/oz-sdk-python/commit/8d537bcbe75b344427bd04e5406ab066769f580a)) + + +### Bug Fixes + +* ensure file data are only sent as 1 parameter ([6720ea9](https://github.com/warpdotdev/oz-sdk-python/commit/6720ea9331a945cf2d030e20b8851a223697aab3)) + + +### Performance Improvements + +* **client:** optimize file structure copying in multipart requests ([b8e42cc](https://github.com/warpdotdev/oz-sdk-python/commit/b8e42cc08805eef08a3462088ba0e32cf79dac8c)) + + +### Chores + +* update SDK settings ([f2dd099](https://github.com/warpdotdev/oz-sdk-python/commit/f2dd099128bf4f98e304778556d55e80a4fd219c)) + ## 0.11.0 (2026-04-09) Full Changelog: [v0.10.1...v0.11.0](https://github.com/warpdotdev/oz-sdk-python/compare/v0.10.1...v0.11.0) diff --git a/api.md b/api.md index 3ef6227..918ca45 100644 --- a/api.md +++ b/api.md @@ -7,6 +7,7 @@ from oz_agent_sdk.types import ( AgentSkill, AmbientAgentConfig, AwsProviderConfig, + CloudEnvironment, CloudEnvironmentConfig, Error, ErrorCode, @@ -16,6 +17,7 @@ from oz_agent_sdk.types import ( UserProfile, AgentListResponse, AgentGetArtifactResponse, + AgentListEnvironmentsResponse, AgentRunResponse, ) ``` @@ -24,6 +26,7 @@ Methods: - client.agent.list(\*\*params) -> AgentListResponse - client.agent.get_artifact(artifact_uid) -> AgentGetArtifactResponse +- client.agent.list_environments(\*\*params) -> AgentListEnvironmentsResponse - client.agent.run(\*\*params) -> AgentRunResponse ## Runs @@ -69,6 +72,26 @@ Methods: - client.agent.schedules.pause(schedule_id) -> ScheduledAgentItem - client.agent.schedules.resume(schedule_id) -> ScheduledAgentItem +## Agent + +Types: + +```python +from oz_agent_sdk.types.agent import ( + AgentResponse, + CreateAgentRequest, + ListAgentIdentitiesResponse, + UpdateAgentRequest, +) +``` + +Methods: + +- client.agent.agent.create(\*\*params) -> AgentResponse +- client.agent.agent.update(uid, \*\*params) -> AgentResponse +- client.agent.agent.list() -> ListAgentIdentitiesResponse +- client.agent.agent.delete(uid) -> None + ## Sessions Types: diff --git a/pyproject.toml b/pyproject.toml index 01da747..6b28584 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "oz-agent-sdk" -version = "0.11.0" +version = "0.12.0" description = "The official Python library for the oz-api API" dynamic = ["readme"] license = "Apache-2.0" diff --git a/src/oz_agent_sdk/_files.py b/src/oz_agent_sdk/_files.py index cc14c14..0fdce17 100644 --- a/src/oz_agent_sdk/_files.py +++ b/src/oz_agent_sdk/_files.py @@ -3,8 +3,8 @@ import io import os import pathlib -from typing import overload -from typing_extensions import TypeGuard +from typing import Sequence, cast, overload +from typing_extensions import TypeVar, TypeGuard import anyio @@ -17,7 +17,9 @@ HttpxFileContent, HttpxRequestFiles, ) -from ._utils import is_tuple_t, is_mapping_t, is_sequence_t +from ._utils import is_list, is_mapping, is_tuple_t, is_mapping_t, is_sequence_t + +_T = TypeVar("_T") def is_base64_file_input(obj: object) -> TypeGuard[Base64FileInput]: @@ -121,3 +123,51 @@ async def async_read_file_content(file: FileContent) -> HttpxFileContent: return await anyio.Path(file).read_bytes() return file + + +def deepcopy_with_paths(item: _T, paths: Sequence[Sequence[str]]) -> _T: + """Copy only the containers along the given paths. + + Used to guard against mutation by extract_files without copying the entire structure. + Only dicts and lists that lie on a path are copied; everything else + is returned by reference. + + For example, given paths=[["foo", "files", "file"]] and the structure: + { + "foo": { + "bar": {"baz": {}}, + "files": {"file": } + } + } + The root dict, "foo", and "files" are copied (they lie on the path). + "bar" and "baz" are returned by reference (off the path). + """ + return _deepcopy_with_paths(item, paths, 0) + + +def _deepcopy_with_paths(item: _T, paths: Sequence[Sequence[str]], index: int) -> _T: + if not paths: + return item + if is_mapping(item): + key_to_paths: dict[str, list[Sequence[str]]] = {} + for path in paths: + if index < len(path): + key_to_paths.setdefault(path[index], []).append(path) + + # if no path continues through this mapping, it won't be mutated and copying it is redundant + if not key_to_paths: + return item + + result = dict(item) + for key, subpaths in key_to_paths.items(): + if key in result: + result[key] = _deepcopy_with_paths(result[key], subpaths, index + 1) + return cast(_T, result) + if is_list(item): + array_paths = [path for path in paths if index < len(path) and path[index] == ""] + + # if no path expects a list here, nothing will be mutated inside it - return by reference + if not array_paths: + return cast(_T, item) + return cast(_T, [_deepcopy_with_paths(entry, array_paths, index + 1) for entry in item]) + return item diff --git a/src/oz_agent_sdk/_utils/__init__.py b/src/oz_agent_sdk/_utils/__init__.py index 10cb66d..1c090e5 100644 --- a/src/oz_agent_sdk/_utils/__init__.py +++ b/src/oz_agent_sdk/_utils/__init__.py @@ -24,7 +24,6 @@ coerce_integer as coerce_integer, file_from_path as file_from_path, strip_not_given as strip_not_given, - deepcopy_minimal as deepcopy_minimal, get_async_library as get_async_library, maybe_coerce_float as maybe_coerce_float, get_required_header as get_required_header, diff --git a/src/oz_agent_sdk/_utils/_utils.py b/src/oz_agent_sdk/_utils/_utils.py index eec7f4a..771859f 100644 --- a/src/oz_agent_sdk/_utils/_utils.py +++ b/src/oz_agent_sdk/_utils/_utils.py @@ -86,8 +86,9 @@ def _extract_items( index += 1 if is_dict(obj): try: - # We are at the last entry in the path so we must remove the field - if (len(path)) == index: + # Remove the field if there are no more dict keys in the path, + # only "" traversal markers or end. + if all(p == "" for p in path[index:]): item = obj.pop(key) else: item = obj[key] @@ -176,21 +177,6 @@ def is_iterable(obj: object) -> TypeGuard[Iterable[object]]: return isinstance(obj, Iterable) -def deepcopy_minimal(item: _T) -> _T: - """Minimal reimplementation of copy.deepcopy() that will only copy certain object types: - - - mappings, e.g. `dict` - - list - - This is done for performance reasons. - """ - if is_mapping(item): - return cast(_T, {k: deepcopy_minimal(v) for k, v in item.items()}) - if is_list(item): - return cast(_T, [deepcopy_minimal(entry) for entry in item]) - return item - - # copied from https://github.com/Rapptz/RoboDanny def human_join(seq: Sequence[str], *, delim: str = ", ", final: str = "or") -> str: size = len(seq) diff --git a/src/oz_agent_sdk/_version.py b/src/oz_agent_sdk/_version.py index 113b87a..b96fedf 100644 --- a/src/oz_agent_sdk/_version.py +++ b/src/oz_agent_sdk/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "oz_agent_sdk" -__version__ = "0.11.0" # x-release-please-version +__version__ = "0.12.0" # x-release-please-version diff --git a/src/oz_agent_sdk/resources/agent/agent.py b/src/oz_agent_sdk/resources/agent/agent.py index b720375..0b12e59 100644 --- a/src/oz_agent_sdk/resources/agent/agent.py +++ b/src/oz_agent_sdk/resources/agent/agent.py @@ -7,6 +7,7 @@ import httpx +from . import agent_ as agent from .runs import ( RunsResource, AsyncRunsResource, @@ -15,7 +16,7 @@ RunsResourceWithStreamingResponse, AsyncRunsResourceWithStreamingResponse, ) -from ...types import agent_run_params, agent_list_params +from ...types import agent_run_params, agent_list_params, agent_list_environments_params from ..._types import Body, Omit, Query, Headers, NotGiven, omit, not_given from ..._utils import path_template, maybe_transform, async_maybe_transform from .sessions import ( @@ -47,6 +48,7 @@ from ...types.agent_list_response import AgentListResponse from ...types.ambient_agent_config_param import AmbientAgentConfigParam from ...types.agent_get_artifact_response import AgentGetArtifactResponse +from ...types.agent_list_environments_response import AgentListEnvironmentsResponse __all__ = ["AgentResource", "AsyncAgentResource"] @@ -64,6 +66,11 @@ def schedules(self) -> SchedulesResource: """Operations for creating and managing scheduled agents""" return SchedulesResource(self._client) + @cached_property + def agent(self) -> agent.AgentResource: + """Operations for running and managing cloud agents""" + return agent.AgentResource(self._client) + @cached_property def sessions(self) -> SessionsResource: """Operations for running and managing cloud agents""" @@ -163,8 +170,9 @@ def get_artifact( ) -> AgentGetArtifactResponse: """Retrieve an artifact by its UUID. - For supported downloadable artifacts, returns - a time-limited signed download URL. + For downloadable file-like artifacts, returns + a time-limited signed download URL. For plan artifacts, returns the current plan + content inline. Args: extra_headers: Send extra headers @@ -190,6 +198,49 @@ def get_artifact( ), ) + def list_environments( + self, + *, + sort_by: Literal["name", "last_updated"] | Omit = omit, + # 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, + ) -> AgentListEnvironmentsResponse: + """Retrieve cloud environments accessible to the authenticated principal. + + Returns + environments the caller owns, has been granted guest access to, or has accessed + via link sharing. + + Args: + sort_by: Sort order for the returned environments. + + - `name`: alphabetical by environment name + - `last_updated`: most recently updated first (default) + + 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 + """ + return self._get( + "/agent/environments", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform({"sort_by": sort_by}, agent_list_environments_params.AgentListEnvironmentsParams), + ), + cast_to=AgentListEnvironmentsResponse, + ) + def run( self, *, @@ -293,6 +344,11 @@ def schedules(self) -> AsyncSchedulesResource: """Operations for creating and managing scheduled agents""" return AsyncSchedulesResource(self._client) + @cached_property + def agent(self) -> agent.AsyncAgentResource: + """Operations for running and managing cloud agents""" + return agent.AsyncAgentResource(self._client) + @cached_property def sessions(self) -> AsyncSessionsResource: """Operations for running and managing cloud agents""" @@ -392,8 +448,9 @@ async def get_artifact( ) -> AgentGetArtifactResponse: """Retrieve an artifact by its UUID. - For supported downloadable artifacts, returns - a time-limited signed download URL. + For downloadable file-like artifacts, returns + a time-limited signed download URL. For plan artifacts, returns the current plan + content inline. Args: extra_headers: Send extra headers @@ -419,6 +476,51 @@ async def get_artifact( ), ) + async def list_environments( + self, + *, + sort_by: Literal["name", "last_updated"] | Omit = omit, + # 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, + ) -> AgentListEnvironmentsResponse: + """Retrieve cloud environments accessible to the authenticated principal. + + Returns + environments the caller owns, has been granted guest access to, or has accessed + via link sharing. + + Args: + sort_by: Sort order for the returned environments. + + - `name`: alphabetical by environment name + - `last_updated`: most recently updated first (default) + + 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 + """ + return await self._get( + "/agent/environments", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform( + {"sort_by": sort_by}, agent_list_environments_params.AgentListEnvironmentsParams + ), + ), + cast_to=AgentListEnvironmentsResponse, + ) + async def run( self, *, @@ -519,6 +621,9 @@ def __init__(self, agent: AgentResource) -> None: self.get_artifact = to_raw_response_wrapper( agent.get_artifact, ) + self.list_environments = to_raw_response_wrapper( + agent.list_environments, + ) self.run = to_raw_response_wrapper( agent.run, ) @@ -533,6 +638,11 @@ def schedules(self) -> SchedulesResourceWithRawResponse: """Operations for creating and managing scheduled agents""" return SchedulesResourceWithRawResponse(self._agent.schedules) + @cached_property + def agent(self) -> agent.AgentResourceWithRawResponse: + """Operations for running and managing cloud agents""" + return agent.AgentResourceWithRawResponse(self._agent.agent) + @cached_property def sessions(self) -> SessionsResourceWithRawResponse: """Operations for running and managing cloud agents""" @@ -549,6 +659,9 @@ def __init__(self, agent: AsyncAgentResource) -> None: self.get_artifact = async_to_raw_response_wrapper( agent.get_artifact, ) + self.list_environments = async_to_raw_response_wrapper( + agent.list_environments, + ) self.run = async_to_raw_response_wrapper( agent.run, ) @@ -563,6 +676,11 @@ def schedules(self) -> AsyncSchedulesResourceWithRawResponse: """Operations for creating and managing scheduled agents""" return AsyncSchedulesResourceWithRawResponse(self._agent.schedules) + @cached_property + def agent(self) -> agent.AsyncAgentResourceWithRawResponse: + """Operations for running and managing cloud agents""" + return agent.AsyncAgentResourceWithRawResponse(self._agent.agent) + @cached_property def sessions(self) -> AsyncSessionsResourceWithRawResponse: """Operations for running and managing cloud agents""" @@ -579,6 +697,9 @@ def __init__(self, agent: AgentResource) -> None: self.get_artifact = to_streamed_response_wrapper( agent.get_artifact, ) + self.list_environments = to_streamed_response_wrapper( + agent.list_environments, + ) self.run = to_streamed_response_wrapper( agent.run, ) @@ -593,6 +714,11 @@ def schedules(self) -> SchedulesResourceWithStreamingResponse: """Operations for creating and managing scheduled agents""" return SchedulesResourceWithStreamingResponse(self._agent.schedules) + @cached_property + def agent(self) -> agent.AgentResourceWithStreamingResponse: + """Operations for running and managing cloud agents""" + return agent.AgentResourceWithStreamingResponse(self._agent.agent) + @cached_property def sessions(self) -> SessionsResourceWithStreamingResponse: """Operations for running and managing cloud agents""" @@ -609,6 +735,9 @@ def __init__(self, agent: AsyncAgentResource) -> None: self.get_artifact = async_to_streamed_response_wrapper( agent.get_artifact, ) + self.list_environments = async_to_streamed_response_wrapper( + agent.list_environments, + ) self.run = async_to_streamed_response_wrapper( agent.run, ) @@ -623,6 +752,11 @@ def schedules(self) -> AsyncSchedulesResourceWithStreamingResponse: """Operations for creating and managing scheduled agents""" return AsyncSchedulesResourceWithStreamingResponse(self._agent.schedules) + @cached_property + def agent(self) -> agent.AsyncAgentResourceWithStreamingResponse: + """Operations for running and managing cloud agents""" + return agent.AsyncAgentResourceWithStreamingResponse(self._agent.agent) + @cached_property def sessions(self) -> AsyncSessionsResourceWithStreamingResponse: """Operations for running and managing cloud agents""" diff --git a/src/oz_agent_sdk/resources/agent/agent_.py b/src/oz_agent_sdk/resources/agent/agent_.py new file mode 100644 index 0000000..2c82516 --- /dev/null +++ b/src/oz_agent_sdk/resources/agent/agent_.py @@ -0,0 +1,402 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import httpx + +from ..._types import Body, Omit, Query, Headers, NoneType, NotGiven, omit, not_given +from ..._utils import path_template, maybe_transform, async_maybe_transform +from ..._compat import cached_property +from ..._resource import SyncAPIResource, AsyncAPIResource +from ..._response import ( + to_raw_response_wrapper, + to_streamed_response_wrapper, + async_to_raw_response_wrapper, + async_to_streamed_response_wrapper, +) +from ...types.agent import agent_create_params, agent_update_params +from ..._base_client import make_request_options +from ...types.agent.agent_response import AgentResponse +from ...types.agent.list_agent_identities_response import ListAgentIdentitiesResponse + +__all__ = ["AgentResource", "AsyncAgentResource"] + + +class AgentResource(SyncAPIResource): + """Operations for running and managing cloud agents""" + + @cached_property + def with_raw_response(self) -> AgentResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/warpdotdev/oz-sdk-python#accessing-raw-response-data-eg-headers + """ + return AgentResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AgentResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/warpdotdev/oz-sdk-python#with_streaming_response + """ + return AgentResourceWithStreamingResponse(self) + + def create( + self, + *, + name: str, + # 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, + ) -> AgentResponse: + """Create a new agent for the caller's team. + + Agents can be used as the execution + principal for team-owned runs. + + Args: + name: A name for the agent + + 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 + """ + return self._post( + "/agent/identities", + body=maybe_transform({"name": name}, agent_create_params.AgentCreateParams), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=AgentResponse, + ) + + def update( + self, + uid: str, + *, + name: str | Omit = omit, + # 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, + ) -> AgentResponse: + """ + Update an existing agent. + + Args: + name: The new name for the agent + + 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 uid: + raise ValueError(f"Expected a non-empty value for `uid` but received {uid!r}") + return self._put( + path_template("/agent/identities/{uid}", uid=uid), + body=maybe_transform({"name": name}, agent_update_params.AgentUpdateParams), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=AgentResponse, + ) + + def list( + self, + *, + # 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, + ) -> ListAgentIdentitiesResponse: + """List all agents for the caller's team. + + Each agent includes an `available` flag + indicating whether it is within the team's plan limit and may be used for runs. + """ + return self._get( + "/agent/identities", + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=ListAgentIdentitiesResponse, + ) + + def delete( + self, + uid: str, + *, + # 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, + ) -> None: + """Delete an agent. + + All API keys associated with the agent are deleted atomically. + + Args: + 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 uid: + raise ValueError(f"Expected a non-empty value for `uid` but received {uid!r}") + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + return self._delete( + path_template("/agent/identities/{uid}", uid=uid), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + + +class AsyncAgentResource(AsyncAPIResource): + """Operations for running and managing cloud agents""" + + @cached_property + def with_raw_response(self) -> AsyncAgentResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/warpdotdev/oz-sdk-python#accessing-raw-response-data-eg-headers + """ + return AsyncAgentResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncAgentResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/warpdotdev/oz-sdk-python#with_streaming_response + """ + return AsyncAgentResourceWithStreamingResponse(self) + + async def create( + self, + *, + name: str, + # 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, + ) -> AgentResponse: + """Create a new agent for the caller's team. + + Agents can be used as the execution + principal for team-owned runs. + + Args: + name: A name for the agent + + 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 + """ + return await self._post( + "/agent/identities", + body=await async_maybe_transform({"name": name}, agent_create_params.AgentCreateParams), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=AgentResponse, + ) + + async def update( + self, + uid: str, + *, + name: str | Omit = omit, + # 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, + ) -> AgentResponse: + """ + Update an existing agent. + + Args: + name: The new name for the agent + + 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 uid: + raise ValueError(f"Expected a non-empty value for `uid` but received {uid!r}") + return await self._put( + path_template("/agent/identities/{uid}", uid=uid), + body=await async_maybe_transform({"name": name}, agent_update_params.AgentUpdateParams), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=AgentResponse, + ) + + async def list( + self, + *, + # 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, + ) -> ListAgentIdentitiesResponse: + """List all agents for the caller's team. + + Each agent includes an `available` flag + indicating whether it is within the team's plan limit and may be used for runs. + """ + return await self._get( + "/agent/identities", + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=ListAgentIdentitiesResponse, + ) + + async def delete( + self, + uid: str, + *, + # 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, + ) -> None: + """Delete an agent. + + All API keys associated with the agent are deleted atomically. + + Args: + 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 uid: + raise ValueError(f"Expected a non-empty value for `uid` but received {uid!r}") + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + return await self._delete( + path_template("/agent/identities/{uid}", uid=uid), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + + +class AgentResourceWithRawResponse: + def __init__(self, agent: AgentResource) -> None: + self._agent = agent + + self.create = to_raw_response_wrapper( + agent.create, + ) + self.update = to_raw_response_wrapper( + agent.update, + ) + self.list = to_raw_response_wrapper( + agent.list, + ) + self.delete = to_raw_response_wrapper( + agent.delete, + ) + + +class AsyncAgentResourceWithRawResponse: + def __init__(self, agent: AsyncAgentResource) -> None: + self._agent = agent + + self.create = async_to_raw_response_wrapper( + agent.create, + ) + self.update = async_to_raw_response_wrapper( + agent.update, + ) + self.list = async_to_raw_response_wrapper( + agent.list, + ) + self.delete = async_to_raw_response_wrapper( + agent.delete, + ) + + +class AgentResourceWithStreamingResponse: + def __init__(self, agent: AgentResource) -> None: + self._agent = agent + + self.create = to_streamed_response_wrapper( + agent.create, + ) + self.update = to_streamed_response_wrapper( + agent.update, + ) + self.list = to_streamed_response_wrapper( + agent.list, + ) + self.delete = to_streamed_response_wrapper( + agent.delete, + ) + + +class AsyncAgentResourceWithStreamingResponse: + def __init__(self, agent: AsyncAgentResource) -> None: + self._agent = agent + + self.create = async_to_streamed_response_wrapper( + agent.create, + ) + self.update = async_to_streamed_response_wrapper( + agent.update, + ) + self.list = async_to_streamed_response_wrapper( + agent.list, + ) + self.delete = async_to_streamed_response_wrapper( + agent.delete, + ) diff --git a/src/oz_agent_sdk/resources/agent/runs.py b/src/oz_agent_sdk/resources/agent/runs.py index e6e55b5..00c3f49 100644 --- a/src/oz_agent_sdk/resources/agent/runs.py +++ b/src/oz_agent_sdk/resources/agent/runs.py @@ -87,6 +87,7 @@ def retrieve( def list( self, *, + ancestor_run_id: str | Omit = omit, artifact_type: Literal["PLAN", "PULL_REQUEST", "SCREENSHOT", "FILE"] | Omit = omit, created_after: Union[str, datetime] | Omit = omit, created_before: Union[str, datetime] | Omit = omit, @@ -119,6 +120,9 @@ def list( to `sort_by=updated_at` and `sort_order=desc`. Args: + ancestor_run_id: Filter runs by ancestor run ID. The referenced run must exist and be accessible + to the caller. + artifact_type: Filter runs by artifact type created_after: Filter runs created after this timestamp (RFC3339 format) @@ -182,6 +186,7 @@ def list( timeout=timeout, query=maybe_transform( { + "ancestor_run_id": ancestor_run_id, "artifact_type": artifact_type, "created_after": created_after, "created_before": created_before, @@ -307,6 +312,7 @@ async def retrieve( def list( self, *, + ancestor_run_id: str | Omit = omit, artifact_type: Literal["PLAN", "PULL_REQUEST", "SCREENSHOT", "FILE"] | Omit = omit, created_after: Union[str, datetime] | Omit = omit, created_before: Union[str, datetime] | Omit = omit, @@ -339,6 +345,9 @@ def list( to `sort_by=updated_at` and `sort_order=desc`. Args: + ancestor_run_id: Filter runs by ancestor run ID. The referenced run must exist and be accessible + to the caller. + artifact_type: Filter runs by artifact type created_after: Filter runs created after this timestamp (RFC3339 format) @@ -402,6 +411,7 @@ def list( timeout=timeout, query=maybe_transform( { + "ancestor_run_id": ancestor_run_id, "artifact_type": artifact_type, "created_after": created_after, "created_before": created_before, diff --git a/src/oz_agent_sdk/types/__init__.py b/src/oz_agent_sdk/types/__init__.py index 4c520a7..e27ca02 100644 --- a/src/oz_agent_sdk/types/__init__.py +++ b/src/oz_agent_sdk/types/__init__.py @@ -8,6 +8,7 @@ from .user_profile import UserProfile as UserProfile from .agent_run_params import AgentRunParams as AgentRunParams from .agent_list_params import AgentListParams as AgentListParams +from .cloud_environment import CloudEnvironment as CloudEnvironment from .mcp_server_config import McpServerConfig as McpServerConfig from .agent_run_response import AgentRunResponse as AgentRunResponse from .agent_list_response import AgentListResponse as AgentListResponse @@ -18,3 +19,5 @@ from .cloud_environment_config import CloudEnvironmentConfig as CloudEnvironmentConfig from .ambient_agent_config_param import AmbientAgentConfigParam as AmbientAgentConfigParam from .agent_get_artifact_response import AgentGetArtifactResponse as AgentGetArtifactResponse +from .agent_list_environments_params import AgentListEnvironmentsParams as AgentListEnvironmentsParams +from .agent_list_environments_response import AgentListEnvironmentsResponse as AgentListEnvironmentsResponse diff --git a/src/oz_agent_sdk/types/agent/__init__.py b/src/oz_agent_sdk/types/agent/__init__.py index 1176974..ca0f9d8 100644 --- a/src/oz_agent_sdk/types/agent/__init__.py +++ b/src/oz_agent_sdk/types/agent/__init__.py @@ -5,8 +5,11 @@ from .run_item import RunItem as RunItem from .run_state import RunState as RunState from .artifact_item import ArtifactItem as ArtifactItem +from .agent_response import AgentResponse as AgentResponse from .run_list_params import RunListParams as RunListParams from .run_source_type import RunSourceType as RunSourceType +from .agent_create_params import AgentCreateParams as AgentCreateParams +from .agent_update_params import AgentUpdateParams as AgentUpdateParams from .run_cancel_response import RunCancelResponse as RunCancelResponse from .scheduled_agent_item import ScheduledAgentItem as ScheduledAgentItem from .schedule_create_params import ScheduleCreateParams as ScheduleCreateParams @@ -14,4 +17,5 @@ from .schedule_update_params import ScheduleUpdateParams as ScheduleUpdateParams from .schedule_delete_response import ScheduleDeleteResponse as ScheduleDeleteResponse from .scheduled_agent_history_item import ScheduledAgentHistoryItem as ScheduledAgentHistoryItem +from .list_agent_identities_response import ListAgentIdentitiesResponse as ListAgentIdentitiesResponse from .session_check_redirect_response import SessionCheckRedirectResponse as SessionCheckRedirectResponse diff --git a/src/oz_agent_sdk/types/agent/agent_create_params.py b/src/oz_agent_sdk/types/agent/agent_create_params.py new file mode 100644 index 0000000..5f0a737 --- /dev/null +++ b/src/oz_agent_sdk/types/agent/agent_create_params.py @@ -0,0 +1,12 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, TypedDict + +__all__ = ["AgentCreateParams"] + + +class AgentCreateParams(TypedDict, total=False): + name: Required[str] + """A name for the agent""" diff --git a/src/oz_agent_sdk/types/agent/agent_response.py b/src/oz_agent_sdk/types/agent/agent_response.py new file mode 100644 index 0000000..d748157 --- /dev/null +++ b/src/oz_agent_sdk/types/agent/agent_response.py @@ -0,0 +1,21 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from datetime import datetime + +from ..._models import BaseModel + +__all__ = ["AgentResponse"] + + +class AgentResponse(BaseModel): + available: bool + """Whether this agent is within the team's plan limit and can be used for runs""" + + created_at: datetime + """When the agent was created (RFC3339)""" + + name: str + """Name of the agent""" + + uid: str + """Unique identifier for the agent""" diff --git a/src/oz_agent_sdk/types/agent/agent_update_params.py b/src/oz_agent_sdk/types/agent/agent_update_params.py new file mode 100644 index 0000000..e5aecdd --- /dev/null +++ b/src/oz_agent_sdk/types/agent/agent_update_params.py @@ -0,0 +1,12 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import TypedDict + +__all__ = ["AgentUpdateParams"] + + +class AgentUpdateParams(TypedDict, total=False): + name: str + """The new name for the agent""" diff --git a/src/oz_agent_sdk/types/agent/artifact_item.py b/src/oz_agent_sdk/types/agent/artifact_item.py index 299379b..437b857 100644 --- a/src/oz_agent_sdk/types/agent/artifact_item.py +++ b/src/oz_agent_sdk/types/agent/artifact_item.py @@ -24,12 +24,21 @@ class PlanArtifactData(BaseModel): document_uid: str """Unique identifier for the plan document""" + artifact_uid: Optional[str] = None + """ + Unique identifier for the plan artifact, usable with the artifact retrieval + endpoint + """ + notebook_uid: Optional[str] = None """Unique identifier for the associated notebook""" title: Optional[str] = None """Title of the plan""" + url: Optional[str] = None + """URL to open the plan in Warp Drive""" + class PlanArtifact(BaseModel): artifact_type: Literal["PLAN"] diff --git a/src/oz_agent_sdk/types/agent/list_agent_identities_response.py b/src/oz_agent_sdk/types/agent/list_agent_identities_response.py new file mode 100644 index 0000000..d796e6f --- /dev/null +++ b/src/oz_agent_sdk/types/agent/list_agent_identities_response.py @@ -0,0 +1,12 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List + +from ..._models import BaseModel +from .agent_response import AgentResponse + +__all__ = ["ListAgentIdentitiesResponse"] + + +class ListAgentIdentitiesResponse(BaseModel): + agents: List[AgentResponse] diff --git a/src/oz_agent_sdk/types/agent/run_item.py b/src/oz_agent_sdk/types/agent/run_item.py index d246450..61dd012 100644 --- a/src/oz_agent_sdk/types/agent/run_item.py +++ b/src/oz_agent_sdk/types/agent/run_item.py @@ -215,3 +215,6 @@ class RunItem(BaseModel): For terminal error states, includes structured error code and retryability info from the platform error catalog. """ + + trigger_url: Optional[str] = None + """URL to the run trigger (e.g. Slack thread, Linear issue, schedule)""" diff --git a/src/oz_agent_sdk/types/agent/run_list_params.py b/src/oz_agent_sdk/types/agent/run_list_params.py index 0b4e389..b53a299 100644 --- a/src/oz_agent_sdk/types/agent/run_list_params.py +++ b/src/oz_agent_sdk/types/agent/run_list_params.py @@ -14,6 +14,12 @@ class RunListParams(TypedDict, total=False): + ancestor_run_id: str + """Filter runs by ancestor run ID. + + The referenced run must exist and be accessible to the caller. + """ + artifact_type: Literal["PLAN", "PULL_REQUEST", "SCREENSHOT", "FILE"] """Filter runs by artifact type""" diff --git a/src/oz_agent_sdk/types/agent_get_artifact_response.py b/src/oz_agent_sdk/types/agent_get_artifact_response.py index 30405bc..7e835c8 100644 --- a/src/oz_agent_sdk/types/agent_get_artifact_response.py +++ b/src/oz_agent_sdk/types/agent_get_artifact_response.py @@ -9,6 +9,8 @@ __all__ = [ "AgentGetArtifactResponse", + "PlanArtifactResponse", + "PlanArtifactResponseData", "ScreenshotArtifactResponse", "ScreenshotArtifactResponseData", "FileArtifactResponse", @@ -16,6 +18,44 @@ ] +class PlanArtifactResponseData(BaseModel): + """Response data for a plan artifact, including current markdown content.""" + + content: str + """Current markdown content of the plan""" + + content_type: str + """MIME type of the returned plan content""" + + document_uid: str + """Unique identifier for the plan document""" + + notebook_uid: str + """Unique identifier for the associated notebook""" + + title: Optional[str] = None + """Current title of the plan""" + + url: Optional[str] = None + """URL to open the plan in Warp Drive""" + + +class PlanArtifactResponse(BaseModel): + """Response for retrieving a plan artifact.""" + + artifact_type: Literal["PLAN"] + """Type of the artifact""" + + artifact_uid: str + """Unique identifier (UUID) for the artifact""" + + created_at: datetime + """Timestamp when the artifact was created (RFC3339)""" + + data: PlanArtifactResponseData + """Response data for a plan artifact, including current markdown content.""" + + class ScreenshotArtifactResponseData(BaseModel): """Response data for a screenshot artifact, including a signed download URL.""" @@ -90,5 +130,6 @@ class FileArtifactResponse(BaseModel): AgentGetArtifactResponse: TypeAlias = Annotated[ - Union[ScreenshotArtifactResponse, FileArtifactResponse], PropertyInfo(discriminator="artifact_type") + Union[PlanArtifactResponse, ScreenshotArtifactResponse, FileArtifactResponse], + PropertyInfo(discriminator="artifact_type"), ] diff --git a/src/oz_agent_sdk/types/agent_list_environments_params.py b/src/oz_agent_sdk/types/agent_list_environments_params.py new file mode 100644 index 0000000..481c31d --- /dev/null +++ b/src/oz_agent_sdk/types/agent_list_environments_params.py @@ -0,0 +1,16 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Literal, TypedDict + +__all__ = ["AgentListEnvironmentsParams"] + + +class AgentListEnvironmentsParams(TypedDict, total=False): + sort_by: Literal["name", "last_updated"] + """Sort order for the returned environments. + + - `name`: alphabetical by environment name + - `last_updated`: most recently updated first (default) + """ diff --git a/src/oz_agent_sdk/types/agent_list_environments_response.py b/src/oz_agent_sdk/types/agent_list_environments_response.py new file mode 100644 index 0000000..103845a --- /dev/null +++ b/src/oz_agent_sdk/types/agent_list_environments_response.py @@ -0,0 +1,13 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List + +from .._models import BaseModel +from .cloud_environment import CloudEnvironment + +__all__ = ["AgentListEnvironmentsResponse"] + + +class AgentListEnvironmentsResponse(BaseModel): + environments: List[CloudEnvironment] + """List of accessible cloud environments""" diff --git a/src/oz_agent_sdk/types/agent_skill.py b/src/oz_agent_sdk/types/agent_skill.py index 9b3386f..b7b9ef4 100644 --- a/src/oz_agent_sdk/types/agent_skill.py +++ b/src/oz_agent_sdk/types/agent_skill.py @@ -26,6 +26,13 @@ class VariantSource(BaseModel): skill_path: str """Path to the skill definition file within the repository""" + worker_host: Optional[str] = None + """ + Self-hosted worker host that reported this skill. Present only for skills + discovered from self-hosted workers (as opposed to skills from GitHub repos + linked to environments). + """ + class Variant(BaseModel): id: str diff --git a/src/oz_agent_sdk/types/ambient_agent_config.py b/src/oz_agent_sdk/types/ambient_agent_config.py index abd16c1..69eaf3a 100644 --- a/src/oz_agent_sdk/types/ambient_agent_config.py +++ b/src/oz_agent_sdk/types/ambient_agent_config.py @@ -8,7 +8,7 @@ from .._models import BaseModel from .mcp_server_config import McpServerConfig -__all__ = ["AmbientAgentConfig", "Harness"] +__all__ = ["AmbientAgentConfig", "Harness", "HarnessAuthSecrets"] class Harness(BaseModel): @@ -17,15 +17,6 @@ class Harness(BaseModel): Default (nil/empty) uses Warp's built-in harness. """ - auth_secret_name: Optional[str] = None - """Name of a managed secret to use as the authentication credential for the - harness. - - The secret must exist within the caller's personal or team scope. The - environment variable injected into the agent is determined by the secret type - (e.g. ANTHROPIC_API_KEY for anthropic_api_key secrets). - """ - type: Optional[Literal["oz", "claude"]] = None """The harness type identifier. @@ -34,6 +25,20 @@ class Harness(BaseModel): """ +class HarnessAuthSecrets(BaseModel): + """ + Authentication secrets for third-party harnesses. + Only the secret for the harness specified gets injected into the environment. + """ + + claude_auth_secret_name: Optional[str] = None + """ + Name of a managed secret for Claude Code harness authentication. The secret must + exist within the caller's personal or team scope. Only applicable when harness + type is "claude". + """ + + class AmbientAgentConfig(BaseModel): """Configuration for a cloud agent run""" @@ -55,6 +60,12 @@ class AmbientAgentConfig(BaseModel): uses Warp's built-in harness. """ + harness_auth_secrets: Optional[HarnessAuthSecrets] = None + """ + Authentication secrets for third-party harnesses. Only the secret for the + harness specified gets injected into the environment. + """ + idle_timeout_minutes: Optional[int] = None """ Number of minutes to keep the agent environment alive after task completion. If diff --git a/src/oz_agent_sdk/types/ambient_agent_config_param.py b/src/oz_agent_sdk/types/ambient_agent_config_param.py index 24da607..8ea7901 100644 --- a/src/oz_agent_sdk/types/ambient_agent_config_param.py +++ b/src/oz_agent_sdk/types/ambient_agent_config_param.py @@ -7,7 +7,7 @@ from .mcp_server_config_param import McpServerConfigParam -__all__ = ["AmbientAgentConfigParam", "Harness"] +__all__ = ["AmbientAgentConfigParam", "Harness", "HarnessAuthSecrets"] class Harness(TypedDict, total=False): @@ -16,15 +16,6 @@ class Harness(TypedDict, total=False): Default (nil/empty) uses Warp's built-in harness. """ - auth_secret_name: str - """Name of a managed secret to use as the authentication credential for the - harness. - - The secret must exist within the caller's personal or team scope. The - environment variable injected into the agent is determined by the secret type - (e.g. ANTHROPIC_API_KEY for anthropic_api_key secrets). - """ - type: Literal["oz", "claude"] """The harness type identifier. @@ -33,6 +24,20 @@ class Harness(TypedDict, total=False): """ +class HarnessAuthSecrets(TypedDict, total=False): + """ + Authentication secrets for third-party harnesses. + Only the secret for the harness specified gets injected into the environment. + """ + + claude_auth_secret_name: str + """ + Name of a managed secret for Claude Code harness authentication. The secret must + exist within the caller's personal or team scope. Only applicable when harness + type is "claude". + """ + + class AmbientAgentConfigParam(TypedDict, total=False): """Configuration for a cloud agent run""" @@ -54,6 +59,12 @@ class AmbientAgentConfigParam(TypedDict, total=False): uses Warp's built-in harness. """ + harness_auth_secrets: HarnessAuthSecrets + """ + Authentication secrets for third-party harnesses. Only the secret for the + harness specified gets injected into the environment. + """ + idle_timeout_minutes: int """ Number of minutes to keep the agent environment alive after task completion. If diff --git a/src/oz_agent_sdk/types/cloud_environment.py b/src/oz_agent_sdk/types/cloud_environment.py new file mode 100644 index 0000000..4bde98c --- /dev/null +++ b/src/oz_agent_sdk/types/cloud_environment.py @@ -0,0 +1,74 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional +from datetime import datetime + +from .scope import Scope +from .._models import BaseModel +from .user_profile import UserProfile +from .agent.run_state import RunState +from .cloud_environment_config import CloudEnvironmentConfig + +__all__ = ["CloudEnvironment", "LastTaskCreated"] + + +class LastTaskCreated(BaseModel): + """Summary of the most recently created task for an environment""" + + id: str + """Unique identifier of the task""" + + created_at: datetime + """When the task was created (RFC3339)""" + + state: RunState + """Current state of the run: + + - QUEUED: Run is waiting to be picked up + - PENDING: Run is being prepared + - CLAIMED: Run has been claimed by a worker + - INPROGRESS: Run is actively being executed + - SUCCEEDED: Run completed successfully + - FAILED: Run failed + - BLOCKED: Run is blocked (e.g., awaiting user input or approval) + - ERROR: Run encountered an error + - CANCELLED: Run was cancelled by user + """ + + title: str + """Title of the task""" + + updated_at: datetime + """When the task was last updated (RFC3339)""" + + started_at: Optional[datetime] = None + """When the task started running (RFC3339), null if not yet started""" + + +class CloudEnvironment(BaseModel): + """A cloud environment for running agents""" + + config: CloudEnvironmentConfig + """Configuration for a cloud environment used by scheduled agents""" + + last_updated: datetime + """Timestamp when the environment was last updated (RFC3339)""" + + setup_failed: bool + """True when the most recent task failed during setup before it started running""" + + uid: str + """Unique identifier for the environment""" + + creator: Optional[UserProfile] = None + + last_editor: Optional[UserProfile] = None + + last_task_created: Optional[LastTaskCreated] = None + """Summary of the most recently created task for an environment""" + + last_task_run_timestamp: Optional[datetime] = None + """Timestamp of the most recent task run in this environment (RFC3339)""" + + scope: Optional[Scope] = None + """Ownership scope for a resource (team or personal)""" diff --git a/tests/api_resources/agent/test_agent_.py b/tests/api_resources/agent/test_agent_.py new file mode 100644 index 0000000..9bbc914 --- /dev/null +++ b/tests/api_resources/agent/test_agent_.py @@ -0,0 +1,337 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import os +from typing import Any, cast + +import pytest + +from tests.utils import assert_matches_type +from oz_agent_sdk import OzAPI, AsyncOzAPI +from oz_agent_sdk.types.agent import ( + AgentResponse, + ListAgentIdentitiesResponse, +) + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestAgent: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_create(self, client: OzAPI) -> None: + agent = client.agent.agent.create( + name="name", + ) + assert_matches_type(AgentResponse, agent, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_create(self, client: OzAPI) -> None: + response = client.agent.agent.with_raw_response.create( + name="name", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + agent = response.parse() + assert_matches_type(AgentResponse, agent, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_create(self, client: OzAPI) -> None: + with client.agent.agent.with_streaming_response.create( + name="name", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + agent = response.parse() + assert_matches_type(AgentResponse, agent, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_update(self, client: OzAPI) -> None: + agent = client.agent.agent.update( + uid="uid", + ) + assert_matches_type(AgentResponse, agent, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_update_with_all_params(self, client: OzAPI) -> None: + agent = client.agent.agent.update( + uid="uid", + name="name", + ) + assert_matches_type(AgentResponse, agent, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_update(self, client: OzAPI) -> None: + response = client.agent.agent.with_raw_response.update( + uid="uid", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + agent = response.parse() + assert_matches_type(AgentResponse, agent, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_update(self, client: OzAPI) -> None: + with client.agent.agent.with_streaming_response.update( + uid="uid", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + agent = response.parse() + assert_matches_type(AgentResponse, agent, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_update(self, client: OzAPI) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `uid` but received ''"): + client.agent.agent.with_raw_response.update( + uid="", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_list(self, client: OzAPI) -> None: + agent = client.agent.agent.list() + assert_matches_type(ListAgentIdentitiesResponse, agent, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_list(self, client: OzAPI) -> None: + response = client.agent.agent.with_raw_response.list() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + agent = response.parse() + assert_matches_type(ListAgentIdentitiesResponse, agent, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_list(self, client: OzAPI) -> None: + with client.agent.agent.with_streaming_response.list() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + agent = response.parse() + assert_matches_type(ListAgentIdentitiesResponse, agent, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_delete(self, client: OzAPI) -> None: + agent = client.agent.agent.delete( + "uid", + ) + assert agent is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_delete(self, client: OzAPI) -> None: + response = client.agent.agent.with_raw_response.delete( + "uid", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + agent = response.parse() + assert agent is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_delete(self, client: OzAPI) -> None: + with client.agent.agent.with_streaming_response.delete( + "uid", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + agent = response.parse() + assert agent is None + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_delete(self, client: OzAPI) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `uid` but received ''"): + client.agent.agent.with_raw_response.delete( + "", + ) + + +class TestAsyncAgent: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_create(self, async_client: AsyncOzAPI) -> None: + agent = await async_client.agent.agent.create( + name="name", + ) + assert_matches_type(AgentResponse, agent, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_create(self, async_client: AsyncOzAPI) -> None: + response = await async_client.agent.agent.with_raw_response.create( + name="name", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + agent = await response.parse() + assert_matches_type(AgentResponse, agent, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_create(self, async_client: AsyncOzAPI) -> None: + async with async_client.agent.agent.with_streaming_response.create( + name="name", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + agent = await response.parse() + assert_matches_type(AgentResponse, agent, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_update(self, async_client: AsyncOzAPI) -> None: + agent = await async_client.agent.agent.update( + uid="uid", + ) + assert_matches_type(AgentResponse, agent, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_update_with_all_params(self, async_client: AsyncOzAPI) -> None: + agent = await async_client.agent.agent.update( + uid="uid", + name="name", + ) + assert_matches_type(AgentResponse, agent, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_update(self, async_client: AsyncOzAPI) -> None: + response = await async_client.agent.agent.with_raw_response.update( + uid="uid", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + agent = await response.parse() + assert_matches_type(AgentResponse, agent, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_update(self, async_client: AsyncOzAPI) -> None: + async with async_client.agent.agent.with_streaming_response.update( + uid="uid", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + agent = await response.parse() + assert_matches_type(AgentResponse, agent, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_update(self, async_client: AsyncOzAPI) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `uid` but received ''"): + await async_client.agent.agent.with_raw_response.update( + uid="", + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_list(self, async_client: AsyncOzAPI) -> None: + agent = await async_client.agent.agent.list() + assert_matches_type(ListAgentIdentitiesResponse, agent, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_list(self, async_client: AsyncOzAPI) -> None: + response = await async_client.agent.agent.with_raw_response.list() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + agent = await response.parse() + assert_matches_type(ListAgentIdentitiesResponse, agent, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_list(self, async_client: AsyncOzAPI) -> None: + async with async_client.agent.agent.with_streaming_response.list() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + agent = await response.parse() + assert_matches_type(ListAgentIdentitiesResponse, agent, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_delete(self, async_client: AsyncOzAPI) -> None: + agent = await async_client.agent.agent.delete( + "uid", + ) + assert agent is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_delete(self, async_client: AsyncOzAPI) -> None: + response = await async_client.agent.agent.with_raw_response.delete( + "uid", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + agent = await response.parse() + assert agent is None + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_delete(self, async_client: AsyncOzAPI) -> None: + async with async_client.agent.agent.with_streaming_response.delete( + "uid", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + agent = await response.parse() + assert agent is None + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_delete(self, async_client: AsyncOzAPI) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `uid` but received ''"): + await async_client.agent.agent.with_raw_response.delete( + "", + ) diff --git a/tests/api_resources/agent/test_runs.py b/tests/api_resources/agent/test_runs.py index bf257f8..cf95a65 100644 --- a/tests/api_resources/agent/test_runs.py +++ b/tests/api_resources/agent/test_runs.py @@ -71,6 +71,7 @@ def test_method_list(self, client: OzAPI) -> None: @parametrize def test_method_list_with_all_params(self, client: OzAPI) -> None: run = client.agent.runs.list( + ancestor_run_id="ancestor_run_id", artifact_type="PLAN", created_after=parse_datetime("2019-12-27T18:11:19.117Z"), created_before=parse_datetime("2019-12-27T18:11:19.117Z"), @@ -215,6 +216,7 @@ async def test_method_list(self, async_client: AsyncOzAPI) -> None: @parametrize async def test_method_list_with_all_params(self, async_client: AsyncOzAPI) -> None: run = await async_client.agent.runs.list( + ancestor_run_id="ancestor_run_id", artifact_type="PLAN", created_after=parse_datetime("2019-12-27T18:11:19.117Z"), created_before=parse_datetime("2019-12-27T18:11:19.117Z"), diff --git a/tests/api_resources/agent/test_schedules.py b/tests/api_resources/agent/test_schedules.py index 9b22b45..51fbae1 100644 --- a/tests/api_resources/agent/test_schedules.py +++ b/tests/api_resources/agent/test_schedules.py @@ -40,10 +40,8 @@ def test_method_create_with_all_params(self, client: OzAPI) -> None: "base_prompt": "base_prompt", "computer_use_enabled": True, "environment_id": "environment_id", - "harness": { - "auth_secret_name": "auth_secret_name", - "type": "oz", - }, + "harness": {"type": "oz"}, + "harness_auth_secrets": {"claude_auth_secret_name": "claude_auth_secret_name"}, "idle_timeout_minutes": 1, "mcp_servers": { "foo": { @@ -159,10 +157,8 @@ def test_method_update_with_all_params(self, client: OzAPI) -> None: "base_prompt": "base_prompt", "computer_use_enabled": True, "environment_id": "environment_id", - "harness": { - "auth_secret_name": "auth_secret_name", - "type": "oz", - }, + "harness": {"type": "oz"}, + "harness_auth_secrets": {"claude_auth_secret_name": "claude_auth_secret_name"}, "idle_timeout_minutes": 1, "mcp_servers": { "foo": { @@ -405,10 +401,8 @@ async def test_method_create_with_all_params(self, async_client: AsyncOzAPI) -> "base_prompt": "base_prompt", "computer_use_enabled": True, "environment_id": "environment_id", - "harness": { - "auth_secret_name": "auth_secret_name", - "type": "oz", - }, + "harness": {"type": "oz"}, + "harness_auth_secrets": {"claude_auth_secret_name": "claude_auth_secret_name"}, "idle_timeout_minutes": 1, "mcp_servers": { "foo": { @@ -524,10 +518,8 @@ async def test_method_update_with_all_params(self, async_client: AsyncOzAPI) -> "base_prompt": "base_prompt", "computer_use_enabled": True, "environment_id": "environment_id", - "harness": { - "auth_secret_name": "auth_secret_name", - "type": "oz", - }, + "harness": {"type": "oz"}, + "harness_auth_secrets": {"claude_auth_secret_name": "claude_auth_secret_name"}, "idle_timeout_minutes": 1, "mcp_servers": { "foo": { diff --git a/tests/api_resources/test_agent.py b/tests/api_resources/test_agent.py index 70c07fb..7f628ef 100644 --- a/tests/api_resources/test_agent.py +++ b/tests/api_resources/test_agent.py @@ -13,6 +13,7 @@ AgentRunResponse, AgentListResponse, AgentGetArtifactResponse, + AgentListEnvironmentsResponse, ) base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") @@ -102,6 +103,42 @@ def test_path_params_get_artifact(self, client: OzAPI) -> None: "", ) + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_list_environments(self, client: OzAPI) -> None: + agent = client.agent.list_environments() + assert_matches_type(AgentListEnvironmentsResponse, agent, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_list_environments_with_all_params(self, client: OzAPI) -> None: + agent = client.agent.list_environments( + sort_by="name", + ) + assert_matches_type(AgentListEnvironmentsResponse, agent, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_list_environments(self, client: OzAPI) -> None: + response = client.agent.with_raw_response.list_environments() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + agent = response.parse() + assert_matches_type(AgentListEnvironmentsResponse, agent, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_list_environments(self, client: OzAPI) -> None: + with client.agent.with_streaming_response.list_environments() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + agent = response.parse() + assert_matches_type(AgentListEnvironmentsResponse, agent, path=["response"]) + + assert cast(Any, response.is_closed) is True + @pytest.mark.skip(reason="Mock server tests are disabled") @parametrize def test_method_run(self, client: OzAPI) -> None: @@ -124,10 +161,8 @@ def test_method_run_with_all_params(self, client: OzAPI) -> None: "base_prompt": "base_prompt", "computer_use_enabled": True, "environment_id": "environment_id", - "harness": { - "auth_secret_name": "auth_secret_name", - "type": "oz", - }, + "harness": {"type": "oz"}, + "harness_auth_secrets": {"claude_auth_secret_name": "claude_auth_secret_name"}, "idle_timeout_minutes": 1, "mcp_servers": { "foo": { @@ -263,6 +298,42 @@ async def test_path_params_get_artifact(self, async_client: AsyncOzAPI) -> None: "", ) + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_list_environments(self, async_client: AsyncOzAPI) -> None: + agent = await async_client.agent.list_environments() + assert_matches_type(AgentListEnvironmentsResponse, agent, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_list_environments_with_all_params(self, async_client: AsyncOzAPI) -> None: + agent = await async_client.agent.list_environments( + sort_by="name", + ) + assert_matches_type(AgentListEnvironmentsResponse, agent, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_list_environments(self, async_client: AsyncOzAPI) -> None: + response = await async_client.agent.with_raw_response.list_environments() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + agent = await response.parse() + assert_matches_type(AgentListEnvironmentsResponse, agent, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_list_environments(self, async_client: AsyncOzAPI) -> None: + async with async_client.agent.with_streaming_response.list_environments() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + agent = await response.parse() + assert_matches_type(AgentListEnvironmentsResponse, agent, path=["response"]) + + assert cast(Any, response.is_closed) is True + @pytest.mark.skip(reason="Mock server tests are disabled") @parametrize async def test_method_run(self, async_client: AsyncOzAPI) -> None: @@ -285,10 +356,8 @@ async def test_method_run_with_all_params(self, async_client: AsyncOzAPI) -> Non "base_prompt": "base_prompt", "computer_use_enabled": True, "environment_id": "environment_id", - "harness": { - "auth_secret_name": "auth_secret_name", - "type": "oz", - }, + "harness": {"type": "oz"}, + "harness_auth_secrets": {"claude_auth_secret_name": "claude_auth_secret_name"}, "idle_timeout_minutes": 1, "mcp_servers": { "foo": { diff --git a/tests/test_deepcopy.py b/tests/test_deepcopy.py deleted file mode 100644 index 009b9b9..0000000 --- a/tests/test_deepcopy.py +++ /dev/null @@ -1,58 +0,0 @@ -from oz_agent_sdk._utils import deepcopy_minimal - - -def assert_different_identities(obj1: object, obj2: object) -> None: - assert obj1 == obj2 - assert id(obj1) != id(obj2) - - -def test_simple_dict() -> None: - obj1 = {"foo": "bar"} - obj2 = deepcopy_minimal(obj1) - assert_different_identities(obj1, obj2) - - -def test_nested_dict() -> None: - obj1 = {"foo": {"bar": True}} - obj2 = deepcopy_minimal(obj1) - assert_different_identities(obj1, obj2) - assert_different_identities(obj1["foo"], obj2["foo"]) - - -def test_complex_nested_dict() -> None: - obj1 = {"foo": {"bar": [{"hello": "world"}]}} - obj2 = deepcopy_minimal(obj1) - assert_different_identities(obj1, obj2) - assert_different_identities(obj1["foo"], obj2["foo"]) - assert_different_identities(obj1["foo"]["bar"], obj2["foo"]["bar"]) - assert_different_identities(obj1["foo"]["bar"][0], obj2["foo"]["bar"][0]) - - -def test_simple_list() -> None: - obj1 = ["a", "b", "c"] - obj2 = deepcopy_minimal(obj1) - assert_different_identities(obj1, obj2) - - -def test_nested_list() -> None: - obj1 = ["a", [1, 2, 3]] - obj2 = deepcopy_minimal(obj1) - assert_different_identities(obj1, obj2) - assert_different_identities(obj1[1], obj2[1]) - - -class MyObject: ... - - -def test_ignores_other_types() -> None: - # custom classes - my_obj = MyObject() - obj1 = {"foo": my_obj} - obj2 = deepcopy_minimal(obj1) - assert_different_identities(obj1, obj2) - assert obj1["foo"] is my_obj - - # tuples - obj3 = ("a", "b") - obj4 = deepcopy_minimal(obj3) - assert obj3 is obj4 diff --git a/tests/test_extract_files.py b/tests/test_extract_files.py index 95b71bd..f014004 100644 --- a/tests/test_extract_files.py +++ b/tests/test_extract_files.py @@ -35,6 +35,15 @@ def test_multiple_files() -> None: assert query == {"documents": [{}, {}]} +def test_top_level_file_array() -> None: + query = {"files": [b"file one", b"file two"], "title": "hello"} + assert extract_files(query, paths=[["files", ""]]) == [ + ("files[]", b"file one"), + ("files[]", b"file two"), + ] + assert query == {"title": "hello"} + + @pytest.mark.parametrize( "query,paths,expected", [ diff --git a/tests/test_files.py b/tests/test_files.py index a1255cc..26486e1 100644 --- a/tests/test_files.py +++ b/tests/test_files.py @@ -4,7 +4,8 @@ import pytest from dirty_equals import IsDict, IsList, IsBytes, IsTuple -from oz_agent_sdk._files import to_httpx_files, async_to_httpx_files +from oz_agent_sdk._files import to_httpx_files, deepcopy_with_paths, async_to_httpx_files +from oz_agent_sdk._utils import extract_files readme_path = Path(__file__).parent.parent.joinpath("README.md") @@ -49,3 +50,99 @@ def test_string_not_allowed() -> None: "file": "foo", # type: ignore } ) + + +def assert_different_identities(obj1: object, obj2: object) -> None: + assert obj1 == obj2 + assert obj1 is not obj2 + + +class TestDeepcopyWithPaths: + def test_copies_top_level_dict(self) -> None: + original = {"file": b"data", "other": "value"} + result = deepcopy_with_paths(original, [["file"]]) + assert_different_identities(result, original) + + def test_file_value_is_same_reference(self) -> None: + file_bytes = b"contents" + original = {"file": file_bytes} + result = deepcopy_with_paths(original, [["file"]]) + assert_different_identities(result, original) + assert result["file"] is file_bytes + + def test_list_popped_wholesale(self) -> None: + files = [b"f1", b"f2"] + original = {"files": files, "title": "t"} + result = deepcopy_with_paths(original, [["files", ""]]) + assert_different_identities(result, original) + result_files = result["files"] + assert isinstance(result_files, list) + assert_different_identities(result_files, files) + + def test_nested_array_path_copies_list_and_elements(self) -> None: + elem1 = {"file": b"f1", "extra": 1} + elem2 = {"file": b"f2", "extra": 2} + original = {"items": [elem1, elem2]} + result = deepcopy_with_paths(original, [["items", "", "file"]]) + assert_different_identities(result, original) + result_items = result["items"] + assert isinstance(result_items, list) + assert_different_identities(result_items, original["items"]) + assert_different_identities(result_items[0], elem1) + assert_different_identities(result_items[1], elem2) + + def test_empty_paths_returns_same_object(self) -> None: + original = {"foo": "bar"} + result = deepcopy_with_paths(original, []) + assert result is original + + def test_multiple_paths(self) -> None: + f1 = b"file1" + f2 = b"file2" + original = {"a": f1, "b": f2, "c": "unchanged"} + result = deepcopy_with_paths(original, [["a"], ["b"]]) + assert_different_identities(result, original) + assert result["a"] is f1 + assert result["b"] is f2 + assert result["c"] is original["c"] + + def test_extract_files_does_not_mutate_original_top_level(self) -> None: + file_bytes = b"contents" + original = {"file": file_bytes, "other": "value"} + + copied = deepcopy_with_paths(original, [["file"]]) + extracted = extract_files(copied, paths=[["file"]]) + + assert extracted == [("file", file_bytes)] + assert original == {"file": file_bytes, "other": "value"} + assert copied == {"other": "value"} + + def test_extract_files_does_not_mutate_original_nested_array_path(self) -> None: + file1 = b"f1" + file2 = b"f2" + original = { + "items": [ + {"file": file1, "extra": 1}, + {"file": file2, "extra": 2}, + ], + "title": "example", + } + + copied = deepcopy_with_paths(original, [["items", "", "file"]]) + extracted = extract_files(copied, paths=[["items", "", "file"]]) + + assert extracted == [("items[][file]", file1), ("items[][file]", file2)] + assert original == { + "items": [ + {"file": file1, "extra": 1}, + {"file": file2, "extra": 2}, + ], + "title": "example", + } + assert copied == { + "items": [ + {"extra": 1}, + {"extra": 2}, + ], + "title": "example", + }